Add ACL support to OAuth guard and error handling

- Integrated ACL (Access Control List) functionality into the OAuth guard, enabling permission checks and rate limiting based on ACL configurations.
- Updated error responses for token validation and refresh token handling to use standardized error types.
- Enhanced error handling for ACL-related issues, providing detailed HTTP responses based on specific ACL error types.
- Improved overall security and user experience by ensuring proper authorization checks are enforced during API requests.
This commit is contained in:
Max 2025-10-18 09:22:35 +08:00
parent 140368a69b
commit 667d40b5a1
9 changed files with 586 additions and 5 deletions

263
openapi/oauth/ERRORS.md Normal file
View file

@ -0,0 +1,263 @@
# OAuth Error Handling Documentation
This document describes the error handling specifications and ACL error definitions for OAuth services.
## Overview
OAuth services use a standardized error response format. All errors follow the `ErrorResponse` structure:
```go
type ErrorResponse struct {
Code string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
ErrorURI string `json:"error_uri,omitempty"`
State string `json:"state,omitempty"`
}
```
## Configuration Errors
These errors are used during OAuth service initialization and configuration:
| Error Code | Variable Name | Description |
| ---------------------------- | ----------------------------- | -------------------------------------------------------------------- |
| `invalid_configuration` | `ErrInvalidConfiguration` | Invalid OAuth service configuration |
| `store_missing` | `ErrStoreMissing` | Store is required for OAuth service |
| `issuer_url_missing` | `ErrIssuerURLMissing` | Issuer URL is missing |
| `certificate_missing` | `ErrCertificateMissing` | JWT signing certificate and key paths must both be provided or empty |
| `invalid_token_lifetime` | `ErrInvalidTokenLifetime` | Token lifetime must be greater than 0 |
| `pkce_configuration_invalid` | `ErrPKCEConfigurationInvalid` | PKCE configuration is invalid |
## Authentication & Authorization Errors
### Token Related Errors
| Error Code | Variable Name | HTTP Status | Description |
| ----------------------- | ------------------------ | ----------- | -------------------------------------------------- |
| `token_missing` | `ErrTokenMissing` | 401 | No access token provided in the request |
| `invalid_token` | `ErrInvalidToken` | 401 | The access token is invalid, expired or malformed |
| `token_expired` | `ErrTokenExpired` | 401 | The access token has expired |
| `unauthorized` | `ErrUnauthorized` | 401 | Authentication is required to access this resource |
| `refresh_token_missing` | `ErrRefreshTokenMissing` | 401 | No refresh token provided in the request |
| `invalid_refresh_token` | `ErrInvalidRefreshToken` | 401 | The refresh token is invalid or expired |
### Permission Related Errors
| Error Code | Variable Name | HTTP Status | Description |
| -------------------- | ---------------------- | ----------- | -------------------------------------------------- |
| `forbidden` | `ErrForbidden` | 403 | You do not have permission to access this resource |
| `access_denied` | `ErrAccessDenied` | 403 | Access to this resource has been denied |
| `insufficient_scope` | `ErrInsufficientScope` | 403 | The access token does not have the required scope |
### ACL Errors
| Error Code | Variable Name | HTTP Status | Description |
| -------------------- | --------------------- | ----------- | ---------------------------------------- |
| `acl_check_failed` | `ErrACLCheckFailed` | 500 | ACL verification failed |
| `acl_internal_error` | `ErrACLInternalError` | 500 | Internal error occurred during ACL check |
### Rate Limiting Errors
| Error Code | Variable Name | HTTP Status | Description |
| --------------------- | ---------------------- | ----------- | ----------------------------------------- |
| `rate_limit_exceeded` | `ErrRateLimitExceeded` | 429 | Too many requests. Please try again later |
| `too_many_requests` | `ErrTooManyRequests` | 429 | Request rate limit exceeded |
### Resource Errors
| Error Code | Variable Name | HTTP Status | Description |
| -------------------- | --------------------- | ----------- | ------------------------------------------------ |
| `resource_not_found` | `ErrResourceNotFound` | 404 | The requested resource was not found |
| `method_not_allowed` | `ErrMethodNotAllowed` | 405 | The HTTP method is not allowed for this resource |
### Server Errors
| Error Code | Variable Name | HTTP Status | Description |
| ----------------------- | ------------------------ | ----------- | -------------------------------------- |
| `internal_server_error` | `ErrInternalServerError` | 500 | An internal server error occurred |
| `service_unavailable` | `ErrServiceUnavailable` | 503 | The service is temporarily unavailable |
## ACL Error Types
ACL implementations can return the following error types, which the Guard middleware automatically converts to appropriate HTTP responses:
### acl.Error Structure
```go
type Error struct {
Type ErrorType // Error type
Message string // Error message
Details map[string]interface{} // Additional error details
RetryAfter int // Retry wait time (seconds)
}
```
### ACL Error Type Definitions
| Error Type | HTTP Status | Description | Retryable |
| ---------------------- | ----------- | ------------------------------------------------- | --------- |
| `permission_denied` | 403 | User does not have required permissions | No |
| `rate_limit_exceeded` | 429 | Request rate limit exceeded | Yes |
| `insufficient_scope` | 403 | Token scope is insufficient | No |
| `resource_not_allowed` | 403 | Access to the resource is not allowed | No |
| `method_not_allowed` | 405 | The HTTP method is not allowed | No |
| `ip_blocked` | 403 | IP address is blocked | No |
| `geo_restricted` | 403 | Access is restricted based on geographic location | No |
| `time_restricted` | 403 | Access is restricted based on time | Yes |
| `quota_exceeded` | 429 | Usage quota has been exceeded | Yes |
| `invalid_request` | 400 | Request is invalid | No |
| `internal_error` | 500 | Internal error occurred during ACL check | Yes |
### ACL Error Creation Functions
```go
// Basic error creation
acl.NewError(errorType, message)
// Permission denied
acl.NewPermissionDeniedError("User does not have admin role")
// Rate limit error (with retry time)
acl.NewRateLimitError("Too many requests", 60) // Retry after 60 seconds
// Insufficient scope
acl.NewInsufficientScopeError("Missing required scope", []string{"read", "write"})
// Resource not allowed
acl.NewResourceNotAllowedError("/admin/users")
// Method not allowed
acl.NewMethodNotAllowedError("DELETE", []string{"GET", "POST"})
// IP blocked
acl.NewIPBlockedError("192.168.1.1")
// Quota exceeded
acl.NewQuotaExceededError("API quota exceeded", "api_calls", 1000, 1050)
// Internal error
acl.NewInternalError("Failed to load ACL rules")
```
## Usage Examples
### Using in Guard Middleware
The Guard middleware automatically handles all error types:
```go
func (s *Service) Guard(c *gin.Context) {
// Token validation
token := s.getAccessToken(c)
if token == "" {
c.JSON(http.StatusUnauthorized, types.ErrTokenMissing)
c.Abort()
return
}
// ACL check
ok, err := acl.Global.Enforce(c)
if err != nil {
s.handleACLError(c, err) // Automatically handles different types of ACL errors
return
}
}
```
### Implementing ACL Enforce Method
```go
func (a *MyACL) Enforce(c *gin.Context) (bool, error) {
// Check rate limit
if rateLimitExceeded {
return false, acl.NewRateLimitError("Too many requests", 60)
}
// Check permissions
if !hasPermission {
return false, acl.NewPermissionDeniedError("User does not have required permission")
}
// Check IP
if ipBlocked {
return false, acl.NewIPBlockedError(clientIP)
}
// Check quota
if quotaExceeded {
return false, acl.NewQuotaExceededError("API quota exceeded", "api_calls", limit, current)
}
return true, nil
}
```
### Error Response Examples
#### Standard Error Response
```json
{
"error": "token_missing",
"error_description": "No access token provided in the request"
}
```
#### Rate Limit Error Response (with Retry-After header)
```
HTTP/1.1 429 Too Many Requests
Retry-After: 60
{
"error": "rate_limit_exceeded",
"error_description": "Too many requests. Please try again later"
}
```
#### Forbidden Response
```json
{
"error": "forbidden",
"error_description": "You do not have permission to access this resource"
}
```
## Error Handling Best Practices
1. **Use Predefined Error Constants**: Always use `types.Err*` constants instead of manually creating error responses
2. **Provide Detailed Error Information**: For ACL errors, use the Details field to provide additional context
3. **Set Appropriate HTTP Status Codes**: The Guard middleware handles this automatically, but ensure correct status codes elsewhere
4. **Set Retry-After for Retryable Errors**: For rate limiting and quota errors, provide retry time
5. **Log Error Details**: Log detailed error information for debugging before returning errors
6. **Avoid Leaking Sensitive Information**: Error messages should be user-friendly but not expose internal system details
## Standard HTTP Status Code Mapping
- **200 OK**: Request successful
- **400 Bad Request**: Invalid request parameters
- **401 Unauthorized**: Not authenticated or authentication failed
- **403 Forbidden**: Authenticated but not authorized to access
- **404 Not Found**: Resource does not exist
- **405 Method Not Allowed**: HTTP method not allowed
- **429 Too Many Requests**: Rate limit or quota exceeded
- **500 Internal Server Error**: Internal server error
- **503 Service Unavailable**: Service temporarily unavailable
## Testing Error Handling
It is recommended to test the following scenarios:
1. Missing Token
2. Invalid Token
3. Expired Token
4. Insufficient Permissions
5. Rate Limit Exceeded
6. Quota Exceeded
7. IP Blocked
8. Resource Not Found
9. Method Not Allowed
10. ACL Internal Error
Each scenario should return the appropriate HTTP status code and error response.

