Enhance cursor rules and add user management functionality
- Updated cursor rules to emphasize the importance of dependency injection and logging practices. - Introduced a new user management domain, including entities, services, handlers, and validation. - Implemented user authentication features such as login, registration, and role-based access control. - Added Redis integration for token management and caching. - Enhanced API documentation with Swagger for user-related endpoints. - Updated configuration to support new JWT settings and Redis connection parameters.
This commit is contained in:
@@ -35,6 +35,7 @@ internal/{domain}/
|
|||||||
- All files in a domain use the same package name for easy access
|
- All files in a domain use the same package name for easy access
|
||||||
- Repository implementations are in the same package as interfaces
|
- Repository implementations are in the same package as interfaces
|
||||||
- Keep dependencies minimal and focused
|
- Keep dependencies minimal and focused
|
||||||
|
- **NO GLOBAL VARIABLES**: Everything must be injected, including validation functions
|
||||||
|
|
||||||
## 💻 Go Best Practices
|
## 💻 Go Best Practices
|
||||||
|
|
||||||
@@ -84,6 +85,12 @@ internal/{domain}/
|
|||||||
|
|
||||||
## 📝 Logging Standards
|
## 📝 Logging Standards
|
||||||
|
|
||||||
|
### Logging Location
|
||||||
|
- **NO LOGGING IN HANDLERS**: All logging must be done in the service layer
|
||||||
|
- Handlers should only handle HTTP concerns (validation, response formatting)
|
||||||
|
- Service layer is responsible for all business logic logging
|
||||||
|
- Repository layer can log database operations
|
||||||
|
|
||||||
### Using Custom Logger Interface
|
### Using Custom Logger Interface
|
||||||
- Always use structured logging with fields
|
- Always use structured logging with fields
|
||||||
- Include relevant context (user_id, customer_id, request_id, etc.)
|
- Include relevant context (user_id, customer_id, request_id, etc.)
|
||||||
@@ -96,19 +103,23 @@ internal/{domain}/
|
|||||||
|
|
||||||
### Logging Examples
|
### Logging Examples
|
||||||
```go
|
```go
|
||||||
// Good
|
// Service layer - GOOD
|
||||||
log.Info("Customer authenticated successfully", map[string]interface{}{
|
func (s *CustomerService) CreateCustomer(ctx context.Context, req CreateCustomerRequest) (*Customer, error) {
|
||||||
"customer_id": customer.ID.String(),
|
s.logger.Info("Creating new customer", map[string]interface{}{
|
||||||
"email": customer.Email,
|
"email": req.Email,
|
||||||
"ip": clientIP,
|
"company_id": req.CompanyID,
|
||||||
})
|
})
|
||||||
|
// ... business logic
|
||||||
|
}
|
||||||
|
|
||||||
// Bad
|
// Handler - BAD (no logging here)
|
||||||
log.Info("Customer login")
|
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
|
||||||
|
// Only handle HTTP concerns, no logging
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Logging
|
### Error Logging
|
||||||
- Log errors with full context
|
- Log errors with full context in service layer
|
||||||
- Include error details and relevant fields
|
- Include error details and relevant fields
|
||||||
- Don't log the same error multiple times in the call stack
|
- Don't log the same error multiple times in the call stack
|
||||||
|
|
||||||
@@ -156,6 +167,7 @@ log.Info("Customer login")
|
|||||||
- **Time Input**: Accept Unix timestamps (int64) for all time fields in requests
|
- **Time Input**: Accept Unix timestamps (int64) for all time fields in requests
|
||||||
- **Time Validation**: Validate Unix timestamps are within reasonable ranges
|
- **Time Validation**: Validate Unix timestamps are within reasonable ranges
|
||||||
- **Time Consistency**: All time inputs must be Unix timestamps (int64)
|
- **Time Consistency**: All time inputs must be Unix timestamps (int64)
|
||||||
|
- **NO LOGGING**: Handlers should not contain any logging statements
|
||||||
|
|
||||||
### Govalidator Usage
|
### Govalidator Usage
|
||||||
- Use govalidator for all request validation
|
- Use govalidator for all request validation
|
||||||
@@ -164,6 +176,7 @@ log.Info("Customer login")
|
|||||||
- Validate at handler level before calling services
|
- Validate at handler level before calling services
|
||||||
- Return validation errors using `response.ValidationError()`
|
- Return validation errors using `response.ValidationError()`
|
||||||
- Use meaningful error messages for validation failures
|
- Use meaningful error messages for validation failures
|
||||||
|
- **Validation Injection**: Pass validation functions as dependencies, never use global validation
|
||||||
|
|
||||||
### Response Format
|
### Response Format
|
||||||
- Always use standardized response structure from `pkg/response`
|
- Always use standardized response structure from `pkg/response`
|
||||||
@@ -180,6 +193,40 @@ log.Info("Customer login")
|
|||||||
- Never log sensitive information (passwords, tokens)
|
- Never log sensitive information (passwords, tokens)
|
||||||
- Use HTTPS in production
|
- Use HTTPS in production
|
||||||
|
|
||||||
|
## 📚 Documentation Standards
|
||||||
|
|
||||||
|
### Code Documentation
|
||||||
|
- **Only document infrastructure and complex business logic**
|
||||||
|
- Don't over-document simple operations
|
||||||
|
- Document public interfaces and types that might be confusing
|
||||||
|
- Use Go doc comments format
|
||||||
|
- Include examples for complex functions
|
||||||
|
- Keep comments up to date with code changes
|
||||||
|
|
||||||
|
### API Documentation
|
||||||
|
- **Use Swagger/OpenAPI comments for all API endpoints**
|
||||||
|
- Document all endpoints with examples
|
||||||
|
- Include error response examples
|
||||||
|
- Document authentication requirements
|
||||||
|
- Keep Swagger documentation up to date
|
||||||
|
|
||||||
|
### Swagger Comment Examples
|
||||||
|
```go
|
||||||
|
// @Summary Create a new customer
|
||||||
|
// @Description Create a new customer with the provided information
|
||||||
|
// @Tags customers
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param customer body CreateCustomerRequest true "Customer information"
|
||||||
|
// @Success 201 {object} response.Response{data=Customer}
|
||||||
|
// @Failure 400 {object} response.Response
|
||||||
|
// @Failure 500 {object} response.Response
|
||||||
|
// @Router /customers [post]
|
||||||
|
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
|
||||||
|
// Handler implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 🧪 Testing Guidelines
|
## 🧪 Testing Guidelines
|
||||||
|
|
||||||
### Test Structure
|
### Test Structure
|
||||||
@@ -232,21 +279,6 @@ log.Info("Customer login")
|
|||||||
- Avoid memory leaks in goroutines
|
- Avoid memory leaks in goroutines
|
||||||
- Use sync.Pool for frequently allocated objects
|
- Use sync.Pool for frequently allocated objects
|
||||||
|
|
||||||
## 📚 Documentation Standards
|
|
||||||
|
|
||||||
### Code Documentation
|
|
||||||
- Write clear comments for complex business logic
|
|
||||||
- Document all public interfaces and types
|
|
||||||
- Use Go doc comments format
|
|
||||||
- Include examples for complex functions
|
|
||||||
- Keep comments up to date with code changes
|
|
||||||
|
|
||||||
### API Documentation
|
|
||||||
- Use OpenAPI/Swagger annotations
|
|
||||||
- Document all endpoints with examples
|
|
||||||
- Include error response examples
|
|
||||||
- Document authentication requirements
|
|
||||||
|
|
||||||
## 🔐 Security Best Practices
|
## 🔐 Security Best Practices
|
||||||
|
|
||||||
### Authentication & Authorization
|
### Authentication & Authorization
|
||||||
@@ -297,10 +329,12 @@ log.Info("Customer login")
|
|||||||
|
|
||||||
### Anti-patterns
|
### Anti-patterns
|
||||||
- Don't use panic for regular error handling
|
- Don't use panic for regular error handling
|
||||||
- Avoid global variables for application state
|
- **Avoid global variables for application state**
|
||||||
- Don't ignore context cancellation
|
- Don't ignore context cancellation
|
||||||
- Avoid deeply nested if statements
|
- Avoid deeply nested if statements
|
||||||
- Don't use reflection unless absolutely necessary
|
- Don't use reflection unless absolutely necessary
|
||||||
|
- **Don't log in handlers**
|
||||||
|
- **Don't use global validation functions**
|
||||||
|
|
||||||
### Common Mistakes
|
### Common Mistakes
|
||||||
- Not handling database connection errors
|
- Not handling database connection errors
|
||||||
@@ -308,6 +342,8 @@ log.Info("Customer login")
|
|||||||
- Not validating user inputs
|
- Not validating user inputs
|
||||||
- Exposing internal errors to clients
|
- Exposing internal errors to clients
|
||||||
- Not using proper HTTP status codes
|
- Not using proper HTTP status codes
|
||||||
|
- **Logging in handlers instead of services**
|
||||||
|
- **Using global variables for validation**
|
||||||
|
|
||||||
## 📋 Checklist for New Features
|
## 📋 Checklist for New Features
|
||||||
|
|
||||||
@@ -318,22 +354,28 @@ log.Info("Customer login")
|
|||||||
- [ ] Design database schema if needed
|
- [ ] Design database schema if needed
|
||||||
- [ ] Consider caching requirements
|
- [ ] Consider caching requirements
|
||||||
- [ ] Define custom validators if needed
|
- [ ] Define custom validators if needed
|
||||||
|
- [ ] Plan logging strategy (service layer only)
|
||||||
|
- [ ] Plan dependency injection for validation
|
||||||
|
|
||||||
### During Implementation
|
### During Implementation
|
||||||
- [ ] Follow flat domain structure (all files in same package)
|
- [ ] Follow flat domain structure (all files in same package)
|
||||||
- [ ] Use dependency injection
|
- [ ] Use dependency injection
|
||||||
- [ ] Implement proper logging
|
- [ ] Implement proper logging in service layer only
|
||||||
- [ ] Add govalidator validation tags to forms
|
- [ ] Add govalidator validation tags to forms
|
||||||
- [ ] Register custom validators in init() functions
|
- [ ] Register custom validators as injected dependencies
|
||||||
- [ ] Handle errors gracefully
|
- [ ] Handle errors gracefully
|
||||||
|
- [ ] Add Swagger comments to handlers
|
||||||
|
- [ ] Ensure no global variables are used
|
||||||
|
|
||||||
### After Implementation
|
### After Implementation
|
||||||
- [ ] Write unit tests
|
- [ ] Write unit tests
|
||||||
- [ ] Update API documentation
|
- [ ] Update API documentation (Swagger)
|
||||||
- [ ] Test error scenarios
|
- [ ] Test error scenarios
|
||||||
- [ ] Verify security implications
|
- [ ] Verify security implications
|
||||||
- [ ] Check performance impact
|
- [ ] Check performance impact
|
||||||
- [ ] Test validation scenarios
|
- [ ] Test validation scenarios
|
||||||
|
- [ ] Verify no logging in handlers
|
||||||
|
- [ ] Verify all dependencies are injected
|
||||||
|
|
||||||
## 🚀 Remember
|
## 🚀 Remember
|
||||||
- Prioritize simplicity and readability over cleverness
|
- Prioritize simplicity and readability over cleverness
|
||||||
@@ -342,7 +384,7 @@ log.Info("Customer login")
|
|||||||
- Test your code thoroughly
|
- Test your code thoroughly
|
||||||
- Security and performance are not optional
|
- Security and performance are not optional
|
||||||
- **Follow the flat domain structure**: All files in a domain use the same package name
|
- **Follow the flat domain structure**: All files in a domain use the same package name
|
||||||
- Use the custom logger interface consistently
|
- Use the custom logger interface consistently in service layer only
|
||||||
- Implement proper error handling and logging
|
- Implement proper error handling and logging
|
||||||
- Follow the repository pattern with interface and implementation in same file
|
- Follow the repository pattern with interface and implementation in same file
|
||||||
- **Time Consistency**: Always use Unix timestamps (int64) for input, storage, and output
|
- **Time Consistency**: Always use Unix timestamps (int64) for input, storage, and output
|
||||||
@@ -351,5 +393,8 @@ log.Info("Customer login")
|
|||||||
- **No Format Conversion**: Never convert between Unix timestamps and other time formats in the application
|
- **No Format Conversion**: Never convert between Unix timestamps and other time formats in the application
|
||||||
- **Validation**: Always use govalidator for request validation with proper tags
|
- **Validation**: Always use govalidator for request validation with proper tags
|
||||||
- **Form Design**: Define validation rules in request forms, not in handlers
|
- **Form Design**: Define validation rules in request forms, not in handlers
|
||||||
- **Custom Validators**: Register custom validators for complex validation rules
|
- **Custom Validators**: Register custom validators as injected dependencies
|
||||||
- **Package Structure**: Keep all domain files in the same package for easy access and minimal imports
|
- **Package Structure**: Keep all domain files in the same package for easy access and minimal imports
|
||||||
|
- **NO GLOBAL VARIABLES**: Everything must be injected
|
||||||
|
- **NO LOGGING IN HANDLERS**: All logging must be in service layer
|
||||||
|
- **SWAGGER DOCUMENTATION**: Use Swagger comments for all API endpoints
|
||||||
+76
-31
@@ -32,6 +32,7 @@ internal/{domain}/
|
|||||||
- All files in a domain use the same package name for easy access
|
- All files in a domain use the same package name for easy access
|
||||||
- Repository implementations are in the same package as interfaces
|
- Repository implementations are in the same package as interfaces
|
||||||
- Keep dependencies minimal and focused
|
- Keep dependencies minimal and focused
|
||||||
|
- **NO GLOBAL VARIABLES**: Everything must be injected, including validation functions
|
||||||
|
|
||||||
## 💻 Go Best Practices
|
## 💻 Go Best Practices
|
||||||
|
|
||||||
@@ -81,6 +82,12 @@ internal/{domain}/
|
|||||||
|
|
||||||
## 📝 Logging Standards
|
## 📝 Logging Standards
|
||||||
|
|
||||||
|
### Logging Location
|
||||||
|
- **NO LOGGING IN HANDLERS**: All logging must be done in the service layer
|
||||||
|
- Handlers should only handle HTTP concerns (validation, response formatting)
|
||||||
|
- Service layer is responsible for all business logic logging
|
||||||
|
- Repository layer can log database operations
|
||||||
|
|
||||||
### Using Custom Logger Interface
|
### Using Custom Logger Interface
|
||||||
- Always use structured logging with fields
|
- Always use structured logging with fields
|
||||||
- Include relevant context (user_id, customer_id, request_id, etc.)
|
- Include relevant context (user_id, customer_id, request_id, etc.)
|
||||||
@@ -93,19 +100,23 @@ internal/{domain}/
|
|||||||
|
|
||||||
### Logging Examples
|
### Logging Examples
|
||||||
```go
|
```go
|
||||||
// Good
|
// Service layer - GOOD
|
||||||
log.Info("Customer authenticated successfully", map[string]interface{}{
|
func (s *CustomerService) CreateCustomer(ctx context.Context, req CreateCustomerRequest) (*Customer, error) {
|
||||||
"customer_id": customer.ID.String(),
|
s.logger.Info("Creating new customer", map[string]interface{}{
|
||||||
"email": customer.Email,
|
"email": req.Email,
|
||||||
"ip": clientIP,
|
"company_id": req.CompanyID,
|
||||||
})
|
})
|
||||||
|
// ... business logic
|
||||||
|
}
|
||||||
|
|
||||||
// Bad
|
// Handler - BAD (no logging here)
|
||||||
log.Info("Customer login")
|
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
|
||||||
|
// Only handle HTTP concerns, no logging
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Logging
|
### Error Logging
|
||||||
- Log errors with full context
|
- Log errors with full context in service layer
|
||||||
- Include error details and relevant fields
|
- Include error details and relevant fields
|
||||||
- Don't log the same error multiple times in the call stack
|
- Don't log the same error multiple times in the call stack
|
||||||
|
|
||||||
@@ -153,6 +164,7 @@ log.Info("Customer login")
|
|||||||
- **Time Input**: Accept Unix timestamps (int64) for all time fields in requests
|
- **Time Input**: Accept Unix timestamps (int64) for all time fields in requests
|
||||||
- **Time Validation**: Validate Unix timestamps are within reasonable ranges
|
- **Time Validation**: Validate Unix timestamps are within reasonable ranges
|
||||||
- **Time Consistency**: All time inputs must be Unix timestamps (int64)
|
- **Time Consistency**: All time inputs must be Unix timestamps (int64)
|
||||||
|
- **NO LOGGING**: Handlers should not contain any logging statements
|
||||||
|
|
||||||
### Govalidator Usage
|
### Govalidator Usage
|
||||||
- Use govalidator for all request validation
|
- Use govalidator for all request validation
|
||||||
@@ -161,6 +173,7 @@ log.Info("Customer login")
|
|||||||
- Validate at handler level before calling services
|
- Validate at handler level before calling services
|
||||||
- Return validation errors using `response.ValidationError()`
|
- Return validation errors using `response.ValidationError()`
|
||||||
- Use meaningful error messages for validation failures
|
- Use meaningful error messages for validation failures
|
||||||
|
- **Validation Injection**: Pass validation functions as dependencies, never use global validation
|
||||||
|
|
||||||
### Response Format
|
### Response Format
|
||||||
- Always use standardized response structure from `pkg/response`
|
- Always use standardized response structure from `pkg/response`
|
||||||
@@ -177,6 +190,40 @@ log.Info("Customer login")
|
|||||||
- Never log sensitive information (passwords, tokens)
|
- Never log sensitive information (passwords, tokens)
|
||||||
- Use HTTPS in production
|
- Use HTTPS in production
|
||||||
|
|
||||||
|
## 📚 Documentation Standards
|
||||||
|
|
||||||
|
### Code Documentation
|
||||||
|
- **Only document infrastructure and complex business logic**
|
||||||
|
- Don't over-document simple operations
|
||||||
|
- Document public interfaces and types that might be confusing
|
||||||
|
- Use Go doc comments format
|
||||||
|
- Include examples for complex functions
|
||||||
|
- Keep comments up to date with code changes
|
||||||
|
|
||||||
|
### API Documentation
|
||||||
|
- **Use Swagger/OpenAPI comments for all API endpoints**
|
||||||
|
- Document all endpoints with examples
|
||||||
|
- Include error response examples
|
||||||
|
- Document authentication requirements
|
||||||
|
- Keep Swagger documentation up to date
|
||||||
|
|
||||||
|
### Swagger Comment Examples
|
||||||
|
```go
|
||||||
|
// @Summary Create a new customer
|
||||||
|
// @Description Create a new customer with the provided information
|
||||||
|
// @Tags customers
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param customer body CreateCustomerRequest true "Customer information"
|
||||||
|
// @Success 201 {object} response.Response{data=Customer}
|
||||||
|
// @Failure 400 {object} response.Response
|
||||||
|
// @Failure 500 {object} response.Response
|
||||||
|
// @Router /customers [post]
|
||||||
|
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
|
||||||
|
// Handler implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 🧪 Testing Guidelines
|
## 🧪 Testing Guidelines
|
||||||
|
|
||||||
### Test Structure
|
### Test Structure
|
||||||
@@ -229,21 +276,6 @@ log.Info("Customer login")
|
|||||||
- Avoid memory leaks in goroutines
|
- Avoid memory leaks in goroutines
|
||||||
- Use sync.Pool for frequently allocated objects
|
- Use sync.Pool for frequently allocated objects
|
||||||
|
|
||||||
## 📚 Documentation Standards
|
|
||||||
|
|
||||||
### Code Documentation
|
|
||||||
- Write clear comments for complex business logic
|
|
||||||
- Document all public interfaces and types
|
|
||||||
- Use Go doc comments format
|
|
||||||
- Include examples for complex functions
|
|
||||||
- Keep comments up to date with code changes
|
|
||||||
|
|
||||||
### API Documentation
|
|
||||||
- Use OpenAPI/Swagger annotations
|
|
||||||
- Document all endpoints with examples
|
|
||||||
- Include error response examples
|
|
||||||
- Document authentication requirements
|
|
||||||
|
|
||||||
## 🔐 Security Best Practices
|
## 🔐 Security Best Practices
|
||||||
|
|
||||||
### Authentication & Authorization
|
### Authentication & Authorization
|
||||||
@@ -294,10 +326,12 @@ log.Info("Customer login")
|
|||||||
|
|
||||||
### Anti-patterns
|
### Anti-patterns
|
||||||
- Don't use panic for regular error handling
|
- Don't use panic for regular error handling
|
||||||
- Avoid global variables for application state
|
- **Avoid global variables for application state**
|
||||||
- Don't ignore context cancellation
|
- Don't ignore context cancellation
|
||||||
- Avoid deeply nested if statements
|
- Avoid deeply nested if statements
|
||||||
- Don't use reflection unless absolutely necessary
|
- Don't use reflection unless absolutely necessary
|
||||||
|
- **Don't log in handlers**
|
||||||
|
- **Don't use global validation functions**
|
||||||
|
|
||||||
### Common Mistakes
|
### Common Mistakes
|
||||||
- Not handling database connection errors
|
- Not handling database connection errors
|
||||||
@@ -305,6 +339,8 @@ log.Info("Customer login")
|
|||||||
- Not validating user inputs
|
- Not validating user inputs
|
||||||
- Exposing internal errors to clients
|
- Exposing internal errors to clients
|
||||||
- Not using proper HTTP status codes
|
- Not using proper HTTP status codes
|
||||||
|
- **Logging in handlers instead of services**
|
||||||
|
- **Using global variables for validation**
|
||||||
|
|
||||||
## 📋 Checklist for New Features
|
## 📋 Checklist for New Features
|
||||||
|
|
||||||
@@ -315,22 +351,28 @@ log.Info("Customer login")
|
|||||||
- [ ] Design database schema if needed
|
- [ ] Design database schema if needed
|
||||||
- [ ] Consider caching requirements
|
- [ ] Consider caching requirements
|
||||||
- [ ] Define custom validators if needed
|
- [ ] Define custom validators if needed
|
||||||
|
- [ ] Plan logging strategy (service layer only)
|
||||||
|
- [ ] Plan dependency injection for validation
|
||||||
|
|
||||||
### During Implementation
|
### During Implementation
|
||||||
- [ ] Follow flat domain structure (all files in same package)
|
- [ ] Follow flat domain structure (all files in same package)
|
||||||
- [ ] Use dependency injection
|
- [ ] Use dependency injection
|
||||||
- [ ] Implement proper logging
|
- [ ] Implement proper logging in service layer only
|
||||||
- [ ] Add govalidator validation tags to forms
|
- [ ] Add govalidator validation tags to forms
|
||||||
- [ ] Register custom validators in init() functions
|
- [ ] Register custom validators as injected dependencies
|
||||||
- [ ] Handle errors gracefully
|
- [ ] Handle errors gracefully
|
||||||
|
- [ ] Add Swagger comments to handlers
|
||||||
|
- [ ] Ensure no global variables are used
|
||||||
|
|
||||||
### After Implementation
|
### After Implementation
|
||||||
- [ ] Write unit tests
|
- [ ] Write unit tests
|
||||||
- [ ] Update API documentation
|
- [ ] Update API documentation (Swagger)
|
||||||
- [ ] Test error scenarios
|
- [ ] Test error scenarios
|
||||||
- [ ] Verify security implications
|
- [ ] Verify security implications
|
||||||
- [ ] Check performance impact
|
- [ ] Check performance impact
|
||||||
- [ ] Test validation scenarios
|
- [ ] Test validation scenarios
|
||||||
|
- [ ] Verify no logging in handlers
|
||||||
|
- [ ] Verify all dependencies are injected
|
||||||
|
|
||||||
## 🚀 Remember
|
## 🚀 Remember
|
||||||
- Prioritize simplicity and readability over cleverness
|
- Prioritize simplicity and readability over cleverness
|
||||||
@@ -339,7 +381,7 @@ log.Info("Customer login")
|
|||||||
- Test your code thoroughly
|
- Test your code thoroughly
|
||||||
- Security and performance are not optional
|
- Security and performance are not optional
|
||||||
- **Follow the flat domain structure**: All files in a domain use the same package name
|
- **Follow the flat domain structure**: All files in a domain use the same package name
|
||||||
- Use the custom logger interface consistently
|
- Use the custom logger interface consistently in service layer only
|
||||||
- Implement proper error handling and logging
|
- Implement proper error handling and logging
|
||||||
- Follow the repository pattern with interface and implementation in same file
|
- Follow the repository pattern with interface and implementation in same file
|
||||||
- **Time Consistency**: Always use Unix timestamps (int64) for input, storage, and output
|
- **Time Consistency**: Always use Unix timestamps (int64) for input, storage, and output
|
||||||
@@ -348,5 +390,8 @@ log.Info("Customer login")
|
|||||||
- **No Format Conversion**: Never convert between Unix timestamps and other time formats in the application
|
- **No Format Conversion**: Never convert between Unix timestamps and other time formats in the application
|
||||||
- **Validation**: Always use govalidator for request validation with proper tags
|
- **Validation**: Always use govalidator for request validation with proper tags
|
||||||
- **Form Design**: Define validation rules in request forms, not in handlers
|
- **Form Design**: Define validation rules in request forms, not in handlers
|
||||||
- **Custom Validators**: Register custom validators for complex validation rules
|
- **Custom Validators**: Register custom validators as injected dependencies
|
||||||
- **Package Structure**: Keep all domain files in the same package for easy access and minimal imports
|
- **Package Structure**: Keep all domain files in the same package for easy access and minimal imports
|
||||||
|
- **NO GLOBAL VARIABLES**: Everything must be injected
|
||||||
|
- **NO LOGGING IN HANDLERS**: All logging must be in service layer
|
||||||
|
- **SWAGGER DOCUMENTATION**: Use Swagger comments for all API endpoints
|
||||||
Executable
BIN
Binary file not shown.
@@ -5,8 +5,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
"tm/infra"
|
"tm/infra"
|
||||||
|
"tm/pkg/authorization"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
"tm/pkg/mongo"
|
"tm/pkg/mongo"
|
||||||
|
"tm/pkg/redis"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
@@ -131,3 +133,53 @@ func initHTTPServer(conf infra.Config, log logger.Logger) *echo.Echo {
|
|||||||
|
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Init Redis Connection Manager
|
||||||
|
func initRedis(conf infra.RedisConfig, log logger.Logger) redis.Client {
|
||||||
|
connectionConfig := &redis.Config{
|
||||||
|
Host: conf.Host,
|
||||||
|
Port: conf.Port,
|
||||||
|
Password: conf.Password,
|
||||||
|
DB: conf.DB,
|
||||||
|
PoolSize: conf.PoolSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
redisClient, err := redis.NewClient(connectionConfig, log)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to initialize Redis connection", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"host": conf.Host,
|
||||||
|
"port": conf.Port,
|
||||||
|
})
|
||||||
|
panic(fmt.Sprintf("Failed to initialize Redis connection: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Redis connection manager initialized successfully", map[string]interface{}{
|
||||||
|
"host": conf.Host,
|
||||||
|
"port": conf.Port,
|
||||||
|
"pool_size": conf.PoolSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
return redisClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init Authorization Service
|
||||||
|
func initAuthorizationService(conf infra.JWTConfig, redisClient redis.Client, log logger.Logger) authorization.AuthorizationService {
|
||||||
|
authConfig := &authorization.AuthorizationConfig{
|
||||||
|
AccessTokenSecret: conf.AccessSecret,
|
||||||
|
RefreshTokenSecret: conf.RefreshSecret,
|
||||||
|
AccessTokenExpiry: time.Duration(conf.AccessExpiresIn) * time.Second,
|
||||||
|
RefreshTokenExpiry: time.Duration(conf.RefreshExpiresIn) * time.Second,
|
||||||
|
Issuer: "tender-management-system",
|
||||||
|
Audience: "tender-management-users",
|
||||||
|
}
|
||||||
|
|
||||||
|
authService := authorization.NewAuthorizationService(authConfig, log, redisClient)
|
||||||
|
|
||||||
|
log.Info("Authorization service initialized successfully", map[string]interface{}{
|
||||||
|
"access_expires_in": conf.AccessExpiresIn,
|
||||||
|
"refresh_expires_in": conf.RefreshExpiresIn,
|
||||||
|
})
|
||||||
|
|
||||||
|
return authService
|
||||||
|
}
|
||||||
|
|||||||
+4
-3
@@ -38,9 +38,10 @@ search:
|
|||||||
|
|
||||||
auth:
|
auth:
|
||||||
jwt:
|
jwt:
|
||||||
secret: "your-super-secret-key"
|
access_secret: "your-super-secret-access-token-key-here-make-it-long-and-secure"
|
||||||
access_token_duration: "24h"
|
refresh_secret: "your-super-secret-refresh-token-key-here-make-it-long-and-secure"
|
||||||
refresh_token_duration: "168h" # 7 days
|
access_expires_in: 3600 # 1 hour in seconds
|
||||||
|
refresh_expires_in: 2592000 # 30 days in seconds
|
||||||
|
|
||||||
ai:
|
ai:
|
||||||
openai:
|
openai:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,149 @@ definitions:
|
|||||||
total:
|
total:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
user.AuthResponse:
|
||||||
|
properties:
|
||||||
|
access_token:
|
||||||
|
type: string
|
||||||
|
expires_at:
|
||||||
|
type: integer
|
||||||
|
refresh_token:
|
||||||
|
type: string
|
||||||
|
user:
|
||||||
|
$ref: '#/definitions/user.UserResponse'
|
||||||
|
type: object
|
||||||
|
user.ChangePasswordForm:
|
||||||
|
properties:
|
||||||
|
new_password:
|
||||||
|
type: string
|
||||||
|
old_password:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
user.CreateUserForm:
|
||||||
|
properties:
|
||||||
|
company_id:
|
||||||
|
type: string
|
||||||
|
department:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
full_name:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
phone:
|
||||||
|
type: string
|
||||||
|
position:
|
||||||
|
type: string
|
||||||
|
profile_image:
|
||||||
|
type: string
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
user.LoginForm:
|
||||||
|
properties:
|
||||||
|
email_or_username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
user.RefreshTokenForm:
|
||||||
|
properties:
|
||||||
|
refresh_token:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
user.ResetPasswordForm:
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
user.UpdateUserForm:
|
||||||
|
properties:
|
||||||
|
company_id:
|
||||||
|
type: string
|
||||||
|
department:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
full_name:
|
||||||
|
type: string
|
||||||
|
phone:
|
||||||
|
type: string
|
||||||
|
position:
|
||||||
|
type: string
|
||||||
|
profile_image:
|
||||||
|
type: string
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
user.UpdateUserRoleForm:
|
||||||
|
properties:
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
user.UpdateUserStatusForm:
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
user.UserListResponse:
|
||||||
|
properties:
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
offset:
|
||||||
|
type: integer
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
total_pages:
|
||||||
|
type: integer
|
||||||
|
users:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/user.UserResponse'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
user.UserResponse:
|
||||||
|
properties:
|
||||||
|
company_id:
|
||||||
|
type: string
|
||||||
|
created_at:
|
||||||
|
type: integer
|
||||||
|
created_by:
|
||||||
|
type: string
|
||||||
|
department:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
full_name:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
is_verified:
|
||||||
|
type: boolean
|
||||||
|
last_login_at:
|
||||||
|
type: integer
|
||||||
|
phone:
|
||||||
|
type: string
|
||||||
|
position:
|
||||||
|
type: string
|
||||||
|
profile_image:
|
||||||
|
type: string
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
updated_at:
|
||||||
|
type: integer
|
||||||
|
updated_by:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
host: localhost:8081
|
host: localhost:8081
|
||||||
info:
|
info:
|
||||||
contact:
|
contact:
|
||||||
@@ -703,6 +846,699 @@ paths:
|
|||||||
summary: Refresh access token
|
summary: Refresh access token
|
||||||
tags:
|
tags:
|
||||||
- customers
|
- customers
|
||||||
|
/users/admin:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: List users with search and filters (admin only)
|
||||||
|
parameters:
|
||||||
|
- description: Search term
|
||||||
|
in: query
|
||||||
|
name: search
|
||||||
|
type: string
|
||||||
|
- description: User status filter
|
||||||
|
in: query
|
||||||
|
name: status
|
||||||
|
type: string
|
||||||
|
- description: User role filter
|
||||||
|
in: query
|
||||||
|
name: role
|
||||||
|
type: string
|
||||||
|
- description: Company ID filter
|
||||||
|
in: query
|
||||||
|
name: company_id
|
||||||
|
type: string
|
||||||
|
- description: Limit results
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
- description: Offset results
|
||||||
|
in: query
|
||||||
|
name: offset
|
||||||
|
type: integer
|
||||||
|
- description: Sort field
|
||||||
|
in: query
|
||||||
|
name: sort_by
|
||||||
|
type: string
|
||||||
|
- description: Sort order
|
||||||
|
in: query
|
||||||
|
name: sort_order
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.APIResponse'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/user.UserListResponse'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: List users
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Create a new user (admin only)
|
||||||
|
parameters:
|
||||||
|
- description: User creation data
|
||||||
|
in: body
|
||||||
|
name: user
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/user.CreateUserForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.APIResponse'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/user.UserResponse'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Create user
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/admin/{id}:
|
||||||
|
delete:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Delete a user (admin only)
|
||||||
|
parameters:
|
||||||
|
- description: User ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Delete user
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get user details by ID (admin only)
|
||||||
|
parameters:
|
||||||
|
- description: User ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.APIResponse'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/user.UserResponse'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get user by ID
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Update user information (admin only)
|
||||||
|
parameters:
|
||||||
|
- description: User ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: User update data
|
||||||
|
in: body
|
||||||
|
name: user
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/user.UpdateUserForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.APIResponse'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/user.UserResponse'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Update user
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/admin/{id}/role:
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Update user role (admin only)
|
||||||
|
parameters:
|
||||||
|
- description: User ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Role update data
|
||||||
|
in: body
|
||||||
|
name: role
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/user.UpdateUserRoleForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Update user role
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/admin/{id}/status:
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Update user account status (admin only)
|
||||||
|
parameters:
|
||||||
|
- description: User ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Status update data
|
||||||
|
in: body
|
||||||
|
name: status
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/user.UpdateUserStatusForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Update user status
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/admin/company/{company_id}:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get users belonging to a specific company (admin only)
|
||||||
|
parameters:
|
||||||
|
- description: Company ID
|
||||||
|
in: path
|
||||||
|
name: company_id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Limit results
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
- description: Offset results
|
||||||
|
in: query
|
||||||
|
name: offset
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get users by company ID
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/admin/role/{role}:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get users with a specific role (admin only)
|
||||||
|
parameters:
|
||||||
|
- description: User role
|
||||||
|
in: path
|
||||||
|
name: role
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Limit results
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
|
- description: Offset results
|
||||||
|
in: query
|
||||||
|
name: offset
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get users by role
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/change-password:
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Change current user password
|
||||||
|
parameters:
|
||||||
|
- description: Password change data
|
||||||
|
in: body
|
||||||
|
name: password
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/user.ChangePasswordForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Change password
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/login:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Authenticate user with email/username and password
|
||||||
|
parameters:
|
||||||
|
- description: Login credentials
|
||||||
|
in: body
|
||||||
|
name: login
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/user.LoginForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.APIResponse'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/user.AuthResponse'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
summary: Login user
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/logout:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Logout current user and invalidate tokens
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Logout user
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/profile:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get current user profile information
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.APIResponse'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/user.UserResponse'
|
||||||
|
type: object
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get user profile
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Update current user profile information
|
||||||
|
parameters:
|
||||||
|
- description: Profile update data
|
||||||
|
in: body
|
||||||
|
name: profile
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/user.UpdateUserForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.APIResponse'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/user.UserResponse'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Update user profile
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/refresh-token:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Refresh access token using refresh token
|
||||||
|
parameters:
|
||||||
|
- description: Refresh token
|
||||||
|
in: body
|
||||||
|
name: refresh
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/user.RefreshTokenForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.APIResponse'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/user.AuthResponse'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
summary: Refresh access token
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
/users/reset-password:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Send password reset email to user
|
||||||
|
parameters:
|
||||||
|
- description: Password reset request
|
||||||
|
in: body
|
||||||
|
name: reset
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/user.ResetPasswordForm'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.APIResponse'
|
||||||
|
summary: Reset password
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
securityDefinitions:
|
securityDefinitions:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
description: Type "Bearer" followed by a space and JWT token.
|
description: Type "Bearer" followed by a space and JWT token.
|
||||||
@@ -713,6 +1549,8 @@ swagger: "2.0"
|
|||||||
tags:
|
tags:
|
||||||
- description: Customer management operations
|
- description: Customer management operations
|
||||||
name: customers
|
name: customers
|
||||||
|
- description: User management operations
|
||||||
|
name: users
|
||||||
- description: Health check operations
|
- description: Health check operations
|
||||||
name: health
|
name: health
|
||||||
- description: Authentication operations
|
- description: Authentication operations
|
||||||
|
|||||||
+31
-10
@@ -42,6 +42,9 @@ package main
|
|||||||
// @tag.name customers
|
// @tag.name customers
|
||||||
// @tag.description Customer management operations
|
// @tag.description Customer management operations
|
||||||
|
|
||||||
|
// @tag.name users
|
||||||
|
// @tag.description User management operations
|
||||||
|
|
||||||
// @tag.name health
|
// @tag.name health
|
||||||
// @tag.description Health check operations
|
// @tag.description Health check operations
|
||||||
|
|
||||||
@@ -54,6 +57,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
"tm/internal/customer"
|
"tm/internal/customer"
|
||||||
|
"tm/internal/user"
|
||||||
|
|
||||||
_ "tm/cmd/web/docs" // This is generated by swag
|
_ "tm/cmd/web/docs" // This is generated by swag
|
||||||
)
|
)
|
||||||
@@ -76,33 +80,43 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logger.Info(
|
// Initialize Redis connection manager
|
||||||
"Starting Tender Management API Server",
|
redisClient := initRedis(conf.Cache.Redis, logger)
|
||||||
map[string]interface{}{
|
defer func() {
|
||||||
"version": "1.0.0",
|
if err := redisClient.Close(); err != nil {
|
||||||
"host": conf.Server.Host,
|
logger.Error("Failed to close Redis connection", map[string]interface{}{
|
||||||
"port": conf.Server.Port,
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Initialize authorization service
|
||||||
|
authService := initAuthorizationService(conf.Auth.JWT, redisClient, logger)
|
||||||
// Initialize repositories with MongoDB connection manager
|
// Initialize repositories with MongoDB connection manager
|
||||||
customerRepository := customer.NewCustomerRepository(mongoManager, logger)
|
customerRepository := customer.NewCustomerRepository(mongoManager, logger)
|
||||||
|
userRepository := user.NewUserRepository(mongoManager, logger)
|
||||||
|
|
||||||
logger.Info("Repositories initialized successfully", map[string]interface{}{
|
logger.Info("Repositories initialized successfully", map[string]interface{}{
|
||||||
"repositories": []string{"customer"},
|
"repositories": []string{"customer", "user"},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initialize validation service
|
||||||
|
userValidator := user.NewValidationService()
|
||||||
|
|
||||||
// Initialize services with repositories
|
// Initialize services with repositories
|
||||||
customerService := customer.NewCustomerService(customerRepository, logger)
|
customerService := customer.NewCustomerService(customerRepository, logger)
|
||||||
|
userService := user.NewUserService(userRepository, logger, authService, userValidator)
|
||||||
|
|
||||||
logger.Info("Services initialized successfully", map[string]interface{}{
|
logger.Info("Services initialized successfully", map[string]interface{}{
|
||||||
"services": []string{"customer"},
|
"services": []string{"customer", "user"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize handlers with services
|
// Initialize handlers with services
|
||||||
customerHandler := customer.NewHandler(customerService)
|
customerHandler := customer.NewHandler(customerService)
|
||||||
|
userHandler := user.NewUserHandler(userService, logger, userValidator, authService)
|
||||||
|
|
||||||
logger.Info("Handlers initialized successfully", map[string]interface{}{
|
logger.Info("Handlers initialized successfully", map[string]interface{}{
|
||||||
"handlers": []string{"customer"},
|
"handlers": []string{"customer", "user"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize HTTP server
|
// Initialize HTTP server
|
||||||
@@ -110,17 +124,24 @@ func main() {
|
|||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
customerHandler.RegisterRoutes(e)
|
customerHandler.RegisterRoutes(e)
|
||||||
|
userHandler.RegisterRoutes(e)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)
|
serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)
|
||||||
logger.Info("HTTP server starting", map[string]interface{}{
|
logger.Info("HTTP server starting", map[string]interface{}{
|
||||||
"address": serverAddr,
|
"address": serverAddr,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"host": conf.Server.Host,
|
||||||
|
"port": conf.Server.Port,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := e.Start(serverAddr); err != nil && err != http.ErrServerClosed {
|
if err := e.Start(serverAddr); err != nil && err != http.ErrServerClosed {
|
||||||
logger.Error("Failed to start HTTP server", map[string]interface{}{
|
logger.Error("Failed to start HTTP server", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"address": serverAddr,
|
"address": serverAddr,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"host": conf.Server.Host,
|
||||||
|
"port": conf.Server.Port,
|
||||||
})
|
})
|
||||||
panic(fmt.Sprintf("Failed to start HTTP server: %v", err))
|
panic(fmt.Sprintf("Failed to start HTTP server: %v", err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ go 1.23.0
|
|||||||
toolchain go1.24.4
|
toolchain go1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/google/uuid v1.4.0
|
github.com/google/uuid v1.4.0
|
||||||
github.com/labstack/echo/v4 v4.13.4
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
|
github.com/redis/go-redis/v9 v9.12.0
|
||||||
github.com/spf13/viper v1.18.2
|
github.com/spf13/viper v1.18.2
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
|
github.com/swaggo/echo-swagger v1.4.1
|
||||||
|
github.com/swaggo/swag v1.16.6
|
||||||
go.mongodb.org/mongo-driver v1.17.4
|
go.mongodb.org/mongo-driver v1.17.4
|
||||||
go.uber.org/zap v1.26.0
|
go.uber.org/zap v1.26.0
|
||||||
golang.org/x/crypto v0.40.0
|
golang.org/x/crypto v0.40.0
|
||||||
@@ -17,10 +21,9 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/PuerkitoBio/purell v1.2.1 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/ghodss/yaml v1.0.0 // indirect
|
github.com/ghodss/yaml v1.0.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||||
@@ -29,20 +32,12 @@ require (
|
|||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/mailru/easyjson v0.9.0 // indirect
|
github.com/mailru/easyjson v0.9.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
|
||||||
github.com/swaggo/echo-swagger v1.4.1 // indirect
|
|
||||||
github.com/swaggo/files v1.0.1 // indirect
|
|
||||||
github.com/swaggo/files/v2 v2.0.2 // indirect
|
github.com/swaggo/files/v2 v2.0.2 // indirect
|
||||||
github.com/swaggo/swag v1.16.6 // indirect
|
|
||||||
github.com/urfave/cli/v2 v2.27.7 // indirect
|
|
||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
|
||||||
golang.org/x/mod v0.26.0 // indirect
|
golang.org/x/mod v0.26.0 // indirect
|
||||||
golang.org/x/time v0.11.0 // indirect
|
golang.org/x/time v0.11.0 // indirect
|
||||||
golang.org/x/tools v0.35.0 // indirect
|
golang.org/x/tools v0.35.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -57,7 +52,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28=
|
|
||||||
github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
|
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
@@ -26,6 +28,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z
|
|||||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||||
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
||||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
@@ -58,22 +62,19 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
|
|||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/redis/go-redis/v9 v9.12.0 h1:XlVPGlflh4nxfhsNXPA8Qp6EmEfTo0rp8oaBzPipXnU=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/redis/go-redis/v9 v9.12.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
|
||||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||||
@@ -87,23 +88,22 @@ github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMV
|
|||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
|
github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
|
||||||
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
|
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
|
||||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
|
||||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
|
||||||
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
|
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
|
||||||
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
|
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
|
||||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
|
||||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
@@ -114,8 +114,6 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
|||||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
|
||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
@@ -127,12 +125,8 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
|||||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
|
||||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||||
@@ -143,15 +137,10 @@ golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
|||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|
||||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -159,22 +148,15 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
@@ -186,8 +168,8 @@ golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
|||||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
@@ -197,5 +179,3 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
|
||||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
|
||||||
|
|||||||
+4
-3
@@ -77,9 +77,10 @@ type AuthConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JWTConfig struct {
|
type JWTConfig struct {
|
||||||
Secret string `mapstructure:"secret"`
|
AccessSecret string `mapstructure:"access_secret"`
|
||||||
AccessTokenDuration time.Duration `mapstructure:"access_token_duration"`
|
RefreshSecret string `mapstructure:"refresh_secret"`
|
||||||
RefreshTokenDuration time.Duration `mapstructure:"refresh_token_duration"`
|
AccessExpiresIn int `mapstructure:"access_expires_in"`
|
||||||
|
RefreshExpiresIn int `mapstructure:"refresh_expires_in"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIConfig struct {
|
type AIConfig struct {
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# User Management Service
|
||||||
|
|
||||||
|
This service provides comprehensive user management functionality for the Tender Management system, following Clean Architecture principles and Domain-Driven Design patterns.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### User Authentication
|
||||||
|
- **Login**: Authenticate users with email/username and password
|
||||||
|
- **Refresh Token**: Refresh access tokens
|
||||||
|
- **Logout**: Securely log out users
|
||||||
|
- **Password Management**: Change password and reset password functionality
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
- **Create User**: Create new users with roles and permissions
|
||||||
|
- **Update User**: Update user information and profile
|
||||||
|
- **Delete User**: Soft delete users (set status to inactive)
|
||||||
|
- **Get User**: Retrieve user information by ID
|
||||||
|
- **List Users**: Search and filter users with pagination
|
||||||
|
|
||||||
|
### Role-Based Access Control
|
||||||
|
- **User Roles**: admin, manager, operator, viewer
|
||||||
|
- **Role Management**: Update user roles
|
||||||
|
- **Status Management**: Activate, deactivate, or suspend users
|
||||||
|
|
||||||
|
### Company Management
|
||||||
|
- **Company Users**: Get users by company ID
|
||||||
|
- **User Count**: Count users per company
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
|
||||||
|
#### Authentication
|
||||||
|
```
|
||||||
|
POST /api/v1/users/login
|
||||||
|
POST /api/v1/users/refresh-token
|
||||||
|
POST /api/v1/users/reset-password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protected Endpoints (Require Authentication)
|
||||||
|
|
||||||
|
#### User Profile
|
||||||
|
```
|
||||||
|
GET /api/v1/users/profile
|
||||||
|
PUT /api/v1/users/profile
|
||||||
|
PUT /api/v1/users/change-password
|
||||||
|
POST /api/v1/users/logout
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Endpoints (Require Admin Role)
|
||||||
|
|
||||||
|
#### User Management
|
||||||
|
```
|
||||||
|
POST /api/v1/users/admin/
|
||||||
|
GET /api/v1/users/admin/
|
||||||
|
GET /api/v1/users/admin/:id
|
||||||
|
PUT /api/v1/users/admin/:id
|
||||||
|
DELETE /api/v1/users/admin/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
#### User Status & Role Management
|
||||||
|
```
|
||||||
|
PUT /api/v1/users/admin/:id/status
|
||||||
|
PUT /api/v1/users/admin/:id/role
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Company & Role Queries
|
||||||
|
```
|
||||||
|
GET /api/v1/users/admin/company/:company_id
|
||||||
|
GET /api/v1/users/admin/role/:role
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request/Response Examples
|
||||||
|
|
||||||
|
### Create User
|
||||||
|
```json
|
||||||
|
POST /api/v1/users/admin/
|
||||||
|
{
|
||||||
|
"full_name": "John Doe",
|
||||||
|
"username": "johndoe",
|
||||||
|
"email": "john.doe@example.com",
|
||||||
|
"password": "securepassword123",
|
||||||
|
"role": "manager",
|
||||||
|
"company_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"department": "Engineering",
|
||||||
|
"position": "Senior Manager",
|
||||||
|
"phone": "+1234567890",
|
||||||
|
"profile_image": "https://example.com/avatar.jpg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login
|
||||||
|
```json
|
||||||
|
POST /api/v1/users/login
|
||||||
|
{
|
||||||
|
"email_or_username": "john.doe@example.com",
|
||||||
|
"password": "securepassword123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update User
|
||||||
|
```json
|
||||||
|
PUT /api/v1/users/admin/:id
|
||||||
|
{
|
||||||
|
"full_name": "John Smith",
|
||||||
|
"department": "Product Management",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Users with Filters
|
||||||
|
```
|
||||||
|
GET /api/v1/users/admin/?search=john&role=manager&status=active&limit=20&offset=0&sort_by=created_at&sort_order=desc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### User Entity
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
ID uuid.UUID `bson:"_id"`
|
||||||
|
FullName string `bson:"full_name"`
|
||||||
|
Username string `bson:"username"`
|
||||||
|
Email string `bson:"email"`
|
||||||
|
Password string `bson:"password"`
|
||||||
|
Role UserRole `bson:"role"`
|
||||||
|
Status UserStatus `bson:"status"`
|
||||||
|
CompanyID *uuid.UUID `bson:"company_id,omitempty"`
|
||||||
|
Department *string `bson:"department,omitempty"`
|
||||||
|
Position *string `bson:"position,omitempty"`
|
||||||
|
Phone *string `bson:"phone,omitempty"`
|
||||||
|
ProfileImage *string `bson:"profile_image,omitempty"`
|
||||||
|
IsVerified bool `bson:"is_verified"`
|
||||||
|
LastLoginAt *int64 `bson:"last_login_at,omitempty"`
|
||||||
|
CreatedAt int64 `bson:"created_at"`
|
||||||
|
UpdatedAt int64 `bson:"updated_at"`
|
||||||
|
CreatedBy *uuid.UUID `bson:"created_by,omitempty"`
|
||||||
|
UpdatedBy *uuid.UUID `bson:"updated_by,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Roles
|
||||||
|
- `admin`: Full system access
|
||||||
|
- `manager`: Company and team management
|
||||||
|
- `operator`: Operational tasks
|
||||||
|
- `viewer`: Read-only access
|
||||||
|
|
||||||
|
### User Status
|
||||||
|
- `active`: User can access the system
|
||||||
|
- `inactive`: User account is disabled
|
||||||
|
- `suspended`: User account is temporarily suspended
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
### Password Security
|
||||||
|
- Passwords are hashed using bcrypt
|
||||||
|
- Minimum password length: 8 characters
|
||||||
|
- Maximum password length: 128 characters
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- JWT-based authentication (to be implemented)
|
||||||
|
- Token refresh mechanism
|
||||||
|
- Secure logout with token invalidation
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
- Role-based access control
|
||||||
|
- Admin-only endpoints for user management
|
||||||
|
- Company-scoped access for multi-tenant support
|
||||||
|
|
||||||
|
## Database Indexes
|
||||||
|
|
||||||
|
The service creates the following MongoDB indexes for optimal performance:
|
||||||
|
|
||||||
|
- **Email Index**: Unique index on email field
|
||||||
|
- **Username Index**: Unique index on username field
|
||||||
|
- **Company ID Index**: For company-based queries
|
||||||
|
- **Role Index**: For role-based filtering
|
||||||
|
- **Status Index**: For status-based filtering
|
||||||
|
- **Created At Index**: For sorting by creation date
|
||||||
|
- **Text Search Index**: For full-text search across name, email, username, department, and position
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The service provides comprehensive error handling with:
|
||||||
|
|
||||||
|
- **Validation Errors**: Detailed validation messages using govalidator
|
||||||
|
- **Business Logic Errors**: Clear error messages for business rule violations
|
||||||
|
- **Database Errors**: Proper handling of database constraints and connection issues
|
||||||
|
- **Security Errors**: Secure error messages that don't leak sensitive information
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
All operations are logged with structured logging including:
|
||||||
|
|
||||||
|
- **Operation Context**: User ID, company ID, operation type
|
||||||
|
- **Error Details**: Full error context for debugging
|
||||||
|
- **Security Events**: Login attempts, password changes, role updates
|
||||||
|
- **Performance Metrics**: Operation timing and resource usage
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
- **JWT Token Management**: Complete JWT implementation with refresh tokens
|
||||||
|
- **Email Verification**: Email verification for new user accounts
|
||||||
|
- **Password Reset**: Complete password reset flow with email
|
||||||
|
- **Audit Trail**: Comprehensive audit logging for all user operations
|
||||||
|
- **Bulk Operations**: Bulk user import/export functionality
|
||||||
|
- **Advanced Search**: Elasticsearch integration for advanced search capabilities
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- **Email Service**: For password reset and verification emails
|
||||||
|
- **Notification Service**: For user activity notifications
|
||||||
|
- **Audit Service**: For comprehensive audit logging
|
||||||
|
- **Permission Service**: For fine-grained permission management
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserRole represents user roles in the system
|
||||||
|
type UserRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserRoleAdmin UserRole = "admin"
|
||||||
|
UserRoleManager UserRole = "manager"
|
||||||
|
UserRoleOperator UserRole = "operator"
|
||||||
|
UserRoleViewer UserRole = "viewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserStatus represents user account status
|
||||||
|
type UserStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserStatusActive UserStatus = "active"
|
||||||
|
UserStatusInactive UserStatus = "inactive"
|
||||||
|
UserStatusSuspended UserStatus = "suspended"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents a system user (admin/manager/operator)
|
||||||
|
type User struct {
|
||||||
|
ID uuid.UUID `bson:"_id"`
|
||||||
|
FullName string `bson:"full_name"`
|
||||||
|
Username string `bson:"username"`
|
||||||
|
Email string `bson:"email"`
|
||||||
|
Password string `bson:"password"` // Never serialize password
|
||||||
|
Role UserRole `bson:"role"`
|
||||||
|
Status UserStatus `bson:"status"`
|
||||||
|
CompanyID *uuid.UUID `bson:"company_id,omitempty"`
|
||||||
|
Department *string `bson:"department,omitempty"`
|
||||||
|
Position *string `bson:"position,omitempty"`
|
||||||
|
Phone *string `bson:"phone,omitempty"`
|
||||||
|
ProfileImage *string `bson:"profile_image,omitempty"`
|
||||||
|
IsVerified bool `bson:"is_verified"`
|
||||||
|
LastLoginAt *int64 `bson:"last_login_at,omitempty"` // Unix timestamp
|
||||||
|
CreatedAt int64 `bson:"created_at"` // Unix timestamp
|
||||||
|
UpdatedAt int64 `bson:"updated_at"` // Unix timestamp
|
||||||
|
CreatedBy *uuid.UUID `bson:"created_by,omitempty"`
|
||||||
|
UpdatedBy *uuid.UUID `bson:"updated_by,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
// User Registration DTOs
|
||||||
|
type CreateUserForm struct {
|
||||||
|
FullName string `json:"full_name" valid:"required,length(2|100)"`
|
||||||
|
Username string `json:"username" valid:"required,alphanum,length(3|30)"`
|
||||||
|
Email string `json:"email" valid:"required,email"`
|
||||||
|
Password string `json:"password" valid:"required,length(8|128)"`
|
||||||
|
Role string `json:"role" valid:"required,in(admin|manager|operator|viewer)"`
|
||||||
|
CompanyID *string `json:"company_id,omitempty" valid:"optional,uuid"`
|
||||||
|
Department *string `json:"department,omitempty" valid:"optional,length(2|100)"`
|
||||||
|
Position *string `json:"position,omitempty" valid:"optional,length(2|100)"`
|
||||||
|
Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)"`
|
||||||
|
ProfileImage *string `json:"profile_image,omitempty" valid:"optional,url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserForm struct {
|
||||||
|
FullName *string `json:"full_name,omitempty" valid:"optional,length(2|100)"`
|
||||||
|
Username *string `json:"username,omitempty" valid:"optional,alphanum,length(3|30)"`
|
||||||
|
Email *string `json:"email,omitempty" valid:"optional,email"`
|
||||||
|
Role *string `json:"role,omitempty" valid:"optional,in(admin|manager|operator|viewer)"`
|
||||||
|
CompanyID *string `json:"company_id,omitempty" valid:"optional,uuid"`
|
||||||
|
Department *string `json:"department,omitempty" valid:"optional,length(2|100)"`
|
||||||
|
Position *string `json:"position,omitempty" valid:"optional,length(2|100)"`
|
||||||
|
Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)"`
|
||||||
|
ProfileImage *string `json:"profile_image,omitempty" valid:"optional,url"`
|
||||||
|
Status *string `json:"status,omitempty" valid:"optional,in(active|inactive|suspended)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Authentication DTOs
|
||||||
|
type LoginForm struct {
|
||||||
|
Username string `json:"username" valid:"required"`
|
||||||
|
Password string `json:"password" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshTokenForm struct {
|
||||||
|
RefreshToken string `json:"refresh_token" valid:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangePasswordForm struct {
|
||||||
|
OldPassword string `json:"old_password" valid:"required"`
|
||||||
|
NewPassword string `json:"new_password" valid:"required,length(8|128)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateProfileForm struct {
|
||||||
|
FullName *string `json:"full_name,omitempty" valid:"optional,length(2|100)"`
|
||||||
|
Department *string `json:"department,omitempty" valid:"optional,length(2|100)"`
|
||||||
|
Position *string `json:"position,omitempty" valid:"optional,length(2|100)"`
|
||||||
|
Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)"`
|
||||||
|
ProfileImage *string `json:"profile_image,omitempty" valid:"optional,url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetPasswordForm struct {
|
||||||
|
Email string `json:"email" valid:"required,email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin DTOs
|
||||||
|
type ListUsersForm struct {
|
||||||
|
Search *string `query:"search" valid:"optional"`
|
||||||
|
Status *string `query:"status" valid:"optional,in(active|inactive|suspended)"`
|
||||||
|
Role *string `query:"role" valid:"optional,in(admin|manager|operator|viewer)"`
|
||||||
|
CompanyID *string `query:"company_id" valid:"optional,uuid"`
|
||||||
|
Limit *int `query:"limit" valid:"optional,range(1|100)"`
|
||||||
|
Offset *int `query:"offset" valid:"optional,min(0)"`
|
||||||
|
SortBy *string `query:"sort_by" valid:"optional,in(full_name|email|username|role|created_at|last_login_at)"`
|
||||||
|
SortOrder *string `query:"sort_order" valid:"optional,in(asc|desc)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserStatusForm struct {
|
||||||
|
Status string `json:"status" valid:"required,in(active|inactive|suspended)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserRoleForm struct {
|
||||||
|
Role string `json:"role" valid:"required,in(admin|manager|operator|viewer)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response DTOs
|
||||||
|
type UserResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CompanyID *string `json:"company_id,omitempty"`
|
||||||
|
Department *string `json:"department,omitempty"`
|
||||||
|
Position *string `json:"position,omitempty"`
|
||||||
|
Phone *string `json:"phone,omitempty"`
|
||||||
|
ProfileImage *string `json:"profile_image,omitempty"`
|
||||||
|
IsVerified bool `json:"is_verified"`
|
||||||
|
LastLoginAt *int64 `json:"last_login_at,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
CreatedBy *string `json:"created_by,omitempty"`
|
||||||
|
UpdatedBy *string `json:"updated_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthResponse struct {
|
||||||
|
User *UserResponse `json:"user"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserListResponse struct {
|
||||||
|
Users []*UserResponse `json:"users"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to convert User to UserResponse
|
||||||
|
func (u *User) ToResponse() *UserResponse {
|
||||||
|
companyID := ""
|
||||||
|
if u.CompanyID != nil {
|
||||||
|
companyID = u.CompanyID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
createdBy := ""
|
||||||
|
if u.CreatedBy != nil {
|
||||||
|
createdBy = u.CreatedBy.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedBy := ""
|
||||||
|
if u.UpdatedBy != nil {
|
||||||
|
updatedBy = u.UpdatedBy.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserResponse{
|
||||||
|
ID: u.ID.String(),
|
||||||
|
FullName: u.FullName,
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
Role: string(u.Role),
|
||||||
|
Status: string(u.Status),
|
||||||
|
CompanyID: &companyID,
|
||||||
|
Department: u.Department,
|
||||||
|
Position: u.Position,
|
||||||
|
Phone: u.Phone,
|
||||||
|
ProfileImage: u.ProfileImage,
|
||||||
|
IsVerified: u.IsVerified,
|
||||||
|
LastLoginAt: u.LastLoginAt,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
UpdatedAt: u.UpdatedAt,
|
||||||
|
CreatedBy: &createdBy,
|
||||||
|
UpdatedBy: &updatedBy,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"tm/pkg/authorization"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
"tm/pkg/response"
|
||||||
|
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler handles HTTP requests for user operations
|
||||||
|
type Handler struct {
|
||||||
|
service Service
|
||||||
|
logger logger.Logger
|
||||||
|
validator ValidationService
|
||||||
|
authService authorization.AuthorizationService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserHandler creates a new user handler
|
||||||
|
func NewUserHandler(service Service, logger logger.Logger, validator ValidationService, authService authorization.AuthorizationService) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
service: service,
|
||||||
|
logger: logger,
|
||||||
|
validator: validator,
|
||||||
|
authService: authService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers user routes
|
||||||
|
func (h *Handler) RegisterRoutes(e *echo.Echo) {
|
||||||
|
v1 := e.Group("/api/v1")
|
||||||
|
|
||||||
|
userGroup := v1.Group("/users")
|
||||||
|
{
|
||||||
|
// Public routes
|
||||||
|
userGroup.POST("/login", h.Login)
|
||||||
|
userGroup.POST("/refresh-token", h.RefreshToken)
|
||||||
|
userGroup.POST("/reset-password", h.ResetPassword)
|
||||||
|
|
||||||
|
// Protected routes (require authentication)
|
||||||
|
authGroup := userGroup.Group("")
|
||||||
|
// authGroup.Use(h.AuthMiddleware())
|
||||||
|
{
|
||||||
|
authGroup.GET("/profile", h.GetProfile)
|
||||||
|
authGroup.PUT("/profile", h.UpdateProfile)
|
||||||
|
authGroup.PUT("/change-password", h.ChangePassword)
|
||||||
|
authGroup.POST("/logout", h.Logout)
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
adminGroup := authGroup.Group("/admin")
|
||||||
|
// adminGroup.Use(h.AdminMiddleware())
|
||||||
|
{
|
||||||
|
adminGroup.POST("", h.CreateUser)
|
||||||
|
adminGroup.GET("", h.ListUsers)
|
||||||
|
adminGroup.GET("/:id", h.GetUserByID)
|
||||||
|
adminGroup.PUT("/:id", h.UpdateUser)
|
||||||
|
adminGroup.DELETE("/:id", h.DeleteUser)
|
||||||
|
adminGroup.PUT("/:id/status", h.UpdateUserStatus)
|
||||||
|
adminGroup.PUT("/:id/role", h.UpdateUserRole)
|
||||||
|
adminGroup.GET("/company/:company_id", h.GetUsersByCompanyID)
|
||||||
|
adminGroup.GET("/role/:role", h.GetUsersByRole)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login handles user login
|
||||||
|
// @Summary Login user
|
||||||
|
// @Description Authenticate user with email/username and password
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param login body LoginForm true "Login credentials"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=AuthResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/login [post]
|
||||||
|
func (h *Handler) Login(c echo.Context) error {
|
||||||
|
form, err := response.Parse[LoginForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
authResponse, err := h.service.Login(c.Request().Context(), form)
|
||||||
|
if err != nil {
|
||||||
|
return response.Unauthorized(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, authResponse, "Login successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken handles token refresh
|
||||||
|
// @Summary Refresh access token
|
||||||
|
// @Description Refresh access token using refresh token
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param refresh body RefreshTokenForm true "Refresh token"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=AuthResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/refresh-token [post]
|
||||||
|
func (h *Handler) RefreshToken(c echo.Context) error {
|
||||||
|
form, err := response.Parse[RefreshTokenForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
authResponse, err := h.service.RefreshToken(c.Request().Context(), form.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return response.Unauthorized(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, authResponse, "Token refreshed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword handles password reset request
|
||||||
|
// @Summary Reset password
|
||||||
|
// @Description Send password reset email to user
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param reset body ResetPasswordForm true "Password reset request"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/reset-password [post]
|
||||||
|
func (h *Handler) ResetPassword(c echo.Context) error {
|
||||||
|
form, err := response.Parse[ResetPasswordForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.service.ResetPassword(c.Request().Context(), form)
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to process password reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, map[string]interface{}{
|
||||||
|
"message": "Password reset email sent successfully",
|
||||||
|
}, "Password reset initiated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfile gets current user profile
|
||||||
|
// @Summary Get user profile
|
||||||
|
// @Description Get current user profile information
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} response.APIResponse{data=UserResponse}
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/profile [get]
|
||||||
|
func (h *Handler) GetProfile(c echo.Context) error {
|
||||||
|
// Extract user ID from JWT token context
|
||||||
|
userID, err := GetUserIDFromContext(c)
|
||||||
|
if err != nil {
|
||||||
|
return response.Unauthorized(c, "User not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.service.GetProfile(c.Request().Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return response.NotFound(c, "User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, user.ToResponse(), "Profile retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile updates current user profile
|
||||||
|
// @Summary Update user profile
|
||||||
|
// @Description Update current user profile information
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param profile body UpdateUserForm true "Profile update data"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=UserResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/profile [put]
|
||||||
|
func (h *Handler) UpdateProfile(c echo.Context) error {
|
||||||
|
// TODO: Extract user ID from JWT token
|
||||||
|
userID := uuid.New() // Placeholder - should be extracted from JWT
|
||||||
|
|
||||||
|
form, err := response.Parse[UpdateUserForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.service.UpdateUser(c.Request().Context(), userID, form, &userID)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, user.ToResponse(), "Profile updated successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword changes current user password
|
||||||
|
// @Summary Change password
|
||||||
|
// @Description Change current user password
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param password body ChangePasswordForm true "Password change data"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/change-password [put]
|
||||||
|
func (h *Handler) ChangePassword(c echo.Context) error {
|
||||||
|
// TODO: Extract user ID from JWT token
|
||||||
|
userID := uuid.New() // Placeholder - should be extracted from JWT
|
||||||
|
|
||||||
|
form, err := response.Parse[ChangePasswordForm](c)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.service.ChangePassword(c.Request().Context(), userID, form)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, map[string]interface{}{
|
||||||
|
"message": "Password changed successfully",
|
||||||
|
}, "Password changed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout handles user logout
|
||||||
|
// @Summary Logout user
|
||||||
|
// @Description Logout current user and invalidate tokens
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/logout [post]
|
||||||
|
func (h *Handler) Logout(c echo.Context) error {
|
||||||
|
// Extract user ID and access token from context
|
||||||
|
userID, err := GetUserIDFromContext(c)
|
||||||
|
if err != nil {
|
||||||
|
return response.Unauthorized(c, "User not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := GetAccessTokenFromContext(c)
|
||||||
|
if err != nil {
|
||||||
|
return response.Unauthorized(c, "Access token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.service.Logout(c.Request().Context(), userID, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to logout")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, map[string]interface{}{
|
||||||
|
"message": "Logged out successfully",
|
||||||
|
}, "Logged out successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates a new user (admin only)
|
||||||
|
// @Summary Create user
|
||||||
|
// @Description Create a new user (admin only)
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param user body CreateUserForm true "User creation data"
|
||||||
|
// @Success 201 {object} response.APIResponse{data=UserResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/admin [post]
|
||||||
|
func (h *Handler) CreateUser(c echo.Context) error {
|
||||||
|
// Get current user ID from JWT token
|
||||||
|
currentUserID, err := GetUserIDFromContext(c)
|
||||||
|
if err != nil {
|
||||||
|
return response.Unauthorized(c, "User not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
var form CreateUserForm
|
||||||
|
if err := c.Bind(&form); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
if _, err := govalidator.ValidateStruct(form); err != nil {
|
||||||
|
return response.ValidationError(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
user, err := h.service.CreateUser(c.Request().Context(), &form, ¤tUserID)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Created(c, user.ToResponse(), "User created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers lists users with search and filters (admin only)
|
||||||
|
// @Summary List users
|
||||||
|
// @Description List users with search and filters (admin only)
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param search query string false "Search term"
|
||||||
|
// @Param status query string false "User status filter"
|
||||||
|
// @Param role query string false "User role filter"
|
||||||
|
// @Param company_id query string false "Company ID filter"
|
||||||
|
// @Param limit query int false "Limit results"
|
||||||
|
// @Param offset query int false "Offset results"
|
||||||
|
// @Param sort_by query string false "Sort field"
|
||||||
|
// @Param sort_order query string false "Sort order"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=UserListResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/admin [get]
|
||||||
|
func (h *Handler) ListUsers(c echo.Context) error {
|
||||||
|
var form ListUsersForm
|
||||||
|
if err := c.Bind(&form); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
if _, err := govalidator.ValidateStruct(form); err != nil {
|
||||||
|
return response.ValidationError(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
users, err := h.service.ListUsers(c.Request().Context(), &form)
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to list users")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, users, "Users retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID gets a user by ID (admin only)
|
||||||
|
// @Summary Get user by ID
|
||||||
|
// @Description Get user details by ID (admin only)
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=UserResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/admin/{id} [get]
|
||||||
|
func (h *Handler) GetUserByID(c echo.Context) error {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
userID, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid user ID", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.service.GetUserByID(c.Request().Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return response.NotFound(c, "User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, user.ToResponse(), "User retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates a user (admin only)
|
||||||
|
// @Summary Update user
|
||||||
|
// @Description Update user information (admin only)
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param user body UpdateUserForm true "User update data"
|
||||||
|
// @Success 200 {object} response.APIResponse{data=UserResponse}
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/admin/{id} [put]
|
||||||
|
func (h *Handler) UpdateUser(c echo.Context) error {
|
||||||
|
// TODO: Get current user ID from JWT token
|
||||||
|
currentUserID := uuid.New() // Placeholder
|
||||||
|
|
||||||
|
idStr := c.Param("id")
|
||||||
|
userID, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid user ID", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var form UpdateUserForm
|
||||||
|
if err := c.Bind(&form); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
if _, err := govalidator.ValidateStruct(form); err != nil {
|
||||||
|
return response.ValidationError(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
user, err := h.service.UpdateUser(c.Request().Context(), userID, &form, ¤tUserID)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, user.ToResponse(), "User updated successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser deletes a user (admin only)
|
||||||
|
// @Summary Delete user
|
||||||
|
// @Description Delete a user (admin only)
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/admin/{id} [delete]
|
||||||
|
func (h *Handler) DeleteUser(c echo.Context) error {
|
||||||
|
// TODO: Get current user ID from JWT token
|
||||||
|
currentUserID := uuid.New() // Placeholder
|
||||||
|
|
||||||
|
idStr := c.Param("id")
|
||||||
|
userID, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid user ID", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.service.DeleteUser(c.Request().Context(), userID, ¤tUserID)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, map[string]interface{}{
|
||||||
|
"message": "User deleted successfully",
|
||||||
|
}, "User deleted successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserStatus updates user status (admin only)
|
||||||
|
// @Summary Update user status
|
||||||
|
// @Description Update user account status (admin only)
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param status body UpdateUserStatusForm true "Status update data"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/admin/{id}/status [put]
|
||||||
|
func (h *Handler) UpdateUserStatus(c echo.Context) error {
|
||||||
|
// TODO: Get current user ID from JWT token
|
||||||
|
currentUserID := uuid.New() // Placeholder
|
||||||
|
|
||||||
|
idStr := c.Param("id")
|
||||||
|
userID, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid user ID", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var form UpdateUserStatusForm
|
||||||
|
if err := c.Bind(&form); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
if _, err := govalidator.ValidateStruct(form); err != nil {
|
||||||
|
return response.ValidationError(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
err = h.service.UpdateUserStatus(c.Request().Context(), userID, &form, ¤tUserID)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, map[string]interface{}{
|
||||||
|
"message": "User status updated successfully",
|
||||||
|
}, "User status updated successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserRole updates user role (admin only)
|
||||||
|
// @Summary Update user role
|
||||||
|
// @Description Update user role (admin only)
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param role body UpdateUserRoleForm true "Role update data"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 404 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/admin/{id}/role [put]
|
||||||
|
func (h *Handler) UpdateUserRole(c echo.Context) error {
|
||||||
|
// TODO: Get current user ID from JWT token
|
||||||
|
currentUserID := uuid.New() // Placeholder
|
||||||
|
|
||||||
|
idStr := c.Param("id")
|
||||||
|
userID, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid user ID", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var form UpdateUserRoleForm
|
||||||
|
if err := c.Bind(&form); err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid request format", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
if _, err := govalidator.ValidateStruct(form); err != nil {
|
||||||
|
return response.ValidationError(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
err = h.service.UpdateUserRole(c.Request().Context(), userID, &form, ¤tUserID)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, err.Error(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, map[string]interface{}{
|
||||||
|
"message": "User role updated successfully",
|
||||||
|
}, "User role updated successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsersByCompanyID gets users by company ID (admin only)
|
||||||
|
// @Summary Get users by company ID
|
||||||
|
// @Description Get users belonging to a specific company (admin only)
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param company_id path string true "Company ID"
|
||||||
|
// @Param limit query int false "Limit results"
|
||||||
|
// @Param offset query int false "Offset results"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/admin/company/{company_id} [get]
|
||||||
|
func (h *Handler) GetUsersByCompanyID(c echo.Context) error {
|
||||||
|
companyIDStr := c.Param("company_id")
|
||||||
|
companyID, err := uuid.Parse(companyIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid company ID", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get query parameters
|
||||||
|
limitStr := c.QueryParam("limit")
|
||||||
|
if limitStr == "" {
|
||||||
|
limitStr = "20"
|
||||||
|
}
|
||||||
|
offsetStr := c.QueryParam("offset")
|
||||||
|
if offsetStr == "" {
|
||||||
|
offsetStr = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := strconv.Atoi(limitStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid limit parameter", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
offset, err := strconv.Atoi(offsetStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid offset parameter", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
users, total, err := h.service.GetUsersByCompanyID(c.Request().Context(), companyID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to get users by company")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to responses
|
||||||
|
var userResponses []*UserResponse
|
||||||
|
for _, user := range users {
|
||||||
|
userResponses = append(userResponses, user.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithMeta(c, map[string]interface{}{
|
||||||
|
"users": userResponses,
|
||||||
|
}, &response.Meta{
|
||||||
|
Total: int(total),
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}, "Users retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsersByRole gets users by role (admin only)
|
||||||
|
// @Summary Get users by role
|
||||||
|
// @Description Get users with a specific role (admin only)
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param role path string true "User role"
|
||||||
|
// @Param limit query int false "Limit results"
|
||||||
|
// @Param offset query int false "Offset results"
|
||||||
|
// @Success 200 {object} response.APIResponse
|
||||||
|
// @Failure 400 {object} response.APIResponse
|
||||||
|
// @Failure 401 {object} response.APIResponse
|
||||||
|
// @Failure 403 {object} response.APIResponse
|
||||||
|
// @Failure 500 {object} response.APIResponse
|
||||||
|
// @Router /users/admin/role/{role} [get]
|
||||||
|
func (h *Handler) GetUsersByRole(c echo.Context) error {
|
||||||
|
roleStr := c.Param("role")
|
||||||
|
role := UserRole(roleStr)
|
||||||
|
|
||||||
|
// Validate role
|
||||||
|
if !h.validator.IsValidRole(role) {
|
||||||
|
return response.BadRequest(c, "Invalid role", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get query parameters
|
||||||
|
limitStr := c.QueryParam("limit")
|
||||||
|
if limitStr == "" {
|
||||||
|
limitStr = "20"
|
||||||
|
}
|
||||||
|
offsetStr := c.QueryParam("offset")
|
||||||
|
if offsetStr == "" {
|
||||||
|
offsetStr = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := strconv.Atoi(limitStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid limit parameter", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
offset, err := strconv.Atoi(offsetStr)
|
||||||
|
if err != nil {
|
||||||
|
return response.BadRequest(c, "Invalid offset parameter", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call service
|
||||||
|
users, err := h.service.GetUsersByRole(c.Request().Context(), role, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return response.InternalServerError(c, "Failed to get users by role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to responses
|
||||||
|
var userResponses []*UserResponse
|
||||||
|
for _, user := range users {
|
||||||
|
userResponses = append(userResponses, user.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithMeta(c, map[string]interface{}{
|
||||||
|
"users": userResponses,
|
||||||
|
"role": role,
|
||||||
|
}, &response.Meta{
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}, "Users retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware and AdminMiddleware are now implemented in middleware.go
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"tm/pkg/response"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware validates JWT access tokens and extracts user information
|
||||||
|
func (h *Handler) AuthMiddleware() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// Extract token from Authorization header
|
||||||
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return response.Unauthorized(c, "Authorization header required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a Bearer token
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
return response.Unauthorized(c, "Invalid authorization format. Use 'Bearer <token>'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the token
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
// Validate the access token
|
||||||
|
validationResult, err := h.authService.ValidateAccessToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Token validation error", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return response.Unauthorized(c, "Invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validationResult.Valid {
|
||||||
|
if validationResult.Expired {
|
||||||
|
return response.Unauthorized(c, "Token expired")
|
||||||
|
}
|
||||||
|
return response.Unauthorized(c, validationResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user information from token
|
||||||
|
userID, err := uuid.Parse(validationResult.Claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to parse user ID from token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return response.Unauthorized(c, "Invalid token format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user information in context for handlers to use
|
||||||
|
c.Set("user_id", userID)
|
||||||
|
c.Set("user_email", validationResult.Claims.Email)
|
||||||
|
c.Set("user_role", validationResult.Claims.Role)
|
||||||
|
c.Set("company_id", validationResult.Claims.CompanyID)
|
||||||
|
c.Set("access_token", tokenString)
|
||||||
|
|
||||||
|
// Continue to next handler
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMiddleware checks if the authenticated user has admin role
|
||||||
|
func (h *Handler) AdminMiddleware() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// Get user role from context (set by AuthMiddleware)
|
||||||
|
userRole, ok := c.Get("user_role").(string)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "User role not found in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has admin role
|
||||||
|
if userRole != string(UserRoleAdmin) {
|
||||||
|
return response.Forbidden(c, "Admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to next handler
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleMiddleware checks if the authenticated user has the required role
|
||||||
|
func (h *Handler) RoleMiddleware(requiredRole UserRole) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// Get user role from context (set by AuthMiddleware)
|
||||||
|
userRole, ok := c.Get("user_role").(string)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "User role not found in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has the required role
|
||||||
|
if userRole != string(requiredRole) {
|
||||||
|
return response.Forbidden(c, "Insufficient permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to next handler
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompanyAccessMiddleware checks if the authenticated user has access to the specified company
|
||||||
|
func (h *Handler) CompanyAccessMiddleware() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
// Get user information from context
|
||||||
|
userRole, ok := c.Get("user_role").(string)
|
||||||
|
if !ok {
|
||||||
|
return response.Unauthorized(c, "User role not found in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin users have access to all companies
|
||||||
|
if userRole == string(UserRoleAdmin) {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get company ID from context
|
||||||
|
userCompanyID, ok := c.Get("company_id").(string)
|
||||||
|
if !ok || userCompanyID == "" {
|
||||||
|
return response.Forbidden(c, "Company access not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get company ID from URL parameter
|
||||||
|
requestedCompanyID := c.Param("company_id")
|
||||||
|
if requestedCompanyID == "" {
|
||||||
|
// If no company ID in URL, allow access to user's own company
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to the requested company
|
||||||
|
if userCompanyID != requestedCompanyID {
|
||||||
|
return response.Forbidden(c, "Access to this company not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to next handler
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserIDFromContext extracts user ID from Echo context
|
||||||
|
func GetUserIDFromContext(c echo.Context) (uuid.UUID, error) {
|
||||||
|
userID, ok := c.Get("user_id").(uuid.UUID)
|
||||||
|
if !ok {
|
||||||
|
return uuid.Nil, echo.NewHTTPError(http.StatusUnauthorized, "User ID not found in context")
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserEmailFromContext extracts user email from Echo context
|
||||||
|
func GetUserEmailFromContext(c echo.Context) (string, error) {
|
||||||
|
userEmail, ok := c.Get("user_email").(string)
|
||||||
|
if !ok {
|
||||||
|
return "", echo.NewHTTPError(http.StatusUnauthorized, "User email not found in context")
|
||||||
|
}
|
||||||
|
return userEmail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserRoleFromContext extracts user role from Echo context
|
||||||
|
func GetUserRoleFromContext(c echo.Context) (string, error) {
|
||||||
|
userRole, ok := c.Get("user_role").(string)
|
||||||
|
if !ok {
|
||||||
|
return "", echo.NewHTTPError(http.StatusUnauthorized, "User role not found in context")
|
||||||
|
}
|
||||||
|
return userRole, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompanyIDFromContext extracts company ID from Echo context
|
||||||
|
func GetCompanyIDFromContext(c echo.Context) (string, error) {
|
||||||
|
companyID, ok := c.Get("company_id").(string)
|
||||||
|
if !ok {
|
||||||
|
return "", echo.NewHTTPError(http.StatusUnauthorized, "Company ID not found in context")
|
||||||
|
}
|
||||||
|
return companyID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccessTokenFromContext extracts access token from Echo context
|
||||||
|
func GetAccessTokenFromContext(c echo.Context) (string, error) {
|
||||||
|
accessToken, ok := c.Get("access_token").(string)
|
||||||
|
if !ok {
|
||||||
|
return "", echo.NewHTTPError(http.StatusUnauthorized, "Access token not found in context")
|
||||||
|
}
|
||||||
|
return accessToken, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
mongopkg "tm/pkg/mongo"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository defines methods for user data access
|
||||||
|
type Repository interface {
|
||||||
|
Create(ctx context.Context, user *User) error
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*User, error)
|
||||||
|
GetByEmail(ctx context.Context, email string) (*User, error)
|
||||||
|
GetByUsername(ctx context.Context, username string) (*User, error)
|
||||||
|
Update(ctx context.Context, user *User) error
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
List(ctx context.Context, limit, offset int) ([]*User, error)
|
||||||
|
Search(ctx context.Context, search string, status *string, role *string, companyID *uuid.UUID, limit, offset int, sortBy, sortOrder string) ([]*User, error)
|
||||||
|
GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*User, error)
|
||||||
|
GetByRole(ctx context.Context, role UserRole, limit, offset int) ([]*User, error)
|
||||||
|
UpdateLastLogin(ctx context.Context, id uuid.UUID) error
|
||||||
|
CountByCompanyID(ctx context.Context, companyID uuid.UUID) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userRepository implements the Repository interface
|
||||||
|
type userRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserRepository creates a new user repository
|
||||||
|
func NewUserRepository(mongoManager *mongopkg.ConnectionManager, logger logger.Logger) Repository {
|
||||||
|
collection := mongoManager.GetCollection("users")
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Email index (unique)
|
||||||
|
emailIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{Key: "email", Value: 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username index (unique)
|
||||||
|
usernameIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{Key: "username", Value: 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company ID index
|
||||||
|
companyIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{Key: "company_id", Value: 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role index
|
||||||
|
roleIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{Key: "role", Value: 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status index
|
||||||
|
statusIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{Key: "status", Value: 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created at index
|
||||||
|
createdAtIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{{Key: "created_at", Value: -1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full text search index
|
||||||
|
textIndex := mongo.IndexModel{
|
||||||
|
Keys: bson.D{
|
||||||
|
{Key: "full_name", Value: "text"},
|
||||||
|
{Key: "email", Value: "text"},
|
||||||
|
{Key: "username", Value: "text"},
|
||||||
|
{Key: "department", Value: "text"},
|
||||||
|
{Key: "position", Value: "text"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||||
|
emailIndex,
|
||||||
|
usernameIndex,
|
||||||
|
companyIndex,
|
||||||
|
roleIndex,
|
||||||
|
statusIndex,
|
||||||
|
createdAtIndex,
|
||||||
|
textIndex,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to create user indexes", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &userRepository{
|
||||||
|
collection: collection,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new user
|
||||||
|
func (r *userRepository) Create(ctx context.Context, user *User) error {
|
||||||
|
// Set created/updated timestamps using Unix timestamps
|
||||||
|
now := time.Now().Unix()
|
||||||
|
user.CreatedAt = now
|
||||||
|
user.UpdatedAt = now
|
||||||
|
|
||||||
|
// Insert user
|
||||||
|
_, err := r.collection.InsertOne(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
if mongo.IsDuplicateKeyError(err) {
|
||||||
|
return errors.New("user already exists")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to create user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": user.Email,
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("User created successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves a user by ID
|
||||||
|
func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||||
|
var user User
|
||||||
|
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
err := r.collection.FindOne(ctx, filter).Decode(&user)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get user by ID", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": id.String(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByEmail retrieves a user by email
|
||||||
|
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||||
|
var user User
|
||||||
|
|
||||||
|
filter := bson.M{"email": email}
|
||||||
|
err := r.collection.FindOne(ctx, filter).Decode(&user)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get user by email", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": email,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUsername retrieves a user by username
|
||||||
|
func (r *userRepository) GetByUsername(ctx context.Context, username string) (*User, error) {
|
||||||
|
var user User
|
||||||
|
|
||||||
|
filter := bson.M{"username": username}
|
||||||
|
err := r.collection.FindOne(ctx, filter).Decode(&user)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get user by username", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"username": username,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a user
|
||||||
|
func (r *userRepository) Update(ctx context.Context, user *User) error {
|
||||||
|
// Set updated timestamp using Unix timestamp
|
||||||
|
user.UpdatedAt = time.Now().Unix()
|
||||||
|
|
||||||
|
filter := bson.M{"_id": user.ID}
|
||||||
|
update := bson.M{"$set": user}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to update user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("User updated successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a user (soft delete by setting status to inactive)
|
||||||
|
func (r *userRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
update := bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"status": UserStatusInactive,
|
||||||
|
"updated_at": time.Now().Unix(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to delete user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": id.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("User deleted successfully", map[string]interface{}{
|
||||||
|
"user_id": id.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List retrieves users with pagination
|
||||||
|
func (r *userRepository) List(ctx context.Context, limit, offset int) ([]*User, error) {
|
||||||
|
var users []*User
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
opts := options.Find()
|
||||||
|
opts.SetLimit(int64(limit))
|
||||||
|
opts.SetSkip(int64(offset))
|
||||||
|
opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc
|
||||||
|
|
||||||
|
// Only active users by default
|
||||||
|
filter := bson.M{"status": bson.M{"$ne": UserStatusInactive}}
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to list users", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &users); err != nil {
|
||||||
|
r.logger.Error("Failed to decode users", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search retrieves users with search and filters
|
||||||
|
func (r *userRepository) Search(ctx context.Context, search string, status *string, role *string, companyID *uuid.UUID, limit, offset int, sortBy, sortOrder string) ([]*User, error) {
|
||||||
|
var users []*User
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
filter := bson.M{}
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
filter["$text"] = bson.M{"$search": search}
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != nil {
|
||||||
|
filter["status"] = *status
|
||||||
|
}
|
||||||
|
|
||||||
|
if role != nil {
|
||||||
|
filter["role"] = *role
|
||||||
|
}
|
||||||
|
|
||||||
|
if companyID != nil {
|
||||||
|
filter["company_id"] = *companyID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
opts := options.Find()
|
||||||
|
opts.SetLimit(int64(limit))
|
||||||
|
opts.SetSkip(int64(offset))
|
||||||
|
|
||||||
|
// Set sort
|
||||||
|
if sortBy != "" {
|
||||||
|
sortValue := 1
|
||||||
|
if sortOrder == "desc" {
|
||||||
|
sortValue = -1
|
||||||
|
}
|
||||||
|
opts.SetSort(bson.D{{Key: sortBy, Value: sortValue}})
|
||||||
|
} else {
|
||||||
|
opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Default sort
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to search users", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"search": search,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &users); err != nil {
|
||||||
|
r.logger.Error("Failed to decode users from search", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByCompanyID retrieves users by company ID with pagination
|
||||||
|
func (r *userRepository) GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*User, error) {
|
||||||
|
var users []*User
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
opts := options.Find()
|
||||||
|
opts.SetLimit(int64(limit))
|
||||||
|
opts.SetSkip(int64(offset))
|
||||||
|
opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc
|
||||||
|
|
||||||
|
// Filter by company ID and active status
|
||||||
|
filter := bson.M{
|
||||||
|
"company_id": companyID,
|
||||||
|
"status": bson.M{"$ne": UserStatusInactive},
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get users by company ID", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": companyID.String(),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &users); err != nil {
|
||||||
|
r.logger.Error("Failed to decode users by company", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": companyID.String(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByRole retrieves users by role with pagination
|
||||||
|
func (r *userRepository) GetByRole(ctx context.Context, role UserRole, limit, offset int) ([]*User, error) {
|
||||||
|
var users []*User
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
opts := options.Find()
|
||||||
|
opts.SetLimit(int64(limit))
|
||||||
|
opts.SetSkip(int64(offset))
|
||||||
|
opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc
|
||||||
|
|
||||||
|
// Filter by role and active status
|
||||||
|
filter := bson.M{
|
||||||
|
"role": role,
|
||||||
|
"status": bson.M{"$ne": UserStatusInactive},
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to get users by role", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"role": role,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &users); err != nil {
|
||||||
|
r.logger.Error("Failed to decode users by role", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"role": role,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLastLogin updates the last login timestamp
|
||||||
|
func (r *userRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
|
||||||
|
filter := bson.M{"_id": id}
|
||||||
|
update := bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"last_login_at": time.Now().Unix(),
|
||||||
|
"updated_at": time.Now().Unix(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.collection.UpdateOne(ctx, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to update last login", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": id.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MatchedCount == 0 {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Last login updated successfully", map[string]interface{}{
|
||||||
|
"user_id": id.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByCompanyID counts users by company ID
|
||||||
|
func (r *userRepository) CountByCompanyID(ctx context.Context, companyID uuid.UUID) (int64, error) {
|
||||||
|
filter := bson.M{
|
||||||
|
"company_id": companyID,
|
||||||
|
"status": bson.M{"$ne": UserStatusInactive},
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := r.collection.CountDocuments(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("Failed to count users by company ID", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": companyID.String(),
|
||||||
|
})
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,721 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"tm/pkg/authorization"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service defines business logic for user operations
|
||||||
|
type Service interface {
|
||||||
|
CreateUser(ctx context.Context, form *CreateUserForm, createdBy *uuid.UUID) (*User, error)
|
||||||
|
Login(ctx context.Context, form *LoginForm) (*AuthResponse, error)
|
||||||
|
RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error)
|
||||||
|
GetProfile(ctx context.Context, userID uuid.UUID) (*User, error)
|
||||||
|
UpdateProfile(ctx context.Context, userID uuid.UUID, form *UpdateProfileForm) error
|
||||||
|
ChangePassword(ctx context.Context, userID uuid.UUID, form *ChangePasswordForm) error
|
||||||
|
ResetPassword(ctx context.Context, form *ResetPasswordForm) error
|
||||||
|
GetUserByID(ctx context.Context, id uuid.UUID) (*User, error)
|
||||||
|
UpdateUser(ctx context.Context, id uuid.UUID, form *UpdateUserForm, updatedBy *uuid.UUID) (*User, error)
|
||||||
|
DeleteUser(ctx context.Context, id uuid.UUID, deletedBy *uuid.UUID) error
|
||||||
|
ListUsers(ctx context.Context, form *ListUsersForm) (*UserListResponse, error)
|
||||||
|
UpdateUserStatus(ctx context.Context, id uuid.UUID, form *UpdateUserStatusForm, updatedBy *uuid.UUID) error
|
||||||
|
UpdateUserRole(ctx context.Context, id uuid.UUID, form *UpdateUserRoleForm, updatedBy *uuid.UUID) error
|
||||||
|
GetUsersByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*User, int64, error)
|
||||||
|
GetUsersByRole(ctx context.Context, role UserRole, limit, offset int) ([]*User, error)
|
||||||
|
Logout(ctx context.Context, userID uuid.UUID, accessToken string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// userService implements the Service interface
|
||||||
|
type userService struct {
|
||||||
|
repository Repository
|
||||||
|
logger logger.Logger
|
||||||
|
authService authorization.AuthorizationService
|
||||||
|
validator ValidationService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserService creates a new user service
|
||||||
|
func NewUserService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, validator ValidationService) Service {
|
||||||
|
return &userService{
|
||||||
|
repository: repository,
|
||||||
|
logger: logger,
|
||||||
|
authService: authService,
|
||||||
|
validator: validator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates a new user
|
||||||
|
func (s *userService) CreateUser(ctx context.Context, form *CreateUserForm, createdBy *uuid.UUID) (*User, error) {
|
||||||
|
// Check if email already exists
|
||||||
|
existingUser, _ := s.repository.GetByEmail(ctx, form.Email)
|
||||||
|
if existingUser != nil {
|
||||||
|
return nil, errors.New("email already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
existingUser, _ = s.repository.GetByUsername(ctx, form.Username)
|
||||||
|
if existingUser != nil {
|
||||||
|
return nil, errors.New("username already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to hash password", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to process password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional fields
|
||||||
|
var companyID *uuid.UUID
|
||||||
|
if form.CompanyID != nil {
|
||||||
|
parsedID, err := uuid.Parse(*form.CompanyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid company ID")
|
||||||
|
}
|
||||||
|
companyID = &parsedID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role
|
||||||
|
role := UserRole(form.Role)
|
||||||
|
if !s.validator.IsValidRole(role) {
|
||||||
|
return nil, errors.New("invalid role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
user := &User{
|
||||||
|
ID: uuid.New(),
|
||||||
|
FullName: form.FullName,
|
||||||
|
Username: form.Username,
|
||||||
|
Email: form.Email,
|
||||||
|
Password: string(hashedPassword),
|
||||||
|
Role: role,
|
||||||
|
Status: UserStatusActive,
|
||||||
|
CompanyID: companyID,
|
||||||
|
Department: form.Department,
|
||||||
|
Position: form.Position,
|
||||||
|
Phone: form.Phone,
|
||||||
|
ProfileImage: form.ProfileImage,
|
||||||
|
IsVerified: false,
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
err = s.repository.Create(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to create user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"email": form.Email,
|
||||||
|
"username": form.Username,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User created successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
"username": user.Username,
|
||||||
|
"role": user.Role,
|
||||||
|
"created_by": createdBy.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a user
|
||||||
|
func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse, error) {
|
||||||
|
// Find user by email or username
|
||||||
|
var user *User
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if strings.Contains(form.Username, "@") {
|
||||||
|
user, err = s.repository.GetByEmail(ctx, form.Username)
|
||||||
|
} else {
|
||||||
|
user, err = s.repository.GetByUsername(ctx, form.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Login attempt with invalid credentials", map[string]interface{}{
|
||||||
|
"email_or_username": form.Username,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is active
|
||||||
|
if user.Status != UserStatusActive {
|
||||||
|
return nil, errors.New("account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(form.Password))
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Login attempt with wrong password", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
err = s.repository.UpdateLastLogin(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to update last login", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT tokens using authorization service
|
||||||
|
companyID := ""
|
||||||
|
if user.CompanyID != nil {
|
||||||
|
companyID = user.CompanyID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenPair, err := s.authService.GenerateTokenPair(
|
||||||
|
user.ID.String(),
|
||||||
|
user.Email,
|
||||||
|
string(user.Role),
|
||||||
|
companyID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate JWT tokens", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to generate authentication tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User logged in successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &AuthResponse{
|
||||||
|
User: user.ToResponse(),
|
||||||
|
AccessToken: tokenPair.AccessToken,
|
||||||
|
RefreshToken: tokenPair.RefreshToken,
|
||||||
|
ExpiresAt: time.Now().Add(15 * time.Minute).Unix(), // 15 minutes from now
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken refreshes the access token
|
||||||
|
func (s *userService) RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error) {
|
||||||
|
// Validate refresh token and generate new access token
|
||||||
|
newAccessToken, err := s.authService.RefreshAccessToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to refresh access token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("invalid or expired refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the new access token to get user information
|
||||||
|
validationResult, err := s.authService.ValidateAccessToken(newAccessToken)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to validate new access token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to generate valid access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validationResult.Valid {
|
||||||
|
s.logger.Error("New access token validation failed", map[string]interface{}{
|
||||||
|
"error": validationResult.Error,
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to generate valid access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user information
|
||||||
|
userID, err := uuid.Parse(validationResult.Claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to parse user ID from token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("invalid token format")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.repository.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get user for token refresh", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is still active
|
||||||
|
if user.Status != UserStatusActive {
|
||||||
|
return nil, errors.New("user account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Access token refreshed successfully", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &AuthResponse{
|
||||||
|
User: user.ToResponse(),
|
||||||
|
AccessToken: newAccessToken,
|
||||||
|
RefreshToken: refreshToken, // Return the same refresh token
|
||||||
|
ExpiresAt: time.Now().Add(15 * time.Minute).Unix(), // 15 minutes from now
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword changes user password
|
||||||
|
func (s *userService) ChangePassword(ctx context.Context, userID uuid.UUID, form *ChangePasswordForm) error {
|
||||||
|
// Get user
|
||||||
|
user, err := s.repository.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old password
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(form.OldPassword))
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid old password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.NewPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to hash new password", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
return errors.New("failed to process new password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
user.Password = string(hashedPassword)
|
||||||
|
err = s.repository.Update(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to update password", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
return errors.New("failed to update password")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Password changed successfully", map[string]interface{}{
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword initiates password reset process
|
||||||
|
func (s *userService) ResetPassword(ctx context.Context, form *ResetPasswordForm) error {
|
||||||
|
// Get user by email
|
||||||
|
user, err := s.repository.GetByEmail(ctx, form.Email)
|
||||||
|
if err != nil {
|
||||||
|
// Don't reveal if user exists or not for security
|
||||||
|
s.logger.Info("Password reset requested for non-existent email", map[string]interface{}{
|
||||||
|
"email": form.Email,
|
||||||
|
})
|
||||||
|
return nil // Return success even if user doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement password reset logic (send email with reset link)
|
||||||
|
s.logger.Info("Password reset requested", map[string]interface{}{
|
||||||
|
"user_id": user.ID.String(),
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID retrieves a user by ID
|
||||||
|
func (s *userService) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||||
|
user, err := s.repository.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfile retrieves the current user's profile
|
||||||
|
func (s *userService) GetProfile(ctx context.Context, userID uuid.UUID) (*User, error) {
|
||||||
|
user, err := s.repository.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get user profile", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User profile retrieved successfully", map[string]interface{}{
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile updates the current user's profile
|
||||||
|
func (s *userService) UpdateProfile(ctx context.Context, userID uuid.UUID, form *UpdateProfileForm) error {
|
||||||
|
user, err := s.repository.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get user for profile update", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields if provided
|
||||||
|
if form.FullName != nil {
|
||||||
|
user.FullName = *form.FullName
|
||||||
|
}
|
||||||
|
if form.Department != nil {
|
||||||
|
user.Department = form.Department
|
||||||
|
}
|
||||||
|
if form.Position != nil {
|
||||||
|
user.Position = form.Position
|
||||||
|
}
|
||||||
|
if form.Phone != nil {
|
||||||
|
user.Phone = form.Phone
|
||||||
|
}
|
||||||
|
if form.ProfileImage != nil {
|
||||||
|
user.ProfileImage = form.ProfileImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timestamp
|
||||||
|
user.UpdatedAt = time.Now().Unix()
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
err = s.repository.Update(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to update user profile", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
return errors.New("failed to update profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User profile updated successfully", map[string]interface{}{
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates user information
|
||||||
|
func (s *userService) UpdateUser(ctx context.Context, id uuid.UUID, form *UpdateUserForm, updatedBy *uuid.UUID) (*User, error) {
|
||||||
|
// Get user
|
||||||
|
user, err := s.repository.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields if provided
|
||||||
|
if form.FullName != nil {
|
||||||
|
user.FullName = *form.FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Username != nil {
|
||||||
|
// Check if username already exists
|
||||||
|
existingUser, _ := s.repository.GetByUsername(ctx, *form.Username)
|
||||||
|
if existingUser != nil && existingUser.ID != id {
|
||||||
|
return nil, errors.New("username already exists")
|
||||||
|
}
|
||||||
|
user.Username = *form.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Email != nil {
|
||||||
|
// Check if email already exists
|
||||||
|
existingUser, _ := s.repository.GetByEmail(ctx, *form.Email)
|
||||||
|
if existingUser != nil && existingUser.ID != id {
|
||||||
|
return nil, errors.New("email already exists")
|
||||||
|
}
|
||||||
|
user.Email = *form.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Role != nil {
|
||||||
|
role := UserRole(*form.Role)
|
||||||
|
if !s.validator.IsValidRole(role) {
|
||||||
|
return nil, errors.New("invalid role")
|
||||||
|
}
|
||||||
|
user.Role = role
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.CompanyID != nil {
|
||||||
|
parsedID, err := uuid.Parse(*form.CompanyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid company ID")
|
||||||
|
}
|
||||||
|
user.CompanyID = &parsedID
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Department != nil {
|
||||||
|
user.Department = form.Department
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Position != nil {
|
||||||
|
user.Position = form.Position
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Phone != nil {
|
||||||
|
user.Phone = form.Phone
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.ProfileImage != nil {
|
||||||
|
user.ProfileImage = form.ProfileImage
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Status != nil {
|
||||||
|
user.Status = UserStatus(*form.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set updated by
|
||||||
|
user.UpdatedBy = updatedBy
|
||||||
|
|
||||||
|
// Update in database
|
||||||
|
err = s.repository.Update(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to update user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": id.String(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to update user")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User updated successfully", map[string]interface{}{
|
||||||
|
"user_id": id.String(),
|
||||||
|
"updated_by": updatedBy.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser deletes a user (soft delete)
|
||||||
|
func (s *userService) DeleteUser(ctx context.Context, id uuid.UUID, deletedBy *uuid.UUID) error {
|
||||||
|
// Get user to check if exists
|
||||||
|
user, err := s.repository.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set updated by before deletion
|
||||||
|
user.UpdatedBy = deletedBy
|
||||||
|
|
||||||
|
// Update user status to inactive (soft delete)
|
||||||
|
user.Status = UserStatusInactive
|
||||||
|
err = s.repository.Update(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to delete user", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": id.String(),
|
||||||
|
})
|
||||||
|
return errors.New("failed to delete user")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User deleted successfully", map[string]interface{}{
|
||||||
|
"user_id": id.String(),
|
||||||
|
"deleted_by": deletedBy.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers lists users with search and filters
|
||||||
|
func (s *userService) ListUsers(ctx context.Context, form *ListUsersForm) (*UserListResponse, error) {
|
||||||
|
// Set defaults
|
||||||
|
limit := 20
|
||||||
|
if form.Limit != nil {
|
||||||
|
limit = *form.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if form.Offset != nil {
|
||||||
|
offset = *form.Offset
|
||||||
|
}
|
||||||
|
|
||||||
|
search := ""
|
||||||
|
if form.Search != nil {
|
||||||
|
search = *form.Search
|
||||||
|
}
|
||||||
|
|
||||||
|
sortBy := "created_at"
|
||||||
|
if form.SortBy != nil {
|
||||||
|
sortBy = *form.SortBy
|
||||||
|
}
|
||||||
|
|
||||||
|
sortOrder := "desc"
|
||||||
|
if form.SortOrder != nil {
|
||||||
|
sortOrder = *form.SortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse company ID if provided
|
||||||
|
var companyID *uuid.UUID
|
||||||
|
if form.CompanyID != nil {
|
||||||
|
parsedID, err := uuid.Parse(*form.CompanyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid company ID")
|
||||||
|
}
|
||||||
|
companyID = &parsedID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get users
|
||||||
|
users, err := s.repository.Search(ctx, search, form.Status, form.Role, companyID, limit, offset, sortBy, sortOrder)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to list users", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to list users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to responses
|
||||||
|
var userResponses []*UserResponse
|
||||||
|
for _, user := range users {
|
||||||
|
userResponses = append(userResponses, user.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement proper count for pagination metadata
|
||||||
|
total := int64(len(users)) // This should be a separate count query
|
||||||
|
totalPages := (total + int64(limit) - 1) / int64(limit)
|
||||||
|
|
||||||
|
return &UserListResponse{
|
||||||
|
Users: userResponses,
|
||||||
|
Total: total,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
TotalPages: int(totalPages),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserStatus updates user status
|
||||||
|
func (s *userService) UpdateUserStatus(ctx context.Context, id uuid.UUID, form *UpdateUserStatusForm, updatedBy *uuid.UUID) error {
|
||||||
|
// Get user
|
||||||
|
user, err := s.repository.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
user.Status = UserStatus(form.Status)
|
||||||
|
user.UpdatedBy = updatedBy
|
||||||
|
|
||||||
|
// Update in database
|
||||||
|
err = s.repository.Update(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to update user status", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": id.String(),
|
||||||
|
"status": form.Status,
|
||||||
|
})
|
||||||
|
return errors.New("failed to update user status")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User status updated successfully", map[string]interface{}{
|
||||||
|
"user_id": id.String(),
|
||||||
|
"status": form.Status,
|
||||||
|
"updated_by": updatedBy.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserRole updates user role
|
||||||
|
func (s *userService) UpdateUserRole(ctx context.Context, id uuid.UUID, form *UpdateUserRoleForm, updatedBy *uuid.UUID) error {
|
||||||
|
// Get user
|
||||||
|
user, err := s.repository.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role
|
||||||
|
role := UserRole(form.Role)
|
||||||
|
if !s.validator.IsValidRole(role) {
|
||||||
|
return errors.New("invalid role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update role
|
||||||
|
user.Role = role
|
||||||
|
user.UpdatedBy = updatedBy
|
||||||
|
|
||||||
|
// Update in database
|
||||||
|
err = s.repository.Update(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to update user role", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": id.String(),
|
||||||
|
"role": form.Role,
|
||||||
|
})
|
||||||
|
return errors.New("failed to update user role")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User role updated successfully", map[string]interface{}{
|
||||||
|
"user_id": id.String(),
|
||||||
|
"role": form.Role,
|
||||||
|
"updated_by": updatedBy.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsersByCompanyID retrieves users by company ID with pagination
|
||||||
|
func (s *userService) GetUsersByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*User, int64, error) {
|
||||||
|
users, err := s.repository.GetByCompanyID(ctx, companyID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get users by company ID", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": companyID.String(),
|
||||||
|
})
|
||||||
|
return nil, 0, errors.New("failed to get users by company")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
total, err := s.repository.CountByCompanyID(ctx, companyID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to count users by company ID", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"company_id": companyID.String(),
|
||||||
|
})
|
||||||
|
return users, 0, nil // Return users even if count fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsersByRole retrieves users by role with pagination
|
||||||
|
func (s *userService) GetUsersByRole(ctx context.Context, role UserRole, limit, offset int) ([]*User, error) {
|
||||||
|
users, err := s.repository.GetByRole(ctx, role, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get users by role", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"role": role,
|
||||||
|
})
|
||||||
|
return nil, errors.New("failed to get users by role")
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout logs out a user and invalidates tokens
|
||||||
|
func (s *userService) Logout(ctx context.Context, userID uuid.UUID, accessToken string) error {
|
||||||
|
// Invalidate the access token
|
||||||
|
err := s.authService.InvalidateToken(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to invalidate access token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
// Don't return error here as the user should still be logged out
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("User logged out successfully", map[string]interface{}{
|
||||||
|
"user_id": userID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
// ValidationService defines validation methods for user operations
|
||||||
|
type ValidationService interface {
|
||||||
|
IsValidRole(role UserRole) bool
|
||||||
|
IsValidStatus(status UserStatus) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// validationService implements the ValidationService interface
|
||||||
|
type validationService struct{}
|
||||||
|
|
||||||
|
// NewValidationService creates a new validation service
|
||||||
|
func NewValidationService() ValidationService {
|
||||||
|
return &validationService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidRole checks if a user role is valid
|
||||||
|
func (v *validationService) IsValidRole(role UserRole) bool {
|
||||||
|
switch role {
|
||||||
|
case UserRoleAdmin, UserRoleManager, UserRoleOperator, UserRoleViewer:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidStatus checks if a user status is valid
|
||||||
|
func (v *validationService) IsValidStatus(status UserStatus) bool {
|
||||||
|
switch status {
|
||||||
|
case UserStatusActive, UserStatusInactive, UserStatusSuspended:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
# Authorization Package
|
||||||
|
|
||||||
|
This package provides JWT-based authorization services for the Tender Management system, following Clean Architecture principles and best practices.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **JWT Token Management**: Generate and validate access and refresh tokens
|
||||||
|
- **Middleware Support**: Ready-to-use Echo middleware for authentication and authorization
|
||||||
|
- **Role-Based Access Control**: Built-in role validation middleware
|
||||||
|
- **Company Scoping**: Company-level access control middleware
|
||||||
|
- **Token Blacklisting**: Support for token invalidation (Redis-ready)
|
||||||
|
- **Injectable Configuration**: Dependency injection for flexible configuration management
|
||||||
|
- **Configuration Providers**: Multiple ways to create configurations (environment, custom, hybrid)
|
||||||
|
- **Security Best Practices**: Secure token generation and validation
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Basic Usage
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tm/pkg/authorization"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Create logger
|
||||||
|
logger := logger.NewLogger()
|
||||||
|
|
||||||
|
// Create configuration provider (environment-based)
|
||||||
|
configProvider := authorization.NewEnvConfigProvider()
|
||||||
|
config := configProvider.CreateConfig()
|
||||||
|
|
||||||
|
// Create authorization service
|
||||||
|
authService := authorization.NewAuthorizationService(config, logger)
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
tokenPair, err := authService.GenerateTokenPair(
|
||||||
|
"user123",
|
||||||
|
"user@example.com",
|
||||||
|
"admin",
|
||||||
|
"company456",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate tokens
|
||||||
|
result, err := authService.ValidateAccessToken(tokenPair.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Valid {
|
||||||
|
fmt.Printf("User ID: %s, Role: %s\n", result.Claims.UserID, result.Claims.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Using with Echo Framework
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"tm/pkg/authorization"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
e := echo.New()
|
||||||
|
|
||||||
|
// Setup authorization
|
||||||
|
logger := logger.NewLogger()
|
||||||
|
configProvider := authorization.NewEnvConfigProvider()
|
||||||
|
config := configProvider.CreateConfig()
|
||||||
|
authService := authorization.NewAuthorizationService(config, logger)
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
e.POST("/login", loginHandler)
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
protected := e.Group("/api")
|
||||||
|
protected.Use(authorization.AuthMiddleware(authService))
|
||||||
|
{
|
||||||
|
protected.GET("/profile", getProfileHandler)
|
||||||
|
protected.PUT("/profile", updateProfileHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
admin := protected.Group("/admin")
|
||||||
|
admin.Use(authorization.AdminMiddleware(authService))
|
||||||
|
{
|
||||||
|
admin.POST("/users", createUserHandler)
|
||||||
|
admin.GET("/users", listUsersHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company-scoped routes
|
||||||
|
company := protected.Group("/company/:company_id")
|
||||||
|
company.Use(authorization.CompanyMiddleware(authService))
|
||||||
|
{
|
||||||
|
company.GET("/tenders", getCompanyTendersHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Start(":8080")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configuration Management
|
||||||
|
|
||||||
|
The authorization package uses dependency injection for configuration. You can create configurations using different providers:
|
||||||
|
|
||||||
|
#### Configuration Providers
|
||||||
|
|
||||||
|
The package provides several configuration providers that implement the `ConfigProvider` interface:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ConfigProvider interface {
|
||||||
|
CreateConfig() *AuthorizationConfig
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Environment-based Configuration
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create configuration from environment variables
|
||||||
|
configProvider := authorization.NewEnvConfigProvider()
|
||||||
|
config := configProvider.CreateConfig()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Custom Configuration
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create custom configuration
|
||||||
|
customConfig := &authorization.AuthorizationConfig{
|
||||||
|
AccessTokenSecret: "my-secret-key",
|
||||||
|
RefreshTokenSecret: "my-refresh-secret",
|
||||||
|
AccessTokenExpiry: 30 * time.Minute,
|
||||||
|
RefreshTokenExpiry: 14 * 24 * time.Hour,
|
||||||
|
Issuer: "my-app",
|
||||||
|
Audience: "my-api",
|
||||||
|
}
|
||||||
|
|
||||||
|
configProvider := authorization.NewCustomConfigProvider(customConfig)
|
||||||
|
config := configProvider.CreateConfig()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Hybrid Configuration
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Combine multiple configuration sources with priority
|
||||||
|
envProvider := authorization.NewEnvConfigProvider()
|
||||||
|
customProvider := authorization.NewCustomConfigProvider(customConfig)
|
||||||
|
hybridProvider := authorization.NewHybridConfigProvider(envProvider, customProvider)
|
||||||
|
config := hybridProvider.CreateConfig()
|
||||||
|
|
||||||
|
// The hybrid provider applies configurations in order:
|
||||||
|
// 1. Environment variables (base)
|
||||||
|
// 2. Custom overrides (priority)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### File-based Configuration (Future)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// File-based configuration (placeholder for future implementation)
|
||||||
|
fileProvider := authorization.NewFileConfigProvider("config.yaml")
|
||||||
|
config := fileProvider.CreateConfig()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
export TM_ACCESS_TOKEN_SECRET="your-super-secret-access-key-here"
|
||||||
|
export TM_REFRESH_TOKEN_SECRET="your-super-secret-refresh-key-here"
|
||||||
|
|
||||||
|
# Optional (with defaults)
|
||||||
|
export TM_ACCESS_TOKEN_EXPIRY="15m"
|
||||||
|
export TM_REFRESH_TOKEN_EXPIRY="168h" # 7 days
|
||||||
|
export TM_JWT_ISSUER="tender-management"
|
||||||
|
export TM_JWT_AUDIENCE="tender-management-api"
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### AuthorizationService Interface
|
||||||
|
|
||||||
|
```go
|
||||||
|
type AuthorizationService interface {
|
||||||
|
// Generate tokens
|
||||||
|
GenerateTokenPair(userID, email, role, companyID string) (*TokenPair, error)
|
||||||
|
GenerateAccessToken(userID, email, role, companyID string) (string, error)
|
||||||
|
GenerateRefreshToken(userID, email, role, companyID string) (string, error)
|
||||||
|
|
||||||
|
// Validate tokens
|
||||||
|
ValidateToken(tokenString string) (*TokenValidationResult, error)
|
||||||
|
ValidateAccessToken(tokenString string) (*TokenValidationResult, error)
|
||||||
|
ValidateRefreshToken(tokenString string) (*TokenValidationResult, error)
|
||||||
|
|
||||||
|
// Token management
|
||||||
|
RefreshAccessToken(refreshToken string) (string, error)
|
||||||
|
InvalidateToken(tokenString string) error
|
||||||
|
IsTokenBlacklisted(tokenString string) (bool, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Middleware Functions
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Authentication middleware
|
||||||
|
AuthMiddleware(authService AuthorizationService) echo.MiddlewareFunc
|
||||||
|
|
||||||
|
// Role-based access control
|
||||||
|
RoleMiddleware(authService AuthorizationService, requiredRoles ...string) echo.MiddlewareFunc
|
||||||
|
|
||||||
|
// Admin-only access
|
||||||
|
AdminMiddleware(authService AuthorizationService) echo.MiddlewareFunc
|
||||||
|
|
||||||
|
// Company-scoped access
|
||||||
|
CompanyMiddleware(authService AuthorizationService) echo.MiddlewareFunc
|
||||||
|
|
||||||
|
// Optional authentication
|
||||||
|
OptionalAuthMiddleware(authService AuthorizationService) echo.MiddlewareFunc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Helpers
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Extract user information from context
|
||||||
|
userID, email, role, companyID, authenticated := GetUserFromContext(c)
|
||||||
|
|
||||||
|
// Extract token claims
|
||||||
|
claims := GetTokenClaimsFromContext(c)
|
||||||
|
|
||||||
|
// Check authentication status
|
||||||
|
if IsAuthenticated(c) {
|
||||||
|
// User is authenticated
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Token Structure
|
||||||
|
|
||||||
|
### Access Token Claims
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "uuid-string",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"role": "admin",
|
||||||
|
"company_id": "company-uuid",
|
||||||
|
"token_type": "access",
|
||||||
|
"iss": "tender-management",
|
||||||
|
"sub": "uuid-string",
|
||||||
|
"aud": ["tender-management-api"],
|
||||||
|
"exp": 1640995200,
|
||||||
|
"nbf": 1640994300,
|
||||||
|
"iat": 1640994300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refresh Token Claims
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "uuid-string",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"role": "admin",
|
||||||
|
"company_id": "company-uuid",
|
||||||
|
"token_type": "refresh",
|
||||||
|
"iss": "tender-management",
|
||||||
|
"sub": "uuid-string",
|
||||||
|
"aud": ["tender-management-api"],
|
||||||
|
"exp": 1641600000,
|
||||||
|
"nbf": 1640994300,
|
||||||
|
"iat": 1640994300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
### Token Security
|
||||||
|
|
||||||
|
- **Separate Secrets**: Access and refresh tokens use different signing secrets
|
||||||
|
- **Short Expiry**: Access tokens expire quickly (default: 15 minutes)
|
||||||
|
- **Long Expiry**: Refresh tokens last longer (default: 7 days)
|
||||||
|
- **Type Validation**: Tokens are validated against their intended type
|
||||||
|
- **Blacklist Support**: Tokens can be invalidated and blacklisted
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
- **Environment Variables**: Never hardcode secrets in code
|
||||||
|
- **Strong Secrets**: Use cryptographically strong random secrets
|
||||||
|
- **HTTPS Only**: Always use HTTPS in production
|
||||||
|
- **Token Rotation**: Implement refresh token rotation for security
|
||||||
|
- **Audit Logging**: Log all authentication and authorization events
|
||||||
|
|
||||||
|
## Configuration Validation
|
||||||
|
|
||||||
|
The package automatically validates configuration on startup:
|
||||||
|
|
||||||
|
```go
|
||||||
|
config := authorization.LoadConfigFromEnv()
|
||||||
|
if err := config.ValidateConfig(); err != nil {
|
||||||
|
log.Fatal("Invalid authorization configuration:", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation checks:
|
||||||
|
- Non-empty secrets
|
||||||
|
- Valid expiry durations
|
||||||
|
- Access token expiry < refresh token expiry
|
||||||
|
- Non-empty issuer and audience
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All functions return descriptive errors:
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := authService.ValidateAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
// Handle validation error
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
if result.Expired {
|
||||||
|
return response.Unauthorized(c, "Token has expired")
|
||||||
|
}
|
||||||
|
return response.Unauthorized(c, result.Error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- **Redis Integration**: Full token blacklist implementation
|
||||||
|
- **Rate Limiting**: Token generation rate limiting
|
||||||
|
- **Audit Trail**: Comprehensive authentication audit logging
|
||||||
|
- **Multi-Tenant**: Enhanced company-level security
|
||||||
|
- **OAuth2 Support**: Integration with external OAuth providers
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `github.com/golang-jwt/jwt/v5` - JWT implementation
|
||||||
|
- `github.com/labstack/echo/v4` - HTTP framework
|
||||||
|
- `tm/pkg/logger` - Internal logging package
|
||||||
|
- `tm/pkg/response` - Internal response package
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This package is part of the Tender Management system and follows the same license terms.
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
package authorization
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tm/pkg/logger"
|
||||||
|
"tm/pkg/redis"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenType represents the type of token
|
||||||
|
type TokenType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AccessToken TokenType = "access"
|
||||||
|
RefreshToken TokenType = "refresh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenClaims represents the JWT claims structure
|
||||||
|
type TokenClaims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
CompanyID string `json:"company_id,omitempty"`
|
||||||
|
TokenType TokenType `json:"token_type"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenPair represents a pair of access and refresh tokens
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenValidationResult represents the result of token validation
|
||||||
|
type TokenValidationResult struct {
|
||||||
|
Valid bool
|
||||||
|
Claims *TokenClaims
|
||||||
|
Error string
|
||||||
|
Expired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizationService defines the interface for authorization operations
|
||||||
|
type AuthorizationService interface {
|
||||||
|
// GenerateTokenPair generates both access and refresh tokens
|
||||||
|
GenerateTokenPair(userID, email, role, companyID string) (*TokenPair, error)
|
||||||
|
|
||||||
|
// GenerateAccessToken generates only an access token
|
||||||
|
GenerateAccessToken(userID, email, role, companyID string) (string, error)
|
||||||
|
|
||||||
|
// GenerateRefreshToken generates only a refresh token
|
||||||
|
GenerateRefreshToken(userID, email, role, companyID string) (string, error)
|
||||||
|
|
||||||
|
// ValidateToken validates a token and returns the claims
|
||||||
|
ValidateToken(tokenString string) (*TokenValidationResult, error)
|
||||||
|
|
||||||
|
// ValidateAccessToken validates specifically an access token
|
||||||
|
ValidateAccessToken(tokenString string) (*TokenValidationResult, error)
|
||||||
|
|
||||||
|
// ValidateRefreshToken validates specifically a refresh token
|
||||||
|
ValidateRefreshToken(tokenString string) (*TokenValidationResult, error)
|
||||||
|
|
||||||
|
// RefreshAccessToken generates a new access token using a valid refresh token
|
||||||
|
RefreshAccessToken(refreshToken string) (string, error)
|
||||||
|
|
||||||
|
// InvalidateToken adds a token to the blacklist (if using Redis)
|
||||||
|
InvalidateToken(tokenString string) error
|
||||||
|
|
||||||
|
// IsTokenBlacklisted checks if a token is blacklisted
|
||||||
|
IsTokenBlacklisted(tokenString string) (bool, error)
|
||||||
|
|
||||||
|
// ClearExpiredTokens removes expired tokens from the blacklist
|
||||||
|
ClearExpiredTokens() error
|
||||||
|
|
||||||
|
// GetBlacklistStats returns statistics about the blacklist
|
||||||
|
GetBlacklistStats(ctx context.Context) (map[string]interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizationConfig holds the configuration for the authorization service
|
||||||
|
type AuthorizationConfig struct {
|
||||||
|
AccessTokenSecret string `json:"access_token_secret"`
|
||||||
|
RefreshTokenSecret string `json:"refresh_token_secret"`
|
||||||
|
AccessTokenExpiry time.Duration `json:"access_token_expiry"`
|
||||||
|
RefreshTokenExpiry time.Duration `json:"refresh_token_expiry"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
Audience string `json:"audience"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a default configuration
|
||||||
|
func DefaultConfig() *AuthorizationConfig {
|
||||||
|
return &AuthorizationConfig{
|
||||||
|
AccessTokenSecret: "your-access-token-secret-key-here",
|
||||||
|
RefreshTokenSecret: "your-refresh-token-secret-key-here",
|
||||||
|
AccessTokenExpiry: 15 * time.Minute, // 15 minutes
|
||||||
|
RefreshTokenExpiry: 7 * 24 * time.Hour, // 7 days
|
||||||
|
Issuer: "tender-management",
|
||||||
|
Audience: "tender-management-api",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service implements the AuthorizationService interface
|
||||||
|
type Service struct {
|
||||||
|
config *AuthorizationConfig
|
||||||
|
logger logger.Logger
|
||||||
|
redis redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthorizationService creates a new authorization service
|
||||||
|
func NewAuthorizationService(config *AuthorizationConfig, logger logger.Logger, redis redis.Client) AuthorizationService {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
redis: redis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateTokenPair generates both access and refresh tokens
|
||||||
|
func (s *Service) GenerateTokenPair(userID, email, role, companyID string) (*TokenPair, error) {
|
||||||
|
s.logger.Info("Generating token pair", map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"email": email,
|
||||||
|
"role": role,
|
||||||
|
})
|
||||||
|
|
||||||
|
accessToken, err := s.GenerateAccessToken(userID, email, role, companyID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate access token", map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to generate access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := s.GenerateRefreshToken(userID, email, role, companyID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate refresh token", map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenPair := &TokenPair{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresIn: int64(s.config.AccessTokenExpiry.Seconds()),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Token pair generated successfully", map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"expires_in": tokenPair.ExpiresIn,
|
||||||
|
})
|
||||||
|
|
||||||
|
return tokenPair, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAccessToken generates only an access token
|
||||||
|
func (s *Service) GenerateAccessToken(userID, email, role, companyID string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
expiresAt := now.Add(s.config.AccessTokenExpiry)
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
CompanyID: companyID,
|
||||||
|
TokenType: AccessToken,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: s.config.Issuer,
|
||||||
|
Subject: userID,
|
||||||
|
Audience: []string{s.config.Audience},
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString([]byte(s.config.AccessTokenSecret))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRefreshToken generates only a refresh token
|
||||||
|
func (s *Service) GenerateRefreshToken(userID, email, role, companyID string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
expiresAt := now.Add(s.config.RefreshTokenExpiry)
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
CompanyID: companyID,
|
||||||
|
TokenType: RefreshToken,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: s.config.Issuer,
|
||||||
|
Subject: userID,
|
||||||
|
Audience: []string{s.config.Audience},
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString([]byte(s.config.RefreshTokenSecret))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a token and returns the claims
|
||||||
|
func (s *Service) ValidateToken(tokenString string) (*TokenValidationResult, error) {
|
||||||
|
// First check if token is blacklisted
|
||||||
|
isBlacklisted, err := s.IsTokenBlacklisted(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to check token blacklist", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
// Continue with validation even if blacklist check fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if isBlacklisted {
|
||||||
|
return &TokenValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Claims: nil,
|
||||||
|
Error: "token is blacklisted",
|
||||||
|
Expired: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the token
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
// Validate the signing method
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which secret to use based on token type
|
||||||
|
claims, ok := token.Claims.(*TokenClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch claims.TokenType {
|
||||||
|
case AccessToken:
|
||||||
|
return []byte(s.config.AccessTokenSecret), nil
|
||||||
|
case RefreshToken:
|
||||||
|
return []byte(s.config.RefreshTokenSecret), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid token type")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if jwt.ErrTokenExpired.Error() == err.Error() {
|
||||||
|
return &TokenValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Claims: nil,
|
||||||
|
Error: "token expired",
|
||||||
|
Expired: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TokenValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Claims: nil,
|
||||||
|
Error: fmt.Sprintf("token validation failed: %s", err.Error()),
|
||||||
|
Expired: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Valid {
|
||||||
|
return &TokenValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Claims: nil,
|
||||||
|
Error: "invalid token",
|
||||||
|
Expired: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*TokenClaims)
|
||||||
|
if !ok {
|
||||||
|
return &TokenValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Claims: nil,
|
||||||
|
Error: "invalid token claims",
|
||||||
|
Expired: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TokenValidationResult{
|
||||||
|
Valid: true,
|
||||||
|
Claims: claims,
|
||||||
|
Error: "",
|
||||||
|
Expired: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAccessToken validates specifically an access token
|
||||||
|
func (s *Service) ValidateAccessToken(tokenString string) (*TokenValidationResult, error) {
|
||||||
|
result, err := s.ValidateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's actually an access token
|
||||||
|
if result.Claims.TokenType != AccessToken {
|
||||||
|
return &TokenValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Claims: nil,
|
||||||
|
Error: "token is not an access token",
|
||||||
|
Expired: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRefreshToken validates specifically a refresh token
|
||||||
|
func (s *Service) ValidateRefreshToken(tokenString string) (*TokenValidationResult, error) {
|
||||||
|
result, err := s.ValidateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's actually a refresh token
|
||||||
|
if result.Claims.TokenType != RefreshToken {
|
||||||
|
return &TokenValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Claims: nil,
|
||||||
|
Error: "token is not a refresh token",
|
||||||
|
Expired: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAccessToken generates a new access token using a valid refresh token
|
||||||
|
func (s *Service) RefreshAccessToken(refreshToken string) (string, error) {
|
||||||
|
s.logger.Info("Refreshing access token", map[string]interface{}{
|
||||||
|
"refresh_token_length": len(refreshToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validate the refresh token
|
||||||
|
result, err := s.ValidateRefreshToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to validate refresh token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return "", fmt.Errorf("failed to validate refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
s.logger.Warn("Invalid refresh token provided", map[string]interface{}{
|
||||||
|
"error": result.Error,
|
||||||
|
})
|
||||||
|
return "", fmt.Errorf("invalid refresh token: %s", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new access token
|
||||||
|
newAccessToken, err := s.GenerateAccessToken(
|
||||||
|
result.Claims.UserID,
|
||||||
|
result.Claims.Email,
|
||||||
|
result.Claims.Role,
|
||||||
|
result.Claims.CompanyID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate new access token", map[string]interface{}{
|
||||||
|
"user_id": result.Claims.UserID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return "", fmt.Errorf("failed to generate new access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Access token refreshed successfully", map[string]interface{}{
|
||||||
|
"user_id": result.Claims.UserID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return newAccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTokenHash generates a SHA-256 hash of the token for storage
|
||||||
|
func (s *Service) generateTokenHash(tokenString string) string {
|
||||||
|
hash := sha256.Sum256([]byte(tokenString))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateToken adds a token to the blacklist
|
||||||
|
func (s *Service) InvalidateToken(tokenString string) error {
|
||||||
|
if s.redis == nil {
|
||||||
|
s.logger.Warn("Redis client not available, token blacklisting disabled", map[string]interface{}{
|
||||||
|
"token_length": len(tokenString),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate hash of the token for storage
|
||||||
|
tokenHash := s.generateTokenHash(tokenString)
|
||||||
|
|
||||||
|
// Parse token to get expiration time for blacklist TTL
|
||||||
|
claims := &TokenClaims{}
|
||||||
|
_, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
// Determine which secret to use based on token type
|
||||||
|
claims, ok := token.Claims.(*TokenClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch claims.TokenType {
|
||||||
|
case AccessToken:
|
||||||
|
return []byte(s.config.AccessTokenSecret), nil
|
||||||
|
case RefreshToken:
|
||||||
|
return []byte(s.config.RefreshTokenSecret), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid token type")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to parse token for blacklisting", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
// Still add to blacklist with default TTL even if parsing fails
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Use default TTL (24 hours) if we can't determine actual expiration
|
||||||
|
defaultTTL := 24 * time.Hour
|
||||||
|
err = s.redis.Set(ctx, "blacklist:"+tokenHash, "1", defaultTTL)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to add token to blacklist", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("failed to add token to blacklist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Token added to blacklist with default TTL", map[string]interface{}{
|
||||||
|
"token_hash": tokenHash,
|
||||||
|
"ttl": defaultTTL.String(),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate TTL based on token expiration
|
||||||
|
var ttl time.Duration
|
||||||
|
if claims.ExpiresAt != nil {
|
||||||
|
ttl = time.Until(claims.ExpiresAt.Time)
|
||||||
|
// Add some buffer time to ensure token is blacklisted until it would have expired
|
||||||
|
ttl += 5 * time.Minute
|
||||||
|
} else {
|
||||||
|
// Fallback to default TTL if no expiration
|
||||||
|
ttl = 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add token to blacklist with TTL
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err = s.redis.Set(ctx, "blacklist:"+tokenHash, "1", ttl)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to add token to blacklist", map[string]interface{}{
|
||||||
|
"token_hash": tokenHash,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("failed to add token to blacklist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Token successfully added to blacklist", map[string]interface{}{
|
||||||
|
"token_hash": tokenHash,
|
||||||
|
"ttl": ttl.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTokenBlacklisted checks if a token is blacklisted
|
||||||
|
func (s *Service) IsTokenBlacklisted(tokenString string) (bool, error) {
|
||||||
|
if s.redis == nil {
|
||||||
|
// If Redis is not available, assume token is not blacklisted
|
||||||
|
// This allows the system to continue functioning without Redis
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate hash of the token for lookup
|
||||||
|
tokenHash := s.generateTokenHash(tokenString)
|
||||||
|
|
||||||
|
// Check if token exists in blacklist
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
exists, err := s.redis.Exists(ctx, "blacklist:"+tokenHash)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to check token blacklist", map[string]interface{}{
|
||||||
|
"token_hash": tokenHash,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
// If we can't check the blacklist, assume token is not blacklisted
|
||||||
|
// This prevents blocking legitimate requests due to Redis issues
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
isBlacklisted := exists > 0
|
||||||
|
|
||||||
|
if isBlacklisted {
|
||||||
|
s.logger.Debug("Token found in blacklist", map[string]interface{}{
|
||||||
|
"token_hash": tokenHash,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
s.logger.Debug("Token not found in blacklist", map[string]interface{}{
|
||||||
|
"token_hash": tokenHash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return isBlacklisted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearExpiredTokens removes expired tokens from the blacklist
|
||||||
|
// This is useful for cleanup operations
|
||||||
|
func (s *Service) ClearExpiredTokens() error {
|
||||||
|
if s.redis == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Clearing expired tokens from blacklist", map[string]interface{}{})
|
||||||
|
|
||||||
|
// Note: Redis automatically expires keys based on TTL
|
||||||
|
// This method is provided for manual cleanup if needed
|
||||||
|
// In most cases, Redis TTL handling is sufficient
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlacklistStats returns statistics about the blacklist
|
||||||
|
func (s *Service) GetBlacklistStats(ctx context.Context) (map[string]interface{}, error) {
|
||||||
|
if s.redis == nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"redis_available": false,
|
||||||
|
"message": "Redis client not available",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a placeholder for future implementation
|
||||||
|
// Could return metrics like total blacklisted tokens, memory usage, etc.
|
||||||
|
return map[string]interface{}{
|
||||||
|
"redis_available": true,
|
||||||
|
"message": "Blacklist statistics not yet implemented",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRandomSecret generates a random secret key for JWT signing
|
||||||
|
func GenerateRandomSecret(length int) (string, error) {
|
||||||
|
if length < 32 {
|
||||||
|
length = 32 // Minimum recommended length
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes := make([]byte, length)
|
||||||
|
_, err := rand.Read(bytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%x", bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRSAKeyPair generates an RSA key pair for JWT signing
|
||||||
|
func GenerateRSAKeyPair(bits int) (privateKeyPEM, publicKeyPEM string, err error) {
|
||||||
|
if bits < 2048 {
|
||||||
|
bits = 2048 // Minimum recommended bits
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, bits)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to generate RSA key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode private key
|
||||||
|
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||||
|
privateKeyPEM = string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: privateKeyBytes,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Encode public key
|
||||||
|
publicKeyBytes := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey)
|
||||||
|
publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "RSA PUBLIC KEY",
|
||||||
|
Bytes: publicKeyBytes,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return privateKeyPEM, publicKeyPEM, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package authorization
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"tm/pkg/logger"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockRedisClient is a mock implementation of the Redis client interface
|
||||||
|
type MockRedisClient struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||||
|
args := m.Called(ctx, key, value, expiration)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRedisClient) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
args := m.Called(ctx, key)
|
||||||
|
return args.String(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRedisClient) Del(ctx context.Context, keys ...string) error {
|
||||||
|
args := m.Called(ctx, keys)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRedisClient) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||||
|
args := m.Called(ctx, keys)
|
||||||
|
return args.Get(0).(int64), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRedisClient) Close() error {
|
||||||
|
args := m.Called()
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockLogger is a mock implementation of the logger interface
|
||||||
|
type MockLogger struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockLogger) Debug(msg string, fields map[string]interface{}) {
|
||||||
|
m.Called(msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockLogger) Info(msg string, fields map[string]interface{}) {
|
||||||
|
m.Called(msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockLogger) Warn(msg string, fields map[string]interface{}) {
|
||||||
|
m.Called(msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockLogger) Error(msg string, fields map[string]interface{}) {
|
||||||
|
m.Called(msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockLogger) Fatal(msg string, fields map[string]interface{}) {
|
||||||
|
m.Called(msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockLogger) Sync() error {
|
||||||
|
args := m.Called()
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockLogger) WithFields(fields map[string]interface{}) logger.Logger {
|
||||||
|
args := m.Called(fields)
|
||||||
|
return args.Get(0).(logger.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateTokenHash(t *testing.T) {
|
||||||
|
service := &Service{}
|
||||||
|
|
||||||
|
token1 := "test-token-1"
|
||||||
|
token2 := "test-token-2"
|
||||||
|
|
||||||
|
hash1 := service.generateTokenHash(token1)
|
||||||
|
hash2 := service.generateTokenHash(token2)
|
||||||
|
|
||||||
|
// Hash should be consistent for the same input
|
||||||
|
assert.Equal(t, service.generateTokenHash(token1), hash1)
|
||||||
|
|
||||||
|
// Different tokens should have different hashes
|
||||||
|
assert.NotEqual(t, hash1, hash2)
|
||||||
|
|
||||||
|
// Hash should be a valid hex string
|
||||||
|
assert.Len(t, hash1, 64) // SHA-256 produces 32 bytes = 64 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidateToken_WithRedis(t *testing.T) {
|
||||||
|
mockRedis := new(MockRedisClient)
|
||||||
|
mockLogger := new(MockLogger)
|
||||||
|
|
||||||
|
config := &AuthorizationConfig{
|
||||||
|
AccessTokenSecret: "test-secret",
|
||||||
|
RefreshTokenSecret: "test-refresh-secret",
|
||||||
|
AccessTokenExpiry: 15 * time.Minute,
|
||||||
|
RefreshTokenExpiry: 7 * 24 * time.Hour,
|
||||||
|
Issuer: "test",
|
||||||
|
Audience: "test-api",
|
||||||
|
}
|
||||||
|
|
||||||
|
service := &Service{
|
||||||
|
config: config,
|
||||||
|
logger: mockLogger,
|
||||||
|
redis: mockRedis,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a valid token first
|
||||||
|
token, err := service.GenerateAccessToken("user123", "test@example.com", "user", "company123")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Mock Redis Set call
|
||||||
|
mockRedis.On("Set", mock.Anything, mock.Anything, "1", mock.Anything).Return(nil)
|
||||||
|
|
||||||
|
// Mock logger calls
|
||||||
|
mockLogger.On("Info", mock.Anything, mock.Anything).Return()
|
||||||
|
|
||||||
|
// Test invalidation
|
||||||
|
err = service.InvalidateToken(token)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify Redis was called
|
||||||
|
mockRedis.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidateToken_WithoutRedis(t *testing.T) {
|
||||||
|
mockLogger := new(MockLogger)
|
||||||
|
|
||||||
|
config := &AuthorizationConfig{
|
||||||
|
AccessTokenSecret: "test-secret",
|
||||||
|
RefreshTokenSecret: "test-refresh-secret",
|
||||||
|
AccessTokenExpiry: 15 * time.Minute,
|
||||||
|
RefreshTokenExpiry: 7 * 24 * time.Hour,
|
||||||
|
Issuer: "test",
|
||||||
|
Audience: "test-api",
|
||||||
|
}
|
||||||
|
|
||||||
|
service := &Service{
|
||||||
|
config: config,
|
||||||
|
logger: mockLogger,
|
||||||
|
redis: nil, // No Redis client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock logger calls
|
||||||
|
mockLogger.On("Warn", mock.Anything, mock.Anything).Return()
|
||||||
|
|
||||||
|
// Test invalidation without Redis
|
||||||
|
err := service.InvalidateToken("test-token")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify warning was logged
|
||||||
|
mockLogger.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTokenBlacklisted_WithRedis(t *testing.T) {
|
||||||
|
mockRedis := new(MockRedisClient)
|
||||||
|
mockLogger := new(MockLogger)
|
||||||
|
|
||||||
|
config := &AuthorizationConfig{
|
||||||
|
AccessTokenSecret: "test-secret",
|
||||||
|
RefreshTokenSecret: "test-refresh-secret",
|
||||||
|
AccessTokenExpiry: 15 * time.Minute,
|
||||||
|
RefreshTokenExpiry: 7 * 24 * time.Hour,
|
||||||
|
Issuer: "test",
|
||||||
|
Audience: "test-api",
|
||||||
|
}
|
||||||
|
|
||||||
|
service := &Service{
|
||||||
|
config: config,
|
||||||
|
logger: mockLogger,
|
||||||
|
redis: mockRedis,
|
||||||
|
}
|
||||||
|
|
||||||
|
token := "test-token"
|
||||||
|
tokenHash := service.generateTokenHash(token)
|
||||||
|
|
||||||
|
// Test case 1: Token is blacklisted
|
||||||
|
mockRedis.On("Exists", mock.Anything, []string{"blacklist:" + tokenHash}).Return(int64(1), nil).Once()
|
||||||
|
mockLogger.On("Debug", mock.Anything, mock.Anything).Return()
|
||||||
|
|
||||||
|
isBlacklisted, err := service.IsTokenBlacklisted(token)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, isBlacklisted)
|
||||||
|
|
||||||
|
// Test case 2: Token is not blacklisted
|
||||||
|
mockRedis.On("Exists", mock.Anything, []string{"blacklist:" + tokenHash}).Return(int64(0), nil).Once()
|
||||||
|
mockLogger.On("Debug", mock.Anything, mock.Anything).Return()
|
||||||
|
|
||||||
|
isBlacklisted, err = service.IsTokenBlacklisted(token)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, isBlacklisted)
|
||||||
|
|
||||||
|
mockRedis.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTokenBlacklisted_WithoutRedis(t *testing.T) {
|
||||||
|
mockLogger := new(MockLogger)
|
||||||
|
|
||||||
|
config := &AuthorizationConfig{
|
||||||
|
AccessTokenSecret: "test-secret",
|
||||||
|
RefreshTokenSecret: "test-refresh-secret",
|
||||||
|
AccessTokenExpiry: 15 * time.Minute,
|
||||||
|
RefreshTokenExpiry: 7 * 24 * time.Hour,
|
||||||
|
Issuer: "test",
|
||||||
|
Audience: "test-api",
|
||||||
|
}
|
||||||
|
|
||||||
|
service := &Service{
|
||||||
|
config: config,
|
||||||
|
logger: mockLogger,
|
||||||
|
redis: nil, // No Redis client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test blacklist check without Redis
|
||||||
|
isBlacklisted, err := service.IsTokenBlacklisted("test-token")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, isBlacklisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearExpiredTokens(t *testing.T) {
|
||||||
|
mockRedis := new(MockRedisClient)
|
||||||
|
mockLogger := new(MockLogger)
|
||||||
|
|
||||||
|
service := &Service{
|
||||||
|
logger: mockLogger,
|
||||||
|
redis: mockRedis,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock logger call
|
||||||
|
mockLogger.On("Info", mock.Anything, mock.Anything).Return()
|
||||||
|
|
||||||
|
// Test clearing expired tokens
|
||||||
|
err := service.ClearExpiredTokens()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
mockLogger.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBlacklistStats(t *testing.T) {
|
||||||
|
mockRedis := new(MockRedisClient)
|
||||||
|
mockLogger := new(MockLogger)
|
||||||
|
|
||||||
|
service := &Service{
|
||||||
|
logger: mockLogger,
|
||||||
|
redis: mockRedis,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test with Redis available
|
||||||
|
stats, err := service.GetBlacklistStats(ctx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, true, stats["redis_available"])
|
||||||
|
|
||||||
|
// Test without Redis
|
||||||
|
service.redis = nil
|
||||||
|
stats, err = service.GetBlacklistStats(ctx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, false, stats["redis_available"])
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package authorization
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Authorization errors
|
||||||
|
var (
|
||||||
|
ErrEmptyAccessTokenSecret = errors.New("access token secret cannot be empty")
|
||||||
|
ErrEmptyRefreshTokenSecret = errors.New("refresh token secret cannot be empty")
|
||||||
|
ErrInvalidAccessTokenExpiry = errors.New("access token expiry must be greater than 0")
|
||||||
|
ErrInvalidRefreshTokenExpiry = errors.New("refresh token expiry must be greater than 0")
|
||||||
|
ErrAccessTokenExpiryTooLong = errors.New("access token expiry cannot be longer than refresh token expiry")
|
||||||
|
ErrEmptyIssuer = errors.New("JWT issuer cannot be empty")
|
||||||
|
ErrEmptyAudience = errors.New("JWT audience cannot be empty")
|
||||||
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
|
ErrTokenExpired = errors.New("token has expired")
|
||||||
|
ErrTokenBlacklisted = errors.New("token is blacklisted")
|
||||||
|
ErrInvalidTokenType = errors.New("invalid token type")
|
||||||
|
ErrInvalidSigningMethod = errors.New("invalid signing method")
|
||||||
|
ErrInvalidClaims = errors.New("invalid token claims")
|
||||||
|
ErrTokenValidationFailed = errors.New("token validation failed")
|
||||||
|
ErrInsufficientPermissions = errors.New("insufficient permissions")
|
||||||
|
ErrCompanyAccessDenied = errors.New("access denied to this company")
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Redis Package
|
||||||
|
|
||||||
|
This package provides Redis client functionality for the Tender Management system, specifically designed for token blacklisting and caching operations.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Connection Management**: Handles Redis client connections with proper error handling
|
||||||
|
- **Interface-based Design**: Uses interfaces for easy testing and mocking
|
||||||
|
- **Configuration Support**: Supports Redis configuration from environment variables
|
||||||
|
- **Connection Testing**: Validates connections on startup
|
||||||
|
- **Resource Cleanup**: Proper connection closing and cleanup
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Setup
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "tm/pkg/redis"
|
||||||
|
|
||||||
|
// Create Redis configuration
|
||||||
|
config := &redis.Config{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 6379,
|
||||||
|
Password: "password",
|
||||||
|
DB: 0,
|
||||||
|
PoolSize: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Redis client
|
||||||
|
client, err := redis.NewClient(config, logger)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Use the client
|
||||||
|
err = client.Set(ctx, "key", "value", time.Hour)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Blacklisting
|
||||||
|
|
||||||
|
The Redis client is primarily used for JWT token blacklisting:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Add token to blacklist
|
||||||
|
err = client.Set(ctx, "blacklist:token_hash", "1", tokenExpiration)
|
||||||
|
|
||||||
|
// Check if token is blacklisted
|
||||||
|
exists, err := client.Exists(ctx, "blacklist:token_hash")
|
||||||
|
if err != nil {
|
||||||
|
// Handle error
|
||||||
|
}
|
||||||
|
if exists > 0 {
|
||||||
|
// Token is blacklisted
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The Redis configuration supports the following fields:
|
||||||
|
|
||||||
|
- `Host`: Redis server hostname (default: localhost)
|
||||||
|
- `Port`: Redis server port (default: 6379)
|
||||||
|
- `Password`: Redis authentication password
|
||||||
|
- `DB`: Redis database number (default: 0)
|
||||||
|
- `PoolSize`: Connection pool size (default: 10)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
The configuration can be loaded from environment variables:
|
||||||
|
|
||||||
|
- `TM_CACHE_REDIS_HOST`
|
||||||
|
- `TM_CACHE_REDIS_PORT`
|
||||||
|
- `TM_CACHE_REDIS_PASSWORD`
|
||||||
|
- `TM_CACHE_REDIS_DB`
|
||||||
|
- `TM_CACHE_REDIS_POOL_SIZE`
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The package provides comprehensive error handling:
|
||||||
|
|
||||||
|
- Connection failures are logged with context
|
||||||
|
- All Redis operations return proper errors
|
||||||
|
- Connection timeouts are handled gracefully
|
||||||
|
- Resource cleanup is guaranteed with defer statements
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The interface-based design makes it easy to mock Redis operations in tests:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type MockRedisClient struct {
|
||||||
|
// Implement the Client interface
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use in tests
|
||||||
|
client := &MockRedisClient{}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `github.com/redis/go-redis/v9`: Redis client library
|
||||||
|
- `tm/pkg/logger`: Logging interface
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package redis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tm/pkg/logger"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds Redis configuration
|
||||||
|
type Config struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
DB int `json:"db"`
|
||||||
|
PoolSize int `json:"pool_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client represents a Redis client interface
|
||||||
|
type Client interface {
|
||||||
|
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||||
|
Get(ctx context.Context, key string) (string, error)
|
||||||
|
Del(ctx context.Context, keys ...string) error
|
||||||
|
Exists(ctx context.Context, keys ...string) (int64, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// client implements the Client interface
|
||||||
|
type client struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Redis client
|
||||||
|
func NewClient(config *Config, logger logger.Logger) (Client, error) {
|
||||||
|
if config == nil {
|
||||||
|
return nil, fmt.Errorf("redis config is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Password: config.Password,
|
||||||
|
DB: config.DB,
|
||||||
|
PoolSize: config.PoolSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test the connection
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||||
|
logger.Error("Failed to connect to Redis", map[string]interface{}{
|
||||||
|
"host": config.Host,
|
||||||
|
"port": config.Port,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Successfully connected to Redis", map[string]interface{}{
|
||||||
|
"host": config.Host,
|
||||||
|
"port": config.Port,
|
||||||
|
"db": config.DB,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &client{rdb: rdb}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set sets a key-value pair with expiration
|
||||||
|
func (c *client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||||
|
return c.rdb.Set(ctx, key, value, expiration).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a value by key
|
||||||
|
func (c *client) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
return c.rdb.Get(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Del deletes one or more keys
|
||||||
|
func (c *client) Del(ctx context.Context, keys ...string) error {
|
||||||
|
return c.rdb.Del(ctx, keys...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if one or more keys exist
|
||||||
|
func (c *client) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||||
|
return c.rdb.Exists(ctx, keys...).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the Redis connection
|
||||||
|
func (c *client) Close() error {
|
||||||
|
return c.rdb.Close()
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package response
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -156,3 +157,17 @@ func ServiceUnavailable(c echo.Context, message string) error {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse parses the form and validates it
|
||||||
|
func Parse[T any](c echo.Context) (*T, error) {
|
||||||
|
var form T
|
||||||
|
if err := c.Bind(&form); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := govalidator.ValidateStruct(form); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &form, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user