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:
n.nakhostin
2025-08-10 14:09:17 +03:30
parent 9119e2383e
commit 98f581ec97
28 changed files with 7782 additions and 131 deletions
+75 -30
View File
@@ -35,6 +35,7 @@ internal/{domain}/
- All files in a domain use the same package name for easy access
- Repository implementations are in the same package as interfaces
- Keep dependencies minimal and focused
- **NO GLOBAL VARIABLES**: Everything must be injected, including validation functions
## 💻 Go Best Practices
@@ -84,6 +85,12 @@ internal/{domain}/
## 📝 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
- Always use structured logging with fields
- Include relevant context (user_id, customer_id, request_id, etc.)
@@ -96,19 +103,23 @@ internal/{domain}/
### Logging Examples
```go
// Good
log.Info("Customer authenticated successfully", map[string]interface{}{
"customer_id": customer.ID.String(),
"email": customer.Email,
"ip": clientIP,
})
// Service layer - GOOD
func (s *CustomerService) CreateCustomer(ctx context.Context, req CreateCustomerRequest) (*Customer, error) {
s.logger.Info("Creating new customer", map[string]interface{}{
"email": req.Email,
"company_id": req.CompanyID,
})
// ... business logic
}
// Bad
log.Info("Customer login")
// Handler - BAD (no logging here)
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
// Only handle HTTP concerns, no logging
}
```
### Error Logging
- Log errors with full context
- Log errors with full context in service layer
- Include error details and relevant fields
- 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 Validation**: Validate Unix timestamps are within reasonable ranges
- **Time Consistency**: All time inputs must be Unix timestamps (int64)
- **NO LOGGING**: Handlers should not contain any logging statements
### Govalidator Usage
- Use govalidator for all request validation
@@ -164,6 +176,7 @@ log.Info("Customer login")
- Validate at handler level before calling services
- Return validation errors using `response.ValidationError()`
- Use meaningful error messages for validation failures
- **Validation Injection**: Pass validation functions as dependencies, never use global validation
### Response Format
- Always use standardized response structure from `pkg/response`
@@ -180,6 +193,40 @@ log.Info("Customer login")
- Never log sensitive information (passwords, tokens)
- 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
### Test Structure
@@ -232,21 +279,6 @@ log.Info("Customer login")
- Avoid memory leaks in goroutines
- 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
### Authentication & Authorization
@@ -297,10 +329,12 @@ log.Info("Customer login")
### Anti-patterns
- 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
- Avoid deeply nested if statements
- Don't use reflection unless absolutely necessary
- **Don't log in handlers**
- **Don't use global validation functions**
### Common Mistakes
- Not handling database connection errors
@@ -308,6 +342,8 @@ log.Info("Customer login")
- Not validating user inputs
- Exposing internal errors to clients
- Not using proper HTTP status codes
- **Logging in handlers instead of services**
- **Using global variables for validation**
## 📋 Checklist for New Features
@@ -318,22 +354,28 @@ log.Info("Customer login")
- [ ] Design database schema if needed
- [ ] Consider caching requirements
- [ ] Define custom validators if needed
- [ ] Plan logging strategy (service layer only)
- [ ] Plan dependency injection for validation
### During Implementation
- [ ] Follow flat domain structure (all files in same package)
- [ ] Use dependency injection
- [ ] Implement proper logging
- [ ] Implement proper logging in service layer only
- [ ] Add govalidator validation tags to forms
- [ ] Register custom validators in init() functions
- [ ] Register custom validators as injected dependencies
- [ ] Handle errors gracefully
- [ ] Add Swagger comments to handlers
- [ ] Ensure no global variables are used
### After Implementation
- [ ] Write unit tests
- [ ] Update API documentation
- [ ] Update API documentation (Swagger)
- [ ] Test error scenarios
- [ ] Verify security implications
- [ ] Check performance impact
- [ ] Test validation scenarios
- [ ] Verify no logging in handlers
- [ ] Verify all dependencies are injected
## 🚀 Remember
- Prioritize simplicity and readability over cleverness
@@ -342,7 +384,7 @@ log.Info("Customer login")
- Test your code thoroughly
- Security and performance are not optional
- **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
- Follow the repository pattern with interface and implementation in same file
- **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
- **Validation**: Always use govalidator for request validation with proper tags
- **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
- **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
+75 -30
View File
@@ -32,6 +32,7 @@ internal/{domain}/
- All files in a domain use the same package name for easy access
- Repository implementations are in the same package as interfaces
- Keep dependencies minimal and focused
- **NO GLOBAL VARIABLES**: Everything must be injected, including validation functions
## 💻 Go Best Practices
@@ -81,6 +82,12 @@ internal/{domain}/
## 📝 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
- Always use structured logging with fields
- Include relevant context (user_id, customer_id, request_id, etc.)
@@ -93,19 +100,23 @@ internal/{domain}/
### Logging Examples
```go
// Good
log.Info("Customer authenticated successfully", map[string]interface{}{
"customer_id": customer.ID.String(),
"email": customer.Email,
"ip": clientIP,
})
// Service layer - GOOD
func (s *CustomerService) CreateCustomer(ctx context.Context, req CreateCustomerRequest) (*Customer, error) {
s.logger.Info("Creating new customer", map[string]interface{}{
"email": req.Email,
"company_id": req.CompanyID,
})
// ... business logic
}
// Bad
log.Info("Customer login")
// Handler - BAD (no logging here)
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
// Only handle HTTP concerns, no logging
}
```
### Error Logging
- Log errors with full context
- Log errors with full context in service layer
- Include error details and relevant fields
- 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 Validation**: Validate Unix timestamps are within reasonable ranges
- **Time Consistency**: All time inputs must be Unix timestamps (int64)
- **NO LOGGING**: Handlers should not contain any logging statements
### Govalidator Usage
- Use govalidator for all request validation
@@ -161,6 +173,7 @@ log.Info("Customer login")
- Validate at handler level before calling services
- Return validation errors using `response.ValidationError()`
- Use meaningful error messages for validation failures
- **Validation Injection**: Pass validation functions as dependencies, never use global validation
### Response Format
- Always use standardized response structure from `pkg/response`
@@ -177,6 +190,40 @@ log.Info("Customer login")
- Never log sensitive information (passwords, tokens)
- 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
### Test Structure
@@ -229,21 +276,6 @@ log.Info("Customer login")
- Avoid memory leaks in goroutines
- 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
### Authentication & Authorization
@@ -294,10 +326,12 @@ log.Info("Customer login")
### Anti-patterns
- 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
- Avoid deeply nested if statements
- Don't use reflection unless absolutely necessary
- **Don't log in handlers**
- **Don't use global validation functions**
### Common Mistakes
- Not handling database connection errors
@@ -305,6 +339,8 @@ log.Info("Customer login")
- Not validating user inputs
- Exposing internal errors to clients
- Not using proper HTTP status codes
- **Logging in handlers instead of services**
- **Using global variables for validation**
## 📋 Checklist for New Features
@@ -315,22 +351,28 @@ log.Info("Customer login")
- [ ] Design database schema if needed
- [ ] Consider caching requirements
- [ ] Define custom validators if needed
- [ ] Plan logging strategy (service layer only)
- [ ] Plan dependency injection for validation
### During Implementation
- [ ] Follow flat domain structure (all files in same package)
- [ ] Use dependency injection
- [ ] Implement proper logging
- [ ] Implement proper logging in service layer only
- [ ] Add govalidator validation tags to forms
- [ ] Register custom validators in init() functions
- [ ] Register custom validators as injected dependencies
- [ ] Handle errors gracefully
- [ ] Add Swagger comments to handlers
- [ ] Ensure no global variables are used
### After Implementation
- [ ] Write unit tests
- [ ] Update API documentation
- [ ] Update API documentation (Swagger)
- [ ] Test error scenarios
- [ ] Verify security implications
- [ ] Check performance impact
- [ ] Test validation scenarios
- [ ] Verify no logging in handlers
- [ ] Verify all dependencies are injected
## 🚀 Remember
- Prioritize simplicity and readability over cleverness
@@ -339,7 +381,7 @@ log.Info("Customer login")
- Test your code thoroughly
- Security and performance are not optional
- **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
- Follow the repository pattern with interface and implementation in same file
- **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
- **Validation**: Always use govalidator for request validation with proper tags
- **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
- **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
BIN
View File
Binary file not shown.
+52
View File
@@ -5,8 +5,10 @@ import (
"net/http"
"time"
"tm/infra"
"tm/pkg/authorization"
"tm/pkg/logger"
"tm/pkg/mongo"
"tm/pkg/redis"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
@@ -131,3 +133,53 @@ func initHTTPServer(conf infra.Config, log logger.Logger) *echo.Echo {
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
View File
@@ -38,9 +38,10 @@ search:
auth:
jwt:
secret: "your-super-secret-key"
access_token_duration: "24h"
refresh_token_duration: "168h" # 7 days
access_secret: "your-super-secret-access-token-key-here-make-it-long-and-secure"
refresh_secret: "your-super-secret-refresh-token-key-here-make-it-long-and-secure"
access_expires_in: 3600 # 1 hour in seconds
refresh_expires_in: 2592000 # 30 days in seconds
ai:
openai:
+1335
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+838
View File
@@ -183,6 +183,149 @@ definitions:
total:
type: integer
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
info:
contact:
@@ -703,6 +846,699 @@ paths:
summary: Refresh access token
tags:
- 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:
BearerAuth:
description: Type "Bearer" followed by a space and JWT token.
@@ -713,6 +1549,8 @@ swagger: "2.0"
tags:
- description: Customer management operations
name: customers
- description: User management operations
name: users
- description: Health check operations
name: health
- description: Authentication operations
+31 -10
View File
@@ -42,6 +42,9 @@ package main
// @tag.name customers
// @tag.description Customer management operations
// @tag.name users
// @tag.description User management operations
// @tag.name health
// @tag.description Health check operations
@@ -54,6 +57,7 @@ import (
"net/http"
"time"
"tm/internal/customer"
"tm/internal/user"
_ "tm/cmd/web/docs" // This is generated by swag
)
@@ -76,33 +80,43 @@ func main() {
}
}()
logger.Info(
"Starting Tender Management API Server",
map[string]interface{}{
"version": "1.0.0",
"host": conf.Server.Host,
"port": conf.Server.Port,
})
// Initialize Redis connection manager
redisClient := initRedis(conf.Cache.Redis, logger)
defer func() {
if err := redisClient.Close(); err != nil {
logger.Error("Failed to close Redis connection", map[string]interface{}{
"error": err.Error(),
})
}
}()
// Initialize authorization service
authService := initAuthorizationService(conf.Auth.JWT, redisClient, logger)
// Initialize repositories with MongoDB connection manager
customerRepository := customer.NewCustomerRepository(mongoManager, logger)
userRepository := user.NewUserRepository(mongoManager, logger)
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
customerService := customer.NewCustomerService(customerRepository, logger)
userService := user.NewUserService(userRepository, logger, authService, userValidator)
logger.Info("Services initialized successfully", map[string]interface{}{
"services": []string{"customer"},
"services": []string{"customer", "user"},
})
// Initialize handlers with services
customerHandler := customer.NewHandler(customerService)
userHandler := user.NewUserHandler(userService, logger, userValidator, authService)
logger.Info("Handlers initialized successfully", map[string]interface{}{
"handlers": []string{"customer"},
"handlers": []string{"customer", "user"},
})
// Initialize HTTP server
@@ -110,17 +124,24 @@ func main() {
// Register routes
customerHandler.RegisterRoutes(e)
userHandler.RegisterRoutes(e)
// Start server
serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)
logger.Info("HTTP server starting", map[string]interface{}{
"address": serverAddr,
"version": "1.0.0",
"host": conf.Server.Host,
"port": conf.Server.Port,
})
if err := e.Start(serverAddr); err != nil && err != http.ErrServerClosed {
logger.Error("Failed to start HTTP server", map[string]interface{}{
"error": err.Error(),
"address": serverAddr,
"version": "1.0.0",
"host": conf.Server.Host,
"port": conf.Server.Port,
})
panic(fmt.Sprintf("Failed to start HTTP server: %v", err))
}
+8 -13
View File
@@ -5,10 +5,14 @@ go 1.23.0
toolchain go1.24.4
require (
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.4.0
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/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.uber.org/zap v1.26.0
golang.org/x/crypto v0.40.0
@@ -17,10 +21,9 @@ require (
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.2.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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/go-openapi/jsonpointer v0.21.1 // 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/mailru/easyjson v0.9.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // 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/stretchr/objx v0.5.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/time v0.11.0 // indirect
golang.org/x/tools v0.35.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
require (
@@ -57,7 +52,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // 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/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
+20 -40
View File
@@ -1,17 +1,19 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
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/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
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.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/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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
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/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
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/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
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/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
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.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
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/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/redis/go-redis/v9 v9.12.0 h1:XlVPGlflh4nxfhsNXPA8Qp6EmEfTo0rp8oaBzPipXnU=
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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
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/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
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/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
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.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.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.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.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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
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/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/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/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/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
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/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
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/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
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/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
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-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/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
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-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.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/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-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/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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-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.5.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/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.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.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.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/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
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/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 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
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/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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
View File
@@ -77,9 +77,10 @@ type AuthConfig struct {
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
AccessTokenDuration time.Duration `mapstructure:"access_token_duration"`
RefreshTokenDuration time.Duration `mapstructure:"refresh_token_duration"`
AccessSecret string `mapstructure:"access_secret"`
RefreshSecret string `mapstructure:"refresh_secret"`
AccessExpiresIn int `mapstructure:"access_expires_in"`
RefreshExpiresIn int `mapstructure:"refresh_expires_in"`
}
type AIConfig struct {
+214
View File
@@ -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
+46
View File
@@ -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"`
}
+149
View File
@@ -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,
}
}
+687
View File
@@ -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, &currentUserID)
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, &currentUserID)
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, &currentUserID)
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, &currentUserID)
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, &currentUserID)
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
+192
View File
@@ -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
}
+481
View File
@@ -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
}
+721
View File
@@ -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
}
+35
View File
@@ -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
}
}
+353
View File
@@ -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.
+620
View File
@@ -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
}
+268
View File
@@ -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"])
}
+23
View File
@@ -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")
)
+103
View File
@@ -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
+96
View File
@@ -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()
}
+15
View File
@@ -3,6 +3,7 @@ package response
import (
"net/http"
"github.com/asaskevich/govalidator"
"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
}
Executable
BIN
View File
Binary file not shown.