27
openapi/oauth/acl/acl.go Normal file
View file

@ -0,0 +1,27 @@
package acl
// Global is the global ACL enforcer
var Global Enforcer = nil
// New creates a new ACL enforcer
func New(config *Config) Enforcer {
if config == nil {
config = &DefaultConfig
}
return &ACL{
Config: config,
}
}
// Load loads the ACL enforcer
func Load(config *Config) (Enforcer, error) {
Global = New(config)
return Global, nil
}
// Enabled returns true if the ACL is enabled, otherwise false
func (acl *ACL) Enabled() bool {
return acl.Config.Enabled
}

View file

@ -0,0 +1,8 @@
package acl
import "github.com/gin-gonic/gin"
// Enforce checks if the user has access to the resource
func (acl *ACL) Enforce(c *gin.Context) (bool, error) {
return true, nil
}

126
openapi/oauth/acl/errors.go Normal file
View file

@ -0,0 +1,126 @@
package acl
import "fmt"
// ErrorType represents different types of ACL errors
type ErrorType string
const (
// ErrorTypePermissionDenied indicates the user does not have required permissions
ErrorTypePermissionDenied ErrorType = "permission_denied"
// ErrorTypeRateLimitExceeded indicates the request rate limit has been exceeded
ErrorTypeRateLimitExceeded ErrorType = "rate_limit_exceeded"
// ErrorTypeInsufficientScope indicates the token scope is insufficient
ErrorTypeInsufficientScope ErrorType = "insufficient_scope"
// ErrorTypeResourceNotAllowed indicates access to the resource is not allowed
ErrorTypeResourceNotAllowed ErrorType = "resource_not_allowed"
// ErrorTypeMethodNotAllowed indicates the HTTP method is not allowed
ErrorTypeMethodNotAllowed ErrorType = "method_not_allowed"
// ErrorTypeIPBlocked indicates the request IP is blocked
ErrorTypeIPBlocked ErrorType = "ip_blocked"
// ErrorTypeGeoRestricted indicates access is restricted based on geographic location
ErrorTypeGeoRestricted ErrorType = "geo_restricted"
// ErrorTypeTimeRestricted indicates access is restricted based on time
ErrorTypeTimeRestricted ErrorType = "time_restricted"
// ErrorTypeQuotaExceeded indicates the usage quota has been exceeded
ErrorTypeQuotaExceeded ErrorType = "quota_exceeded"
// ErrorTypeInvalidRequest indicates the request is invalid
ErrorTypeInvalidRequest ErrorType = "invalid_request"
// ErrorTypeInternal indicates an internal error occurred during ACL check
ErrorTypeInternal ErrorType = "internal_error"
)
// Error represents an ACL-related error with additional context
type Error struct {
Type ErrorType
Message string
Details map[string]interface{}
RetryAfter int // seconds to wait before retrying (for rate limit errors)
}
// Error implements the error interface
func (e *Error) Error() string {
if e.Message != "" {
return fmt.Sprintf("ACL error [%s]: %s", e.Type, e.Message)
}
return fmt.Sprintf("ACL error: %s", e.Type)
}
// IsRetryable returns true if the error is retryable (e.g., rate limit)
func (e *Error) IsRetryable() bool {
return e.Type == ErrorTypeRateLimitExceeded || e.Type == ErrorTypeQuotaExceeded
}
// NewError creates a new ACL error
func NewError(errorType ErrorType, message string) *Error {
return &Error{
Type: errorType,
Message: message,
Details: make(map[string]interface{}),
}
}
// NewPermissionDeniedError creates a permission denied error
func NewPermissionDeniedError(message string) *Error {
return NewError(ErrorTypePermissionDenied, message)
}
// NewRateLimitError creates a rate limit error with retry-after information
func NewRateLimitError(message string, retryAfter int) *Error {
err := NewError(ErrorTypeRateLimitExceeded, message)
err.RetryAfter = retryAfter
return err
}
// NewInsufficientScopeError creates an insufficient scope error
func NewInsufficientScopeError(message string, requiredScopes []string) *Error {
err := NewError(ErrorTypeInsufficientScope, message)
err.Details["required_scopes"] = requiredScopes
return err
}
// NewResourceNotAllowedError creates a resource not allowed error
func NewResourceNotAllowedError(resource string) *Error {
err := NewError(ErrorTypeResourceNotAllowed, "Access to this resource is not allowed")
err.Details["resource"] = resource
return err
}
// NewMethodNotAllowedError creates a method not allowed error
func NewMethodNotAllowedError(method string, allowedMethods []string) *Error {
err := NewError(ErrorTypeMethodNotAllowed, fmt.Sprintf("HTTP method '%s' is not allowed", method))
err.Details["method"] = method
err.Details["allowed_methods"] = allowedMethods
return err
}
// NewIPBlockedError creates an IP blocked error
func NewIPBlockedError(ip string) *Error {
err := NewError(ErrorTypeIPBlocked, "Access from this IP address is blocked")
err.Details["ip"] = ip
return err
}
// NewQuotaExceededError creates a quota exceeded error
func NewQuotaExceededError(message string, quotaType string, limit int64, current int64) *Error {
err := NewError(ErrorTypeQuotaExceeded, message)
err.Details["quota_type"] = quotaType
err.Details["limit"] = limit
err.Details["current"] = current
return err
}
// NewInternalError creates an internal error
func NewInternalError(message string) *Error {
return NewError(ErrorTypeInternal, message)
}

View file

@ -0,0 +1,12 @@
package acl
import "github.com/gin-gonic/gin"
// Enforcer interface is used to enforce access control rules
type Enforcer interface {
// Enforce checks if a user has access to a resource
Enforce(c *gin.Context) (bool, error)
// Enabled returns true if the ACL is enabled, otherwise false
Enabled() bool
}

View file

@ -0,0 +1,16 @@
package acl
// DefaultConfig is the default configuration for the ACL
var DefaultConfig = Config{
Enabled: false,
}
// Config is the configuration for the ACL
type Config struct {
Enabled bool `json:"enabled"`
}
// ACL is the ACL checker
type ACL struct {
Config *Config
}

View file

@ -1,11 +1,13 @@
package oauth
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/acl"
"github.com/yaoapp/yao/openapi/oauth/types"
)
@ -16,7 +18,7 @@ func (s *Service) Guard(c *gin.Context) {
// Validate the token
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.JSON(http.StatusUnauthorized, types.ErrTokenMissing)
c.Abort()
return
}
@ -24,7 +26,7 @@ func (s *Service) Guard(c *gin.Context) {
// Validate the token
claims, err := s.VerifyToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.JSON(http.StatusUnauthorized, types.ErrInvalidToken)
c.Abort()
return
}
@ -36,6 +38,25 @@ func (s *Service) Guard(c *gin.Context) {
// Set Authorized Info
s.setAuthorizedInfo(c, claims)
// Check if ACL is enabled
if acl.Global == nil || !acl.Global.Enabled() {
return
}
// Check permissions and enforce rate limits when ACL is configured
ok, err := acl.Global.Enforce(c)
if err != nil {
s.handleACLError(c, err)
return
}
// If permissions are not granted, return forbidden
if !ok {
c.JSON(http.StatusForbidden, types.ErrForbidden)
c.Abort()
return
}
}
// GetAuthorizedInfo Get Authorized Info from context
@ -114,7 +135,7 @@ func (s *Service) setAuthorizedInfo(c *gin.Context, claims *types.TokenClaims) {
func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) {
refreshToken := s.getRefreshToken(c)
if refreshToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.JSON(http.StatusUnauthorized, types.ErrRefreshTokenMissing)
c.Abort()
return
}
@ -122,7 +143,7 @@ func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) {
// Verify the refresh token
_, err := s.VerifyToken(refreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.JSON(http.StatusUnauthorized, types.ErrInvalidRefreshToken)
c.Abort()
return
}
@ -195,3 +216,72 @@ func (s *Service) getSessionID(c *gin.Context) string {
return ""
}
// handleACLError handles ACL errors and returns appropriate HTTP responses
func (s *Service) handleACLError(c *gin.Context, err error) {
// Check if it's an ACL error with detailed information
if aclErr, ok := err.(*acl.Error); ok {
var statusCode int
var errResponse *types.ErrorResponse
switch aclErr.Type {
case acl.ErrorTypeRateLimitExceeded:
statusCode = http.StatusTooManyRequests
errResponse = types.ErrRateLimitExceeded
// Set Retry-After header if available
if aclErr.RetryAfter > 0 {
c.Header("Retry-After", fmt.Sprintf("%d", aclErr.RetryAfter))
}
case acl.ErrorTypeQuotaExceeded:
statusCode = http.StatusTooManyRequests
errResponse = &types.ErrorResponse{
Code: "quota_exceeded",
ErrorDescription: aclErr.Message,
}
case acl.ErrorTypeInsufficientScope:
statusCode = http.StatusForbidden
errResponse = types.ErrInsufficientScope
case acl.ErrorTypePermissionDenied:
statusCode = http.StatusForbidden
errResponse = types.ErrForbidden
case acl.ErrorTypeResourceNotAllowed:
statusCode = http.StatusForbidden
errResponse = types.ErrAccessDenied
case acl.ErrorTypeMethodNotAllowed:
statusCode = http.StatusMethodNotAllowed
errResponse = types.ErrMethodNotAllowed
case acl.ErrorTypeIPBlocked, acl.ErrorTypeGeoRestricted, acl.ErrorTypeTimeRestricted:
statusCode = http.StatusForbidden
errResponse = types.ErrAccessDenied
case acl.ErrorTypeInvalidRequest:
statusCode = http.StatusBadRequest
errResponse = &types.ErrorResponse{
Code: "invalid_request",
ErrorDescription: aclErr.Message,
}
case acl.ErrorTypeInternal:
statusCode = http.StatusInternalServerError
errResponse = types.ErrACLInternalError
default:
statusCode = http.StatusInternalServerError
errResponse = types.ErrACLInternalError
}
c.JSON(statusCode, errResponse)
c.Abort()
return
}
// If it's not an ACL error, treat it as an internal error
c.JSON(http.StatusInternalServerError, types.ErrACLInternalError)
c.Abort()
}

View file

@ -1,6 +1,6 @@
package types
// Error definitions
// Configuration Error definitions
var (
ErrInvalidConfiguration = &ErrorResponse{Code: "invalid_configuration", ErrorDescription: "Invalid OAuth service configuration"}
ErrStoreMissing = &ErrorResponse{Code: "store_missing", ErrorDescription: "Store is required for OAuth service"}
@ -9,3 +9,35 @@ var (
ErrInvalidTokenLifetime = &ErrorResponse{Code: "invalid_token_lifetime", ErrorDescription: "Token lifetime must be greater than 0"}
ErrPKCEConfigurationInvalid = &ErrorResponse{Code: "pkce_configuration_invalid", ErrorDescription: "PKCE configuration is invalid"}
)
// Authentication & Authorization Error definitions
var (
// Token related errors
ErrUnauthorized = &ErrorResponse{Code: "unauthorized", ErrorDescription: "Authentication is required to access this resource"}
ErrInvalidToken = &ErrorResponse{Code: "invalid_token", ErrorDescription: "The access token provided is invalid, expired or malformed"}
ErrTokenExpired = &ErrorResponse{Code: "token_expired", ErrorDescription: "The access token has expired"}
ErrTokenMissing = &ErrorResponse{Code: "token_missing", ErrorDescription: "No access token provided in the request"}
ErrInvalidRefreshToken = &ErrorResponse{Code: "invalid_refresh_token", ErrorDescription: "The refresh token provided is invalid or expired"}
ErrRefreshTokenMissing = &ErrorResponse{Code: "refresh_token_missing", ErrorDescription: "No refresh token provided in the request"}
// Permission related errors
ErrForbidden = &ErrorResponse{Code: "forbidden", ErrorDescription: "You do not have permission to access this resource"}
ErrInsufficientScope = &ErrorResponse{Code: "insufficient_scope", ErrorDescription: "The access token does not have the required scope"}
ErrAccessDenied = &ErrorResponse{Code: "access_denied", ErrorDescription: "Access to this resource has been denied"}
// ACL related errors
ErrACLCheckFailed = &ErrorResponse{Code: "acl_check_failed", ErrorDescription: "ACL verification failed"}
ErrACLInternalError = &ErrorResponse{Code: "acl_internal_error", ErrorDescription: "Internal error occurred during ACL verification"}
// Rate limiting errors
ErrRateLimitExceeded = &ErrorResponse{Code: "rate_limit_exceeded", ErrorDescription: "Too many requests. Please try again later"}
ErrTooManyRequests = &ErrorResponse{Code: "too_many_requests", ErrorDescription: "Request rate limit exceeded"}
// Resource related errors
ErrResourceNotFound = &ErrorResponse{Code: "resource_not_found", ErrorDescription: "The requested resource was not found"}
ErrMethodNotAllowed = &ErrorResponse{Code: "method_not_allowed", ErrorDescription: "The HTTP method is not allowed for this resource"}
// Server errors
ErrInternalServerError = &ErrorResponse{Code: "internal_server_error", ErrorDescription: "An internal server error occurred"}
ErrServiceUnavailable = &ErrorResponse{Code: "service_unavailable", ErrorDescription: "The service is temporarily unavailable"}
)

View file

@ -15,6 +15,7 @@ import (
"github.com/yaoapp/yao/openapi/kb"
"github.com/yaoapp/yao/openapi/messenger"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/acl"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/team"
"github.com/yaoapp/yao/openapi/user"
@ -63,6 +64,12 @@ func Load(appConfig config.Config) (*OpenAPI, error) {
return nil, err
}
// Load the ACL enforcer
_, err = acl.Load(&acl.Config{Enabled: true})
if err != nil {
return nil, err
}
// Create the OpenAPI server
Server = &OpenAPI{Config: &config, OAuth: oauthService}
return Server, nil