fix(go.sum): update ResolveSpec dependency to v1.0.87
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
||||
# Keystore
|
||||
|
||||
Per-user named auth keys with pluggable storage. Each user can hold multiple keys of different types — JWT secrets, header API keys, OAuth2 client credentials, or generic API keys. Keys are identified by a human-readable name ("CI deploy", "mobile app") and can carry scopes and arbitrary metadata.
|
||||
|
||||
## Key types
|
||||
|
||||
| Constant | Value | Use case |
|
||||
|---|---|---|
|
||||
| `KeyTypeJWTSecret` | `jwt_secret` | Per-user JWT signing secret |
|
||||
| `KeyTypeHeaderAPI` | `header_api` | Static API key sent in a request header |
|
||||
| `KeyTypeOAuth2` | `oauth2` | OAuth2 client credentials |
|
||||
| `KeyTypeGenericAPI` | `api` | General-purpose application key |
|
||||
|
||||
## Storage backends
|
||||
|
||||
### ConfigKeyStore
|
||||
|
||||
In-memory store seeded from a static list. Suitable for a small, fixed set of service-account keys loaded from a config file. Keys created at runtime via `CreateKey` are held in memory and lost on restart.
|
||||
|
||||
```go
|
||||
// Pre-load keys from config (KeyHash = SHA-256 hex of the raw key)
|
||||
store := security.NewConfigKeyStore([]security.UserKey{
|
||||
{
|
||||
UserID: 1,
|
||||
KeyType: security.KeyTypeGenericAPI,
|
||||
KeyHash: "e3b0c44298fc1c149afb...", // sha256(rawKey)
|
||||
Name: "CI deploy",
|
||||
Scopes: []string{"deploy"},
|
||||
IsActive: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### DatabaseKeyStore
|
||||
|
||||
Backed by PostgreSQL stored procedures. Supports optional caching (default 2-minute TTL). Apply `keystore_schema.sql` before use.
|
||||
|
||||
```go
|
||||
db, _ := sql.Open("postgres", dsn)
|
||||
|
||||
store := security.NewDatabaseKeyStore(db)
|
||||
|
||||
// With options
|
||||
store = security.NewDatabaseKeyStore(db, security.DatabaseKeyStoreOptions{
|
||||
CacheTTL: 5 * time.Minute,
|
||||
SQLNames: &security.KeyStoreSQLNames{
|
||||
ValidateKey: "myapp_keystore_validate", // override one procedure name
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Managing keys
|
||||
|
||||
```go
|
||||
ctx := context.Background()
|
||||
|
||||
// Create — raw key returned once; store it securely
|
||||
resp, err := store.CreateKey(ctx, security.CreateKeyRequest{
|
||||
UserID: 42,
|
||||
KeyType: security.KeyTypeGenericAPI,
|
||||
Name: "mobile app",
|
||||
Scopes: []string{"read", "write"},
|
||||
})
|
||||
fmt.Println(resp.RawKey) // only shown here; hashed internally
|
||||
|
||||
// List
|
||||
keys, err := store.GetUserKeys(ctx, 42, "") // "" = all types
|
||||
keys, err = store.GetUserKeys(ctx, 42, security.KeyTypeGenericAPI)
|
||||
|
||||
// Revoke
|
||||
err = store.DeleteKey(ctx, 42, resp.Key.ID)
|
||||
|
||||
// Validate (used by authenticators internally)
|
||||
key, err := store.ValidateKey(ctx, rawKey, "")
|
||||
```
|
||||
|
||||
## HTTP authentication
|
||||
|
||||
`KeyStoreAuthenticator` wraps any `KeyStore` and implements the `Authenticator` interface. It is drop-in compatible with `DatabaseAuthenticator` and works in `CompositeSecurityProvider`.
|
||||
|
||||
Keys are extracted from the request in this order:
|
||||
|
||||
1. `Authorization: Bearer <key>`
|
||||
2. `Authorization: ApiKey <key>`
|
||||
3. `X-API-Key: <key>`
|
||||
|
||||
```go
|
||||
auth := security.NewKeyStoreAuthenticator(store, "") // "" = accept any key type
|
||||
// Restrict to a specific type:
|
||||
auth = security.NewKeyStoreAuthenticator(store, security.KeyTypeGenericAPI)
|
||||
```
|
||||
|
||||
Plug it into a handler:
|
||||
|
||||
```go
|
||||
handler := resolvespec.NewHandler(db, registry,
|
||||
resolvespec.WithAuthenticator(auth),
|
||||
)
|
||||
```
|
||||
|
||||
`Login` and `Logout` return an error — key lifecycle is managed through `KeyStore` directly.
|
||||
|
||||
On successful validation the request context receives a `UserContext` where:
|
||||
|
||||
- `UserID` — from the key
|
||||
- `Roles` — the key's `Scopes`
|
||||
- `Claims["key_type"]` — key type string
|
||||
- `Claims["key_name"]` — key name
|
||||
|
||||
## Database setup
|
||||
|
||||
Apply `keystore_schema.sql` to your PostgreSQL database. It requires the `users` table from the main `database_schema.sql`.
|
||||
|
||||
```sql
|
||||
\i pkg/security/keystore_schema.sql
|
||||
```
|
||||
|
||||
This creates:
|
||||
|
||||
- `user_keys` table with indexes on `user_id`, `key_hash`, and `key_type`
|
||||
- `resolvespec_keystore_get_user_keys(p_user_id, p_key_type)`
|
||||
- `resolvespec_keystore_create_key(p_request jsonb)`
|
||||
- `resolvespec_keystore_delete_key(p_user_id, p_key_id)`
|
||||
- `resolvespec_keystore_validate_key(p_key_hash, p_key_type)`
|
||||
|
||||
### Custom procedure names
|
||||
|
||||
```go
|
||||
store := security.NewDatabaseKeyStore(db, security.DatabaseKeyStoreOptions{
|
||||
SQLNames: &security.KeyStoreSQLNames{
|
||||
GetUserKeys: "myschema_get_keys",
|
||||
CreateKey: "myschema_create_key",
|
||||
DeleteKey: "myschema_delete_key",
|
||||
ValidateKey: "myschema_validate_key",
|
||||
},
|
||||
})
|
||||
|
||||
// Validate names at startup
|
||||
names := &security.KeyStoreSQLNames{
|
||||
GetUserKeys: "myschema_get_keys",
|
||||
// ...
|
||||
}
|
||||
if err := security.ValidateKeyStoreSQLNames(names); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Raw keys are never stored. Only the SHA-256 hex digest is persisted.
|
||||
- The raw key is generated with `crypto/rand` (32 bytes, base64url-encoded) and returned exactly once in `CreateKeyResponse.RawKey`.
|
||||
- Hash comparisons in `ConfigKeyStore` use `crypto/subtle.ConstantTimeCompare` to prevent timing side-channels.
|
||||
- `DeleteKey` performs a soft delete (`is_active = false`). The `DatabaseKeyStore` invalidates the cache entry immediately, but due to the cache TTL a revoked key may authenticate for up to `CacheTTL` (default 2 minutes) in a distributed environment. Set `CacheTTL: 0` to disable caching if immediate revocation is required.
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
# OAuth2 Authentication Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The security package provides OAuth2 authentication support for any OAuth2-compliant provider including Google, GitHub, Microsoft, Facebook, and custom providers.
|
||||
|
||||
## Features
|
||||
|
||||
- **Universal OAuth2 Support**: Works with any OAuth2 provider
|
||||
- **Pre-configured Providers**: Google, GitHub, Microsoft, Facebook
|
||||
- **Multi-Provider Support**: Use all OAuth2 providers simultaneously
|
||||
- **Custom Providers**: Easy configuration for any OAuth2 service
|
||||
- **Session Management**: Database-backed session storage
|
||||
- **Token Refresh**: Automatic token refresh support
|
||||
- **State Validation**: Built-in CSRF protection
|
||||
- **User Auto-Creation**: Automatically creates users on first login
|
||||
- **Unified Authentication**: OAuth2 and traditional auth share same session storage
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Database Setup
|
||||
|
||||
```sql
|
||||
-- Run the schema from database_schema.sql
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255),
|
||||
user_level INTEGER DEFAULT 0,
|
||||
roles VARCHAR(500),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TIMESTAMP,
|
||||
remote_id VARCHAR(255),
|
||||
auth_provider VARCHAR(50)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_token VARCHAR(500) NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_activity_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
token_type VARCHAR(50) DEFAULT 'Bearer',
|
||||
auth_provider VARCHAR(50)
|
||||
);
|
||||
|
||||
-- OAuth2 stored procedures (7 functions)
|
||||
-- See database_schema.sql for full implementation
|
||||
```
|
||||
|
||||
### 2. Google OAuth2
|
||||
|
||||
```go
|
||||
import "github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
|
||||
// Create authenticator
|
||||
oauth2Auth := security.NewGoogleAuthenticator(
|
||||
"your-google-client-id",
|
||||
"your-google-client-secret",
|
||||
"http://localhost:8080/auth/google/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
// Login route - redirects to Google
|
||||
router.HandleFunc("/auth/google/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := oauth2Auth.OAuth2GenerateState()
|
||||
authURL, _ := oauth2Auth.OAuth2GetAuthURL(state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
// Callback route - handles Google response
|
||||
router.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
loginResp, err := oauth2Auth.OAuth2HandleCallback(r.Context(), code, state)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Set session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: loginResp.Token,
|
||||
Path: "/",
|
||||
MaxAge: int(loginResp.ExpiresIn),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
|
||||
})
|
||||
```
|
||||
|
||||
### 3. GitHub OAuth2
|
||||
|
||||
```go
|
||||
oauth2Auth := security.NewGitHubAuthenticator(
|
||||
"your-github-client-id",
|
||||
"your-github-client-secret",
|
||||
"http://localhost:8080/auth/github/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
// Same routes pattern as Google
|
||||
router.HandleFunc("/auth/github/login", ...)
|
||||
router.HandleFunc("/auth/github/callback", ...)
|
||||
```
|
||||
|
||||
### 4. Microsoft OAuth2
|
||||
|
||||
```go
|
||||
oauth2Auth := security.NewMicrosoftAuthenticator(
|
||||
"your-microsoft-client-id",
|
||||
"your-microsoft-client-secret",
|
||||
"http://localhost:8080/auth/microsoft/callback",
|
||||
db,
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Facebook OAuth2
|
||||
|
||||
```go
|
||||
oauth2Auth := security.NewFacebookAuthenticator(
|
||||
"your-facebook-client-id",
|
||||
"your-facebook-client-secret",
|
||||
"http://localhost:8080/auth/facebook/callback",
|
||||
db,
|
||||
)
|
||||
```
|
||||
|
||||
## Custom OAuth2 Provider
|
||||
|
||||
```go
|
||||
oauth2Auth := security.NewDatabaseAuthenticator(db).WithOAuth2(security.OAuth2Config{
|
||||
ClientID: "your-client-id",
|
||||
ClientSecret: "your-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
AuthURL: "https://your-provider.com/oauth/authorize",
|
||||
TokenURL: "https://your-provider.com/oauth/token",
|
||||
UserInfoURL: "https://your-provider.com/oauth/userinfo",
|
||||
DB: db,
|
||||
ProviderName: "custom",
|
||||
|
||||
// Optional: Custom user info parser
|
||||
UserInfoParser: func(userInfo map[string]any) (*security.UserContext, error) {
|
||||
return &security.UserContext{
|
||||
UserName: userInfo["username"].(string),
|
||||
Email: userInfo["email"].(string),
|
||||
RemoteID: userInfo["id"].(string),
|
||||
UserLevel: 1,
|
||||
Roles: []string{"user"},
|
||||
Claims: userInfo,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Protected Routes
|
||||
|
||||
```go
|
||||
// Create security provider
|
||||
colSec := security.NewDatabaseColumnSecurityProvider(db)
|
||||
rowSec := security.NewDatabaseRowSecurityProvider(db)
|
||||
provider, _ := security.NewCompositeSecurityProvider(oauth2Auth, colSec, rowSec)
|
||||
securityList, _ := security.NewSecurityList(provider)
|
||||
|
||||
// Apply middleware to protected routes
|
||||
protectedRouter := router.PathPrefix("/api").Subrouter()
|
||||
protectedRouter.Use(security.NewAuthMiddleware(securityList))
|
||||
protectedRouter.Use(security.SetSecurityMiddleware(securityList))
|
||||
|
||||
protectedRouter.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx, _ := security.GetUserContext(r.Context())
|
||||
json.NewEncoder(w).Encode(userCtx)
|
||||
})
|
||||
```
|
||||
|
||||
## Token Refresh
|
||||
|
||||
OAuth2 access tokens expire after a period of time. Use the refresh token to obtain a new access token without requiring the user to log in again.
|
||||
|
||||
```go
|
||||
router.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Provider string `json:"provider"` // "google", "github", etc.
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Default to google if not specified
|
||||
if req.Provider == "" {
|
||||
req.Provider = "google"
|
||||
}
|
||||
|
||||
// Use OAuth2-specific refresh method
|
||||
loginResp, err := oauth2Auth.OAuth2RefreshToken(r.Context(), req.RefreshToken, req.Provider)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Set new session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: loginResp.Token,
|
||||
Path: "/",
|
||||
MaxAge: int(loginResp.ExpiresIn),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
})
|
||||
|
||||
json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- The refresh token is returned in the `LoginResponse.RefreshToken` field after successful OAuth2 callback
|
||||
- Store the refresh token securely on the client side
|
||||
- Each provider must be configured with the appropriate scopes to receive a refresh token (e.g., `access_type=offline` for Google)
|
||||
- The `OAuth2RefreshToken` method requires the provider name to identify which OAuth2 provider to use for refreshing
|
||||
|
||||
## Logout
|
||||
|
||||
```go
|
||||
router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx, _ := security.GetUserContext(r.Context())
|
||||
|
||||
oauth2Auth.Logout(r.Context(), security.LogoutRequest{
|
||||
Token: userCtx.SessionID,
|
||||
UserID: userCtx.UserID,
|
||||
})
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
```
|
||||
|
||||
## Multi-Provider Setup
|
||||
|
||||
```go
|
||||
// Single DatabaseAuthenticator with ALL OAuth2 providers
|
||||
auth := security.NewDatabaseAuthenticator(db).
|
||||
WithOAuth2(security.OAuth2Config{
|
||||
ClientID: "google-client-id",
|
||||
ClientSecret: "google-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/google/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
UserInfoURL: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
ProviderName: "google",
|
||||
}).
|
||||
WithOAuth2(security.OAuth2Config{
|
||||
ClientID: "github-client-id",
|
||||
ClientSecret: "github-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/github/callback",
|
||||
Scopes: []string{"user:email"},
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
UserInfoURL: "https://api.github.com/user",
|
||||
ProviderName: "github",
|
||||
})
|
||||
|
||||
// Get list of configured providers
|
||||
providers := auth.OAuth2GetProviders() // ["google", "github"]
|
||||
|
||||
// Google routes
|
||||
router.HandleFunc("/auth/google/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := auth.OAuth2GenerateState()
|
||||
authURL, _ := auth.OAuth2GetAuthURL("google", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
router.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
loginResp, err := auth.OAuth2HandleCallback(r.Context(), "google",
|
||||
r.URL.Query().Get("code"), r.URL.Query().Get("state"))
|
||||
// ... handle response
|
||||
})
|
||||
|
||||
// GitHub routes
|
||||
router.HandleFunc("/auth/github/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := auth.OAuth2GenerateState()
|
||||
authURL, _ := auth.OAuth2GetAuthURL("github", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
router.HandleFunc("/auth/github/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
loginResp, err := auth.OAuth2HandleCallback(r.Context(), "github",
|
||||
r.URL.Query().Get("code"), r.URL.Query().Get("state"))
|
||||
// ... handle response
|
||||
})
|
||||
|
||||
// Use same authenticator for protected routes - works for ALL providers
|
||||
provider, _ := security.NewCompositeSecurityProvider(auth, colSec, rowSec)
|
||||
securityList, _ := security.NewSecurityList(provider)
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### OAuth2Config Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| ClientID | string | OAuth2 client ID from provider |
|
||||
| ClientSecret | string | OAuth2 client secret |
|
||||
| RedirectURL | string | Callback URL registered with provider |
|
||||
| Scopes | []string | OAuth2 scopes to request |
|
||||
| AuthURL | string | Provider's authorization endpoint |
|
||||
| TokenURL | string | Provider's token endpoint |
|
||||
| UserInfoURL | string | Provider's user info endpoint |
|
||||
| DB | *sql.DB | Database connection for sessions |
|
||||
| UserInfoParser | func | Custom parser for user info (optional) |
|
||||
| StateValidator | func | Custom state validator (optional) |
|
||||
| ProviderName | string | Provider name for logging (optional) |
|
||||
|
||||
## User Info Parsing
|
||||
|
||||
The default parser extracts these standard fields:
|
||||
- `sub` → RemoteID
|
||||
- `email` → Email, UserName
|
||||
- `name` → UserName
|
||||
- `login` → UserName (GitHub)
|
||||
|
||||
Custom parser example:
|
||||
|
||||
```go
|
||||
UserInfoParser: func(userInfo map[string]any) (*security.UserContext, error) {
|
||||
// Extract custom fields
|
||||
ctx := &security.UserContext{
|
||||
UserName: userInfo["preferred_username"].(string),
|
||||
Email: userInfo["email"].(string),
|
||||
RemoteID: userInfo["sub"].(string),
|
||||
UserLevel: 1,
|
||||
Roles: []string{"user"},
|
||||
Claims: userInfo, // Store all claims
|
||||
}
|
||||
|
||||
// Add custom roles based on provider data
|
||||
if groups, ok := userInfo["groups"].([]interface{}); ok {
|
||||
for _, g := range groups {
|
||||
ctx.Roles = append(ctx.Roles, g.(string))
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Always use HTTPS in production**
|
||||
```go
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Secure: true, // Only send over HTTPS
|
||||
HttpOnly: true, // Prevent XSS access
|
||||
SameSite: http.SameSiteLaxMode, // CSRF protection
|
||||
})
|
||||
```
|
||||
|
||||
2. **Store secrets securely**
|
||||
```go
|
||||
clientID := os.Getenv("GOOGLE_CLIENT_ID")
|
||||
clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
|
||||
```
|
||||
|
||||
3. **Validate redirect URLs**
|
||||
- Only register trusted redirect URLs with OAuth2 providers
|
||||
- Never accept redirect URL from request parameters
|
||||
|
||||
5. **Session expiration**
|
||||
- OAuth2 sessions automatically expire based on token expiry
|
||||
- Clean up expired sessions periodically:
|
||||
```sql
|
||||
DELETE FROM user_sessions WHERE expires_at < NOW();
|
||||
```
|
||||
|
||||
4. **State parameter**
|
||||
- Automatically generated with cryptographic randomness
|
||||
- One-time use and expires after 10 minutes
|
||||
- Prevents CSRF attacks
|
||||
|
||||
## Implementation Details
|
||||
|
||||
All database operations use stored procedures for consistency and security:
|
||||
- `resolvespec_oauth_getorcreateuser` - Find or create OAuth2 user
|
||||
- `resolvespec_oauth_createsession` - Create OAuth2 session
|
||||
- `resolvespec_oauth_getsession` - Validate and retrieve session
|
||||
- `resolvespec_oauth_deletesession` - Logout/delete session
|
||||
- `resolvespec_oauth_getrefreshtoken` - Get session by refresh token
|
||||
- `resolvespec_oauth_updaterefreshtoken` - Update tokens after refresh
|
||||
- `resolvespec_oauth_getuser` - Get user data by ID
|
||||
|
||||
## Provider Setup Guides
|
||||
|
||||
### Google
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select existing
|
||||
3. Enable Google+ API
|
||||
4. Create OAuth 2.0 credentials
|
||||
5. Add authorized redirect URI: `http://localhost:8080/auth/google/callback`
|
||||
6. Copy Client ID and Client Secret
|
||||
|
||||
### GitHub
|
||||
|
||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Click "New OAuth App"
|
||||
3. Set Homepage URL: `http://localhost:8080`
|
||||
4. Set Authorization callback URL: `http://localhost:8080/auth/github/callback`
|
||||
5. Copy Client ID and Client Secret
|
||||
|
||||
### Microsoft
|
||||
|
||||
1. Go to [Azure Portal](https://portal.azure.com/)
|
||||
2. Register new application in Azure AD
|
||||
3. Add redirect URI: `http://localhost:8080/auth/microsoft/callback`
|
||||
4. Create client secret
|
||||
5. Copy Application (client) ID and secret value
|
||||
|
||||
### Facebook
|
||||
|
||||
1. Go to [Facebook Developers](https://developers.facebook.com/)
|
||||
2. Create new app
|
||||
3. Add Facebook Login product
|
||||
4. Set Valid OAuth Redirect URIs: `http://localhost:8080/auth/facebook/callback`
|
||||
5. Copy App ID and App Secret
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "redirect_uri_mismatch" error
|
||||
- Ensure the redirect URL in code matches exactly with provider configuration
|
||||
- Include protocol (http/https), domain, port, and path
|
||||
|
||||
### "invalid_client" error
|
||||
- Verify Client ID and Client Secret are correct
|
||||
- Check if credentials are for the correct environment (dev/prod)
|
||||
|
||||
### "invalid_grant" error during token exchange
|
||||
- State parameter validation failed
|
||||
- Token might have expired
|
||||
- Check server time synchronization
|
||||
|
||||
### User not created after successful OAuth2 login
|
||||
- Check database constraints (username/email unique)
|
||||
- Verify UserInfoParser is extracting required fields
|
||||
- Check database logs for constraint violations
|
||||
|
||||
## Testing
|
||||
|
||||
```go
|
||||
func TestOAuth2Flow(t *testing.T) {
|
||||
// Mock database
|
||||
db, mock, _ := sqlmock.New()
|
||||
|
||||
oauth2Auth := security.NewGoogleAuthenticator(
|
||||
"test-client-id",
|
||||
"test-client-secret",
|
||||
"http://localhost/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
// Test state generation
|
||||
state, err := oauth2Auth.GenerateState()
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, state)
|
||||
|
||||
// Test auth URL generation
|
||||
authURL := oauth2Auth.GetAuthURL(state)
|
||||
assert.Contains(t, authURL, "accounts.google.com")
|
||||
assert.Contains(t, authURL, state)
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### DatabaseAuthenticator with OAuth2
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| WithOAuth2(cfg) | Adds OAuth2 provider (can be called multiple times, returns *DatabaseAuthenticator) |
|
||||
| OAuth2GetAuthURL(provider, state) | Returns OAuth2 authorization URL for specified provider |
|
||||
| OAuth2GenerateState() | Generates random state for CSRF protection |
|
||||
| OAuth2HandleCallback(ctx, provider, code, state) | Exchanges code for token and creates session |
|
||||
| OAuth2RefreshToken(ctx, refreshToken, provider) | Refreshes expired access token using refresh token |
|
||||
| OAuth2GetProviders() | Returns list of configured OAuth2 provider names |
|
||||
| Login(ctx, req) | Standard username/password login |
|
||||
| Logout(ctx, req) | Invalidates session (works for both OAuth2 and regular sessions) |
|
||||
| Authenticate(r) | Validates session token from request (works for both OAuth2 and regular sessions) |
|
||||
|
||||
### Pre-configured Constructors
|
||||
|
||||
- `NewGoogleAuthenticator(clientID, secret, redirectURL, db)` - Single provider
|
||||
- `NewGitHubAuthenticator(clientID, secret, redirectURL, db)` - Single provider
|
||||
- `NewMicrosoftAuthenticator(clientID, secret, redirectURL, db)` - Single provider
|
||||
- `NewFacebookAuthenticator(clientID, secret, redirectURL, db)` - Single provider
|
||||
- `NewMultiProviderAuthenticator(db, configs)` - Multiple providers at once
|
||||
|
||||
All return `*DatabaseAuthenticator` with OAuth2 pre-configured.
|
||||
|
||||
For multiple providers, use `WithOAuth2()` multiple times or `NewMultiProviderAuthenticator()`.
|
||||
|
||||
## Examples
|
||||
|
||||
Complete working examples available in `oauth2_examples.go`:
|
||||
- Basic Google OAuth2
|
||||
- GitHub OAuth2
|
||||
- Custom provider
|
||||
- Multi-provider setup
|
||||
- Token refresh
|
||||
- Logout flow
|
||||
- Complete integration with security middleware
|
||||
Generated
Vendored
+281
@@ -0,0 +1,281 @@
|
||||
# OAuth2 Refresh Token - Quick Reference
|
||||
|
||||
## Quick Setup (3 Steps)
|
||||
|
||||
### 1. Initialize Authenticator
|
||||
```go
|
||||
auth := security.NewGoogleAuthenticator(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"http://localhost:8080/auth/google/callback",
|
||||
db,
|
||||
)
|
||||
```
|
||||
|
||||
### 2. OAuth2 Login Flow
|
||||
```go
|
||||
// Login - Redirect to Google
|
||||
router.HandleFunc("/auth/google/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := auth.OAuth2GenerateState()
|
||||
authURL, _ := auth.OAuth2GetAuthURL("google", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
// Callback - Store tokens
|
||||
router.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
loginResp, _ := auth.OAuth2HandleCallback(
|
||||
r.Context(),
|
||||
"google",
|
||||
r.URL.Query().Get("code"),
|
||||
r.URL.Query().Get("state"),
|
||||
)
|
||||
|
||||
// Save refresh_token on client
|
||||
// loginResp.RefreshToken - Store this securely!
|
||||
// loginResp.Token - Session token for API calls
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Refresh Endpoint
|
||||
```go
|
||||
router.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Refresh token
|
||||
loginResp, err := auth.OAuth2RefreshToken(r.Context(), req.RefreshToken, "google")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 401)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Provider Example
|
||||
|
||||
```go
|
||||
// Configure multiple providers
|
||||
auth := security.NewDatabaseAuthenticator(db).
|
||||
WithOAuth2(security.OAuth2Config{
|
||||
ProviderName: "google",
|
||||
ClientID: "google-client-id",
|
||||
ClientSecret: "google-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/google/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
UserInfoURL: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
}).
|
||||
WithOAuth2(security.OAuth2Config{
|
||||
ProviderName: "github",
|
||||
ClientID: "github-client-id",
|
||||
ClientSecret: "github-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/github/callback",
|
||||
Scopes: []string{"user:email"},
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
UserInfoURL: "https://api.github.com/user",
|
||||
})
|
||||
|
||||
// Refresh with provider selection
|
||||
router.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Provider string `json:"provider"` // "google" or "github"
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
loginResp, err := auth.OAuth2RefreshToken(r.Context(), req.RefreshToken, req.Provider)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 401)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client-Side JavaScript
|
||||
|
||||
```javascript
|
||||
// Automatic token refresh on 401
|
||||
async function apiCall(url) {
|
||||
let response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('access_token')
|
||||
}
|
||||
});
|
||||
|
||||
// Token expired - refresh it
|
||||
if (response.status === 401) {
|
||||
await refreshToken();
|
||||
|
||||
// Retry request with new token
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('access_token')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function refreshToken() {
|
||||
const response = await fetch('/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
refresh_token: localStorage.getItem('refresh_token'),
|
||||
provider: localStorage.getItem('provider')
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('access_token', data.token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
} else {
|
||||
// Refresh failed - redirect to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Methods
|
||||
|
||||
| Method | Parameters | Returns |
|
||||
|--------|-----------|---------|
|
||||
| `OAuth2RefreshToken` | `ctx, refreshToken, provider` | `*LoginResponse, error` |
|
||||
| `OAuth2HandleCallback` | `ctx, provider, code, state` | `*LoginResponse, error` |
|
||||
| `OAuth2GetAuthURL` | `provider, state` | `string, error` |
|
||||
| `OAuth2GenerateState` | none | `string, error` |
|
||||
| `OAuth2GetProviders` | none | `[]string` |
|
||||
|
||||
---
|
||||
|
||||
## LoginResponse Structure
|
||||
|
||||
```go
|
||||
type LoginResponse struct {
|
||||
Token string // New session token for API calls
|
||||
RefreshToken string // Refresh token (store securely)
|
||||
User *UserContext // User information
|
||||
ExpiresIn int64 // Seconds until token expires
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Stored Procedures
|
||||
|
||||
- `resolvespec_oauth_getrefreshtoken(refresh_token)` - Get session by refresh token
|
||||
- `resolvespec_oauth_updaterefreshtoken(update_data)` - Update tokens after refresh
|
||||
- `resolvespec_oauth_getuser(user_id)` - Get user data
|
||||
|
||||
All procedures return: `{p_success bool, p_error text, p_data jsonb}`
|
||||
|
||||
---
|
||||
|
||||
## Common Errors
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| `invalid or expired refresh token` | Token revoked/expired | Re-authenticate user |
|
||||
| `OAuth2 provider 'xxx' not found` | Provider not configured | Add with `WithOAuth2()` |
|
||||
| `failed to refresh token with provider` | Provider rejected request | Check credentials, re-auth user |
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Use HTTPS for all OAuth2 endpoints
|
||||
- [ ] Store refresh tokens securely (HttpOnly cookies or encrypted storage)
|
||||
- [ ] Set cookie flags: `HttpOnly`, `Secure`, `SameSite=Strict`
|
||||
- [ ] Implement rate limiting on refresh endpoint
|
||||
- [ ] Log refresh attempts for audit
|
||||
- [ ] Rotate tokens on refresh
|
||||
- [ ] Revoke old sessions after successful refresh
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# 1. Login and get refresh token
|
||||
curl http://localhost:8080/auth/google/login
|
||||
# Follow OAuth2 flow, get refresh_token from callback response
|
||||
|
||||
# 2. Refresh token
|
||||
curl -X POST http://localhost:8080/auth/refresh \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"refresh_token":"ya29.xxx","provider":"google"}'
|
||||
|
||||
# 3. Use new token
|
||||
curl http://localhost:8080/api/protected \
|
||||
-H "Authorization: Bearer sess_abc123..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-configured Providers
|
||||
|
||||
```go
|
||||
// Google
|
||||
auth := security.NewGoogleAuthenticator(clientID, secret, redirectURL, db)
|
||||
|
||||
// GitHub
|
||||
auth := security.NewGitHubAuthenticator(clientID, secret, redirectURL, db)
|
||||
|
||||
// Microsoft
|
||||
auth := security.NewMicrosoftAuthenticator(clientID, secret, redirectURL, db)
|
||||
|
||||
// Facebook
|
||||
auth := security.NewFacebookAuthenticator(clientID, secret, redirectURL, db)
|
||||
|
||||
// All providers at once
|
||||
auth := security.NewMultiProviderAuthenticator(db, map[string]security.OAuth2Config{
|
||||
"google": {...},
|
||||
"github": {...},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider-Specific Notes
|
||||
|
||||
### Google
|
||||
- Add `access_type=offline` to get refresh token
|
||||
- Add `prompt=consent` to force consent screen
|
||||
```go
|
||||
authURL += "&access_type=offline&prompt=consent"
|
||||
```
|
||||
|
||||
### GitHub
|
||||
- Refresh tokens not always provided
|
||||
- May need to request `offline_access` scope
|
||||
|
||||
### Microsoft
|
||||
- Use `offline_access` scope for refresh token
|
||||
|
||||
### Facebook
|
||||
- Tokens expire after 60 days by default
|
||||
- Check app settings for token expiration policy
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
See `/pkg/security/oauth2_examples.go` line 250 for full working example.
|
||||
|
||||
For detailed documentation see `/pkg/security/OAUTH2_REFRESH_TOKEN_IMPLEMENTATION.md`.
|
||||
Generated
Vendored
+495
@@ -0,0 +1,495 @@
|
||||
# OAuth2 Refresh Token Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
OAuth2 refresh token functionality is **fully implemented** in the ResolveSpec security package. This allows refreshing expired access tokens without requiring users to re-authenticate.
|
||||
|
||||
## Implementation Status: ✅ COMPLETE
|
||||
|
||||
### Components Implemented
|
||||
|
||||
1. **✅ Database Schema** - Tables and stored procedures
|
||||
2. **✅ Go Methods** - OAuth2RefreshToken implementation
|
||||
3. **✅ Thread Safety** - Mutex protection for provider map
|
||||
4. **✅ Examples** - Working code examples
|
||||
5. **✅ Documentation** - Complete API reference
|
||||
|
||||
---
|
||||
|
||||
## 1. Database Schema
|
||||
|
||||
### Tables Modified
|
||||
|
||||
```sql
|
||||
-- user_sessions table with OAuth2 token fields
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_token VARCHAR(500) NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_activity_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
access_token TEXT, -- OAuth2 access token
|
||||
refresh_token TEXT, -- OAuth2 refresh token
|
||||
token_type VARCHAR(50), -- "Bearer", etc.
|
||||
auth_provider VARCHAR(50) -- "google", "github", etc.
|
||||
);
|
||||
```
|
||||
|
||||
### Stored Procedures
|
||||
|
||||
**`resolvespec_oauth_getrefreshtoken(p_refresh_token)`**
|
||||
- Gets OAuth2 session data by refresh token
|
||||
- Returns: `{user_id, access_token, token_type, expiry}`
|
||||
- Location: `database_schema.sql:714`
|
||||
|
||||
**`resolvespec_oauth_updaterefreshtoken(p_update_data)`**
|
||||
- Updates session with new tokens after refresh
|
||||
- Input: `{user_id, old_refresh_token, new_session_token, new_access_token, new_refresh_token, expires_at}`
|
||||
- Location: `database_schema.sql:752`
|
||||
|
||||
**`resolvespec_oauth_getuser(p_user_id)`**
|
||||
- Gets user data by ID for building UserContext
|
||||
- Location: `database_schema.sql:791`
|
||||
|
||||
---
|
||||
|
||||
## 2. Go Implementation
|
||||
|
||||
### Method Signature
|
||||
|
||||
```go
|
||||
func (a *DatabaseAuthenticator) OAuth2RefreshToken(
|
||||
ctx context.Context,
|
||||
refreshToken string,
|
||||
providerName string,
|
||||
) (*LoginResponse, error)
|
||||
```
|
||||
|
||||
**Location:** `pkg/security/oauth2_methods.go:375`
|
||||
|
||||
### Implementation Flow
|
||||
|
||||
```
|
||||
1. Validate provider exists
|
||||
├─ getOAuth2Provider(providerName) with RLock
|
||||
└─ Return error if provider not configured
|
||||
|
||||
2. Get session from database
|
||||
├─ Call resolvespec_oauth_getrefreshtoken(refreshToken)
|
||||
└─ Parse session data {user_id, access_token, token_type, expiry}
|
||||
|
||||
3. Refresh token with OAuth2 provider
|
||||
├─ Create oauth2.Token from stored data
|
||||
├─ Use provider.config.TokenSource(ctx, oldToken)
|
||||
└─ Call tokenSource.Token() to get new token
|
||||
|
||||
4. Generate new session token
|
||||
└─ Use OAuth2GenerateState() for secure random token
|
||||
|
||||
5. Update database
|
||||
├─ Call resolvespec_oauth_updaterefreshtoken()
|
||||
└─ Store new session_token, access_token, refresh_token
|
||||
|
||||
6. Get user data
|
||||
├─ Call resolvespec_oauth_getuser(user_id)
|
||||
└─ Build UserContext
|
||||
|
||||
7. Return LoginResponse
|
||||
└─ {Token, RefreshToken, User, ExpiresIn}
|
||||
```
|
||||
|
||||
### Thread Safety
|
||||
|
||||
**Mutex Protection:** All access to `oauth2Providers` map is protected with `sync.RWMutex`
|
||||
|
||||
```go
|
||||
type DatabaseAuthenticator struct {
|
||||
oauth2Providers map[string]*OAuth2Provider
|
||||
oauth2ProvidersMutex sync.RWMutex // Thread-safe access
|
||||
}
|
||||
|
||||
// Read operations use RLock
|
||||
func (a *DatabaseAuthenticator) getOAuth2Provider(name string) {
|
||||
a.oauth2ProvidersMutex.RLock()
|
||||
defer a.oauth2ProvidersMutex.RUnlock()
|
||||
// ... access map
|
||||
}
|
||||
|
||||
// Write operations use Lock
|
||||
func (a *DatabaseAuthenticator) WithOAuth2(cfg OAuth2Config) {
|
||||
a.oauth2ProvidersMutex.Lock()
|
||||
defer a.oauth2ProvidersMutex.Unlock()
|
||||
// ... modify map
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Usage Examples
|
||||
|
||||
### Single Provider (Google)
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func main() {
|
||||
db, _ := sql.Open("postgres", "connection-string")
|
||||
|
||||
// Create Google OAuth2 authenticator
|
||||
auth := security.NewGoogleAuthenticator(
|
||||
"your-client-id",
|
||||
"your-client-secret",
|
||||
"http://localhost:8080/auth/google/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Token refresh endpoint
|
||||
router.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Refresh token (provider name defaults to "google")
|
||||
loginResp, err := auth.OAuth2RefreshToken(r.Context(), req.RefreshToken, "google")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Set new session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: loginResp.Token,
|
||||
Path: "/",
|
||||
MaxAge: int(loginResp.ExpiresIn),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
})
|
||||
|
||||
json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
|
||||
http.ListenAndServe(":8080", router)
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Provider Setup
|
||||
|
||||
```go
|
||||
// Single authenticator with multiple OAuth2 providers
|
||||
auth := security.NewDatabaseAuthenticator(db).
|
||||
WithOAuth2(security.OAuth2Config{
|
||||
ClientID: "google-client-id",
|
||||
ClientSecret: "google-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/google/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
UserInfoURL: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
ProviderName: "google",
|
||||
}).
|
||||
WithOAuth2(security.OAuth2Config{
|
||||
ClientID: "github-client-id",
|
||||
ClientSecret: "github-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/github/callback",
|
||||
Scopes: []string{"user:email"},
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
UserInfoURL: "https://api.github.com/user",
|
||||
ProviderName: "github",
|
||||
})
|
||||
|
||||
// Refresh endpoint with provider selection
|
||||
router.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Provider string `json:"provider"` // "google" or "github"
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Refresh with specific provider
|
||||
loginResp, err := auth.OAuth2RefreshToken(r.Context(), req.RefreshToken, req.Provider)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
```
|
||||
|
||||
### Client-Side Usage
|
||||
|
||||
```javascript
|
||||
// JavaScript client example
|
||||
async function refreshAccessToken() {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const provider = localStorage.getItem('auth_provider'); // "google", "github", etc.
|
||||
|
||||
const response = await fetch('/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
provider: provider
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Store new tokens
|
||||
localStorage.setItem('access_token', data.token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
|
||||
console.log('Token refreshed successfully');
|
||||
return data.token;
|
||||
} else {
|
||||
// Refresh failed - redirect to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically refresh token when API returns 401
|
||||
async function apiCall(endpoint) {
|
||||
let response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('access_token')
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired - try refresh
|
||||
const newToken = await refreshAccessToken();
|
||||
|
||||
// Retry with new token
|
||||
response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + newToken
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Reference
|
||||
|
||||
### DatabaseAuthenticator Methods
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `OAuth2RefreshToken` | `(ctx, refreshToken, provider) (*LoginResponse, error)` | Refreshes expired OAuth2 access token |
|
||||
| `WithOAuth2` | `(cfg OAuth2Config) *DatabaseAuthenticator` | Adds OAuth2 provider (chainable) |
|
||||
| `OAuth2GetAuthURL` | `(provider, state) (string, error)` | Gets authorization URL |
|
||||
| `OAuth2HandleCallback` | `(ctx, provider, code, state) (*LoginResponse, error)` | Handles OAuth2 callback |
|
||||
| `OAuth2GenerateState` | `() (string, error)` | Generates CSRF state token |
|
||||
| `OAuth2GetProviders` | `() []string` | Lists configured providers |
|
||||
|
||||
### LoginResponse Structure
|
||||
|
||||
```go
|
||||
type LoginResponse struct {
|
||||
Token string // New session token
|
||||
RefreshToken string // New refresh token (may be same as input)
|
||||
User *UserContext // User information
|
||||
ExpiresIn int64 // Seconds until expiration
|
||||
}
|
||||
|
||||
type UserContext struct {
|
||||
UserID int // Database user ID
|
||||
UserName string // Username
|
||||
Email string // Email address
|
||||
UserLevel int // Permission level
|
||||
SessionID string // Session token
|
||||
RemoteID string // OAuth2 provider user ID
|
||||
Roles []string // User roles
|
||||
Claims map[string]any // Additional claims
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Important Notes
|
||||
|
||||
### Provider Configuration
|
||||
|
||||
**For Google:** Add `access_type=offline` to get refresh token on first login:
|
||||
|
||||
```go
|
||||
auth := security.NewGoogleAuthenticator(clientID, clientSecret, redirectURL, db)
|
||||
// When generating auth URL, add access_type parameter
|
||||
authURL, _ := auth.OAuth2GetAuthURL("google", state)
|
||||
authURL += "&access_type=offline&prompt=consent"
|
||||
```
|
||||
|
||||
**For GitHub:** Refresh tokens are not always provided. Check provider documentation.
|
||||
|
||||
### Token Storage
|
||||
|
||||
- Store refresh tokens securely on client (localStorage, secure cookie, etc.)
|
||||
- Never log refresh tokens
|
||||
- Refresh tokens are long-lived (days/months depending on provider)
|
||||
- Access tokens are short-lived (minutes/hours)
|
||||
|
||||
### Error Handling
|
||||
|
||||
Common errors:
|
||||
- `"invalid or expired refresh token"` - Token expired or revoked
|
||||
- `"OAuth2 provider 'xxx' not found"` - Provider not configured
|
||||
- `"failed to refresh token with provider"` - Provider rejected refresh request
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
1. **Always use HTTPS** for token transmission
|
||||
2. **Store refresh tokens securely** on client
|
||||
3. **Set appropriate cookie flags**: `HttpOnly`, `Secure`, `SameSite`
|
||||
4. **Implement token rotation** - issue new refresh token on each refresh
|
||||
5. **Revoke old tokens** after successful refresh
|
||||
6. **Rate limit** refresh endpoints
|
||||
7. **Log refresh attempts** for audit trail
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
### Manual Test Flow
|
||||
|
||||
1. **Initial Login:**
|
||||
```bash
|
||||
curl http://localhost:8080/auth/google/login
|
||||
# Follow redirect to Google
|
||||
# Returns to callback with LoginResponse containing refresh_token
|
||||
```
|
||||
|
||||
2. **Wait for Token Expiry (or manually expire in DB)**
|
||||
|
||||
3. **Refresh Token:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/auth/refresh \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"refresh_token": "ya29.a0AfH6SMB...",
|
||||
"provider": "google"
|
||||
}'
|
||||
|
||||
# Response:
|
||||
{
|
||||
"token": "sess_abc123...",
|
||||
"refresh_token": "ya29.a0AfH6SMB...",
|
||||
"user": {
|
||||
"user_id": 1,
|
||||
"user_name": "john_doe",
|
||||
"email": "john@example.com",
|
||||
"session_id": "sess_abc123..."
|
||||
},
|
||||
"expires_in": 3600
|
||||
}
|
||||
```
|
||||
|
||||
4. **Use New Token:**
|
||||
```bash
|
||||
curl http://localhost:8080/api/protected \
|
||||
-H "Authorization: Bearer sess_abc123..."
|
||||
```
|
||||
|
||||
### Database Verification
|
||||
|
||||
```sql
|
||||
-- Check session with refresh token
|
||||
SELECT session_token, user_id, expires_at, refresh_token, auth_provider
|
||||
FROM user_sessions
|
||||
WHERE refresh_token = 'ya29.a0AfH6SMB...';
|
||||
|
||||
-- Verify token was updated after refresh
|
||||
SELECT session_token, access_token, refresh_token,
|
||||
expires_at, last_activity_at
|
||||
FROM user_sessions
|
||||
WHERE user_id = 1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
### "Refresh token not found or expired"
|
||||
|
||||
**Cause:** Refresh token doesn't exist in database or session expired
|
||||
|
||||
**Solution:**
|
||||
- Check if initial OAuth2 login stored refresh token
|
||||
- Verify provider returns refresh token (some require `access_type=offline`)
|
||||
- Check session hasn't been deleted from database
|
||||
|
||||
### "Failed to refresh token with provider"
|
||||
|
||||
**Cause:** OAuth2 provider rejected the refresh request
|
||||
|
||||
**Possible reasons:**
|
||||
- Refresh token was revoked by user
|
||||
- OAuth2 app credentials changed
|
||||
- Network connectivity issues
|
||||
- Provider rate limiting
|
||||
|
||||
**Solution:**
|
||||
- Re-authenticate user (full OAuth2 flow)
|
||||
- Check provider dashboard for app status
|
||||
- Verify client credentials are correct
|
||||
|
||||
### "OAuth2 provider 'xxx' not found"
|
||||
|
||||
**Cause:** Provider not registered with `WithOAuth2()`
|
||||
|
||||
**Solution:**
|
||||
```go
|
||||
// Make sure provider is configured
|
||||
auth := security.NewDatabaseAuthenticator(db).
|
||||
WithOAuth2(security.OAuth2Config{
|
||||
ProviderName: "google", // This name must match refresh call
|
||||
// ... other config
|
||||
})
|
||||
|
||||
// Then use same name in refresh
|
||||
auth.OAuth2RefreshToken(ctx, token, "google") // Must match ProviderName
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Complete Working Example
|
||||
|
||||
See `pkg/security/oauth2_examples.go:250` for full working example with token refresh.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
OAuth2 refresh token functionality is **production-ready** with:
|
||||
|
||||
- ✅ Complete database schema with stored procedures
|
||||
- ✅ Thread-safe Go implementation with mutex protection
|
||||
- ✅ Multi-provider support (Google, GitHub, Microsoft, Facebook, custom)
|
||||
- ✅ Comprehensive error handling
|
||||
- ✅ Working code examples
|
||||
- ✅ Full API documentation
|
||||
- ✅ Security best practices implemented
|
||||
|
||||
**No additional implementation needed - feature is complete and functional.**
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
# Passkey Authentication Quick Reference
|
||||
|
||||
## Overview
|
||||
Passkey authentication (WebAuthn/FIDO2) is now integrated into the DatabaseAuthenticator. This provides passwordless authentication using biometrics, security keys, or device credentials.
|
||||
|
||||
## Setup
|
||||
|
||||
### Database Schema
|
||||
Run the passkey SQL schema (in database_schema.sql):
|
||||
- Creates `user_passkey_credentials` table
|
||||
- Adds stored procedures for passkey operations
|
||||
|
||||
### Go Code
|
||||
```go
|
||||
// Create passkey provider
|
||||
passkeyProvider := security.NewDatabasePasskeyProvider(db,
|
||||
security.DatabasePasskeyProviderOptions{
|
||||
RPID: "example.com",
|
||||
RPName: "Example App",
|
||||
RPOrigin: "https://example.com",
|
||||
Timeout: 60000,
|
||||
})
|
||||
|
||||
// Create authenticator with passkey support
|
||||
auth := security.NewDatabaseAuthenticatorWithOptions(db,
|
||||
security.DatabaseAuthenticatorOptions{
|
||||
PasskeyProvider: passkeyProvider,
|
||||
})
|
||||
|
||||
// Or add passkey to existing authenticator
|
||||
auth = security.NewDatabaseAuthenticator(db).WithPasskey(passkeyProvider)
|
||||
```
|
||||
|
||||
## Registration Flow
|
||||
|
||||
### Backend - Step 1: Begin Registration
|
||||
```go
|
||||
options, err := auth.BeginPasskeyRegistration(ctx,
|
||||
security.PasskeyBeginRegistrationRequest{
|
||||
UserID: 1,
|
||||
Username: "alice",
|
||||
DisplayName: "Alice Smith",
|
||||
})
|
||||
// Send options to client as JSON
|
||||
```
|
||||
|
||||
### Frontend - Step 2: Create Credential
|
||||
```javascript
|
||||
// Convert options from server
|
||||
options.challenge = base64ToArrayBuffer(options.challenge);
|
||||
options.user.id = base64ToArrayBuffer(options.user.id);
|
||||
|
||||
// Create credential
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: options
|
||||
});
|
||||
|
||||
// Send credential back to server
|
||||
```
|
||||
|
||||
### Backend - Step 3: Complete Registration
|
||||
```go
|
||||
credential, err := auth.CompletePasskeyRegistration(ctx,
|
||||
security.PasskeyRegisterRequest{
|
||||
UserID: 1,
|
||||
Response: clientResponse,
|
||||
ExpectedChallenge: storedChallenge,
|
||||
CredentialName: "My iPhone",
|
||||
})
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Backend - Step 1: Begin Authentication
|
||||
```go
|
||||
options, err := auth.BeginPasskeyAuthentication(ctx,
|
||||
security.PasskeyBeginAuthenticationRequest{
|
||||
Username: "alice", // Optional for resident key
|
||||
})
|
||||
// Send options to client as JSON
|
||||
```
|
||||
|
||||
### Frontend - Step 2: Get Credential
|
||||
```javascript
|
||||
// Convert options from server
|
||||
options.challenge = base64ToArrayBuffer(options.challenge);
|
||||
|
||||
// Get credential
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: options
|
||||
});
|
||||
|
||||
// Send assertion back to server
|
||||
```
|
||||
|
||||
### Backend - Step 3: Complete Authentication
|
||||
```go
|
||||
loginResponse, err := auth.LoginWithPasskey(ctx,
|
||||
security.PasskeyLoginRequest{
|
||||
Response: clientAssertion,
|
||||
ExpectedChallenge: storedChallenge,
|
||||
Claims: map[string]any{
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
},
|
||||
})
|
||||
// Returns session token and user info
|
||||
```
|
||||
|
||||
## Credential Management
|
||||
|
||||
### List Credentials
|
||||
```go
|
||||
credentials, err := auth.GetPasskeyCredentials(ctx, userID)
|
||||
```
|
||||
|
||||
### Update Credential Name
|
||||
```go
|
||||
err := auth.UpdatePasskeyCredentialName(ctx, userID, credentialID, "New Name")
|
||||
```
|
||||
|
||||
### Delete Credential
|
||||
```go
|
||||
err := auth.DeletePasskeyCredential(ctx, userID, credentialID)
|
||||
```
|
||||
|
||||
## HTTP Endpoints Example
|
||||
|
||||
### POST /api/passkey/register/begin
|
||||
Request: `{user_id, username, display_name}`
|
||||
Response: PasskeyRegistrationOptions
|
||||
|
||||
### POST /api/passkey/register/complete
|
||||
Request: `{user_id, response, credential_name}`
|
||||
Response: PasskeyCredential
|
||||
|
||||
### POST /api/passkey/login/begin
|
||||
Request: `{username}` (optional)
|
||||
Response: PasskeyAuthenticationOptions
|
||||
|
||||
### POST /api/passkey/login/complete
|
||||
Request: `{response}`
|
||||
Response: LoginResponse with session token
|
||||
|
||||
### GET /api/passkey/credentials
|
||||
Response: Array of PasskeyCredential
|
||||
|
||||
### DELETE /api/passkey/credentials/{id}
|
||||
Request: `{credential_id}`
|
||||
Response: 204 No Content
|
||||
|
||||
## Database Stored Procedures
|
||||
|
||||
- `resolvespec_passkey_store_credential` - Store new credential
|
||||
- `resolvespec_passkey_get_credential` - Get credential by ID
|
||||
- `resolvespec_passkey_get_user_credentials` - Get all user credentials
|
||||
- `resolvespec_passkey_update_counter` - Update sign counter (clone detection)
|
||||
- `resolvespec_passkey_delete_credential` - Delete credential
|
||||
- `resolvespec_passkey_update_name` - Update credential name
|
||||
- `resolvespec_passkey_get_credentials_by_username` - Get credentials for login
|
||||
|
||||
## Security Features
|
||||
|
||||
- **Clone Detection**: Sign counter validation detects credential cloning
|
||||
- **Attestation Support**: Stores attestation type (none, indirect, direct)
|
||||
- **Transport Options**: Tracks authenticator transports (usb, nfc, ble, internal)
|
||||
- **Backup State**: Tracks if credential is backed up/synced
|
||||
- **User Verification**: Supports preferred/required user verification
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **WebAuthn Library**: Current implementation is simplified. For production, use a proper WebAuthn library like `github.com/go-webauthn/webauthn` for full verification.
|
||||
|
||||
2. **Challenge Storage**: Store challenges securely in session/cache. Never expose challenges to client beyond initial request.
|
||||
|
||||
3. **HTTPS Required**: Passkeys only work over HTTPS (except localhost).
|
||||
|
||||
4. **Browser Support**: Check browser compatibility for WebAuthn API.
|
||||
|
||||
5. **Relying Party ID**: Must match your domain exactly.
|
||||
|
||||
## Client-Side Helper Functions
|
||||
|
||||
```javascript
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests: `go test -v ./pkg/security -run Passkey`
|
||||
|
||||
All passkey functionality includes comprehensive tests using sqlmock.
|
||||
+783
@@ -0,0 +1,783 @@
|
||||
# Security Provider - Quick Reference
|
||||
|
||||
## 3-Step Setup
|
||||
|
||||
```go
|
||||
// Step 1: Create security providers
|
||||
auth := security.NewDatabaseAuthenticator(db) // Session-based (recommended)
|
||||
// OR: auth := security.NewJWTAuthenticator("secret-key", db)
|
||||
// OR: auth := security.NewHeaderAuthenticator()
|
||||
// OR: auth := security.NewGoogleAuthenticator(clientID, secret, redirectURL, db) // OAuth2
|
||||
|
||||
colSec := security.NewDatabaseColumnSecurityProvider(db)
|
||||
rowSec := security.NewDatabaseRowSecurityProvider(db)
|
||||
|
||||
// Step 2: Combine providers
|
||||
provider, _ := security.NewCompositeSecurityProvider(auth, colSec, rowSec)
|
||||
|
||||
// Step 3: Setup and apply middleware
|
||||
securityList, _ := security.SetupSecurityProvider(handler, provider)
|
||||
router.Use(security.NewAuthMiddleware(securityList))
|
||||
router.Use(security.SetSecurityMiddleware(securityList))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stored Procedures
|
||||
|
||||
**All database operations use PostgreSQL stored procedures** with `resolvespec_*` naming:
|
||||
|
||||
### Database Authenticators
|
||||
```go
|
||||
// DatabaseAuthenticator uses these stored procedures:
|
||||
resolvespec_login(jsonb) // Login with credentials
|
||||
resolvespec_register(jsonb) // Register new user
|
||||
resolvespec_logout(jsonb) // Invalidate session
|
||||
resolvespec_session(text, text) // Validate session token
|
||||
resolvespec_session_update(text, jsonb) // Update activity timestamp
|
||||
resolvespec_refresh_token(text, jsonb) // Generate new session
|
||||
|
||||
// JWTAuthenticator uses these stored procedures:
|
||||
resolvespec_jwt_login(text, text) // Validate credentials
|
||||
resolvespec_jwt_logout(text, int) // Blacklist token
|
||||
```
|
||||
|
||||
### Security Providers
|
||||
```go
|
||||
// DatabaseColumnSecurityProvider:
|
||||
resolvespec_column_security(int, text, text) // Load column rules
|
||||
|
||||
// DatabaseRowSecurityProvider:
|
||||
resolvespec_row_security(text, text, int) // Load row template
|
||||
```
|
||||
|
||||
All stored procedures return structured results:
|
||||
- Session/Login: `(p_success bool, p_error text, p_data jsonb)`
|
||||
- Security: `(p_success bool, p_error text, p_rules jsonb)`
|
||||
|
||||
See `database_schema.sql` for complete definitions.
|
||||
|
||||
---
|
||||
|
||||
## Interface Signatures
|
||||
|
||||
```go
|
||||
// Authenticator interface
|
||||
type Authenticator interface {
|
||||
Login(ctx context.Context, req LoginRequest) (*LoginResponse, error)
|
||||
Logout(ctx context.Context, req LogoutRequest) error
|
||||
Authenticate(r *http.Request) (*UserContext, error)
|
||||
}
|
||||
|
||||
// ColumnSecurityProvider interface
|
||||
type ColumnSecurityProvider interface {
|
||||
GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error)
|
||||
}
|
||||
|
||||
// RowSecurityProvider interface
|
||||
type RowSecurityProvider interface {
|
||||
GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UserContext Structure
|
||||
|
||||
```go
|
||||
security.UserContext{
|
||||
UserID: 123, // User's unique ID
|
||||
UserName: "john_doe", // Username
|
||||
UserLevel: 5, // User privilege level
|
||||
SessionID: "sess_abc123", // Current session ID
|
||||
RemoteID: "remote_xyz", // Remote system ID
|
||||
Roles: []string{"admin"}, // User roles
|
||||
Email: "john@example.com", // User email
|
||||
Claims: map[string]any{}, // Additional authentication claims
|
||||
Meta: map[string]any{}, // Additional metadata (JSON-serializable)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ColumnSecurity Structure
|
||||
|
||||
```go
|
||||
security.ColumnSecurity{
|
||||
Path: []string{"column_name"}, // ["ssn"] or ["address", "street"]
|
||||
Accesstype: "mask", // "mask" or "hide"
|
||||
MaskStart: 5, // Mask first N chars
|
||||
MaskEnd: 0, // Mask last N chars
|
||||
MaskChar: "*", // Masking character
|
||||
MaskInvert: false, // true = mask middle
|
||||
}
|
||||
```
|
||||
|
||||
### Common Examples
|
||||
|
||||
```go
|
||||
// Hide entire field
|
||||
{Path: []string{"salary"}, Accesstype: "hide"}
|
||||
|
||||
// Mask SSN (show last 4)
|
||||
{Path: []string{"ssn"}, Accesstype: "mask", MaskStart: 5}
|
||||
|
||||
// Mask credit card (show last 4)
|
||||
{Path: []string{"credit_card"}, Accesstype: "mask", MaskStart: 12}
|
||||
|
||||
// Mask email (j***@example.com)
|
||||
{Path: []string{"email"}, Accesstype: "mask", MaskStart: 1, MaskEnd: 0}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RowSecurity Structure
|
||||
|
||||
```go
|
||||
security.RowSecurity{
|
||||
Schema: "public",
|
||||
Tablename: "orders",
|
||||
UserID: 123,
|
||||
Template: "user_id = {UserID}", // WHERE clause
|
||||
HasBlock: false, // true = block all access
|
||||
}
|
||||
```
|
||||
|
||||
### Template Variables
|
||||
|
||||
- `{UserID}` - Current user ID
|
||||
- `{PrimaryKeyName}` - Primary key column
|
||||
- `{TableName}` - Table name
|
||||
- `{SchemaName}` - Schema name
|
||||
|
||||
### Common Examples
|
||||
|
||||
```go
|
||||
// Users see only their records
|
||||
Template: "user_id = {UserID}"
|
||||
|
||||
// Users see their records OR public ones
|
||||
Template: "user_id = {UserID} OR is_public = true"
|
||||
|
||||
// Tenant isolation
|
||||
Template: "tenant_id = 5 AND user_id = {UserID}"
|
||||
|
||||
// Complex with subquery
|
||||
Template: "dept_id IN (SELECT dept_id FROM user_depts WHERE user_id = {UserID})"
|
||||
|
||||
// Block all access
|
||||
HasBlock: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Implementations
|
||||
|
||||
### Database Session Authenticator (Recommended)
|
||||
|
||||
```go
|
||||
// Create authenticator
|
||||
auth := security.NewDatabaseAuthenticator(db)
|
||||
|
||||
// Requires these tables:
|
||||
// - users (id, username, email, password, user_level, roles, is_active)
|
||||
// - user_sessions (session_token, user_id, expires_at, created_at, last_activity_at)
|
||||
// See database_schema.sql for full schema
|
||||
|
||||
// Features:
|
||||
// - Login with username/password
|
||||
// - Session management in database
|
||||
// - Token refresh support (implements Refreshable)
|
||||
// - Automatic session expiration
|
||||
// - Tracks IP address and user agent
|
||||
// - Works with Authorization header or cookie
|
||||
```
|
||||
|
||||
### Simple Header Authenticator
|
||||
|
||||
```go
|
||||
type HeaderAuthenticator struct{}
|
||||
|
||||
func NewHeaderAuthenticator() *HeaderAuthenticator {
|
||||
return &HeaderAuthenticator{}
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticator) Login(ctx context.Context, req security.LoginRequest) (*security.LoginResponse, error) {
|
||||
return nil, fmt.Errorf("not supported")
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticator) Logout(ctx context.Context, req security.LogoutRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticator) Authenticate(r *http.Request) (*security.UserContext, error) {
|
||||
userIDStr := r.Header.Get("X-User-ID")
|
||||
if userIDStr == "" {
|
||||
return nil, fmt.Errorf("X-User-ID required")
|
||||
}
|
||||
userID, _ := strconv.Atoi(userIDStr)
|
||||
return &security.UserContext{
|
||||
UserID: userID,
|
||||
UserName: r.Header.Get("X-User-Name"),
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### JWT Authenticator
|
||||
|
||||
```go
|
||||
type JWTAuthenticator struct {
|
||||
secretKey []byte
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewJWTAuthenticator(secret string, db *gorm.DB) *JWTAuthenticator {
|
||||
return &JWTAuthenticator{secretKey: []byte(secret), db: db}
|
||||
}
|
||||
|
||||
func (a *JWTAuthenticator) Login(ctx context.Context, req security.LoginRequest) (*security.LoginResponse, error) {
|
||||
// Validate credentials against database
|
||||
var user User
|
||||
err := a.db.WithContext(ctx).Where("username = ?", req.Username).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": user.ID,
|
||||
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||
})
|
||||
tokenString, _ := token.SignedString(a.secretKey)
|
||||
|
||||
return &security.LoginResponse{
|
||||
Token: tokenString,
|
||||
User: &security.UserContext{UserID: user.ID},
|
||||
ExpiresIn: 86400,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *JWTAuthenticator) Logout(ctx context.Context, req security.LogoutRequest) error {
|
||||
// Invalidate session via stored procedure
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *JWTAuthenticator) Authenticate(r *http.Request) (*security.UserContext, error) {
|
||||
tokenString := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
|
||||
return a.secretKey, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
return &security.UserContext{
|
||||
UserID: int(claims["user_id"].(float64)),
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Static Column Security
|
||||
|
||||
```go
|
||||
type ConfigColumnSecurityProvider struct {
|
||||
rules map[string][]security.ColumnSecurity
|
||||
}
|
||||
|
||||
func NewConfigColumnSecurityProvider(rules map[string][]security.ColumnSecurity) *ConfigColumnSecurityProvider {
|
||||
return &ConfigColumnSecurityProvider{rules: rules}
|
||||
}
|
||||
|
||||
func (p *ConfigColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]security.ColumnSecurity, error) {
|
||||
key := fmt.Sprintf("%s.%s", schema, table)
|
||||
return p.rules[key], nil
|
||||
}
|
||||
```
|
||||
|
||||
### Database Column Security
|
||||
|
||||
```go
|
||||
type DatabaseColumnSecurityProvider struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDatabaseColumnSecurityProvider(db *gorm.DB) *DatabaseColumnSecurityProvider {
|
||||
return &DatabaseColumnSecurityProvider{db: db}
|
||||
}
|
||||
|
||||
func (p *DatabaseColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]security.ColumnSecurity, error) {
|
||||
var records []struct {
|
||||
Control string
|
||||
Accesstype string
|
||||
JSONValue string
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT control, accesstype, jsonvalue
|
||||
FROM core.secaccess
|
||||
WHERE rid_hub IN (
|
||||
SELECT rid_hub_parent FROM core.hub_link
|
||||
WHERE rid_hub_child = ? AND parent_hubtype = 'secgroup'
|
||||
)
|
||||
AND control ILIKE ?
|
||||
`
|
||||
|
||||
err := p.db.WithContext(ctx).Raw(query, userID, fmt.Sprintf("%s.%s%%", schema, table)).Scan(&records).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rules []security.ColumnSecurity
|
||||
for _, rec := range records {
|
||||
parts := strings.Split(rec.Control, ".")
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, security.ColumnSecurity{
|
||||
Schema: schema,
|
||||
Tablename: table,
|
||||
Path: parts[2:],
|
||||
Accesstype: rec.Accesstype,
|
||||
})
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Static Row Security
|
||||
|
||||
```go
|
||||
type ConfigRowSecurityProvider struct {
|
||||
templates map[string]string
|
||||
blocked map[string]bool
|
||||
}
|
||||
|
||||
func NewConfigRowSecurityProvider(templates map[string]string, blocked map[string]bool) *ConfigRowSecurityProvider {
|
||||
return &ConfigRowSecurityProvider{templates: templates, blocked: blocked}
|
||||
}
|
||||
|
||||
func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (security.RowSecurity, error) {
|
||||
key := fmt.Sprintf("%s.%s", schema, table)
|
||||
|
||||
if p.blocked[key] {
|
||||
return security.RowSecurity{HasBlock: true}, nil
|
||||
}
|
||||
|
||||
return security.RowSecurity{
|
||||
Schema: schema,
|
||||
Tablename: table,
|
||||
UserID: userID,
|
||||
Template: p.templates[key],
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```go
|
||||
// Test Authenticator
|
||||
auth := security.NewHeaderAuthenticator()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-User-ID", "123")
|
||||
userCtx, err := auth.Authenticate(req)
|
||||
assert.Equal(t, 123, userCtx.UserID)
|
||||
|
||||
// Test ColumnSecurityProvider
|
||||
colSec := security.NewConfigColumnSecurityProvider(rules)
|
||||
cols, err := colSec.GetColumnSecurity(context.Background(), 123, "public", "employees")
|
||||
assert.Equal(t, "mask", cols[0].Accesstype)
|
||||
|
||||
// Test RowSecurityProvider
|
||||
rowSec := security.NewConfigRowSecurityProvider(templates, blocked)
|
||||
row, err := rowSec.GetRowSecurity(context.Background(), 123, "public", "orders")
|
||||
assert.Equal(t, "user_id = {UserID}", row.Template)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request Flow
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
↓
|
||||
NewOptionalAuthMiddleware → calls provider.Authenticate()
|
||||
↓ (adds UserContext or guest context; never 401)
|
||||
SetSecurityMiddleware → adds SecurityList to context
|
||||
↓
|
||||
Handler.Handle() → resolves model
|
||||
↓
|
||||
BeforeHandle Hook → CheckModelAuthAllowed(secCtx, operation)
|
||||
├─ SecurityDisabled → allow
|
||||
├─ CanPublicRead/Create/Update/Delete → allow unauthenticated
|
||||
└─ UserID == 0 → abort 401
|
||||
↓
|
||||
BeforeRead Hook → calls provider.GetColumnSecurity() + GetRowSecurity()
|
||||
↓
|
||||
BeforeScan Hook → applies row security (WHERE clause)
|
||||
↓
|
||||
Database Query
|
||||
↓
|
||||
AfterRead Hook → applies column security (masking)
|
||||
↓
|
||||
HTTP Response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Role-Based Security
|
||||
|
||||
```go
|
||||
func (p *MyColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]security.ColumnSecurity, error) {
|
||||
userCtx, _ := security.GetUserContext(ctx)
|
||||
|
||||
if contains(userCtx.Roles, "admin") {
|
||||
return []security.ColumnSecurity{}, nil // No restrictions
|
||||
}
|
||||
|
||||
return loadRestrictions(userID, schema, table), nil
|
||||
}
|
||||
```
|
||||
|
||||
### Tenant Isolation
|
||||
|
||||
```go
|
||||
func (p *MyRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (security.RowSecurity, error) {
|
||||
tenantID := getUserTenant(userID)
|
||||
return security.RowSecurity{
|
||||
Template: fmt.Sprintf("tenant_id = %d", tenantID),
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Caching with Decorator
|
||||
|
||||
```go
|
||||
type CachedColumnSecurityProvider struct {
|
||||
inner security.ColumnSecurityProvider
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
func (p *CachedColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]security.ColumnSecurity, error) {
|
||||
key := fmt.Sprintf("%d:%s.%s", userID, schema, table)
|
||||
|
||||
if cached, found := p.cache.Get(key); found {
|
||||
return cached.([]security.ColumnSecurity), nil
|
||||
}
|
||||
|
||||
rules, err := p.inner.GetColumnSecurity(ctx, userID, schema, table)
|
||||
if err == nil {
|
||||
p.cache.Set(key, rules, cache.DefaultExpiration)
|
||||
}
|
||||
return rules, err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
```go
|
||||
// Panic if provider is nil
|
||||
provider := security.NewCompositeSecurityProvider(auth, colSec, rowSec)
|
||||
// panics if any parameter is nil
|
||||
|
||||
// Auth middleware returns 401 if Authenticate fails
|
||||
func (a *MyAuthenticator) Authenticate(r *http.Request) (*security.UserContext, error) {
|
||||
if invalid {
|
||||
return nil, fmt.Errorf("invalid credentials") // Returns HTTP 401
|
||||
}
|
||||
return &security.UserContext{UserID: userID}, nil
|
||||
}
|
||||
|
||||
// Security loading can fail gracefully
|
||||
func (p *MyProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]security.ColumnSecurity, error) {
|
||||
rules, err := db.Load(...)
|
||||
if err != nil {
|
||||
log.Printf("Failed to load security: %v", err)
|
||||
return []security.ColumnSecurity{}, nil // No rules = no restrictions
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Login/Logout/Register Endpoints
|
||||
|
||||
```go
|
||||
func SetupAuthRoutes(router *mux.Router, securityList *security.SecurityList) {
|
||||
// Register
|
||||
router.HandleFunc("/auth/register", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req security.RegisterRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Check if provider supports registration
|
||||
registrable, ok := securityList.Provider().(security.Registrable)
|
||||
if !ok {
|
||||
http.Error(w, "Registration not supported", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := registrable.Register(r.Context(), req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}).Methods("POST")
|
||||
|
||||
// Login
|
||||
router.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req security.LoginRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
resp, err := securityList.Provider().Login(r.Context(), req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}).Methods("POST")
|
||||
|
||||
// Logout
|
||||
router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
userID, _ := security.GetUserID(r.Context())
|
||||
|
||||
err := securityList.Provider().Logout(r.Context(), security.LogoutRequest{
|
||||
Token: token,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}).Methods("POST")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
```go
|
||||
// Enable debug logging
|
||||
import "github.com/bitechdev/GoCore/pkg/cfg"
|
||||
cfg.SetLogLevel("DEBUG")
|
||||
|
||||
// Log in provider methods
|
||||
func (a *MyAuthenticator) Authenticate(r *http.Request) (*security.UserContext, error) {
|
||||
token := r.Header.Get("Authorization")
|
||||
log.Printf("Auth: token=%s", token)
|
||||
// ...
|
||||
}
|
||||
|
||||
// Check if methods are called
|
||||
func (p *MyColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]security.ColumnSecurity, error) {
|
||||
log.Printf("Loading column security: user=%d, schema=%s, table=%s", userID, schema, table)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Minimal Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/restheadspec"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Simple all-in-one provider
|
||||
type SimpleProvider struct{}
|
||||
|
||||
func (p *SimpleProvider) Login(ctx context.Context, req security.LoginRequest) (*security.LoginResponse, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *SimpleProvider) Logout(ctx context.Context, req security.LogoutRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SimpleProvider) Authenticate(r *http.Request) (*security.UserContext, error) {
|
||||
id, _ := strconv.Atoi(r.Header.Get("X-User-ID"))
|
||||
return &security.UserContext{UserID: id}, nil
|
||||
}
|
||||
|
||||
func (p *SimpleProvider) GetColumnSecurity(ctx context.Context, u int, s, t string) ([]security.ColumnSecurity, error) {
|
||||
return []security.ColumnSecurity{}, nil
|
||||
}
|
||||
|
||||
func (p *SimpleProvider) GetRowSecurity(ctx context.Context, u int, s, t string) (security.RowSecurity, error) {
|
||||
return security.RowSecurity{Template: fmt.Sprintf("user_id = %d", u)}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
handler := restheadspec.NewHandlerWithGORM(db)
|
||||
|
||||
// Setup security
|
||||
provider := &SimpleProvider{}
|
||||
securityList := security.SetupSecurityProvider(handler, provider)
|
||||
|
||||
// Apply middleware
|
||||
router := mux.NewRouter()
|
||||
restheadspec.SetupMuxRoutes(router, handler)
|
||||
router.Use(security.NewAuthMiddleware(securityList))
|
||||
router.Use(security.SetSecurityMiddleware(securityList))
|
||||
|
||||
http.ListenAndServe(":8080", router)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Modes
|
||||
|
||||
```go
|
||||
// Required authentication (default)
|
||||
// Authentication must succeed or returns 401
|
||||
router.Use(security.NewAuthMiddleware(securityList))
|
||||
|
||||
// Skip authentication for specific routes
|
||||
// Always sets guest user context
|
||||
func PublicRoute(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := security.SkipAuth(r.Context())
|
||||
r = r.WithContext(ctx)
|
||||
// Guest context will be set
|
||||
}
|
||||
|
||||
// Optional authentication for specific routes
|
||||
// Tries to authenticate, falls back to guest if it fails
|
||||
func HomeRoute(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := security.OptionalAuth(r.Context())
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
userCtx, _ := security.GetUserContext(r.Context())
|
||||
if userCtx.UserID == 0 {
|
||||
// Guest user
|
||||
} else {
|
||||
// Authenticated user
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Comparison:**
|
||||
- **Required**: Auth must succeed or return 401 (default)
|
||||
- **SkipAuth**: Never tries to authenticate, always guest
|
||||
- **OptionalAuth**: Tries to authenticate, guest on failure
|
||||
|
||||
---
|
||||
|
||||
## Standalone Handlers
|
||||
|
||||
```go
|
||||
// NewAuthHandler - Required authentication (returns 401 on failure)
|
||||
authHandler := security.NewAuthHandler(securityList, myHandler)
|
||||
http.Handle("/api/protected", authHandler)
|
||||
|
||||
// NewOptionalAuthHandler - Optional authentication (guest on failure)
|
||||
optionalHandler := security.NewOptionalAuthHandler(securityList, myHandler)
|
||||
http.Handle("/home", optionalHandler)
|
||||
|
||||
// NewOptionalAuthMiddleware - For spec routes; auth enforcement deferred to BeforeHandle
|
||||
apiRouter.Use(security.NewOptionalAuthMiddleware(securityList))
|
||||
apiRouter.Use(security.SetSecurityMiddleware(securityList))
|
||||
restheadspec.RegisterSecurityHooks(handler, securityList) // includes BeforeHandle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model-Level Access Control
|
||||
|
||||
```go
|
||||
// Register model with rules (pkg/modelregistry)
|
||||
modelregistry.RegisterModelWithRules("public.products", &Product{}, modelregistry.ModelRules{
|
||||
SecurityDisabled: false, // skip all auth when true
|
||||
CanPublicRead: true, // unauthenticated reads allowed
|
||||
CanPublicCreate: false, // requires auth
|
||||
CanPublicUpdate: false, // requires auth
|
||||
CanPublicDelete: false, // requires auth
|
||||
CanUpdate: true, // authenticated can update
|
||||
CanDelete: false, // authenticated cannot delete (enforced in BeforeDelete)
|
||||
})
|
||||
|
||||
// CheckModelAuthAllowed used automatically in BeforeHandle hook
|
||||
// No code needed — call RegisterSecurityHooks and it's applied
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Helpers
|
||||
|
||||
```go
|
||||
// Get full user context
|
||||
userCtx, ok := security.GetUserContext(ctx)
|
||||
|
||||
// Get individual fields
|
||||
userID, ok := security.GetUserID(ctx)
|
||||
userName, ok := security.GetUserName(ctx)
|
||||
userLevel, ok := security.GetUserLevel(ctx)
|
||||
sessionID, ok := security.GetSessionID(ctx)
|
||||
remoteID, ok := security.GetRemoteID(ctx)
|
||||
roles, ok := security.GetUserRoles(ctx)
|
||||
email, ok := security.GetUserEmail(ctx)
|
||||
meta, ok := security.GetUserMeta(ctx)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `INTERFACE_GUIDE.md` | **Start here** - Complete implementation guide |
|
||||
| `OAUTH2.md` | **OAuth2 Guide** - Google, GitHub, Microsoft, Facebook, custom providers |
|
||||
| `examples.go` | Working provider implementations to copy |
|
||||
| `setup_example.go` | 6 complete integration examples |
|
||||
| `README.md` | Architecture overview and migration guide |
|
||||
|
||||
---
|
||||
|
||||
## Cheat Sheet
|
||||
|
||||
```go
|
||||
// ===== REQUIRED SETUP =====
|
||||
auth := security.NewJWTAuthenticator("secret", db)
|
||||
colSec := security.NewDatabaseColumnSecurityProvider(db)
|
||||
rowSec := security.NewDatabaseRowSecurityProvider(db)
|
||||
provider := security.NewCompositeSecurityProvider(auth, colSec, rowSec)
|
||||
securityList := security.SetupSecurityProvider(handler, provider)
|
||||
|
||||
// ===== INTERFACE METHODS =====
|
||||
Authenticate(r *http.Request) (*UserContext, error)
|
||||
Login(ctx context.Context, req LoginRequest) (*LoginResponse, error)
|
||||
Logout(ctx context.Context, req LogoutRequest) error
|
||||
GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error)
|
||||
GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error)
|
||||
|
||||
// ===== QUICK EXAMPLES =====
|
||||
// Header auth
|
||||
&UserContext{UserID: 123, UserName: "john"}
|
||||
|
||||
// Mask SSN
|
||||
{Path: []string{"ssn"}, Accesstype: "mask", MaskStart: 5}
|
||||
|
||||
// User isolation
|
||||
{Template: "user_id = {UserID}"}
|
||||
```
|
||||
+1417
File diff suppressed because it is too large
Load Diff
+440
@@ -0,0 +1,440 @@
|
||||
# Security Features: Blacklist & Rate Limit Inspection
|
||||
|
||||
## IP Blacklist
|
||||
|
||||
The IP blacklist middleware allows you to block specific IP addresses or CIDR ranges from accessing your application.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
import "github.com/bitechdev/ResolveSpec/pkg/middleware"
|
||||
|
||||
// Create blacklist (UseProxy=true if behind a proxy)
|
||||
blacklist := middleware.NewIPBlacklist(middleware.BlacklistConfig{
|
||||
UseProxy: true, // Checks X-Forwarded-For and X-Real-IP headers
|
||||
})
|
||||
|
||||
// Block individual IP
|
||||
blacklist.BlockIP("192.168.1.100", "Suspicious activity detected")
|
||||
|
||||
// Block entire CIDR range
|
||||
blacklist.BlockCIDR("10.0.0.0/8", "Private network blocked")
|
||||
|
||||
// Apply middleware
|
||||
http.Handle("/api/", blacklist.Middleware(yourHandler))
|
||||
```
|
||||
|
||||
### Managing Blacklist
|
||||
|
||||
```go
|
||||
// Unblock an IP
|
||||
blacklist.UnblockIP("192.168.1.100")
|
||||
|
||||
// Unblock a CIDR range
|
||||
blacklist.UnblockCIDR("10.0.0.0/8")
|
||||
|
||||
// Get all blacklisted IPs and CIDRs
|
||||
ips, cidrs := blacklist.GetBlacklist()
|
||||
fmt.Printf("Blocked IPs: %v\n", ips)
|
||||
fmt.Printf("Blocked CIDRs: %v\n", cidrs)
|
||||
|
||||
// Check if specific IP is blocked
|
||||
blocked, reason := blacklist.IsBlocked("192.168.1.100")
|
||||
if blocked {
|
||||
fmt.Printf("IP blocked: %s\n", reason)
|
||||
}
|
||||
```
|
||||
|
||||
### Blacklist Statistics Endpoint
|
||||
|
||||
Expose blacklist statistics via HTTP:
|
||||
|
||||
```go
|
||||
// Add stats endpoint
|
||||
http.Handle("/admin/blacklist-stats", blacklist.StatsHandler())
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"blocked_ips": ["192.168.1.100", "192.168.1.101"],
|
||||
"blocked_cidrs": ["10.0.0.0/8"],
|
||||
"total_ips": 2,
|
||||
"total_cidrs": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Example
|
||||
|
||||
```go
|
||||
func main() {
|
||||
// Create blacklist
|
||||
blacklist := middleware.NewIPBlacklist(middleware.BlacklistConfig{
|
||||
UseProxy: true,
|
||||
})
|
||||
|
||||
// Block known malicious IPs
|
||||
blacklist.BlockIP("203.0.113.1", "Known scanner")
|
||||
blacklist.BlockCIDR("198.51.100.0/24", "Spam network")
|
||||
|
||||
// Create your router
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Protected routes
|
||||
mux.Handle("/api/", blacklist.Middleware(apiHandler))
|
||||
|
||||
// Admin endpoint to manage blacklist
|
||||
mux.HandleFunc("/admin/block-ip", func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.URL.Query().Get("ip")
|
||||
reason := r.URL.Query().Get("reason")
|
||||
|
||||
if err := blacklist.BlockIP(ip, reason); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "Blocked %s: %s", ip, reason)
|
||||
})
|
||||
|
||||
// Stats endpoint
|
||||
mux.Handle("/admin/blacklist-stats", blacklist.StatsHandler())
|
||||
|
||||
http.ListenAndServe(":8080", mux)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limit Inspection
|
||||
|
||||
Monitor and inspect rate limit status per IP address in real-time.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
import "github.com/bitechdev/ResolveSpec/pkg/middleware"
|
||||
|
||||
// Create rate limiter (10 req/sec, burst of 20)
|
||||
rateLimiter := middleware.NewRateLimiter(10, 20)
|
||||
|
||||
// Apply middleware
|
||||
http.Handle("/api/", rateLimiter.Middleware(yourHandler))
|
||||
```
|
||||
|
||||
### Programmatic Inspection
|
||||
|
||||
```go
|
||||
// Get all tracked IPs
|
||||
trackedIPs := rateLimiter.GetTrackedIPs()
|
||||
fmt.Printf("Currently tracking %d IPs\n", len(trackedIPs))
|
||||
|
||||
// Get rate limit info for specific IP
|
||||
info := rateLimiter.GetRateLimitInfo("192.168.1.1")
|
||||
fmt.Printf("IP: %s\n", info.IP)
|
||||
fmt.Printf("Tokens Remaining: %.2f\n", info.TokensRemaining)
|
||||
fmt.Printf("Limit: %.2f req/sec\n", info.Limit)
|
||||
fmt.Printf("Burst: %d\n", info.Burst)
|
||||
|
||||
// Get info for all tracked IPs
|
||||
allInfo := rateLimiter.GetAllRateLimitInfo()
|
||||
for _, info := range allInfo {
|
||||
fmt.Printf("%s: %.2f tokens remaining\n", info.IP, info.TokensRemaining)
|
||||
}
|
||||
```
|
||||
|
||||
### Rate Limit Stats Endpoint
|
||||
|
||||
Expose rate limit statistics via HTTP:
|
||||
|
||||
```go
|
||||
// Add stats endpoint
|
||||
http.Handle("/admin/rate-limit-stats", rateLimiter.StatsHandler())
|
||||
```
|
||||
|
||||
**Example Response (all IPs):**
|
||||
```json
|
||||
{
|
||||
"total_tracked_ips": 3,
|
||||
"rate_limit_config": {
|
||||
"requests_per_second": 10,
|
||||
"burst": 20
|
||||
},
|
||||
"tracked_ips": [
|
||||
{
|
||||
"ip": "192.168.1.1",
|
||||
"tokens_remaining": 15.5,
|
||||
"limit": 10,
|
||||
"burst": 20
|
||||
},
|
||||
{
|
||||
"ip": "192.168.1.2",
|
||||
"tokens_remaining": 18.2,
|
||||
"limit": 10,
|
||||
"burst": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response (specific IP):**
|
||||
```bash
|
||||
GET /admin/rate-limit-stats?ip=192.168.1.1
|
||||
```
|
||||
```json
|
||||
{
|
||||
"ip": "192.168.1.1",
|
||||
"tokens_remaining": 15.5,
|
||||
"limit": 10,
|
||||
"burst": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Integration Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create rate limiter
|
||||
rateLimiter := middleware.NewRateLimiter(10, 20)
|
||||
|
||||
// Create blacklist
|
||||
blacklist := middleware.NewIPBlacklist(middleware.BlacklistConfig{
|
||||
UseProxy: true,
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API handler with both middlewares (blacklist first, then rate limit)
|
||||
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Success",
|
||||
})
|
||||
})
|
||||
|
||||
// Apply middleware chain: blacklist -> rate limit -> handler
|
||||
mux.Handle("/api/", blacklist.Middleware(rateLimiter.Middleware(apiHandler)))
|
||||
|
||||
// Admin endpoints
|
||||
mux.Handle("/admin/rate-limit-stats", rateLimiter.StatsHandler())
|
||||
mux.Handle("/admin/blacklist-stats", blacklist.StatsHandler())
|
||||
|
||||
// Custom monitoring endpoint
|
||||
mux.HandleFunc("/admin/monitor", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get rate limit stats
|
||||
rateLimitInfo := rateLimiter.GetAllRateLimitInfo()
|
||||
|
||||
// Get blacklist stats
|
||||
blockedIPs, blockedCIDRs := blacklist.GetBlacklist()
|
||||
|
||||
response := map[string]interface{}{
|
||||
"rate_limits": rateLimitInfo,
|
||||
"blacklist": map[string]interface{}{
|
||||
"ips": blockedIPs,
|
||||
"cidrs": blockedCIDRs,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
})
|
||||
|
||||
// Dynamic blacklist management
|
||||
mux.HandleFunc("/admin/block", func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.URL.Query().Get("ip")
|
||||
reason := r.URL.Query().Get("reason")
|
||||
|
||||
if ip == "" {
|
||||
http.Error(w, "IP required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := blacklist.BlockIP(ip, reason); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "Blocked %s: %s", ip, reason)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/unblock", func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.URL.Query().Get("ip")
|
||||
if ip == "" {
|
||||
http.Error(w, "IP required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
blacklist.UnblockIP(ip)
|
||||
fmt.Fprintf(w, "Unblocked %s", ip)
|
||||
})
|
||||
|
||||
// Auto-block IPs that exceed rate limit
|
||||
mux.HandleFunc("/admin/auto-block-heavy-users", func(w http.ResponseWriter, r *http.Request) {
|
||||
blocked := 0
|
||||
|
||||
for _, info := range rateLimiter.GetAllRateLimitInfo() {
|
||||
// If tokens are very low, IP is making many requests
|
||||
if info.TokensRemaining < 1.0 {
|
||||
blacklist.BlockIP(info.IP, "Exceeded rate limit")
|
||||
blocked++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "Blocked %d IPs exceeding rate limits", blocked)
|
||||
})
|
||||
|
||||
fmt.Println("Server starting on :8080")
|
||||
fmt.Println("Rate limit stats: http://localhost:8080/admin/rate-limit-stats")
|
||||
fmt.Println("Blacklist stats: http://localhost:8080/admin/blacklist-stats")
|
||||
http.ListenAndServe(":8080", mux)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring Dashboard Example
|
||||
|
||||
Create a simple monitoring page:
|
||||
|
||||
```go
|
||||
mux.HandleFunc("/admin/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||
html := `
|
||||
<html>
|
||||
<head>
|
||||
<title>Security Dashboard</title>
|
||||
<script>
|
||||
async function loadStats() {
|
||||
const rateLimitRes = await fetch('/admin/rate-limit-stats');
|
||||
const rateLimitData = await rateLimitRes.json();
|
||||
|
||||
const blacklistRes = await fetch('/admin/blacklist-stats');
|
||||
const blacklistData = await blacklistRes.json();
|
||||
|
||||
document.getElementById('rate-limit').innerHTML =
|
||||
JSON.stringify(rateLimitData, null, 2);
|
||||
document.getElementById('blacklist').innerHTML =
|
||||
JSON.stringify(blacklistData, null, 2);
|
||||
}
|
||||
|
||||
setInterval(loadStats, 5000); // Refresh every 5 seconds
|
||||
loadStats();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Security Dashboard</h1>
|
||||
|
||||
<h2>Rate Limits</h2>
|
||||
<pre id="rate-limit">Loading...</pre>
|
||||
|
||||
<h2>Blacklist</h2>
|
||||
<pre id="blacklist">Loading...</pre>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(html))
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Proxy Configuration
|
||||
Always set `UseProxy: true` when running behind a reverse proxy (nginx, Cloudflare, etc.):
|
||||
```go
|
||||
blacklist := middleware.NewIPBlacklist(middleware.BlacklistConfig{
|
||||
UseProxy: true, // Checks X-Forwarded-For headers
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Middleware Order
|
||||
Apply blacklist before rate limiting to save resources:
|
||||
```go
|
||||
// Correct order: blacklist -> rate limit -> handler
|
||||
handler := blacklist.Middleware(
|
||||
rateLimiter.Middleware(yourHandler)
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Secure Admin Endpoints
|
||||
Protect admin endpoints with authentication:
|
||||
```go
|
||||
mux.Handle("/admin/", authMiddleware(adminHandler))
|
||||
```
|
||||
|
||||
### 4. Monitoring
|
||||
Set up alerts when:
|
||||
- Many IPs are being rate limited
|
||||
- Blacklist grows too large
|
||||
- Specific IPs are repeatedly blocked
|
||||
|
||||
### 5. Dynamic Response
|
||||
Automatically block IPs that consistently exceed rate limits:
|
||||
```go
|
||||
// Check every minute
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
for _, info := range rateLimiter.GetAllRateLimitInfo() {
|
||||
if info.TokensRemaining < 0.5 {
|
||||
blacklist.BlockIP(info.IP, "Automated block: rate limit exceeded")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
### 6. CIDR for Network Blocks
|
||||
Use CIDR ranges to block entire networks efficiently:
|
||||
```go
|
||||
// Block entire subnets
|
||||
blacklist.BlockCIDR("10.0.0.0/8", "Private network")
|
||||
blacklist.BlockCIDR("192.168.0.0/16", "Local network")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### IPBlacklist
|
||||
|
||||
#### Methods
|
||||
- `BlockIP(ip, reason string) error` - Block a single IP address
|
||||
- `BlockCIDR(cidr, reason string) error` - Block a CIDR range
|
||||
- `UnblockIP(ip string)` - Remove IP from blacklist
|
||||
- `UnblockCIDR(cidr string)` - Remove CIDR from blacklist
|
||||
- `IsBlocked(ip string) (blocked bool, reason string)` - Check if IP is blocked
|
||||
- `GetBlacklist() (ips, cidrs []string)` - Get all blocked IPs and CIDRs
|
||||
- `Middleware(next http.Handler) http.Handler` - HTTP middleware
|
||||
- `StatsHandler() http.Handler` - HTTP handler for statistics
|
||||
|
||||
### RateLimiter
|
||||
|
||||
#### Methods
|
||||
- `GetTrackedIPs() []string` - Get all tracked IP addresses
|
||||
- `GetRateLimitInfo(ip string) *RateLimitInfo` - Get info for specific IP
|
||||
- `GetAllRateLimitInfo() []*RateLimitInfo` - Get info for all tracked IPs
|
||||
- `Middleware(next http.Handler) http.Handler` - HTTP middleware
|
||||
- `StatsHandler() http.Handler` - HTTP handler for statistics
|
||||
|
||||
#### RateLimitInfo Structure
|
||||
```go
|
||||
type RateLimitInfo struct {
|
||||
IP string `json:"ip"`
|
||||
TokensRemaining float64 `json:"tokens_remaining"`
|
||||
Limit float64 `json:"limit"`
|
||||
Burst int `json:"burst"`
|
||||
}
|
||||
```
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ChainAuthenticator tries each authenticator in order, returning the first success.
|
||||
// Login and Logout are delegated to the primary authenticator.
|
||||
type ChainAuthenticator struct {
|
||||
authenticators []Authenticator
|
||||
authenticateCallback func(r *http.Request) (*UserContext, error)
|
||||
}
|
||||
|
||||
// NewChainAuthenticator creates a ChainAuthenticator from the given authenticators.
|
||||
// At least one authenticator is required; the first is treated as primary for Login/Logout.
|
||||
func NewChainAuthenticator(primary Authenticator, rest ...Authenticator) *ChainAuthenticator {
|
||||
return &ChainAuthenticator{
|
||||
authenticators: append([]Authenticator{primary}, rest...),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ChainAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
|
||||
var lastErr error
|
||||
for _, a := range c.authenticators {
|
||||
if uc, err := a.Authenticate(r); err == nil {
|
||||
return uc, nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if c.authenticateCallback != nil {
|
||||
return c.authenticateCallback(r)
|
||||
}
|
||||
return nil, fmt.Errorf("all authenticators failed; last error: %w", lastErr)
|
||||
}
|
||||
|
||||
func (c *ChainAuthenticator) SetAuthenticateCallback(fn func(r *http.Request) (*UserContext, error)) {
|
||||
c.authenticateCallback = fn
|
||||
}
|
||||
|
||||
func (c *ChainAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
return c.authenticators[0].Login(ctx, req)
|
||||
}
|
||||
|
||||
func (c *ChainAuthenticator) LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error) {
|
||||
return c.authenticators[0].LoginWithCookie(ctx, req, w)
|
||||
}
|
||||
|
||||
func (c *ChainAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
|
||||
return c.authenticators[0].Logout(ctx, req)
|
||||
}
|
||||
|
||||
func (c *ChainAuthenticator) LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error {
|
||||
return c.authenticators[0].LogoutWithCookie(ctx, req, w)
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CompositeSecurityProvider combines multiple security providers
|
||||
// Allows separating authentication, column security, and row security concerns
|
||||
type CompositeSecurityProvider struct {
|
||||
auth Authenticator
|
||||
colSec ColumnSecurityProvider
|
||||
rowSec RowSecurityProvider
|
||||
}
|
||||
|
||||
// NewCompositeSecurityProvider creates a composite provider
|
||||
// All parameters are required
|
||||
func NewCompositeSecurityProvider(
|
||||
auth Authenticator,
|
||||
colSec ColumnSecurityProvider,
|
||||
rowSec RowSecurityProvider,
|
||||
) (*CompositeSecurityProvider, error) {
|
||||
if auth == nil {
|
||||
return nil, fmt.Errorf("authenticator cannot be nil")
|
||||
}
|
||||
if colSec == nil {
|
||||
return nil, fmt.Errorf("column security provider cannot be nil")
|
||||
}
|
||||
if rowSec == nil {
|
||||
return nil, fmt.Errorf("row security provider cannot be nil")
|
||||
}
|
||||
|
||||
return &CompositeSecurityProvider{
|
||||
auth: auth,
|
||||
colSec: colSec,
|
||||
rowSec: rowSec,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login delegates to the authenticator
|
||||
func (c *CompositeSecurityProvider) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
return c.auth.Login(ctx, req)
|
||||
}
|
||||
|
||||
// LoginWithCookie delegates to the authenticator
|
||||
func (c *CompositeSecurityProvider) LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error) {
|
||||
return c.auth.LoginWithCookie(ctx, req, w)
|
||||
}
|
||||
|
||||
// Logout delegates to the authenticator
|
||||
func (c *CompositeSecurityProvider) Logout(ctx context.Context, req LogoutRequest) error {
|
||||
return c.auth.Logout(ctx, req)
|
||||
}
|
||||
|
||||
// LogoutWithCookie delegates to the authenticator
|
||||
func (c *CompositeSecurityProvider) LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error {
|
||||
return c.auth.LogoutWithCookie(ctx, req, w)
|
||||
}
|
||||
|
||||
// Authenticate delegates to the authenticator
|
||||
func (c *CompositeSecurityProvider) Authenticate(r *http.Request) (*UserContext, error) {
|
||||
return c.auth.Authenticate(r)
|
||||
}
|
||||
|
||||
// SetAuthenticateCallback delegates to the authenticator
|
||||
func (c *CompositeSecurityProvider) SetAuthenticateCallback(fn func(r *http.Request) (*UserContext, error)) {
|
||||
c.auth.SetAuthenticateCallback(fn)
|
||||
}
|
||||
|
||||
// GetColumnSecurity delegates to the column security provider
|
||||
func (c *CompositeSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error) {
|
||||
return c.colSec.GetColumnSecurity(ctx, userID, schema, table)
|
||||
}
|
||||
|
||||
// GetRowSecurity delegates to the row security provider
|
||||
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
|
||||
return c.rowSec.GetRowSecurity(ctx, userID, schema, table)
|
||||
}
|
||||
|
||||
// Optional interface implementations (if wrapped providers support them)
|
||||
|
||||
// RefreshToken implements Refreshable if the authenticator supports it
|
||||
func (c *CompositeSecurityProvider) RefreshToken(ctx context.Context, refreshToken string) (*LoginResponse, error) {
|
||||
if refreshable, ok := c.auth.(Refreshable); ok {
|
||||
return refreshable.RefreshToken(ctx, refreshToken)
|
||||
}
|
||||
return nil, fmt.Errorf("authenticator does not support token refresh")
|
||||
}
|
||||
|
||||
// ValidateToken implements Validatable if the authenticator supports it
|
||||
func (c *CompositeSecurityProvider) ValidateToken(ctx context.Context, token string) (bool, error) {
|
||||
if validatable, ok := c.auth.(Validatable); ok {
|
||||
return validatable.ValidateToken(ctx, token)
|
||||
}
|
||||
return false, fmt.Errorf("authenticator does not support token validation")
|
||||
}
|
||||
|
||||
// ClearCache implements Cacheable if any provider supports it
|
||||
func (c *CompositeSecurityProvider) ClearCache(ctx context.Context, userID int, schema, table string) error {
|
||||
var errs []error
|
||||
|
||||
if cacheable, ok := c.colSec.(Cacheable); ok {
|
||||
if err := cacheable.ClearCache(ctx, userID, schema, table); err != nil {
|
||||
errs = append(errs, fmt.Errorf("column security cache clear failed: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if cacheable, ok := c.rowSec.(Cacheable); ok {
|
||||
if err := cacheable.ClearCache(ctx, userID, schema, table); err != nil {
|
||||
errs = append(errs, fmt.Errorf("row security cache clear failed: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("cache clear errors: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+1763
File diff suppressed because it is too large
Load Diff
+385
@@ -0,0 +1,385 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
// Optional: Uncomment if you want to use JWT authentication
|
||||
// "github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Example 1: Simple Header-Based Authenticator
|
||||
// =============================================
|
||||
|
||||
type HeaderAuthenticatorExample struct {
|
||||
// Optional: Add any dependencies here (e.g., database, cache)
|
||||
}
|
||||
|
||||
func NewHeaderAuthenticatorExample() *HeaderAuthenticatorExample {
|
||||
return &HeaderAuthenticatorExample{}
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticatorExample) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
// For header-based auth, login might not be used
|
||||
// Could validate credentials against a database here
|
||||
return nil, fmt.Errorf("header authentication does not support login")
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticatorExample) Logout(ctx context.Context, req LogoutRequest) error {
|
||||
// For header-based auth, logout is a no-op
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *HeaderAuthenticatorExample) Authenticate(r *http.Request) (*UserContext, error) {
|
||||
userIDStr := r.Header.Get("X-User-ID")
|
||||
if userIDStr == "" {
|
||||
return nil, fmt.Errorf("X-User-ID header required")
|
||||
}
|
||||
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID: %w", err)
|
||||
}
|
||||
|
||||
return &UserContext{
|
||||
UserID: userID,
|
||||
UserName: r.Header.Get("X-User-Name"),
|
||||
UserLevel: parseIntHeader(r, "X-User-Level", 0),
|
||||
SessionID: r.Header.Get("X-Session-ID"),
|
||||
RemoteID: r.Header.Get("X-Remote-ID"),
|
||||
Email: r.Header.Get("X-User-Email"),
|
||||
Roles: parseRoles(r.Header.Get("X-User-Roles")),
|
||||
Claims: make(map[string]any),
|
||||
Meta: make(map[string]any),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Example 2: JWT Token Authenticator
|
||||
// ====================================
|
||||
// NOTE: To use this, uncomment the jwt import and install: go get github.com/golang-jwt/jwt/v5
|
||||
|
||||
type JWTAuthenticatorExample struct {
|
||||
secretKey []byte
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewJWTAuthenticatorExample(secretKey string, db *gorm.DB) *JWTAuthenticatorExample {
|
||||
return &JWTAuthenticatorExample{
|
||||
secretKey: []byte(secretKey),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *JWTAuthenticatorExample) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
// Validate credentials against database
|
||||
var user struct {
|
||||
ID int
|
||||
Username string
|
||||
Email string
|
||||
Password string // Should be hashed
|
||||
UserLevel int
|
||||
Roles string
|
||||
}
|
||||
|
||||
err := a.db.WithContext(ctx).
|
||||
Table("users").
|
||||
Where("username = ?", req.Username).
|
||||
First(&user).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// TODO: Verify password hash
|
||||
// if !verifyPassword(user.Password, req.Password) {
|
||||
// return nil, fmt.Errorf("invalid credentials")
|
||||
// }
|
||||
|
||||
// Create JWT token
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
|
||||
// Uncomment when using JWT:
|
||||
// token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
// "user_id": user.ID,
|
||||
// "username": user.Username,
|
||||
// "email": user.Email,
|
||||
// "user_level": user.UserLevel,
|
||||
// "roles": user.Roles,
|
||||
// "exp": expiresAt.Unix(),
|
||||
// })
|
||||
// tokenString, err := token.SignedString(a.secretKey)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
// }
|
||||
|
||||
// Placeholder token for example (replace with actual JWT)
|
||||
tokenString := fmt.Sprintf("token_%d_%d", user.ID, expiresAt.Unix())
|
||||
|
||||
return &LoginResponse{
|
||||
Token: tokenString,
|
||||
User: &UserContext{
|
||||
UserID: user.ID,
|
||||
UserName: user.Username,
|
||||
Email: user.Email,
|
||||
UserLevel: user.UserLevel,
|
||||
Roles: parseRoles(user.Roles),
|
||||
Claims: req.Claims,
|
||||
Meta: req.Meta,
|
||||
},
|
||||
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *JWTAuthenticatorExample) Logout(ctx context.Context, req LogoutRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *JWTAuthenticatorExample) Authenticate(r *http.Request) (*UserContext, error) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return nil, fmt.Errorf("authorization header required")
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
return nil, fmt.Errorf("bearer token required")
|
||||
}
|
||||
|
||||
// Uncomment when using JWT:
|
||||
// token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
// return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
// }
|
||||
// return a.secretKey, nil
|
||||
// })
|
||||
//
|
||||
// if err != nil || !token.Valid {
|
||||
// return nil, fmt.Errorf("invalid token: %w", err)
|
||||
// }
|
||||
//
|
||||
// claims, ok := token.Claims.(jwt.MapClaims)
|
||||
// if !ok {
|
||||
// return nil, fmt.Errorf("invalid token claims")
|
||||
// }
|
||||
//
|
||||
// return &UserContext{
|
||||
// UserID: int(claims["user_id"].(float64)),
|
||||
// UserName: getString(claims, "username"),
|
||||
// Email: getString(claims, "email"),
|
||||
// UserLevel: getInt(claims, "user_level"),
|
||||
// Roles: parseRoles(getString(claims, "roles")),
|
||||
// Claims: claims,
|
||||
// }, nil
|
||||
|
||||
// Placeholder implementation (replace with actual JWT parsing)
|
||||
return nil, fmt.Errorf("JWT parsing not implemented - uncomment JWT code above")
|
||||
}
|
||||
|
||||
// Example 3: Database Session Authenticator
|
||||
// ==========================================
|
||||
|
||||
type DatabaseAuthenticatorExample struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDatabaseAuthenticatorExample(db *gorm.DB) *DatabaseAuthenticatorExample {
|
||||
return &DatabaseAuthenticatorExample{db: db}
|
||||
}
|
||||
|
||||
func (a *DatabaseAuthenticatorExample) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
// Query user from database
|
||||
var user struct {
|
||||
ID int
|
||||
Username string
|
||||
Email string
|
||||
Password string // Should be hashed with bcrypt
|
||||
UserLevel int
|
||||
Roles string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
err := a.db.WithContext(ctx).
|
||||
Table("users").
|
||||
Where("username = ? AND is_active = true", req.Username).
|
||||
First(&user).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// TODO: Verify password with bcrypt
|
||||
// if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
// return nil, fmt.Errorf("invalid credentials")
|
||||
// }
|
||||
|
||||
// Generate session token
|
||||
sessionToken := fmt.Sprintf("sess_%s_%d", generateRandomString(32), time.Now().Unix())
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
|
||||
// Create session in database
|
||||
err = a.db.WithContext(ctx).Table("user_sessions").Create(map[string]any{
|
||||
"session_token": sessionToken,
|
||||
"user_id": user.ID,
|
||||
"expires_at": expiresAt,
|
||||
"created_at": time.Now(),
|
||||
"ip_address": req.Claims["ip_address"],
|
||||
"user_agent": req.Claims["user_agent"],
|
||||
}).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
return &LoginResponse{
|
||||
Token: sessionToken,
|
||||
User: &UserContext{
|
||||
UserID: user.ID,
|
||||
UserName: user.Username,
|
||||
Email: user.Email,
|
||||
UserLevel: user.UserLevel,
|
||||
Roles: parseRoles(user.Roles),
|
||||
SessionID: sessionToken,
|
||||
Claims: req.Claims,
|
||||
Meta: req.Meta,
|
||||
},
|
||||
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *DatabaseAuthenticatorExample) Logout(ctx context.Context, req LogoutRequest) error {
|
||||
// Delete session from database
|
||||
err := a.db.WithContext(ctx).
|
||||
Table("user_sessions").
|
||||
Where("session_token = ? AND user_id = ?", req.Token, req.UserID).
|
||||
Delete(nil).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *DatabaseAuthenticatorExample) Authenticate(r *http.Request) (*UserContext, error) {
|
||||
// Extract session token from header or cookie
|
||||
sessionToken := r.Header.Get("Authorization")
|
||||
if sessionToken == "" {
|
||||
// Try cookie
|
||||
cookie, err := r.Cookie("session_token")
|
||||
if err == nil {
|
||||
sessionToken = cookie.Value
|
||||
}
|
||||
} else {
|
||||
// Remove "Bearer " prefix if present
|
||||
sessionToken = strings.TrimPrefix(sessionToken, "Bearer ")
|
||||
}
|
||||
|
||||
if sessionToken == "" {
|
||||
return nil, fmt.Errorf("session token required")
|
||||
}
|
||||
|
||||
// Query session and user from database
|
||||
var session struct {
|
||||
SessionToken string
|
||||
UserID int
|
||||
ExpiresAt time.Time
|
||||
Username string
|
||||
Email string
|
||||
UserLevel int
|
||||
Roles string
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
s.session_token,
|
||||
s.user_id,
|
||||
s.expires_at,
|
||||
u.username,
|
||||
u.email,
|
||||
u.user_level,
|
||||
u.roles
|
||||
FROM user_sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_token = ?
|
||||
AND s.expires_at > ?
|
||||
AND u.is_active = true
|
||||
`
|
||||
|
||||
err := a.db.Raw(query, sessionToken, time.Now()).Scan(&session).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid or expired session")
|
||||
}
|
||||
|
||||
// Update last activity timestamp
|
||||
go a.updateSessionActivity(sessionToken)
|
||||
|
||||
return &UserContext{
|
||||
UserID: session.UserID,
|
||||
UserName: session.Username,
|
||||
Email: session.Email,
|
||||
UserLevel: session.UserLevel,
|
||||
SessionID: sessionToken,
|
||||
Roles: parseRoles(session.Roles),
|
||||
Claims: make(map[string]any),
|
||||
Meta: make(map[string]any),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// updateSessionActivity updates the last activity timestamp for the session
|
||||
func (a *DatabaseAuthenticatorExample) updateSessionActivity(sessionToken string) {
|
||||
a.db.Table("user_sessions").
|
||||
Where("session_token = ?", sessionToken).
|
||||
Update("last_activity_at", time.Now())
|
||||
}
|
||||
|
||||
// Optional: Implement Refreshable interface
|
||||
func (a *DatabaseAuthenticatorExample) RefreshToken(ctx context.Context, refreshToken string) (*LoginResponse, error) {
|
||||
// Query the refresh token
|
||||
var session struct {
|
||||
UserID int
|
||||
Username string
|
||||
Email string
|
||||
}
|
||||
|
||||
err := a.db.WithContext(ctx).Raw(`
|
||||
SELECT u.id as user_id, u.username, u.email
|
||||
FROM user_sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_token = ? AND s.expires_at > ?
|
||||
`, refreshToken, time.Now()).Scan(&session).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid refresh token")
|
||||
}
|
||||
|
||||
// Generate new session token
|
||||
newSessionToken := fmt.Sprintf("sess_%s_%d", generateRandomString(32), time.Now().Unix())
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
|
||||
// Create new session
|
||||
err = a.db.WithContext(ctx).Table("user_sessions").Create(map[string]any{
|
||||
"session_token": newSessionToken,
|
||||
"user_id": session.UserID,
|
||||
"expires_at": expiresAt,
|
||||
"created_at": time.Now(),
|
||||
}).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create new session: %w", err)
|
||||
}
|
||||
|
||||
// Delete old session
|
||||
a.db.WithContext(ctx).Table("user_sessions").Where("session_token = ?", refreshToken).Delete(nil)
|
||||
|
||||
return &LoginResponse{
|
||||
Token: newSessionToken,
|
||||
User: &UserContext{
|
||||
UserID: session.UserID,
|
||||
UserName: session.Username,
|
||||
Email: session.Email,
|
||||
SessionID: newSessionToken,
|
||||
Claims: make(map[string]any),
|
||||
Meta: make(map[string]any),
|
||||
},
|
||||
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package security
|
||||
|
||||
// This file contains usage examples for integrating security with funcspec handlers
|
||||
// These are example snippets - not executable code
|
||||
|
||||
/*
|
||||
Example 1: Wrap handlers with authentication (required)
|
||||
|
||||
import (
|
||||
"github.com/bitechdev/ResolveSpec/pkg/funcspec"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Setup
|
||||
db := ... // your database connection
|
||||
securityList := ... // your security list
|
||||
handler := funcspec.NewHandler(db)
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Wrap handler with required authentication (returns 401 if not authenticated)
|
||||
ordersHandler := security.WithAuth(
|
||||
handler.SqlQueryList("SELECT * FROM orders WHERE user_id = [rid_user]", false, false, false),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/orders", ordersHandler).Methods("GET")
|
||||
|
||||
Example 2: Wrap handlers with optional authentication
|
||||
|
||||
// Wrap handler with optional authentication (falls back to guest if not authenticated)
|
||||
productsHandler := security.WithOptionalAuth(
|
||||
handler.SqlQueryList("SELECT * FROM products WHERE deleted = false", false, false, false),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/products", productsHandler).Methods("GET")
|
||||
|
||||
// The handler will show all products for guests, but could show personalized pricing
|
||||
// or recommendations for authenticated users based on [rid_user]
|
||||
|
||||
Example 3: Wrap handlers with both authentication and security context
|
||||
|
||||
// Use the convenience function for both auth and security context
|
||||
usersHandler := security.WithAuthAndSecurity(
|
||||
handler.SqlQueryList("SELECT * FROM users WHERE active = true", false, false, false),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/users", usersHandler).Methods("GET")
|
||||
|
||||
// Or use WithOptionalAuthAndSecurity for optional auth
|
||||
postsHandler := security.WithOptionalAuthAndSecurity(
|
||||
handler.SqlQueryList("SELECT * FROM posts WHERE published = true", false, false, false),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/posts", postsHandler).Methods("GET")
|
||||
|
||||
Example 4: Wrap a single funcspec handler with security context only
|
||||
|
||||
import (
|
||||
"github.com/bitechdev/ResolveSpec/pkg/funcspec"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Setup
|
||||
db := ... // your database connection
|
||||
securityList := ... // your security list
|
||||
handler := funcspec.NewHandler(db)
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Wrap a specific handler with security context
|
||||
usersHandler := security.WithSecurityContext(
|
||||
handler.SqlQueryList("SELECT * FROM users WHERE active = true", false, false, false),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/users", usersHandler).Methods("GET")
|
||||
|
||||
Example 5: Wrap multiple handlers for different paths
|
||||
|
||||
// Products list endpoint
|
||||
productsHandler := security.WithSecurityContext(
|
||||
handler.SqlQueryList("SELECT * FROM products WHERE deleted = false", false, true, true),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/products", productsHandler).Methods("GET")
|
||||
|
||||
// Single product endpoint
|
||||
productHandler := security.WithSecurityContext(
|
||||
handler.SqlQuery("SELECT * FROM products WHERE id = [id]", true),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/products/{id}", productHandler).Methods("GET")
|
||||
|
||||
// Orders endpoint with user filtering
|
||||
ordersHandler := security.WithSecurityContext(
|
||||
handler.SqlQueryList("SELECT * FROM orders WHERE user_id = [rid_user]", false, false, false),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/orders", ordersHandler).Methods("GET")
|
||||
|
||||
Example 6: Helper function to wrap multiple handlers
|
||||
|
||||
// Create a helper function for your application
|
||||
func secureHandler(h funcspec.HTTPFuncType, sl *SecurityList) funcspec.HTTPFuncType {
|
||||
return security.WithSecurityContext(h, sl)
|
||||
}
|
||||
|
||||
// Use it to wrap handlers
|
||||
router.HandleFunc("/api/users", secureHandler(
|
||||
handler.SqlQueryList("SELECT * FROM users", false, false, false),
|
||||
securityList,
|
||||
)).Methods("GET")
|
||||
|
||||
router.HandleFunc("/api/roles", secureHandler(
|
||||
handler.SqlQueryList("SELECT * FROM roles", false, false, false),
|
||||
securityList,
|
||||
)).Methods("GET")
|
||||
|
||||
Example 7: Access SecurityList and user context in hooks
|
||||
|
||||
// In your funcspec hook, you can now access the SecurityList and user context
|
||||
handler.Hooks().Register(funcspec.BeforeQueryList, func(ctx *funcspec.HookContext) error {
|
||||
// Get SecurityList from context
|
||||
if secList, ok := security.GetSecurityList(ctx.Context); ok {
|
||||
// Use secList to apply security rules
|
||||
// e.g., apply row-level security, column masking, etc.
|
||||
_ = secList
|
||||
}
|
||||
|
||||
// Get user context
|
||||
if userCtx, ok := security.GetUserContext(ctx.Context); ok {
|
||||
// Access user information
|
||||
logger.Info("User %s (ID: %d) accessing resource", userCtx.UserName, userCtx.UserID)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
Example 8: Mixing authentication and security patterns
|
||||
|
||||
// Public endpoint - no auth required, but has security context
|
||||
publicHandler := security.WithSecurityContext(
|
||||
handler.SqlQueryList("SELECT * FROM public_data", false, false, false),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/public", publicHandler).Methods("GET")
|
||||
|
||||
// Optional auth - personalized for logged-in users, works for guests
|
||||
personalizedHandler := security.WithOptionalAuth(
|
||||
handler.SqlQueryList("SELECT * FROM products WHERE category = [category]", false, true, false),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/products/category/{category}", personalizedHandler).Methods("GET")
|
||||
|
||||
// Required auth - must be logged in
|
||||
privateHandler := security.WithAuthAndSecurity(
|
||||
handler.SqlQueryList("SELECT * FROM private_data WHERE user_id = [rid_user]", false, false, false),
|
||||
securityList,
|
||||
)
|
||||
router.HandleFunc("/api/private", privateHandler).Methods("GET")
|
||||
*/
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||
)
|
||||
|
||||
// SecurityContext is a generic interface that any spec can implement to integrate with security features
|
||||
// This interface abstracts the common security context needs across different specs
|
||||
type SecurityContext interface {
|
||||
GetContext() context.Context
|
||||
GetUserID() (int, bool)
|
||||
GetSchema() string
|
||||
GetEntity() string
|
||||
GetModel() interface{}
|
||||
GetQuery() interface{}
|
||||
SetQuery(interface{})
|
||||
GetResult() interface{}
|
||||
SetResult(interface{})
|
||||
}
|
||||
|
||||
// loadSecurityRules loads security configuration for the user and entity (generic version)
|
||||
func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error {
|
||||
// Extract user ID from context
|
||||
userID, ok := secCtx.GetUserID()
|
||||
if !ok {
|
||||
logger.Warn("No user ID in context for security check")
|
||||
return nil
|
||||
}
|
||||
|
||||
schema := secCtx.GetSchema()
|
||||
tablename := secCtx.GetEntity()
|
||||
|
||||
logger.Debug("Loading security rules for user=%d, schema=%s, table=%s", userID, schema, tablename)
|
||||
|
||||
// Load column security rules using the provider
|
||||
err := securityList.LoadColumnSecurity(secCtx.GetContext(), userID, schema, tablename, false)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to load column security: %v", err)
|
||||
// Don't fail the request if no security rules exist
|
||||
// return err
|
||||
}
|
||||
|
||||
// Load row security rules using the provider
|
||||
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userID, schema, tablename, false)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to load row security: %v", err)
|
||||
// Don't fail the request if no security rules exist
|
||||
// return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyRowSecurity applies row-level security filters to the query (generic version)
|
||||
func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
||||
userID, ok := secCtx.GetUserID()
|
||||
if !ok {
|
||||
return nil // No user context, skip
|
||||
}
|
||||
|
||||
schema := secCtx.GetSchema()
|
||||
tablename := secCtx.GetEntity()
|
||||
|
||||
// Get row security template
|
||||
rowSec, err := securityList.GetRowSecurityTemplate(userID, schema, tablename)
|
||||
if err != nil {
|
||||
// No row security defined, allow query to proceed
|
||||
logger.Debug("No row security for %s.%s@%d: %v", schema, tablename, userID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if user has a blocking rule
|
||||
if rowSec.HasBlock {
|
||||
logger.Warn("User %d blocked from accessing %s.%s", userID, schema, tablename)
|
||||
return fmt.Errorf("access denied to %s", tablename)
|
||||
}
|
||||
|
||||
// If there's a security template, apply it as a WHERE clause
|
||||
if rowSec.Template != "" {
|
||||
model := secCtx.GetModel()
|
||||
if model == nil {
|
||||
logger.Debug("No model available for row security on %s.%s", schema, tablename)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get primary key name from model
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Pointer {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
|
||||
// Find primary key field
|
||||
pkName := "id" // default
|
||||
for i := 0; i < modelType.NumField(); i++ {
|
||||
field := modelType.Field(i)
|
||||
if tag := field.Tag.Get("bun"); tag != "" {
|
||||
// Check for primary key tag
|
||||
if contains(tag, "pk") || contains(tag, "primary_key") {
|
||||
if sqlName := extractSQLName(tag); sqlName != "" {
|
||||
pkName = sqlName
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the WHERE clause from template
|
||||
whereClause := rowSec.GetTemplate(pkName, modelType)
|
||||
|
||||
logger.Info("Applying row security filter for user %d on %s.%s: %s",
|
||||
userID, schema, tablename, whereClause)
|
||||
|
||||
// Apply the WHERE clause to the query
|
||||
query := secCtx.GetQuery()
|
||||
if selectQuery, ok := query.(interface {
|
||||
Where(string, ...interface{}) interface{}
|
||||
}); ok {
|
||||
secCtx.SetQuery(selectQuery.Where(whereClause))
|
||||
} else {
|
||||
logger.Debug("Query doesn't support Where method, skipping row security")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyColumnSecurity applies column-level security (masking/hiding) to results (generic version)
|
||||
func applyColumnSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
||||
userID, ok := secCtx.GetUserID()
|
||||
if !ok {
|
||||
return nil // No user context, skip
|
||||
}
|
||||
|
||||
schema := secCtx.GetSchema()
|
||||
tablename := secCtx.GetEntity()
|
||||
|
||||
// Get result data
|
||||
result := secCtx.GetResult()
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("Applying column security for user=%d, schema=%s, table=%s", userID, schema, tablename)
|
||||
|
||||
model := secCtx.GetModel()
|
||||
if model == nil {
|
||||
logger.Debug("No model available for column security on %s.%s", schema, tablename)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get model type
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Pointer {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
|
||||
// Apply column security masking
|
||||
resultValue := reflect.ValueOf(result)
|
||||
if resultValue.Kind() == reflect.Pointer {
|
||||
resultValue = resultValue.Elem()
|
||||
}
|
||||
|
||||
maskedResult, err := securityList.ApplyColumnSecurity(resultValue, modelType, userID, schema, tablename)
|
||||
if err != nil {
|
||||
logger.Warn("Column security error: %v", err)
|
||||
// Don't fail the request, just log the issue
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update the result with masked data
|
||||
if maskedResult.IsValid() && maskedResult.CanInterface() {
|
||||
secCtx.SetResult(maskedResult.Interface())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// logDataAccess logs all data access for audit purposes (generic version)
|
||||
func logDataAccess(secCtx SecurityContext) error {
|
||||
userID, _ := secCtx.GetUserID()
|
||||
|
||||
logger.Info("AUDIT: User %d accessed %s.%s",
|
||||
userID,
|
||||
secCtx.GetSchema(),
|
||||
secCtx.GetEntity(),
|
||||
)
|
||||
|
||||
// TODO: Write to audit log table or external audit service
|
||||
// auditLog := AuditLog{
|
||||
// UserID: userID,
|
||||
// Schema: secCtx.GetSchema(),
|
||||
// Entity: secCtx.GetEntity(),
|
||||
// Action: "READ",
|
||||
// Timestamp: time.Now(),
|
||||
// }
|
||||
// db.Create(&auditLog)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogDataAccess is a public wrapper for logDataAccess that accepts a SecurityContext
|
||||
// This allows other packages to use the audit logging functionality
|
||||
func LogDataAccess(secCtx SecurityContext) error {
|
||||
return logDataAccess(secCtx)
|
||||
}
|
||||
|
||||
// LoadSecurityRules is a public wrapper for loadSecurityRules that accepts a SecurityContext
|
||||
// This allows other packages to load security rules using the generic interface
|
||||
func LoadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error {
|
||||
return loadSecurityRules(secCtx, securityList)
|
||||
}
|
||||
|
||||
// ApplyRowSecurity is a public wrapper for applyRowSecurity that accepts a SecurityContext
|
||||
// This allows other packages to apply row-level security using the generic interface
|
||||
func ApplyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
||||
return applyRowSecurity(secCtx, securityList)
|
||||
}
|
||||
|
||||
// ApplyColumnSecurity is a public wrapper for applyColumnSecurity that accepts a SecurityContext
|
||||
// This allows other packages to apply column-level security using the generic interface
|
||||
func ApplyColumnSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
||||
return applyColumnSecurity(secCtx, securityList)
|
||||
}
|
||||
|
||||
// checkModelUpdateAllowed returns an error if CanUpdate is false for the model.
|
||||
// Rules are read from context (set by NewModelAuthMiddleware) with a fallback to the model registry.
|
||||
func checkModelUpdateAllowed(secCtx SecurityContext) error {
|
||||
rules, ok := GetModelRulesFromContext(secCtx.GetContext())
|
||||
if !ok {
|
||||
schema := secCtx.GetSchema()
|
||||
entity := secCtx.GetEntity()
|
||||
var err error
|
||||
if schema != "" {
|
||||
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
|
||||
}
|
||||
if err != nil || schema == "" {
|
||||
rules, err = modelregistry.GetModelRulesByName(entity)
|
||||
}
|
||||
if err != nil {
|
||||
return nil // model not registered, allow by default
|
||||
}
|
||||
}
|
||||
if !rules.CanUpdate {
|
||||
return fmt.Errorf("update not allowed for %s", secCtx.GetEntity())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkModelDeleteAllowed returns an error if CanDelete is false for the model.
|
||||
// Rules are read from context (set by NewModelAuthMiddleware) with a fallback to the model registry.
|
||||
func checkModelDeleteAllowed(secCtx SecurityContext) error {
|
||||
rules, ok := GetModelRulesFromContext(secCtx.GetContext())
|
||||
if !ok {
|
||||
schema := secCtx.GetSchema()
|
||||
entity := secCtx.GetEntity()
|
||||
var err error
|
||||
if schema != "" {
|
||||
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
|
||||
}
|
||||
if err != nil || schema == "" {
|
||||
rules, err = modelregistry.GetModelRulesByName(entity)
|
||||
}
|
||||
if err != nil {
|
||||
return nil // model not registered, allow by default
|
||||
}
|
||||
}
|
||||
if !rules.CanDelete {
|
||||
return fmt.Errorf("delete not allowed for %s", secCtx.GetEntity())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckModelAuthAllowed checks whether the requested operation is permitted based on
|
||||
// model rules and the current user's authentication state. It is intended for use in
|
||||
// a BeforeHandle hook, fired after model resolution.
|
||||
//
|
||||
// Logic:
|
||||
// 1. Load model rules from context (set by NewModelAuthMiddleware) or fall back to registry.
|
||||
// 2. SecurityDisabled → allow.
|
||||
// 3. operation == "read" && CanPublicRead → allow.
|
||||
// 4. operation == "create" && CanPublicCreate → allow.
|
||||
// 5. operation == "update" && CanPublicUpdate → allow.
|
||||
// 6. operation == "delete" && CanPublicDelete → allow.
|
||||
// 7. Guest (UserID == 0) → return "authentication required".
|
||||
// 8. Authenticated user → allow (operation-specific checks remain in BeforeUpdate/BeforeDelete).
|
||||
func CheckModelAuthAllowed(secCtx SecurityContext, operation string) error {
|
||||
rules, ok := GetModelRulesFromContext(secCtx.GetContext())
|
||||
if !ok {
|
||||
schema := secCtx.GetSchema()
|
||||
entity := secCtx.GetEntity()
|
||||
var err error
|
||||
if schema != "" {
|
||||
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
|
||||
}
|
||||
if err != nil || schema == "" {
|
||||
rules, err = modelregistry.GetModelRulesByName(entity)
|
||||
}
|
||||
if err != nil {
|
||||
// Model not registered - fall through to auth check
|
||||
userID, _ := secCtx.GetUserID()
|
||||
if userID == 0 {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if rules.SecurityDisabled {
|
||||
return nil
|
||||
}
|
||||
if operation == "read" && rules.CanPublicRead {
|
||||
return nil
|
||||
}
|
||||
if operation == "create" && rules.CanPublicCreate {
|
||||
return nil
|
||||
}
|
||||
if operation == "update" && rules.CanPublicUpdate {
|
||||
return nil
|
||||
}
|
||||
if operation == "delete" && rules.CanPublicDelete {
|
||||
return nil
|
||||
}
|
||||
|
||||
userID, _ := secCtx.GetUserID()
|
||||
if userID == 0 {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckModelUpdateAllowed is the public wrapper for checkModelUpdateAllowed.
|
||||
func CheckModelUpdateAllowed(secCtx SecurityContext) error {
|
||||
return checkModelUpdateAllowed(secCtx)
|
||||
}
|
||||
|
||||
// CheckModelDeleteAllowed is the public wrapper for checkModelDeleteAllowed.
|
||||
func CheckModelDeleteAllowed(secCtx SecurityContext) error {
|
||||
return checkModelDeleteAllowed(secCtx)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && s[:len(substr)] == substr ||
|
||||
len(s) > len(substr) && s[len(s)-len(substr):] == substr
|
||||
}
|
||||
|
||||
func extractSQLName(tag string) string {
|
||||
// Simple parser for "column:name" or just "name"
|
||||
// This is a simplified version
|
||||
parts := splitTag(tag, ',')
|
||||
for _, part := range parts {
|
||||
if part != "" && !contains(part, ":") {
|
||||
return part
|
||||
}
|
||||
if contains(part, "column:") {
|
||||
return part[7:] // Skip "column:"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitTag(tag string, sep rune) []string {
|
||||
var parts []string
|
||||
var current string
|
||||
for _, ch := range tag {
|
||||
if ch == sep {
|
||||
if current != "" {
|
||||
parts = append(parts, current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
current += string(ch)
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
parts = append(parts, current)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// UserContext holds authenticated user information
|
||||
type UserContext struct {
|
||||
UserID int `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserLevel int `json:"user_level"`
|
||||
SessionID string `json:"session_id"`
|
||||
SessionRID int64 `json:"session_rid"`
|
||||
RemoteID string `json:"remote_id"`
|
||||
Roles []string `json:"roles"`
|
||||
Email string `json:"email"`
|
||||
Claims map[string]any `json:"claims"`
|
||||
Meta map[string]any `json:"meta"` // Additional metadata that can hold any JSON-serializable values
|
||||
TwoFactorEnabled bool `json:"two_factor_enabled"` // Indicates if 2FA is enabled for this user
|
||||
ProgramUserID int `json:"program_user_id"`
|
||||
ProgramUserTable string `json:"program_user_table"`
|
||||
}
|
||||
|
||||
// LoginRequest contains credentials for login
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TwoFactorCode string `json:"two_factor_code,omitempty"` // TOTP or backup code
|
||||
Claims map[string]any `json:"claims"` // Additional login data
|
||||
Meta map[string]any `json:"meta"` // Additional metadata to be set on user context
|
||||
}
|
||||
|
||||
// RegisterRequest contains information for new user registration
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
UserLevel int `json:"user_level"`
|
||||
Roles []string `json:"roles"`
|
||||
Claims map[string]any `json:"claims"` // Additional registration data
|
||||
Meta map[string]any `json:"meta"` // Additional metadata
|
||||
}
|
||||
|
||||
// LoginResponse contains the result of a login attempt
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User *UserContext `json:"user"`
|
||||
ExpiresIn int64 `json:"expires_in"` // Token expiration in seconds
|
||||
Requires2FA bool `json:"requires_2fa"` // True if 2FA code is required
|
||||
TwoFactorSetupData *TwoFactorSecret `json:"two_factor_setup,omitempty"` // Present when setting up 2FA
|
||||
Meta map[string]any `json:"meta"` // Additional metadata to be set on user context
|
||||
}
|
||||
|
||||
// LogoutRequest contains information for logout
|
||||
type LogoutRequest struct {
|
||||
Token string `json:"token"`
|
||||
UserID int `json:"user_id"`
|
||||
}
|
||||
|
||||
// PasswordResetRequest initiates a password reset for a user
|
||||
type PasswordResetRequest struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
// PasswordResetResponse is returned when a reset is initiated
|
||||
type PasswordResetResponse struct {
|
||||
// Token is the reset token to be delivered out-of-band (e.g. email).
|
||||
// The stored procedure may return it for delivery or leave it empty
|
||||
// if the delivery is handled entirely in the database.
|
||||
Token string `json:"token"`
|
||||
ExpiresIn int64 `json:"expires_in"` // seconds
|
||||
}
|
||||
|
||||
// PasswordResetCompleteRequest completes a password reset using the token
|
||||
type PasswordResetCompleteRequest struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// Authenticator handles user authentication operations
|
||||
type Authenticator interface {
|
||||
// Login authenticates credentials and returns a token
|
||||
Login(ctx context.Context, req LoginRequest) (*LoginResponse, error)
|
||||
|
||||
// LoginWithCookie authenticates credentials and, when cookie sessions are enabled,
|
||||
// writes the session cookie to w. Implementations that do not support cookies
|
||||
// should delegate to Login and ignore w.
|
||||
LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error)
|
||||
|
||||
// Logout invalidates a user's session/token
|
||||
Logout(ctx context.Context, req LogoutRequest) error
|
||||
|
||||
// LogoutWithCookie invalidates a user's session/token and, when cookie sessions are
|
||||
// enabled, clears the session cookie on w. Implementations that do not support cookies
|
||||
// should delegate to Logout and ignore w.
|
||||
LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error
|
||||
|
||||
// Authenticate extracts and validates user from HTTP request
|
||||
// Returns UserContext or error if authentication fails
|
||||
Authenticate(r *http.Request) (*UserContext, error)
|
||||
|
||||
// SetAuthenticateCallback registers a fallback called when primary authentication fails.
|
||||
// If the callback returns a non-nil UserContext, that result is used instead of the error.
|
||||
SetAuthenticateCallback(fn func(r *http.Request) (*UserContext, error))
|
||||
}
|
||||
|
||||
// Registrable allows providers to support user registration
|
||||
type Registrable interface {
|
||||
// Register creates a new user account
|
||||
Register(ctx context.Context, req RegisterRequest) (*LoginResponse, error)
|
||||
}
|
||||
|
||||
// ColumnSecurityProvider handles column-level security (masking/hiding)
|
||||
type ColumnSecurityProvider interface {
|
||||
// GetColumnSecurity loads column security rules for a user and entity
|
||||
GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error)
|
||||
}
|
||||
|
||||
// RowSecurityProvider handles row-level security (filtering)
|
||||
type RowSecurityProvider interface {
|
||||
// GetRowSecurity loads row security rules for a user and entity
|
||||
GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error)
|
||||
}
|
||||
|
||||
// SecurityProvider is the main interface combining all security concerns
|
||||
type SecurityProvider interface {
|
||||
Authenticator
|
||||
ColumnSecurityProvider
|
||||
RowSecurityProvider
|
||||
}
|
||||
|
||||
// Optional interfaces for advanced functionality
|
||||
|
||||
// Refreshable allows providers to support token refresh
|
||||
type Refreshable interface {
|
||||
// RefreshToken exchanges a refresh token for a new access token
|
||||
RefreshToken(ctx context.Context, refreshToken string) (*LoginResponse, error)
|
||||
}
|
||||
|
||||
// Validatable allows providers to validate tokens without full authentication
|
||||
type Validatable interface {
|
||||
// ValidateToken checks if a token is valid without extracting full user context
|
||||
ValidateToken(ctx context.Context, token string) (bool, error)
|
||||
}
|
||||
|
||||
// Cacheable allows providers to support caching of security rules
|
||||
type Cacheable interface {
|
||||
// ClearCache clears cached security rules for a user/entity
|
||||
ClearCache(ctx context.Context, userID int, schema, table string) error
|
||||
}
|
||||
|
||||
// PasswordResettable allows providers to support self-service password reset
|
||||
type PasswordResettable interface {
|
||||
// RequestPasswordReset creates a reset token for the given email/username
|
||||
RequestPasswordReset(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error)
|
||||
|
||||
// CompletePasswordReset validates the token and sets the new password
|
||||
CompletePasswordReset(ctx context.Context, req PasswordResetCompleteRequest) error
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hashSHA256Hex returns the lowercase hex SHA-256 digest of the given string.
|
||||
// Used by all keystore implementations to hash raw keys before storage or lookup.
|
||||
func hashSHA256Hex(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// KeyType identifies the category of an auth key.
|
||||
type KeyType string
|
||||
|
||||
const (
|
||||
// KeyTypeJWTSecret is a per-user JWT signing secret for token generation.
|
||||
KeyTypeJWTSecret KeyType = "jwt_secret"
|
||||
// KeyTypeHeaderAPI is a static API key sent via a request header.
|
||||
KeyTypeHeaderAPI KeyType = "header_api"
|
||||
// KeyTypeOAuth2 holds OAuth2 client credentials (client_id / client_secret).
|
||||
KeyTypeOAuth2 KeyType = "oauth2"
|
||||
// KeyTypeGenericAPI is a generic application API key.
|
||||
KeyTypeGenericAPI KeyType = "api"
|
||||
)
|
||||
|
||||
// UserKey represents a single named auth key belonging to a user.
|
||||
// KeyHash stores the SHA-256 hex digest of the raw key; the raw key is never persisted.
|
||||
type UserKey struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
KeyType KeyType `json:"key_type"`
|
||||
KeyHash string `json:"key_hash"` // SHA-256 hex; never the raw key
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// CreateKeyRequest specifies the parameters for a new key.
|
||||
type CreateKeyRequest struct {
|
||||
UserID int
|
||||
KeyType KeyType
|
||||
Name string
|
||||
Scopes []string
|
||||
Meta map[string]any
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// CreateKeyResponse is returned exactly once when a key is created.
|
||||
// The caller is responsible for persisting RawKey; it is not stored anywhere.
|
||||
type CreateKeyResponse struct {
|
||||
Key UserKey
|
||||
RawKey string // crypto/rand 32 bytes, base64url-encoded
|
||||
}
|
||||
|
||||
// KeyStore manages per-user auth keys with pluggable storage backends.
|
||||
// Implementations: ConfigKeyStore (static list) and DatabaseKeyStore (stored procedures).
|
||||
type KeyStore interface {
|
||||
// CreateKey generates a new key, stores its hash, and returns the raw key once.
|
||||
CreateKey(ctx context.Context, req CreateKeyRequest) (*CreateKeyResponse, error)
|
||||
|
||||
// GetUserKeys returns all active, non-expired keys for a user.
|
||||
// Pass an empty KeyType to return all types.
|
||||
GetUserKeys(ctx context.Context, userID int, keyType KeyType) ([]UserKey, error)
|
||||
|
||||
// DeleteKey soft-deletes a key by ID after verifying ownership.
|
||||
DeleteKey(ctx context.Context, userID int, keyID int64) error
|
||||
|
||||
// ValidateKey checks a raw key, returns the matching UserKey on success.
|
||||
// The implementation hashes the raw key before any lookup.
|
||||
// Pass an empty KeyType to accept any type.
|
||||
ValidateKey(ctx context.Context, rawKey string, keyType KeyType) (*UserKey, error)
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeyStoreAuthenticator implements the Authenticator interface using a KeyStore.
|
||||
// It is suitable for long-lived application credentials (API keys, JWT secrets, etc.)
|
||||
// rather than interactive sessions. Login and Logout are not supported — key lifecycle
|
||||
// is managed directly through the KeyStore.
|
||||
//
|
||||
// Key extraction order:
|
||||
// 1. Authorization: Bearer <key>
|
||||
// 2. Authorization: ApiKey <key>
|
||||
// 3. X-API-Key header
|
||||
type KeyStoreAuthenticator struct {
|
||||
keyStore KeyStore
|
||||
keyType KeyType // empty = accept any type
|
||||
authenticateCallback func(r *http.Request) (*UserContext, error)
|
||||
}
|
||||
|
||||
// NewKeyStoreAuthenticator creates a KeyStoreAuthenticator.
|
||||
// Pass an empty keyType to accept keys of any type.
|
||||
func NewKeyStoreAuthenticator(ks KeyStore, keyType KeyType) *KeyStoreAuthenticator {
|
||||
return &KeyStoreAuthenticator{keyStore: ks, keyType: keyType}
|
||||
}
|
||||
|
||||
// Login is not supported for keystore authentication.
|
||||
func (a *KeyStoreAuthenticator) Login(_ context.Context, _ LoginRequest) (*LoginResponse, error) {
|
||||
return nil, fmt.Errorf("keystore authenticator does not support login")
|
||||
}
|
||||
|
||||
// LoginWithCookie is not supported for keystore authentication.
|
||||
func (a *KeyStoreAuthenticator) LoginWithCookie(_ context.Context, _ LoginRequest, _ http.ResponseWriter) (*LoginResponse, error) {
|
||||
return nil, fmt.Errorf("keystore authenticator does not support login")
|
||||
}
|
||||
|
||||
// Logout is not supported for keystore authentication.
|
||||
func (a *KeyStoreAuthenticator) Logout(_ context.Context, _ LogoutRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogoutWithCookie is not supported for keystore authentication.
|
||||
func (a *KeyStoreAuthenticator) LogoutWithCookie(_ context.Context, _ LogoutRequest, _ http.ResponseWriter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAuthenticateCallback registers a fallback called when key authentication fails.
|
||||
func (a *KeyStoreAuthenticator) SetAuthenticateCallback(fn func(r *http.Request) (*UserContext, error)) {
|
||||
a.authenticateCallback = fn
|
||||
}
|
||||
|
||||
// Authenticate extracts an API key from the request and validates it against the KeyStore.
|
||||
// Returns a UserContext built from the matching UserKey on success.
|
||||
func (a *KeyStoreAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
|
||||
rawKey := extractAPIKey(r)
|
||||
if rawKey == "" {
|
||||
if a.authenticateCallback != nil {
|
||||
return a.authenticateCallback(r)
|
||||
}
|
||||
return nil, fmt.Errorf("API key required (Authorization: Bearer/ApiKey <key> or X-API-Key header)")
|
||||
}
|
||||
|
||||
userKey, err := a.keyStore.ValidateKey(r.Context(), rawKey, a.keyType)
|
||||
if err != nil {
|
||||
if a.authenticateCallback != nil {
|
||||
return a.authenticateCallback(r)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid API key: %w", err)
|
||||
}
|
||||
|
||||
return userKeyToUserContext(userKey), nil
|
||||
}
|
||||
|
||||
// extractAPIKey extracts a raw key from the request using the following precedence:
|
||||
// 1. Authorization: Bearer <key>
|
||||
// 2. Authorization: ApiKey <key>
|
||||
// 3. X-API-Key header
|
||||
func extractAPIKey(r *http.Request) string {
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
if after, ok := strings.CutPrefix(auth, "ApiKey "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(r.Header.Get("X-API-Key"))
|
||||
}
|
||||
|
||||
// userKeyToUserContext converts a UserKey into a UserContext.
|
||||
// Scopes are mapped to Roles. Key type and name are stored in Claims.
|
||||
func userKeyToUserContext(k *UserKey) *UserContext {
|
||||
claims := map[string]any{
|
||||
"key_type": string(k.KeyType),
|
||||
"key_name": k.Name,
|
||||
}
|
||||
|
||||
meta := k.Meta
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
|
||||
roles := k.Scopes
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
|
||||
return &UserContext{
|
||||
UserID: k.UserID,
|
||||
SessionID: fmt.Sprintf("key:%d", k.ID),
|
||||
Roles: roles,
|
||||
Claims: claims,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfigKeyStore is an in-memory keystore backed by a static slice of UserKey values.
|
||||
// It is designed for config-file driven setups (e.g. service accounts defined in YAML)
|
||||
// with a small, bounded number of keys. For large or dynamic key sets use DatabaseKeyStore.
|
||||
//
|
||||
// Pre-existing entries must have KeyHash set to the SHA-256 hex of the intended raw key.
|
||||
// Keys created at runtime via CreateKey are held in memory only and lost on restart.
|
||||
type ConfigKeyStore struct {
|
||||
mu sync.RWMutex
|
||||
keys []UserKey
|
||||
next int64 // monotonic ID counter for runtime-created keys (atomic)
|
||||
}
|
||||
|
||||
// NewConfigKeyStore creates a ConfigKeyStore seeded with the provided keys.
|
||||
// Pass nil or an empty slice to start with no pre-loaded keys.
|
||||
// Zero-value entries (CreatedAt is zero) are treated as active and assigned the current time.
|
||||
func NewConfigKeyStore(keys []UserKey) *ConfigKeyStore {
|
||||
var maxID int64
|
||||
copied := make([]UserKey, len(keys))
|
||||
copy(copied, keys)
|
||||
for i := range copied {
|
||||
if copied[i].CreatedAt.IsZero() {
|
||||
copied[i].IsActive = true
|
||||
copied[i].CreatedAt = time.Now()
|
||||
}
|
||||
if copied[i].ID > maxID {
|
||||
maxID = copied[i].ID
|
||||
}
|
||||
}
|
||||
return &ConfigKeyStore{keys: copied, next: maxID}
|
||||
}
|
||||
|
||||
// CreateKey generates a new raw key, stores its SHA-256 hash, and returns the raw key once.
|
||||
func (s *ConfigKeyStore) CreateKey(_ context.Context, req CreateKeyRequest) (*CreateKeyResponse, error) {
|
||||
rawBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(rawBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key material: %w", err)
|
||||
}
|
||||
rawKey := base64.RawURLEncoding.EncodeToString(rawBytes)
|
||||
hash := hashSHA256Hex(rawKey)
|
||||
|
||||
id := atomic.AddInt64(&s.next, 1)
|
||||
key := UserKey{
|
||||
ID: id,
|
||||
UserID: req.UserID,
|
||||
KeyType: req.KeyType,
|
||||
KeyHash: hash,
|
||||
Name: req.Name,
|
||||
Scopes: req.Scopes,
|
||||
Meta: req.Meta,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
CreatedAt: time.Now(),
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.keys = append(s.keys, key)
|
||||
s.mu.Unlock()
|
||||
|
||||
return &CreateKeyResponse{Key: key, RawKey: rawKey}, nil
|
||||
}
|
||||
|
||||
// GetUserKeys returns all active, non-expired keys for the given user.
|
||||
// Pass an empty KeyType to return all types.
|
||||
func (s *ConfigKeyStore) GetUserKeys(_ context.Context, userID int, keyType KeyType) ([]UserKey, error) {
|
||||
now := time.Now()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var result []UserKey
|
||||
for i := range s.keys {
|
||||
k := &s.keys[i]
|
||||
if k.UserID != userID || !k.IsActive {
|
||||
continue
|
||||
}
|
||||
if k.ExpiresAt != nil && k.ExpiresAt.Before(now) {
|
||||
continue
|
||||
}
|
||||
if keyType != "" && k.KeyType != keyType {
|
||||
continue
|
||||
}
|
||||
result = append(result, *k)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteKey soft-deletes a key by setting IsActive to false after ownership verification.
|
||||
func (s *ConfigKeyStore) DeleteKey(_ context.Context, userID int, keyID int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i := range s.keys {
|
||||
if s.keys[i].ID == keyID {
|
||||
if s.keys[i].UserID != userID {
|
||||
return fmt.Errorf("key not found or permission denied")
|
||||
}
|
||||
s.keys[i].IsActive = false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("key not found")
|
||||
}
|
||||
|
||||
// ValidateKey hashes the raw key and finds a matching, active, non-expired entry.
|
||||
// Uses constant-time comparison to prevent timing side-channels.
|
||||
// Pass an empty KeyType to accept any type.
|
||||
func (s *ConfigKeyStore) ValidateKey(_ context.Context, rawKey string, keyType KeyType) (*UserKey, error) {
|
||||
hash := hashSHA256Hex(rawKey)
|
||||
hashBytes, _ := hex.DecodeString(hash)
|
||||
now := time.Now()
|
||||
|
||||
// Write lock: ValidateKey updates LastUsedAt on the matched entry.
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i := range s.keys {
|
||||
k := &s.keys[i]
|
||||
if !k.IsActive {
|
||||
continue
|
||||
}
|
||||
if k.ExpiresAt != nil && k.ExpiresAt.Before(now) {
|
||||
continue
|
||||
}
|
||||
if keyType != "" && k.KeyType != keyType {
|
||||
continue
|
||||
}
|
||||
stored, _ := hex.DecodeString(k.KeyHash)
|
||||
if subtle.ConstantTimeCompare(hashBytes, stored) != 1 {
|
||||
continue
|
||||
}
|
||||
k.LastUsedAt = &now
|
||||
result := *k
|
||||
return &result, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid or expired key")
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/cache"
|
||||
)
|
||||
|
||||
// DatabaseKeyStoreOptions configures DatabaseKeyStore.
|
||||
type DatabaseKeyStoreOptions struct {
|
||||
// Cache is an optional cache instance. If nil, uses the default cache.
|
||||
Cache *cache.Cache
|
||||
// CacheTTL is the duration to cache ValidateKey results.
|
||||
// Default: 2 minutes.
|
||||
CacheTTL time.Duration
|
||||
// SQLNames provides custom procedure names. If nil, uses DefaultKeyStoreSQLNames().
|
||||
SQLNames *KeyStoreSQLNames
|
||||
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
|
||||
// If nil, reconnection is disabled.
|
||||
DBFactory func() (*sql.DB, error)
|
||||
}
|
||||
|
||||
// DatabaseKeyStore is a KeyStore backed by PostgreSQL stored procedures.
|
||||
// All DB operations go through configurable procedure names; the raw key is
|
||||
// never passed to the database.
|
||||
//
|
||||
// See keystore_schema.sql for the required table and procedure definitions.
|
||||
//
|
||||
// Note: DeleteKey invalidates the cache entry for the deleted key. Due to the
|
||||
// cache TTL, a deleted key may continue to authenticate for up to CacheTTL
|
||||
// (default 2 minutes) if the cache entry cannot be invalidated.
|
||||
type DatabaseKeyStore struct {
|
||||
db *sql.DB
|
||||
dbMu sync.RWMutex
|
||||
dbFactory func() (*sql.DB, error)
|
||||
sqlNames *KeyStoreSQLNames
|
||||
cache *cache.Cache
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
// NewDatabaseKeyStore creates a DatabaseKeyStore with optional configuration.
|
||||
func NewDatabaseKeyStore(db *sql.DB, opts ...DatabaseKeyStoreOptions) *DatabaseKeyStore {
|
||||
o := DatabaseKeyStoreOptions{}
|
||||
if len(opts) > 0 {
|
||||
o = opts[0]
|
||||
}
|
||||
if o.CacheTTL == 0 {
|
||||
o.CacheTTL = 2 * time.Minute
|
||||
}
|
||||
c := o.Cache
|
||||
if c == nil {
|
||||
c = cache.GetDefaultCache()
|
||||
}
|
||||
names := MergeKeyStoreSQLNames(DefaultKeyStoreSQLNames(), o.SQLNames)
|
||||
return &DatabaseKeyStore{
|
||||
db: db,
|
||||
dbFactory: o.DBFactory,
|
||||
sqlNames: names,
|
||||
cache: c,
|
||||
cacheTTL: o.CacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func (ks *DatabaseKeyStore) getDB() *sql.DB {
|
||||
ks.dbMu.RLock()
|
||||
defer ks.dbMu.RUnlock()
|
||||
return ks.db
|
||||
}
|
||||
|
||||
func (ks *DatabaseKeyStore) reconnectDB() error {
|
||||
if ks.dbFactory == nil {
|
||||
return fmt.Errorf("no db factory configured for reconnect")
|
||||
}
|
||||
newDB, err := ks.dbFactory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ks.dbMu.Lock()
|
||||
ks.db = newDB
|
||||
ks.dbMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateKey generates a raw key, stores its SHA-256 hash via the create procedure,
|
||||
// and returns the raw key once.
|
||||
func (ks *DatabaseKeyStore) CreateKey(ctx context.Context, req CreateKeyRequest) (*CreateKeyResponse, error) {
|
||||
rawBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(rawBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key material: %w", err)
|
||||
}
|
||||
rawKey := base64.RawURLEncoding.EncodeToString(rawBytes)
|
||||
hash := hashSHA256Hex(rawKey)
|
||||
|
||||
type createRequest struct {
|
||||
UserID int `json:"user_id"`
|
||||
KeyType KeyType `json:"key_type"`
|
||||
KeyHash string `json:"key_hash"`
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
reqJSON, err := json.Marshal(createRequest{
|
||||
UserID: req.UserID,
|
||||
KeyType: req.KeyType,
|
||||
KeyHash: hash,
|
||||
Name: req.Name,
|
||||
Scopes: req.Scopes,
|
||||
Meta: req.Meta,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal create key request: %w", err)
|
||||
}
|
||||
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var keyJSON sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_key::text FROM %s($1::jsonb)`, ks.sqlNames.CreateKey)
|
||||
if err = ks.getDB().QueryRowContext(ctx, query, string(reqJSON)).Scan(&success, &errorMsg, &keyJSON); err != nil {
|
||||
return nil, fmt.Errorf("create key procedure failed: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return nil, errors.New(nullStringOr(errorMsg, "create key failed"))
|
||||
}
|
||||
|
||||
var key UserKey
|
||||
if err = json.Unmarshal([]byte(keyJSON.String), &key); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse created key: %w", err)
|
||||
}
|
||||
|
||||
return &CreateKeyResponse{Key: key, RawKey: rawKey}, nil
|
||||
}
|
||||
|
||||
// GetUserKeys returns all active, non-expired keys for the given user.
|
||||
// Pass an empty KeyType to return all types.
|
||||
func (ks *DatabaseKeyStore) GetUserKeys(ctx context.Context, userID int, keyType KeyType) ([]UserKey, error) {
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var keysJSON sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_keys::text FROM %s($1, $2)`, ks.sqlNames.GetUserKeys)
|
||||
if err := ks.getDB().QueryRowContext(ctx, query, userID, string(keyType)).Scan(&success, &errorMsg, &keysJSON); err != nil {
|
||||
return nil, fmt.Errorf("get user keys procedure failed: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return nil, errors.New(nullStringOr(errorMsg, "get user keys failed"))
|
||||
}
|
||||
|
||||
var keys []UserKey
|
||||
if keysJSON.Valid && keysJSON.String != "" && keysJSON.String != "[]" {
|
||||
if err := json.Unmarshal([]byte(keysJSON.String), &keys); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user keys: %w", err)
|
||||
}
|
||||
}
|
||||
if keys == nil {
|
||||
keys = []UserKey{}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// DeleteKey soft-deletes a key after verifying ownership and invalidates its cache entry.
|
||||
// The delete procedure returns the key_hash so no separate lookup is needed.
|
||||
// Note: cache invalidation is best-effort; a cached entry may persist for up to CacheTTL.
|
||||
func (ks *DatabaseKeyStore) DeleteKey(ctx context.Context, userID int, keyID int64) error {
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var keyHash sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_key_hash FROM %s($1, $2)`, ks.sqlNames.DeleteKey)
|
||||
if err := ks.getDB().QueryRowContext(ctx, query, userID, keyID).Scan(&success, &errorMsg, &keyHash); err != nil {
|
||||
return fmt.Errorf("delete key procedure failed: %w", err)
|
||||
}
|
||||
if !success {
|
||||
return errors.New(nullStringOr(errorMsg, "delete key failed"))
|
||||
}
|
||||
|
||||
if keyHash.Valid && keyHash.String != "" && ks.cache != nil {
|
||||
_ = ks.cache.Delete(ctx, keystoreCacheKey(keyHash.String))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateKey hashes the raw key and calls the validate procedure.
|
||||
// Results are cached for CacheTTL to reduce DB load on hot paths.
|
||||
func (ks *DatabaseKeyStore) ValidateKey(ctx context.Context, rawKey string, keyType KeyType) (*UserKey, error) {
|
||||
hash := hashSHA256Hex(rawKey)
|
||||
cacheKey := keystoreCacheKey(hash)
|
||||
|
||||
if ks.cache != nil {
|
||||
var cached UserKey
|
||||
if err := ks.cache.Get(ctx, cacheKey, &cached); err == nil {
|
||||
if cached.IsActive {
|
||||
return &cached, nil
|
||||
}
|
||||
return nil, errors.New("invalid or expired key")
|
||||
}
|
||||
}
|
||||
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var keyJSON sql.NullString
|
||||
|
||||
runQuery := func() error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_key::text FROM %s($1, $2)`, ks.sqlNames.ValidateKey)
|
||||
return ks.getDB().QueryRowContext(ctx, query, hash, string(keyType)).Scan(&success, &errorMsg, &keyJSON)
|
||||
}
|
||||
if err := runQuery(); err != nil {
|
||||
if isDBClosed(err) {
|
||||
if reconnErr := ks.reconnectDB(); reconnErr == nil {
|
||||
err = runQuery()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate key procedure failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("validate key procedure failed: %w", err)
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
return nil, errors.New(nullStringOr(errorMsg, "invalid or expired key"))
|
||||
}
|
||||
|
||||
var key UserKey
|
||||
if err := json.Unmarshal([]byte(keyJSON.String), &key); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse validated key: %w", err)
|
||||
}
|
||||
|
||||
if ks.cache != nil {
|
||||
_ = ks.cache.Set(ctx, cacheKey, key, ks.cacheTTL)
|
||||
}
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func keystoreCacheKey(hash string) string {
|
||||
return "keystore:validate:" + hash
|
||||
}
|
||||
|
||||
// nullStringOr returns s.String if valid, otherwise the fallback.
|
||||
func nullStringOr(s sql.NullString, fallback string) string {
|
||||
if s.Valid && s.String != "" {
|
||||
return s.String
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
-- Keystore schema for per-user auth keys
|
||||
-- Apply alongside database_schema.sql (requires the users table)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_keys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
key_type VARCHAR(50) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex digest (64 chars)
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
scopes TEXT, -- JSON array, e.g. '["read","write"]'
|
||||
meta JSONB,
|
||||
expires_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT true
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_user_id ON user_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_key_hash ON user_keys(key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_key_type ON user_keys(key_type);
|
||||
|
||||
-- resolvespec_keystore_get_user_keys
|
||||
-- Returns all active, non-expired keys for a user.
|
||||
-- Pass empty p_key_type to return all key types.
|
||||
CREATE OR REPLACE FUNCTION resolvespec_keystore_get_user_keys(
|
||||
p_user_id INTEGER,
|
||||
p_key_type TEXT DEFAULT ''
|
||||
)
|
||||
RETURNS TABLE(p_success BOOLEAN, p_error TEXT, p_keys JSONB)
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_keys JSONB;
|
||||
BEGIN
|
||||
SELECT COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', k.id,
|
||||
'user_id', k.user_id,
|
||||
'key_type', k.key_type,
|
||||
'name', k.name,
|
||||
'scopes', CASE WHEN k.scopes IS NOT NULL THEN k.scopes::jsonb ELSE '[]'::jsonb END,
|
||||
'meta', COALESCE(k.meta, '{}'::jsonb),
|
||||
'expires_at', k.expires_at,
|
||||
'created_at', k.created_at,
|
||||
'last_used_at', k.last_used_at,
|
||||
'is_active', k.is_active
|
||||
)
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
INTO v_keys
|
||||
FROM user_keys k
|
||||
WHERE k.user_id = p_user_id
|
||||
AND k.is_active = true
|
||||
AND (k.expires_at IS NULL OR k.expires_at > NOW())
|
||||
AND (p_key_type = '' OR k.key_type = p_key_type);
|
||||
|
||||
RETURN QUERY SELECT true, NULL::TEXT, v_keys;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN QUERY SELECT false, SQLERRM, NULL::JSONB;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- resolvespec_keystore_create_key
|
||||
-- Inserts a new key row. key_hash is provided by the caller (Go hashes the raw key).
|
||||
-- Returns the created key record (without key_hash).
|
||||
CREATE OR REPLACE FUNCTION resolvespec_keystore_create_key(
|
||||
p_request JSONB
|
||||
)
|
||||
RETURNS TABLE(p_success BOOLEAN, p_error TEXT, p_key JSONB)
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_id BIGINT;
|
||||
v_created_at TIMESTAMP;
|
||||
v_key JSONB;
|
||||
BEGIN
|
||||
INSERT INTO user_keys (user_id, key_type, key_hash, name, scopes, meta, expires_at)
|
||||
VALUES (
|
||||
(p_request->>'user_id')::INTEGER,
|
||||
p_request->>'key_type',
|
||||
p_request->>'key_hash',
|
||||
COALESCE(p_request->>'name', ''),
|
||||
p_request->>'scopes',
|
||||
p_request->'meta',
|
||||
CASE WHEN p_request->>'expires_at' IS NOT NULL
|
||||
THEN (p_request->>'expires_at')::TIMESTAMP
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
RETURNING id, created_at INTO v_id, v_created_at;
|
||||
|
||||
v_key := jsonb_build_object(
|
||||
'id', v_id,
|
||||
'user_id', (p_request->>'user_id')::INTEGER,
|
||||
'key_type', p_request->>'key_type',
|
||||
'name', COALESCE(p_request->>'name', ''),
|
||||
'scopes', CASE WHEN p_request->>'scopes' IS NOT NULL
|
||||
THEN (p_request->>'scopes')::jsonb
|
||||
ELSE '[]'::jsonb END,
|
||||
'meta', COALESCE(p_request->'meta', '{}'::jsonb),
|
||||
'expires_at', p_request->>'expires_at',
|
||||
'created_at', v_created_at,
|
||||
'is_active', true
|
||||
);
|
||||
|
||||
RETURN QUERY SELECT true, NULL::TEXT, v_key;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN QUERY SELECT false, SQLERRM, NULL::JSONB;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- resolvespec_keystore_delete_key
|
||||
-- Soft-deletes a key (is_active = false) after verifying ownership.
|
||||
-- Returns p_key_hash so the caller can invalidate cache entries without a separate query.
|
||||
CREATE OR REPLACE FUNCTION resolvespec_keystore_delete_key(
|
||||
p_user_id INTEGER,
|
||||
p_key_id BIGINT
|
||||
)
|
||||
RETURNS TABLE(p_success BOOLEAN, p_error TEXT, p_key_hash TEXT)
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_hash TEXT;
|
||||
BEGIN
|
||||
UPDATE user_keys
|
||||
SET is_active = false
|
||||
WHERE id = p_key_id AND user_id = p_user_id AND is_active = true
|
||||
RETURNING key_hash INTO v_hash;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN QUERY SELECT false, 'key not found or already deleted'::TEXT, NULL::TEXT;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY SELECT true, NULL::TEXT, v_hash;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN QUERY SELECT false, SQLERRM, NULL::TEXT;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- resolvespec_keystore_validate_key
|
||||
-- Looks up a key by its SHA-256 hash, checks active status and expiry,
|
||||
-- updates last_used_at, and returns the key record.
|
||||
-- p_key_type can be empty to accept any key type.
|
||||
CREATE OR REPLACE FUNCTION resolvespec_keystore_validate_key(
|
||||
p_key_hash TEXT,
|
||||
p_key_type TEXT DEFAULT ''
|
||||
)
|
||||
RETURNS TABLE(p_success BOOLEAN, p_error TEXT, p_key JSONB)
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_key_rec user_keys%ROWTYPE;
|
||||
v_key JSONB;
|
||||
BEGIN
|
||||
SELECT * INTO v_key_rec
|
||||
FROM user_keys
|
||||
WHERE key_hash = p_key_hash
|
||||
AND is_active = true
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
AND (p_key_type = '' OR key_type = p_key_type);
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN QUERY SELECT false, 'invalid or expired key'::TEXT, NULL::JSONB;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
UPDATE user_keys SET last_used_at = NOW() WHERE id = v_key_rec.id;
|
||||
|
||||
v_key := jsonb_build_object(
|
||||
'id', v_key_rec.id,
|
||||
'user_id', v_key_rec.user_id,
|
||||
'key_type', v_key_rec.key_type,
|
||||
'name', v_key_rec.name,
|
||||
'scopes', CASE WHEN v_key_rec.scopes IS NOT NULL
|
||||
THEN v_key_rec.scopes::jsonb
|
||||
ELSE '[]'::jsonb END,
|
||||
'meta', COALESCE(v_key_rec.meta, '{}'::jsonb),
|
||||
'expires_at', v_key_rec.expires_at,
|
||||
'created_at', v_key_rec.created_at,
|
||||
'last_used_at', NOW(),
|
||||
'is_active', v_key_rec.is_active
|
||||
);
|
||||
|
||||
RETURN QUERY SELECT true, NULL::TEXT, v_key;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN QUERY SELECT false, SQLERRM, NULL::JSONB;
|
||||
END;
|
||||
$$;
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package security
|
||||
|
||||
import "fmt"
|
||||
|
||||
// KeyStoreSQLNames holds the configurable stored procedure names used by DatabaseKeyStore.
|
||||
// Use DefaultKeyStoreSQLNames() for defaults and MergeKeyStoreSQLNames() for partial overrides.
|
||||
type KeyStoreSQLNames struct {
|
||||
GetUserKeys string // default: "resolvespec_keystore_get_user_keys"
|
||||
CreateKey string // default: "resolvespec_keystore_create_key"
|
||||
DeleteKey string // default: "resolvespec_keystore_delete_key"
|
||||
ValidateKey string // default: "resolvespec_keystore_validate_key"
|
||||
}
|
||||
|
||||
// DefaultKeyStoreSQLNames returns a KeyStoreSQLNames with all default resolvespec_keystore_* values.
|
||||
func DefaultKeyStoreSQLNames() *KeyStoreSQLNames {
|
||||
return &KeyStoreSQLNames{
|
||||
GetUserKeys: "resolvespec_keystore_get_user_keys",
|
||||
CreateKey: "resolvespec_keystore_create_key",
|
||||
DeleteKey: "resolvespec_keystore_delete_key",
|
||||
ValidateKey: "resolvespec_keystore_validate_key",
|
||||
}
|
||||
}
|
||||
|
||||
// MergeKeyStoreSQLNames returns a copy of base with any non-empty fields from override applied.
|
||||
// If override is nil, a copy of base is returned.
|
||||
func MergeKeyStoreSQLNames(base, override *KeyStoreSQLNames) *KeyStoreSQLNames {
|
||||
if override == nil {
|
||||
copied := *base
|
||||
return &copied
|
||||
}
|
||||
merged := *base
|
||||
if override.GetUserKeys != "" {
|
||||
merged.GetUserKeys = override.GetUserKeys
|
||||
}
|
||||
if override.CreateKey != "" {
|
||||
merged.CreateKey = override.CreateKey
|
||||
}
|
||||
if override.DeleteKey != "" {
|
||||
merged.DeleteKey = override.DeleteKey
|
||||
}
|
||||
if override.ValidateKey != "" {
|
||||
merged.ValidateKey = override.ValidateKey
|
||||
}
|
||||
return &merged
|
||||
}
|
||||
|
||||
// ValidateKeyStoreSQLNames checks that all non-empty procedure names are valid SQL identifiers.
|
||||
func ValidateKeyStoreSQLNames(names *KeyStoreSQLNames) error {
|
||||
fields := map[string]string{
|
||||
"GetUserKeys": names.GetUserKeys,
|
||||
"CreateKey": names.CreateKey,
|
||||
"DeleteKey": names.DeleteKey,
|
||||
"ValidateKey": names.ValidateKey,
|
||||
}
|
||||
for field, val := range fields {
|
||||
if val != "" && !validSQLIdentifier.MatchString(val) {
|
||||
return fmt.Errorf("KeyStoreSQLNames.%s contains invalid characters: %q", field, val)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+737
@@ -0,0 +1,737 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||
)
|
||||
|
||||
// contextKey is a custom type for context keys to avoid collisions
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// Context keys for user information
|
||||
UserIDKey contextKey = "user_id"
|
||||
UserNameKey contextKey = "user_name"
|
||||
UserLevelKey contextKey = "user_level"
|
||||
SessionIDKey contextKey = "session_id"
|
||||
SessionRIDKey contextKey = "session_rid"
|
||||
RemoteIDKey contextKey = "remote_id"
|
||||
UserRolesKey contextKey = "user_roles"
|
||||
UserEmailKey contextKey = "user_email"
|
||||
UserContextKey contextKey = "user_context"
|
||||
UserMetaKey contextKey = "user_meta"
|
||||
SkipAuthKey contextKey = "skip_auth"
|
||||
OptionalAuthKey contextKey = "optional_auth"
|
||||
ModelRulesKey contextKey = "model_rules"
|
||||
)
|
||||
|
||||
// SkipAuth returns a context with skip auth flag set to true
|
||||
// Use this to mark routes that should bypass authentication middleware
|
||||
func SkipAuth(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, SkipAuthKey, true)
|
||||
}
|
||||
|
||||
// OptionalAuth returns a context with optional auth flag set to true
|
||||
// Use this to mark routes that should try to authenticate, but fall back to guest if authentication fails
|
||||
func OptionalAuth(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, OptionalAuthKey, true)
|
||||
}
|
||||
|
||||
// createGuestContext creates a guest user context for unauthenticated requests
|
||||
func createGuestContext(r *http.Request) *UserContext {
|
||||
return &UserContext{
|
||||
UserID: 0,
|
||||
UserName: "guest",
|
||||
UserLevel: 0,
|
||||
SessionID: "",
|
||||
RemoteID: r.RemoteAddr,
|
||||
Roles: []string{"guest"},
|
||||
Email: "",
|
||||
Claims: map[string]any{},
|
||||
Meta: map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
// setUserContext adds a user context to the request context
|
||||
func setUserContext(r *http.Request, userCtx *UserContext) *http.Request {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserContextKey, userCtx)
|
||||
ctx = context.WithValue(ctx, UserIDKey, userCtx.UserID)
|
||||
ctx = context.WithValue(ctx, UserNameKey, userCtx.UserName)
|
||||
ctx = context.WithValue(ctx, UserLevelKey, userCtx.UserLevel)
|
||||
ctx = context.WithValue(ctx, SessionIDKey, userCtx.SessionID)
|
||||
ctx = context.WithValue(ctx, SessionRIDKey, userCtx.SessionRID)
|
||||
ctx = context.WithValue(ctx, RemoteIDKey, userCtx.RemoteID)
|
||||
ctx = context.WithValue(ctx, UserRolesKey, userCtx.Roles)
|
||||
|
||||
if userCtx.Email != "" {
|
||||
ctx = context.WithValue(ctx, UserEmailKey, userCtx.Email)
|
||||
}
|
||||
if len(userCtx.Meta) > 0 {
|
||||
ctx = context.WithValue(ctx, UserMetaKey, userCtx.Meta)
|
||||
}
|
||||
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// authenticateRequest performs authentication and adds user context to the request
|
||||
// This is the shared authentication logic used by both handler and middleware
|
||||
func authenticateRequest(w http.ResponseWriter, r *http.Request, provider SecurityProvider) (*http.Request, bool) {
|
||||
// Call the provider's Authenticate method
|
||||
userCtx, err := provider.Authenticate(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return setUserContext(r, userCtx), true
|
||||
}
|
||||
|
||||
// NewAuthHandler creates an authentication handler that can be used standalone
|
||||
// This handler performs authentication and returns 401 if authentication fails
|
||||
// Use this when you need authentication logic without middleware wrapping
|
||||
func NewAuthHandler(securityList *SecurityList, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the security provider
|
||||
provider := securityList.Provider()
|
||||
if provider == nil {
|
||||
http.Error(w, "Security provider not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
authenticatedReq, ok := authenticateRequest(w, r, provider)
|
||||
if !ok {
|
||||
return // authenticateRequest already wrote the error response
|
||||
}
|
||||
|
||||
// Continue with authenticated context
|
||||
next.ServeHTTP(w, authenticatedReq)
|
||||
})
|
||||
}
|
||||
|
||||
// NewOptionalAuthHandler creates an optional authentication handler that can be used standalone
|
||||
// This handler tries to authenticate but falls back to guest context if authentication fails
|
||||
// Use this for routes that should show personalized content for authenticated users but still work for guests
|
||||
func NewOptionalAuthHandler(securityList *SecurityList, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the security provider
|
||||
provider := securityList.Provider()
|
||||
if provider == nil {
|
||||
http.Error(w, "Security provider not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to authenticate
|
||||
userCtx, err := provider.Authenticate(r)
|
||||
if err != nil {
|
||||
// Authentication failed - set guest context and continue
|
||||
guestCtx := createGuestContext(r)
|
||||
next.ServeHTTP(w, setUserContext(r, guestCtx))
|
||||
return
|
||||
}
|
||||
|
||||
// Authentication succeeded - set user context
|
||||
next.ServeHTTP(w, setUserContext(r, userCtx))
|
||||
})
|
||||
}
|
||||
|
||||
// NewOptionalAuthMiddleware creates authentication middleware that always continues.
|
||||
// On auth failure, a guest user context is set instead of returning 401.
|
||||
// Intended for spec routes where auth enforcement is deferred to a BeforeHandle hook
|
||||
// after model resolution.
|
||||
func NewOptionalAuthMiddleware(securityList *SecurityList) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
provider := securityList.Provider()
|
||||
if provider == nil {
|
||||
http.Error(w, "Security provider not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
userCtx, err := provider.Authenticate(r)
|
||||
if err != nil {
|
||||
guestCtx := createGuestContext(r)
|
||||
next.ServeHTTP(w, setUserContext(r, guestCtx))
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, setUserContext(r, userCtx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates an authentication middleware with the given security list
|
||||
// This middleware extracts user authentication from the request and adds it to context
|
||||
// Routes can skip authentication by setting SkipAuthKey context value (use SkipAuth helper)
|
||||
// Routes can use optional authentication by setting OptionalAuthKey context value (use OptionalAuth helper)
|
||||
// When authentication is skipped or fails with optional auth, a guest user context is set instead
|
||||
func NewAuthMiddleware(securityList *SecurityList) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if this route should skip authentication
|
||||
if skip, ok := r.Context().Value(SkipAuthKey).(bool); ok && skip {
|
||||
// Set guest user context for skipped routes
|
||||
guestCtx := createGuestContext(r)
|
||||
next.ServeHTTP(w, setUserContext(r, guestCtx))
|
||||
return
|
||||
}
|
||||
|
||||
// Get the security provider
|
||||
provider := securityList.Provider()
|
||||
if provider == nil {
|
||||
http.Error(w, "Security provider not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this route has optional authentication
|
||||
optional, _ := r.Context().Value(OptionalAuthKey).(bool)
|
||||
|
||||
// Try to authenticate
|
||||
userCtx, err := provider.Authenticate(r)
|
||||
if err != nil {
|
||||
if optional {
|
||||
// Optional auth failed - set guest context and continue
|
||||
guestCtx := createGuestContext(r)
|
||||
next.ServeHTTP(w, setUserContext(r, guestCtx))
|
||||
return
|
||||
}
|
||||
// Required auth failed - return error
|
||||
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Authentication succeeded - set user context
|
||||
next.ServeHTTP(w, setUserContext(r, userCtx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NewModelAuthMiddleware creates authentication middleware that respects ModelRules for the given model name.
|
||||
// It first checks if ModelRules are set for the model:
|
||||
// - If SecurityDisabled is true, authentication is skipped and a guest context is set.
|
||||
// - Otherwise, all checks from NewAuthMiddleware apply (SkipAuthKey, provider check, OptionalAuthKey, Authenticate).
|
||||
//
|
||||
// If the model is not found in any registry, the middleware falls back to standard NewAuthMiddleware behaviour.
|
||||
func NewModelAuthMiddleware(securityList *SecurityList, modelName string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check ModelRules first
|
||||
if rules, err := modelregistry.GetModelRulesByName(modelName); err == nil {
|
||||
// Store rules in context for downstream use (e.g., security hooks)
|
||||
r = r.WithContext(context.WithValue(r.Context(), ModelRulesKey, rules))
|
||||
|
||||
if rules.SecurityDisabled {
|
||||
guestCtx := createGuestContext(r)
|
||||
next.ServeHTTP(w, setUserContext(r, guestCtx))
|
||||
return
|
||||
}
|
||||
isRead := r.Method == http.MethodGet || r.Method == http.MethodHead
|
||||
isUpdate := r.Method == http.MethodPut || r.Method == http.MethodPatch
|
||||
if (isRead && rules.CanPublicRead) || (isUpdate && rules.CanPublicUpdate) {
|
||||
guestCtx := createGuestContext(r)
|
||||
next.ServeHTTP(w, setUserContext(r, guestCtx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this route should skip authentication
|
||||
if skip, ok := r.Context().Value(SkipAuthKey).(bool); ok && skip {
|
||||
guestCtx := createGuestContext(r)
|
||||
next.ServeHTTP(w, setUserContext(r, guestCtx))
|
||||
return
|
||||
}
|
||||
|
||||
// Get the security provider
|
||||
provider := securityList.Provider()
|
||||
if provider == nil {
|
||||
http.Error(w, "Security provider not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this route has optional authentication
|
||||
optional, _ := r.Context().Value(OptionalAuthKey).(bool)
|
||||
|
||||
// Try to authenticate
|
||||
userCtx, err := provider.Authenticate(r)
|
||||
if err != nil {
|
||||
if optional {
|
||||
guestCtx := createGuestContext(r)
|
||||
next.ServeHTTP(w, setUserContext(r, guestCtx))
|
||||
return
|
||||
}
|
||||
http.Error(w, "Authentication failed: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, setUserContext(r, userCtx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SetSecurityMiddleware adds security context to requests
|
||||
// This middleware should be applied after AuthMiddleware
|
||||
func SetSecurityMiddleware(securityList *SecurityList) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), SECURITY_CONTEXT_KEY, securityList)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuth wraps an HTTPFuncType handler with required authentication
|
||||
// This function performs authentication and returns 401 if authentication fails
|
||||
// Use this for handlers that require authenticated users
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// handler := funcspec.NewHandler(db)
|
||||
// wrappedHandler := security.WithAuth(handler.SqlQueryList("SELECT * FROM orders WHERE user_id = [rid_user]", false, false, false), securityList)
|
||||
// router.HandleFunc("/api/orders", wrappedHandler)
|
||||
func WithAuth(handler func(http.ResponseWriter, *http.Request), securityList *SecurityList) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the security provider
|
||||
provider := securityList.Provider()
|
||||
if provider == nil {
|
||||
http.Error(w, "Security provider not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
authenticatedReq, ok := authenticateRequest(w, r, provider)
|
||||
if !ok {
|
||||
return // authenticateRequest already wrote the error response
|
||||
}
|
||||
|
||||
// Continue with authenticated context
|
||||
handler(w, authenticatedReq)
|
||||
}
|
||||
}
|
||||
|
||||
// WithOptionalAuth wraps an HTTPFuncType handler with optional authentication
|
||||
// This function tries to authenticate but falls back to guest context if authentication fails
|
||||
// Use this for handlers that should show personalized content for authenticated users but still work for guests
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// handler := funcspec.NewHandler(db)
|
||||
// wrappedHandler := security.WithOptionalAuth(handler.SqlQueryList("SELECT * FROM products", false, false, false), securityList)
|
||||
// router.HandleFunc("/api/products", wrappedHandler)
|
||||
func WithOptionalAuth(handler func(http.ResponseWriter, *http.Request), securityList *SecurityList) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the security provider
|
||||
provider := securityList.Provider()
|
||||
if provider == nil {
|
||||
http.Error(w, "Security provider not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to authenticate
|
||||
userCtx, err := provider.Authenticate(r)
|
||||
if err != nil {
|
||||
// Authentication failed - set guest context and continue
|
||||
guestCtx := createGuestContext(r)
|
||||
handler(w, setUserContext(r, guestCtx))
|
||||
return
|
||||
}
|
||||
|
||||
// Authentication succeeded - set user context
|
||||
handler(w, setUserContext(r, userCtx))
|
||||
}
|
||||
}
|
||||
|
||||
// WithSecurityContext wraps an HTTPFuncType handler with security context
|
||||
// This function allows you to add security context to specific handler functions
|
||||
// without needing to apply middleware globally
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// handler := funcspec.NewHandler(db)
|
||||
// wrappedHandler := security.WithSecurityContext(handler.SqlQueryList("SELECT * FROM users", false, false, false), securityList)
|
||||
// router.HandleFunc("/api/users", wrappedHandler)
|
||||
func WithSecurityContext(handler func(http.ResponseWriter, *http.Request), securityList *SecurityList) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), SECURITY_CONTEXT_KEY, securityList)
|
||||
handler(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthAndSecurity wraps an HTTPFuncType handler with both authentication and security context
|
||||
// This is a convenience function that combines WithAuth and WithSecurityContext
|
||||
// Use this when you need both authentication and security context for a handler
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// handler := funcspec.NewHandler(db)
|
||||
// wrappedHandler := security.WithAuthAndSecurity(handler.SqlQueryList("SELECT * FROM users", false, false, false), securityList)
|
||||
// router.HandleFunc("/api/users", wrappedHandler)
|
||||
func WithAuthAndSecurity(handler func(http.ResponseWriter, *http.Request), securityList *SecurityList) func(http.ResponseWriter, *http.Request) {
|
||||
return WithAuth(WithSecurityContext(handler, securityList), securityList)
|
||||
}
|
||||
|
||||
// WithOptionalAuthAndSecurity wraps an HTTPFuncType handler with optional authentication and security context
|
||||
// This is a convenience function that combines WithOptionalAuth and WithSecurityContext
|
||||
// Use this when you want optional authentication and security context for a handler
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// handler := funcspec.NewHandler(db)
|
||||
// wrappedHandler := security.WithOptionalAuthAndSecurity(handler.SqlQueryList("SELECT * FROM products", false, false, false), securityList)
|
||||
// router.HandleFunc("/api/products", wrappedHandler)
|
||||
func WithOptionalAuthAndSecurity(handler func(http.ResponseWriter, *http.Request), securityList *SecurityList) func(http.ResponseWriter, *http.Request) {
|
||||
return WithOptionalAuth(WithSecurityContext(handler, securityList), securityList)
|
||||
}
|
||||
|
||||
// GetSecurityList extracts the SecurityList from request context
|
||||
func GetSecurityList(ctx context.Context) (*SecurityList, bool) {
|
||||
securityList, ok := ctx.Value(SECURITY_CONTEXT_KEY).(*SecurityList)
|
||||
return securityList, ok
|
||||
}
|
||||
|
||||
// GetUserContext extracts the full user context from request context
|
||||
func GetUserContext(ctx context.Context) (*UserContext, bool) {
|
||||
userCtx, ok := ctx.Value(UserContextKey).(*UserContext)
|
||||
return userCtx, ok
|
||||
}
|
||||
|
||||
// GetUserID extracts the user ID from context
|
||||
func GetUserID(ctx context.Context) (int, bool) {
|
||||
userID, ok := ctx.Value(UserIDKey).(int)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
// GetUserName extracts the user name from context
|
||||
func GetUserName(ctx context.Context) (string, bool) {
|
||||
userName, ok := ctx.Value(UserNameKey).(string)
|
||||
return userName, ok
|
||||
}
|
||||
|
||||
// GetUserLevel extracts the user level from context
|
||||
func GetUserLevel(ctx context.Context) (int, bool) {
|
||||
userLevel, ok := ctx.Value(UserLevelKey).(int)
|
||||
return userLevel, ok
|
||||
}
|
||||
|
||||
// GetSessionID extracts the session ID from context
|
||||
func GetSessionID(ctx context.Context) (string, bool) {
|
||||
sessionID, ok := ctx.Value(SessionIDKey).(string)
|
||||
return sessionID, ok
|
||||
}
|
||||
|
||||
// GetSessionID extracts the session ID from context
|
||||
func GetSessionRID(ctx context.Context) (int64, bool) {
|
||||
sessionRIDStr, ok := ctx.Value(SessionRIDKey).(string)
|
||||
sessionRID, err := strconv.ParseInt(sessionRIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return sessionRID, ok
|
||||
}
|
||||
|
||||
// GetRemoteID extracts the remote ID from context
|
||||
func GetRemoteID(ctx context.Context) (string, bool) {
|
||||
remoteID, ok := ctx.Value(RemoteIDKey).(string)
|
||||
return remoteID, ok
|
||||
}
|
||||
|
||||
// GetUserRoles extracts user roles from context
|
||||
func GetUserRoles(ctx context.Context) ([]string, bool) {
|
||||
roles, ok := ctx.Value(UserRolesKey).([]string)
|
||||
return roles, ok
|
||||
}
|
||||
|
||||
// GetUserEmail extracts user email from context
|
||||
func GetUserEmail(ctx context.Context) (string, bool) {
|
||||
email, ok := ctx.Value(UserEmailKey).(string)
|
||||
return email, ok
|
||||
}
|
||||
|
||||
// GetUserMeta extracts user metadata from context
|
||||
func GetUserMeta(ctx context.Context) (map[string]any, bool) {
|
||||
meta, ok := ctx.Value(UserMetaKey).(map[string]any)
|
||||
return meta, ok
|
||||
}
|
||||
|
||||
// SessionCookieOptions configures the session cookie set by SetSessionCookie.
|
||||
// All fields are optional; sensible secure defaults are applied when omitted.
|
||||
type SessionCookieOptions struct {
|
||||
// Name is the cookie name. Defaults to "session_token".
|
||||
Name string
|
||||
// Path is the cookie path. Defaults to "/".
|
||||
Path string
|
||||
// Domain restricts the cookie to a specific domain. Empty means current host.
|
||||
Domain string
|
||||
// Secure sets the Secure flag. Defaults to true.
|
||||
// Set to false only in local development over HTTP.
|
||||
Secure *bool
|
||||
// SameSite sets the SameSite policy. Defaults to http.SameSiteLaxMode.
|
||||
SameSite http.SameSite
|
||||
}
|
||||
|
||||
func (o SessionCookieOptions) name() string {
|
||||
if o.Name != "" {
|
||||
return o.Name
|
||||
}
|
||||
return "session_token"
|
||||
}
|
||||
|
||||
func (o SessionCookieOptions) path() string {
|
||||
if o.Path != "" {
|
||||
return o.Path
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
func (o SessionCookieOptions) secure() bool {
|
||||
if o.Secure != nil {
|
||||
return *o.Secure
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (o SessionCookieOptions) sameSite() http.SameSite {
|
||||
if o.SameSite != 0 {
|
||||
return o.SameSite
|
||||
}
|
||||
return http.SameSiteLaxMode
|
||||
}
|
||||
|
||||
// SetSessionCookie writes the session_token cookie to the response after a successful login.
|
||||
// Call this immediately after a successful Authenticator.Login() call.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// resp, err := auth.Login(r.Context(), req)
|
||||
// if err != nil { ... }
|
||||
// security.SetSessionCookie(w, resp)
|
||||
// json.NewEncoder(w).Encode(resp)
|
||||
func SetSessionCookie(w http.ResponseWriter, loginResp *LoginResponse, opts ...SessionCookieOptions) {
|
||||
var o SessionCookieOptions
|
||||
if len(opts) > 0 {
|
||||
o = opts[0]
|
||||
}
|
||||
|
||||
maxAge := 0
|
||||
if loginResp.ExpiresIn > 0 {
|
||||
maxAge = int(loginResp.ExpiresIn)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: o.name(),
|
||||
Value: loginResp.Token,
|
||||
Path: o.path(),
|
||||
Domain: o.Domain,
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: o.secure(),
|
||||
SameSite: o.sameSite(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetSessionCookie returns the session token value from the request cookie, or empty string if not present.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// token := security.GetSessionCookie(r)
|
||||
func GetSessionCookie(r *http.Request, opts ...SessionCookieOptions) string {
|
||||
var o SessionCookieOptions
|
||||
if len(opts) > 0 {
|
||||
o = opts[0]
|
||||
}
|
||||
cookie, err := r.Cookie(o.name())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
// ClearSessionCookie expires the session_token cookie, effectively logging the user out on the browser side.
|
||||
// Call this after a successful Authenticator.Logout() call.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := auth.Logout(r.Context(), req)
|
||||
// if err != nil { ... }
|
||||
// security.ClearSessionCookie(w)
|
||||
func ClearSessionCookie(w http.ResponseWriter, opts ...SessionCookieOptions) {
|
||||
var o SessionCookieOptions
|
||||
if len(opts) > 0 {
|
||||
o = opts[0]
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: o.name(),
|
||||
Value: "",
|
||||
Path: o.path(),
|
||||
Domain: o.Domain,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: o.secure(),
|
||||
SameSite: o.sameSite(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetModelRulesFromContext extracts ModelRules stored by NewModelAuthMiddleware
|
||||
func GetModelRulesFromContext(ctx context.Context) (modelregistry.ModelRules, bool) {
|
||||
rules, ok := ctx.Value(ModelRulesKey).(modelregistry.ModelRules)
|
||||
return rules, ok
|
||||
}
|
||||
|
||||
// // Handler adapters for resolvespec/restheadspec compatibility
|
||||
// // These functions allow using NewAuthHandler and NewOptionalAuthHandler with custom handler abstractions
|
||||
|
||||
// // SpecHandlerAdapter is an interface for handler adapters that need authentication
|
||||
// // Implement this interface to create adapters for custom handler types
|
||||
// type SpecHandlerAdapter interface {
|
||||
// // AdaptToHTTPHandler converts the custom handler to a standard http.Handler
|
||||
// AdaptToHTTPHandler() http.Handler
|
||||
// }
|
||||
|
||||
// // ResolveSpecHandlerAdapter adapts a resolvespec/restheadspec handler method to http.Handler
|
||||
// type ResolveSpecHandlerAdapter struct {
|
||||
// // HandlerMethod is the method to call (e.g., handler.Handle, handler.HandleGet)
|
||||
// HandlerMethod func(w any, r any, params map[string]string)
|
||||
// // Params are the route parameters (e.g., {"schema": "public", "entity": "users"})
|
||||
// Params map[string]string
|
||||
// // RequestAdapter converts *http.Request to the custom Request interface
|
||||
// // Use router.NewHTTPRequest from pkg/common/adapters/router
|
||||
// RequestAdapter func(*http.Request) any
|
||||
// // ResponseAdapter converts http.ResponseWriter to the custom ResponseWriter interface
|
||||
// // Use router.NewHTTPResponseWriter from pkg/common/adapters/router
|
||||
// ResponseAdapter func(http.ResponseWriter) any
|
||||
// }
|
||||
|
||||
// // AdaptToHTTPHandler implements SpecHandlerAdapter
|
||||
// func (a *ResolveSpecHandlerAdapter) AdaptToHTTPHandler() http.Handler {
|
||||
// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// req := a.RequestAdapter(r)
|
||||
// resp := a.ResponseAdapter(w)
|
||||
// a.HandlerMethod(resp, req, a.Params)
|
||||
// })
|
||||
// }
|
||||
|
||||
// // WrapSpecHandler wraps a spec handler adapter with authentication
|
||||
// // Use this to apply NewAuthHandler or NewOptionalAuthHandler to resolvespec/restheadspec handlers
|
||||
// //
|
||||
// // Example with required auth:
|
||||
// //
|
||||
// // adapter := &security.ResolveSpecHandlerAdapter{
|
||||
// // HandlerMethod: handler.Handle,
|
||||
// // Params: map[string]string{"schema": "public", "entity": "users"},
|
||||
// // RequestAdapter: func(r *http.Request) any { return router.NewHTTPRequest(r) },
|
||||
// // ResponseAdapter: func(w http.ResponseWriter) any { return router.NewHTTPResponseWriter(w) },
|
||||
// // }
|
||||
// // authHandler := security.WrapSpecHandler(securityList, adapter, false)
|
||||
// // muxRouter.Handle("/api/users", authHandler)
|
||||
// func WrapSpecHandler(securityList *SecurityList, adapter SpecHandlerAdapter, optional bool) http.Handler {
|
||||
// httpHandler := adapter.AdaptToHTTPHandler()
|
||||
// if optional {
|
||||
// return NewOptionalAuthHandler(securityList, httpHandler)
|
||||
// }
|
||||
// return NewAuthHandler(securityList, httpHandler)
|
||||
// }
|
||||
|
||||
// // MuxRouteBuilder helps build authenticated routes with Gorilla Mux
|
||||
// type MuxRouteBuilder struct {
|
||||
// securityList *SecurityList
|
||||
// requestAdapter func(*http.Request) any
|
||||
// responseAdapter func(http.ResponseWriter) any
|
||||
// paramExtractor func(*http.Request) map[string]string
|
||||
// }
|
||||
|
||||
// // NewMuxRouteBuilder creates a route builder for Gorilla Mux with standard router adapters
|
||||
// // Usage:
|
||||
// //
|
||||
// // builder := security.NewMuxRouteBuilder(securityList, router.NewHTTPRequest, router.NewHTTPResponseWriter)
|
||||
// func NewMuxRouteBuilder(
|
||||
// securityList *SecurityList,
|
||||
// requestAdapter func(*http.Request) any,
|
||||
// responseAdapter func(http.ResponseWriter) any,
|
||||
// ) *MuxRouteBuilder {
|
||||
// return &MuxRouteBuilder{
|
||||
// securityList: securityList,
|
||||
// requestAdapter: requestAdapter,
|
||||
// responseAdapter: responseAdapter,
|
||||
// paramExtractor: nil, // Will be set per route using mux.Vars
|
||||
// }
|
||||
// }
|
||||
|
||||
// // HandleAuth creates an authenticated route handler
|
||||
// // pattern: the route pattern (e.g., "/{schema}/{entity}")
|
||||
// // handler: the handler method to call (e.g., handler.Handle)
|
||||
// // optional: true for optional auth (guest fallback), false for required auth (401 on failure)
|
||||
// // methods: HTTP methods (e.g., "GET", "POST")
|
||||
// //
|
||||
// // Usage:
|
||||
// //
|
||||
// // builder.HandleAuth(router, "/{schema}/{entity}", handler.Handle, false, "POST")
|
||||
// func (b *MuxRouteBuilder) HandleAuth(
|
||||
// router interface {
|
||||
// HandleFunc(pattern string, f func(http.ResponseWriter, *http.Request)) interface{ Methods(...string) interface{} }
|
||||
// },
|
||||
// pattern string,
|
||||
// handlerMethod func(w any, r any, params map[string]string),
|
||||
// optional bool,
|
||||
// methods ...string,
|
||||
// ) {
|
||||
// router.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
|
||||
// // Extract params using the registered extractor or default to empty map
|
||||
// var params map[string]string
|
||||
// if b.paramExtractor != nil {
|
||||
// params = b.paramExtractor(r)
|
||||
// } else {
|
||||
// params = make(map[string]string)
|
||||
// }
|
||||
|
||||
// adapter := &ResolveSpecHandlerAdapter{
|
||||
// HandlerMethod: handlerMethod,
|
||||
// Params: params,
|
||||
// RequestAdapter: b.requestAdapter,
|
||||
// ResponseAdapter: b.responseAdapter,
|
||||
// }
|
||||
// authHandler := WrapSpecHandler(b.securityList, adapter, optional)
|
||||
// authHandler.ServeHTTP(w, r)
|
||||
// }).Methods(methods...)
|
||||
// }
|
||||
|
||||
// // SetParamExtractor sets a custom parameter extractor function
|
||||
// // For Gorilla Mux, you would use: builder.SetParamExtractor(mux.Vars)
|
||||
// func (b *MuxRouteBuilder) SetParamExtractor(extractor func(*http.Request) map[string]string) {
|
||||
// b.paramExtractor = extractor
|
||||
// }
|
||||
|
||||
// // SetupAuthenticatedSpecRoutes sets up all standard resolvespec/restheadspec routes with authentication
|
||||
// // This is a convenience function that sets up the common route patterns
|
||||
// //
|
||||
// // Usage:
|
||||
// //
|
||||
// // security.SetupAuthenticatedSpecRoutes(router, handler, securityList, router.NewHTTPRequest, router.NewHTTPResponseWriter, mux.Vars)
|
||||
// func SetupAuthenticatedSpecRoutes(
|
||||
// router interface {
|
||||
// HandleFunc(pattern string, f func(http.ResponseWriter, *http.Request)) interface{ Methods(...string) interface{} }
|
||||
// },
|
||||
// handler interface {
|
||||
// Handle(w any, r any, params map[string]string)
|
||||
// HandleGet(w any, r any, params map[string]string)
|
||||
// },
|
||||
// securityList *SecurityList,
|
||||
// requestAdapter func(*http.Request) any,
|
||||
// responseAdapter func(http.ResponseWriter) any,
|
||||
// paramExtractor func(*http.Request) map[string]string,
|
||||
// ) {
|
||||
// builder := NewMuxRouteBuilder(securityList, requestAdapter, responseAdapter)
|
||||
// builder.SetParamExtractor(paramExtractor)
|
||||
|
||||
// // POST /{schema}/{entity}
|
||||
// builder.HandleAuth(router, "/{schema}/{entity}", handler.Handle, false, "POST")
|
||||
|
||||
// // POST /{schema}/{entity}/{id}
|
||||
// builder.HandleAuth(router, "/{schema}/{entity}/{id}", handler.Handle, false, "POST")
|
||||
|
||||
// // GET /{schema}/{entity}
|
||||
// builder.HandleAuth(router, "/{schema}/{entity}", handler.HandleGet, false, "GET")
|
||||
// }
|
||||
+615
@@ -0,0 +1,615 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Example: OAuth2 Authentication with Google
|
||||
func ExampleOAuth2Google() {
|
||||
db, _ := sql.Open("postgres", "connection-string")
|
||||
|
||||
// Create OAuth2 authenticator for Google
|
||||
oauth2Auth := NewGoogleAuthenticator(
|
||||
"your-client-id",
|
||||
"your-client-secret",
|
||||
"http://localhost:8080/auth/google/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Login endpoint - redirects to Google
|
||||
router.HandleFunc("/auth/google/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := oauth2Auth.OAuth2GenerateState()
|
||||
authURL, _ := oauth2Auth.OAuth2GetAuthURL("google", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
// Callback endpoint - handles Google response
|
||||
router.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
loginResp, err := oauth2Auth.OAuth2HandleCallback(r.Context(), "google", code, state)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Set session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: loginResp.Token,
|
||||
Path: "/",
|
||||
MaxAge: int(loginResp.ExpiresIn),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
// Return user info as JSON
|
||||
_ = json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
|
||||
_ = http.ListenAndServe(":8080", router)
|
||||
}
|
||||
|
||||
// Example: OAuth2 Authentication with GitHub
|
||||
func ExampleOAuth2GitHub() {
|
||||
db, _ := sql.Open("postgres", "connection-string")
|
||||
|
||||
oauth2Auth := NewGitHubAuthenticator(
|
||||
"your-github-client-id",
|
||||
"your-github-client-secret",
|
||||
"http://localhost:8080/auth/github/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
router.HandleFunc("/auth/github/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := oauth2Auth.OAuth2GenerateState()
|
||||
authURL, _ := oauth2Auth.OAuth2GetAuthURL("github", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
router.HandleFunc("/auth/github/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
loginResp, err := oauth2Auth.OAuth2HandleCallback(r.Context(), "github", code, state)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
|
||||
_ = http.ListenAndServe(":8080", router)
|
||||
}
|
||||
|
||||
// Example: Custom OAuth2 Provider
|
||||
func ExampleOAuth2Custom() {
|
||||
db, _ := sql.Open("postgres", "connection-string")
|
||||
|
||||
// Custom OAuth2 provider configuration
|
||||
oauth2Auth := NewDatabaseAuthenticator(db).WithOAuth2(OAuth2Config{
|
||||
ClientID: "your-client-id",
|
||||
ClientSecret: "your-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
AuthURL: "https://your-provider.com/oauth/authorize",
|
||||
TokenURL: "https://your-provider.com/oauth/token",
|
||||
UserInfoURL: "https://your-provider.com/oauth/userinfo",
|
||||
ProviderName: "custom-provider",
|
||||
|
||||
// Custom user info parser
|
||||
UserInfoParser: func(userInfo map[string]any) (*UserContext, error) {
|
||||
// Extract custom fields from your provider
|
||||
return &UserContext{
|
||||
UserName: userInfo["username"].(string),
|
||||
Email: userInfo["email"].(string),
|
||||
RemoteID: userInfo["id"].(string),
|
||||
UserLevel: 1,
|
||||
Roles: []string{"user"},
|
||||
Claims: userInfo,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
router.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := oauth2Auth.OAuth2GenerateState()
|
||||
authURL, _ := oauth2Auth.OAuth2GetAuthURL("custom-provider", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
router.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
loginResp, err := oauth2Auth.OAuth2HandleCallback(r.Context(), "custom-provider", code, state)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
|
||||
_ = http.ListenAndServe(":8080", router)
|
||||
}
|
||||
|
||||
// Example: Multi-Provider OAuth2 with Security Integration
|
||||
func ExampleOAuth2MultiProvider() {
|
||||
db, _ := sql.Open("postgres", "connection-string")
|
||||
|
||||
// Create OAuth2 authenticators for multiple providers
|
||||
googleAuth := NewGoogleAuthenticator(
|
||||
"google-client-id",
|
||||
"google-client-secret",
|
||||
"http://localhost:8080/auth/google/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
githubAuth := NewGitHubAuthenticator(
|
||||
"github-client-id",
|
||||
"github-client-secret",
|
||||
"http://localhost:8080/auth/github/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
// Create column and row security providers
|
||||
colSec := NewDatabaseColumnSecurityProvider(db)
|
||||
rowSec := NewDatabaseRowSecurityProvider(db)
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Google OAuth2 routes
|
||||
router.HandleFunc("/auth/google/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := googleAuth.OAuth2GenerateState()
|
||||
authURL, _ := googleAuth.OAuth2GetAuthURL("google", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
router.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
loginResp, err := googleAuth.OAuth2HandleCallback(r.Context(), "google", code, state)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: loginResp.Token,
|
||||
Path: "/",
|
||||
MaxAge: int(loginResp.ExpiresIn),
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
// GitHub OAuth2 routes
|
||||
router.HandleFunc("/auth/github/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := githubAuth.OAuth2GenerateState()
|
||||
authURL, _ := githubAuth.OAuth2GetAuthURL("github", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
router.HandleFunc("/auth/github/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
loginResp, err := githubAuth.OAuth2HandleCallback(r.Context(), "github", code, state)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: loginResp.Token,
|
||||
Path: "/",
|
||||
MaxAge: int(loginResp.ExpiresIn),
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
// Use Google auth for protected routes (or GitHub - both work)
|
||||
provider, _ := NewCompositeSecurityProvider(googleAuth, colSec, rowSec)
|
||||
securityList, _ := NewSecurityList(provider)
|
||||
|
||||
// Protected route with authentication
|
||||
protectedRouter := router.PathPrefix("/api").Subrouter()
|
||||
protectedRouter.Use(NewAuthMiddleware(securityList))
|
||||
protectedRouter.Use(SetSecurityMiddleware(securityList))
|
||||
|
||||
protectedRouter.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx, _ := GetUserContext(r.Context())
|
||||
_ = json.NewEncoder(w).Encode(userCtx)
|
||||
})
|
||||
|
||||
_ = http.ListenAndServe(":8080", router)
|
||||
}
|
||||
|
||||
// Example: OAuth2 with Token Refresh
|
||||
func ExampleOAuth2TokenRefresh() {
|
||||
db, _ := sql.Open("postgres", "connection-string")
|
||||
|
||||
oauth2Auth := NewGoogleAuthenticator(
|
||||
"your-client-id",
|
||||
"your-client-secret",
|
||||
"http://localhost:8080/auth/google/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Refresh token endpoint
|
||||
router.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Provider string `json:"provider"` // "google", "github", etc.
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Default to google if not specified
|
||||
if req.Provider == "" {
|
||||
req.Provider = "google"
|
||||
}
|
||||
|
||||
// Use OAuth2-specific refresh method
|
||||
loginResp, err := oauth2Auth.OAuth2RefreshToken(r.Context(), req.RefreshToken, req.Provider)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Set new session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: loginResp.Token,
|
||||
Path: "/",
|
||||
MaxAge: int(loginResp.ExpiresIn),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
_ = json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
|
||||
_ = http.ListenAndServe(":8080", router)
|
||||
}
|
||||
|
||||
// Example: OAuth2 Logout
|
||||
func ExampleOAuth2Logout() {
|
||||
db, _ := sql.Open("postgres", "connection-string")
|
||||
|
||||
oauth2Auth := NewGoogleAuthenticator(
|
||||
"your-client-id",
|
||||
"your-client-secret",
|
||||
"http://localhost:8080/auth/google/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
cookie, err := r.Cookie("session_token")
|
||||
if err == nil {
|
||||
token = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
// Get user ID from session
|
||||
userCtx, err := oauth2Auth.Authenticate(r)
|
||||
if err == nil {
|
||||
_ = oauth2Auth.Logout(r.Context(), LogoutRequest{
|
||||
Token: token,
|
||||
UserID: userCtx.UserID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("Logged out successfully"))
|
||||
})
|
||||
|
||||
_ = http.ListenAndServe(":8080", router)
|
||||
}
|
||||
|
||||
// Example: Complete OAuth2 Integration with Database Setup
|
||||
func ExampleOAuth2Complete() {
|
||||
db, _ := sql.Open("postgres", "connection-string")
|
||||
|
||||
// Create tables (run once)
|
||||
setupOAuth2Tables(db)
|
||||
|
||||
// Create OAuth2 authenticator
|
||||
oauth2Auth := NewGoogleAuthenticator(
|
||||
"your-client-id",
|
||||
"your-client-secret",
|
||||
"http://localhost:8080/auth/google/callback",
|
||||
db,
|
||||
)
|
||||
|
||||
// Create security providers
|
||||
colSec := NewDatabaseColumnSecurityProvider(db)
|
||||
rowSec := NewDatabaseRowSecurityProvider(db)
|
||||
provider, _ := NewCompositeSecurityProvider(oauth2Auth, colSec, rowSec)
|
||||
securityList, _ := NewSecurityList(provider)
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Public routes
|
||||
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("Welcome! <a href='/auth/google/login'>Login with Google</a>"))
|
||||
})
|
||||
|
||||
router.HandleFunc("/auth/google/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := oauth2Auth.OAuth2GenerateState()
|
||||
authURL, _ := oauth2Auth.OAuth2GetAuthURL("github", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
router.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
loginResp, err := oauth2Auth.OAuth2HandleCallback(r.Context(), "github", code, state)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: loginResp.Token,
|
||||
Path: "/",
|
||||
MaxAge: int(loginResp.ExpiresIn),
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
// Protected routes
|
||||
protectedRouter := router.PathPrefix("/").Subrouter()
|
||||
protectedRouter.Use(NewAuthMiddleware(securityList))
|
||||
protectedRouter.Use(SetSecurityMiddleware(securityList))
|
||||
|
||||
protectedRouter.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx, _ := GetUserContext(r.Context())
|
||||
_, _ = fmt.Fprintf(w, "Welcome, %s! Your email: %s", userCtx.UserName, userCtx.Email)
|
||||
})
|
||||
|
||||
protectedRouter.HandleFunc("/api/profile", func(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx, _ := GetUserContext(r.Context())
|
||||
_ = json.NewEncoder(w).Encode(userCtx)
|
||||
})
|
||||
|
||||
protectedRouter.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx, _ := GetUserContext(r.Context())
|
||||
_ = oauth2Auth.Logout(r.Context(), LogoutRequest{
|
||||
Token: userCtx.SessionID,
|
||||
UserID: userCtx.UserID,
|
||||
})
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
_ = http.ListenAndServe(":8080", router)
|
||||
}
|
||||
|
||||
func setupOAuth2Tables(db *sql.DB) {
|
||||
// Create tables from database_schema.sql
|
||||
// This is a helper function - in production, use migrations
|
||||
ctx := context.Background()
|
||||
|
||||
// Create users table if not exists
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255),
|
||||
user_level INTEGER DEFAULT 0,
|
||||
roles VARCHAR(500),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TIMESTAMP,
|
||||
remote_id VARCHAR(255),
|
||||
auth_provider VARCHAR(50)
|
||||
)
|
||||
`)
|
||||
|
||||
// Create user_sessions table (used for both regular and OAuth2 sessions)
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_token VARCHAR(500) NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_activity_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
token_type VARCHAR(50) DEFAULT 'Bearer',
|
||||
auth_provider VARCHAR(50)
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
// Example: All OAuth2 Providers at Once
|
||||
func ExampleOAuth2AllProviders() {
|
||||
db, _ := sql.Open("postgres", "connection-string")
|
||||
|
||||
// Create authenticator with ALL OAuth2 providers
|
||||
auth := NewDatabaseAuthenticator(db).
|
||||
WithOAuth2(OAuth2Config{
|
||||
ClientID: "google-client-id",
|
||||
ClientSecret: "google-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/google/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
UserInfoURL: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
ProviderName: "google",
|
||||
}).
|
||||
WithOAuth2(OAuth2Config{
|
||||
ClientID: "github-client-id",
|
||||
ClientSecret: "github-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/github/callback",
|
||||
Scopes: []string{"user:email"},
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
UserInfoURL: "https://api.github.com/user",
|
||||
ProviderName: "github",
|
||||
}).
|
||||
WithOAuth2(OAuth2Config{
|
||||
ClientID: "microsoft-client-id",
|
||||
ClientSecret: "microsoft-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/microsoft/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
UserInfoURL: "https://graph.microsoft.com/v1.0/me",
|
||||
ProviderName: "microsoft",
|
||||
}).
|
||||
WithOAuth2(OAuth2Config{
|
||||
ClientID: "facebook-client-id",
|
||||
ClientSecret: "facebook-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/facebook/callback",
|
||||
Scopes: []string{"email"},
|
||||
AuthURL: "https://www.facebook.com/v12.0/dialog/oauth",
|
||||
TokenURL: "https://graph.facebook.com/v12.0/oauth/access_token",
|
||||
UserInfoURL: "https://graph.facebook.com/me?fields=id,name,email",
|
||||
ProviderName: "facebook",
|
||||
})
|
||||
|
||||
// Get list of configured providers
|
||||
providers := auth.OAuth2GetProviders()
|
||||
fmt.Printf("Configured OAuth2 providers: %v\n", providers)
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Google routes
|
||||
router.HandleFunc("/auth/google/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := auth.OAuth2GenerateState()
|
||||
authURL, _ := auth.OAuth2GetAuthURL("google", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
router.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
loginResp, err := auth.OAuth2HandleCallback(r.Context(), "google", r.URL.Query().Get("code"), r.URL.Query().Get("state"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
|
||||
// GitHub routes
|
||||
router.HandleFunc("/auth/github/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := auth.OAuth2GenerateState()
|
||||
authURL, _ := auth.OAuth2GetAuthURL("github", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
router.HandleFunc("/auth/github/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
loginResp, err := auth.OAuth2HandleCallback(r.Context(), "github", r.URL.Query().Get("code"), r.URL.Query().Get("state"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
|
||||
// Microsoft routes
|
||||
router.HandleFunc("/auth/microsoft/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := auth.OAuth2GenerateState()
|
||||
authURL, _ := auth.OAuth2GetAuthURL("microsoft", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
router.HandleFunc("/auth/microsoft/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
loginResp, err := auth.OAuth2HandleCallback(r.Context(), "microsoft", r.URL.Query().Get("code"), r.URL.Query().Get("state"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
|
||||
// Facebook routes
|
||||
router.HandleFunc("/auth/facebook/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
state, _ := auth.OAuth2GenerateState()
|
||||
authURL, _ := auth.OAuth2GetAuthURL("facebook", state)
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
router.HandleFunc("/auth/facebook/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
loginResp, err := auth.OAuth2HandleCallback(r.Context(), "facebook", r.URL.Query().Get("code"), r.URL.Query().Get("state"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(loginResp)
|
||||
})
|
||||
|
||||
// Create security list for protected routes
|
||||
colSec := NewDatabaseColumnSecurityProvider(db)
|
||||
rowSec := NewDatabaseRowSecurityProvider(db)
|
||||
provider, _ := NewCompositeSecurityProvider(auth, colSec, rowSec)
|
||||
securityList, _ := NewSecurityList(provider)
|
||||
|
||||
// Protected routes work for ALL OAuth2 providers + regular sessions
|
||||
protectedRouter := router.PathPrefix("/api").Subrouter()
|
||||
protectedRouter.Use(NewAuthMiddleware(securityList))
|
||||
protectedRouter.Use(SetSecurityMiddleware(securityList))
|
||||
|
||||
protectedRouter.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx, _ := GetUserContext(r.Context())
|
||||
_ = json.NewEncoder(w).Encode(userCtx)
|
||||
})
|
||||
|
||||
_ = http.ListenAndServe(":8080", router)
|
||||
}
|
||||
+579
@@ -0,0 +1,579 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OAuth2Config contains configuration for OAuth2 authentication
|
||||
type OAuth2Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
Scopes []string
|
||||
AuthURL string
|
||||
TokenURL string
|
||||
UserInfoURL string
|
||||
ProviderName string
|
||||
|
||||
// Optional: Custom user info parser
|
||||
// If not provided, will use standard claims (sub, email, name)
|
||||
UserInfoParser func(userInfo map[string]any) (*UserContext, error)
|
||||
}
|
||||
|
||||
// OAuth2Provider holds configuration and state for a single OAuth2 provider
|
||||
type OAuth2Provider struct {
|
||||
config *oauth2.Config
|
||||
userInfoURL string
|
||||
userInfoParser func(userInfo map[string]any) (*UserContext, error)
|
||||
providerName string
|
||||
states map[string]time.Time // state -> expiry time
|
||||
statesMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// WithOAuth2 configures OAuth2 support for the DatabaseAuthenticator
|
||||
// Can be called multiple times to add multiple OAuth2 providers
|
||||
// Returns the same DatabaseAuthenticator instance for method chaining
|
||||
func (a *DatabaseAuthenticator) WithOAuth2(cfg OAuth2Config) *DatabaseAuthenticator {
|
||||
if cfg.ProviderName == "" {
|
||||
cfg.ProviderName = "oauth2"
|
||||
}
|
||||
|
||||
if cfg.UserInfoParser == nil {
|
||||
cfg.UserInfoParser = defaultOAuth2UserInfoParser
|
||||
}
|
||||
|
||||
provider := &OAuth2Provider{
|
||||
config: &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Scopes: cfg.Scopes,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: cfg.AuthURL,
|
||||
TokenURL: cfg.TokenURL,
|
||||
},
|
||||
},
|
||||
userInfoURL: cfg.UserInfoURL,
|
||||
userInfoParser: cfg.UserInfoParser,
|
||||
providerName: cfg.ProviderName,
|
||||
states: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// Initialize providers map if needed
|
||||
a.oauth2ProvidersMutex.Lock()
|
||||
if a.oauth2Providers == nil {
|
||||
a.oauth2Providers = make(map[string]*OAuth2Provider)
|
||||
}
|
||||
|
||||
// Register provider
|
||||
a.oauth2Providers[cfg.ProviderName] = provider
|
||||
a.oauth2ProvidersMutex.Unlock()
|
||||
|
||||
// Start state cleanup goroutine for this provider
|
||||
go provider.cleanupStates()
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// OAuth2GetAuthURL returns the OAuth2 authorization URL for redirecting users
|
||||
func (a *DatabaseAuthenticator) OAuth2GetAuthURL(providerName, state string) (string, error) {
|
||||
provider, err := a.getOAuth2Provider(providerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Store state for validation
|
||||
provider.statesMutex.Lock()
|
||||
provider.states[state] = time.Now().Add(10 * time.Minute)
|
||||
provider.statesMutex.Unlock()
|
||||
|
||||
return provider.config.AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
// OAuth2GenerateState generates a random state string for CSRF protection
|
||||
func (a *DatabaseAuthenticator) OAuth2GenerateState() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// OAuth2HandleCallback handles the OAuth2 callback and exchanges code for token
|
||||
func (a *DatabaseAuthenticator) OAuth2HandleCallback(ctx context.Context, providerName, code, state string) (*LoginResponse, error) {
|
||||
provider, err := a.getOAuth2Provider(providerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate state
|
||||
if !provider.validateState(state) {
|
||||
return nil, fmt.Errorf("invalid state parameter")
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
token, err := provider.config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
|
||||
// Fetch user info
|
||||
client := provider.config.Client(ctx, token)
|
||||
resp, err := client.Get(provider.userInfoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch user info: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read user info: %w", err)
|
||||
}
|
||||
|
||||
var userInfo map[string]any
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user info: %w", err)
|
||||
}
|
||||
|
||||
// Parse user info
|
||||
userCtx, err := provider.userInfoParser(userInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user context: %w", err)
|
||||
}
|
||||
|
||||
// Get or create user in database
|
||||
userID, err := a.oauth2GetOrCreateUser(ctx, userCtx, providerName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get or create user: %w", err)
|
||||
}
|
||||
userCtx.UserID = userID
|
||||
|
||||
// Create session token
|
||||
sessionToken, err := a.OAuth2GenerateState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
if token.Expiry.After(time.Now()) {
|
||||
expiresAt = token.Expiry
|
||||
}
|
||||
|
||||
// Store session in database
|
||||
err = a.oauth2CreateSession(ctx, sessionToken, userCtx.UserID, token, expiresAt, providerName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
userCtx.SessionID = sessionToken
|
||||
|
||||
return &LoginResponse{
|
||||
Token: sessionToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
User: userCtx,
|
||||
ExpiresIn: int64(time.Until(expiresAt).Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OAuth2GetProviders returns list of configured OAuth2 provider names
|
||||
func (a *DatabaseAuthenticator) OAuth2GetProviders() []string {
|
||||
a.oauth2ProvidersMutex.RLock()
|
||||
defer a.oauth2ProvidersMutex.RUnlock()
|
||||
|
||||
if a.oauth2Providers == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
providers := make([]string, 0, len(a.oauth2Providers))
|
||||
for name := range a.oauth2Providers {
|
||||
providers = append(providers, name)
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
// getOAuth2Provider retrieves a registered OAuth2 provider by name
|
||||
func (a *DatabaseAuthenticator) getOAuth2Provider(providerName string) (*OAuth2Provider, error) {
|
||||
a.oauth2ProvidersMutex.RLock()
|
||||
defer a.oauth2ProvidersMutex.RUnlock()
|
||||
|
||||
if a.oauth2Providers == nil {
|
||||
return nil, fmt.Errorf("OAuth2 not configured - call WithOAuth2() first")
|
||||
}
|
||||
|
||||
provider, ok := a.oauth2Providers[providerName]
|
||||
if !ok {
|
||||
// Build provider list without calling OAuth2GetProviders to avoid recursion
|
||||
providerNames := make([]string, 0, len(a.oauth2Providers))
|
||||
for name := range a.oauth2Providers {
|
||||
providerNames = append(providerNames, name)
|
||||
}
|
||||
return nil, fmt.Errorf("OAuth2 provider '%s' not found - available providers: %v", providerName, providerNames)
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// oauth2GetOrCreateUser finds or creates a user based on OAuth2 info using stored procedure
|
||||
func (a *DatabaseAuthenticator) oauth2GetOrCreateUser(ctx context.Context, userCtx *UserContext, providerName string) (int, error) {
|
||||
userData := map[string]interface{}{
|
||||
"username": userCtx.UserName,
|
||||
"email": userCtx.Email,
|
||||
"remote_id": userCtx.RemoteID,
|
||||
"user_level": userCtx.UserLevel,
|
||||
"roles": userCtx.Roles,
|
||||
"auth_provider": providerName,
|
||||
}
|
||||
|
||||
userJSON, err := json.Marshal(userData)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to marshal user data: %w", err)
|
||||
}
|
||||
|
||||
var success bool
|
||||
var errMsg *string
|
||||
var userID *int
|
||||
|
||||
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error, p_user_id
|
||||
FROM %s($1::jsonb)
|
||||
`, a.sqlNames.OAuthGetOrCreateUser), userJSON).Scan(&success, &errMsg, &userID)
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get or create user: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errMsg != nil {
|
||||
return 0, fmt.Errorf("%s", *errMsg)
|
||||
}
|
||||
return 0, fmt.Errorf("failed to get or create user")
|
||||
}
|
||||
|
||||
if userID == nil {
|
||||
return 0, fmt.Errorf("user ID not returned")
|
||||
}
|
||||
|
||||
return *userID, nil
|
||||
}
|
||||
|
||||
// oauth2CreateSession creates a new OAuth2 session using stored procedure
|
||||
func (a *DatabaseAuthenticator) oauth2CreateSession(ctx context.Context, sessionToken string, userID int, token *oauth2.Token, expiresAt time.Time, providerName string) error {
|
||||
sessionData := map[string]interface{}{
|
||||
"session_token": sessionToken,
|
||||
"user_id": userID,
|
||||
"access_token": token.AccessToken,
|
||||
"refresh_token": token.RefreshToken,
|
||||
"token_type": token.TokenType,
|
||||
"expires_at": expiresAt,
|
||||
"auth_provider": providerName,
|
||||
}
|
||||
|
||||
sessionJSON, err := json.Marshal(sessionData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal session data: %w", err)
|
||||
}
|
||||
|
||||
var success bool
|
||||
var errMsg *string
|
||||
|
||||
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error
|
||||
FROM %s($1::jsonb)
|
||||
`, a.sqlNames.OAuthCreateSession), sessionJSON).Scan(&success, &errMsg)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errMsg != nil {
|
||||
return fmt.Errorf("%s", *errMsg)
|
||||
}
|
||||
return fmt.Errorf("failed to create session")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateState validates state using in-memory storage
|
||||
func (p *OAuth2Provider) validateState(state string) bool {
|
||||
p.statesMutex.Lock()
|
||||
defer p.statesMutex.Unlock()
|
||||
|
||||
expiry, ok := p.states[state]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if time.Now().After(expiry) {
|
||||
delete(p.states, state)
|
||||
return false
|
||||
}
|
||||
|
||||
delete(p.states, state) // One-time use
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanupStates removes expired states periodically
|
||||
func (p *OAuth2Provider) cleanupStates() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
p.statesMutex.Lock()
|
||||
now := time.Now()
|
||||
for state, expiry := range p.states {
|
||||
if now.After(expiry) {
|
||||
delete(p.states, state)
|
||||
}
|
||||
}
|
||||
p.statesMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// defaultOAuth2UserInfoParser parses standard OAuth2 user info claims
|
||||
func defaultOAuth2UserInfoParser(userInfo map[string]any) (*UserContext, error) {
|
||||
ctx := &UserContext{
|
||||
Claims: userInfo,
|
||||
Roles: []string{"user"},
|
||||
}
|
||||
|
||||
// Extract standard claims
|
||||
if sub, ok := userInfo["sub"].(string); ok {
|
||||
ctx.RemoteID = sub
|
||||
}
|
||||
if email, ok := userInfo["email"].(string); ok {
|
||||
ctx.Email = email
|
||||
// Use email as username if name not available
|
||||
ctx.UserName = strings.Split(email, "@")[0]
|
||||
}
|
||||
if name, ok := userInfo["name"].(string); ok {
|
||||
ctx.UserName = name
|
||||
}
|
||||
if login, ok := userInfo["login"].(string); ok {
|
||||
ctx.UserName = login // GitHub uses "login"
|
||||
}
|
||||
|
||||
if ctx.UserName == "" {
|
||||
return nil, fmt.Errorf("could not extract username from user info")
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// OAuth2RefreshToken refreshes an expired OAuth2 access token using the refresh token
|
||||
// Takes the refresh token and returns a new LoginResponse with updated tokens
|
||||
func (a *DatabaseAuthenticator) OAuth2RefreshToken(ctx context.Context, refreshToken, providerName string) (*LoginResponse, error) {
|
||||
provider, err := a.getOAuth2Provider(providerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get session by refresh token from database
|
||||
var success bool
|
||||
var errMsg *string
|
||||
var sessionData []byte
|
||||
|
||||
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error, p_data::text
|
||||
FROM %s($1)
|
||||
`, a.sqlNames.OAuthGetRefreshToken), refreshToken).Scan(&success, &errMsg, &sessionData)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get session by refresh token: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errMsg != nil {
|
||||
return nil, fmt.Errorf("%s", *errMsg)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid or expired refresh token")
|
||||
}
|
||||
|
||||
// Parse session data
|
||||
var session struct {
|
||||
UserID int `json:"user_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
}
|
||||
if err := json.Unmarshal(sessionData, &session); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse session data: %w", err)
|
||||
}
|
||||
|
||||
// Create oauth2.Token from stored data
|
||||
oldToken := &oauth2.Token{
|
||||
AccessToken: session.AccessToken,
|
||||
TokenType: session.TokenType,
|
||||
RefreshToken: refreshToken,
|
||||
Expiry: session.Expiry,
|
||||
}
|
||||
|
||||
// Use OAuth2 provider to refresh the token
|
||||
tokenSource := provider.config.TokenSource(ctx, oldToken)
|
||||
newToken, err := tokenSource.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh token with provider: %w", err)
|
||||
}
|
||||
|
||||
// Generate new session token
|
||||
newSessionToken, err := a.OAuth2GenerateState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate new session token: %w", err)
|
||||
}
|
||||
|
||||
// Update session in database with new tokens
|
||||
updateData := map[string]interface{}{
|
||||
"user_id": session.UserID,
|
||||
"old_refresh_token": refreshToken,
|
||||
"new_session_token": newSessionToken,
|
||||
"new_access_token": newToken.AccessToken,
|
||||
"new_refresh_token": newToken.RefreshToken,
|
||||
"expires_at": newToken.Expiry,
|
||||
}
|
||||
|
||||
updateJSON, err := json.Marshal(updateData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal update data: %w", err)
|
||||
}
|
||||
|
||||
var updateSuccess bool
|
||||
var updateErrMsg *string
|
||||
|
||||
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error
|
||||
FROM %s($1::jsonb)
|
||||
`, a.sqlNames.OAuthUpdateRefreshToken), updateJSON).Scan(&updateSuccess, &updateErrMsg)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update session: %w", err)
|
||||
}
|
||||
|
||||
if !updateSuccess {
|
||||
if updateErrMsg != nil {
|
||||
return nil, fmt.Errorf("%s", *updateErrMsg)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to update session")
|
||||
}
|
||||
|
||||
// Get user data
|
||||
var userSuccess bool
|
||||
var userErrMsg *string
|
||||
var userData []byte
|
||||
|
||||
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error, p_data::text
|
||||
FROM %s($1)
|
||||
`, a.sqlNames.OAuthGetUser), session.UserID).Scan(&userSuccess, &userErrMsg, &userData)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user data: %w", err)
|
||||
}
|
||||
|
||||
if !userSuccess {
|
||||
if userErrMsg != nil {
|
||||
return nil, fmt.Errorf("%s", *userErrMsg)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user data")
|
||||
}
|
||||
|
||||
// Parse user context
|
||||
var userCtx UserContext
|
||||
if err := json.Unmarshal(userData, &userCtx); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user context: %w", err)
|
||||
}
|
||||
|
||||
userCtx.SessionID = newSessionToken
|
||||
|
||||
return &LoginResponse{
|
||||
Token: newSessionToken,
|
||||
RefreshToken: newToken.RefreshToken,
|
||||
User: &userCtx,
|
||||
ExpiresIn: int64(time.Until(newToken.Expiry).Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Pre-configured OAuth2 factory methods
|
||||
|
||||
// NewGoogleAuthenticator creates a DatabaseAuthenticator configured for Google OAuth2
|
||||
func NewGoogleAuthenticator(clientID, clientSecret, redirectURL string, db *sql.DB) *DatabaseAuthenticator {
|
||||
auth := NewDatabaseAuthenticator(db)
|
||||
return auth.WithOAuth2(OAuth2Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
UserInfoURL: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
ProviderName: "google",
|
||||
})
|
||||
}
|
||||
|
||||
// NewGitHubAuthenticator creates a DatabaseAuthenticator configured for GitHub OAuth2
|
||||
func NewGitHubAuthenticator(clientID, clientSecret, redirectURL string, db *sql.DB) *DatabaseAuthenticator {
|
||||
auth := NewDatabaseAuthenticator(db)
|
||||
return auth.WithOAuth2(OAuth2Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{"user:email"},
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
UserInfoURL: "https://api.github.com/user",
|
||||
ProviderName: "github",
|
||||
})
|
||||
}
|
||||
|
||||
// NewMicrosoftAuthenticator creates a DatabaseAuthenticator configured for Microsoft OAuth2
|
||||
func NewMicrosoftAuthenticator(clientID, clientSecret, redirectURL string, db *sql.DB) *DatabaseAuthenticator {
|
||||
auth := NewDatabaseAuthenticator(db)
|
||||
return auth.WithOAuth2(OAuth2Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
UserInfoURL: "https://graph.microsoft.com/v1.0/me",
|
||||
ProviderName: "microsoft",
|
||||
})
|
||||
}
|
||||
|
||||
// NewFacebookAuthenticator creates a DatabaseAuthenticator configured for Facebook OAuth2
|
||||
func NewFacebookAuthenticator(clientID, clientSecret, redirectURL string, db *sql.DB) *DatabaseAuthenticator {
|
||||
auth := NewDatabaseAuthenticator(db)
|
||||
return auth.WithOAuth2(OAuth2Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{"email"},
|
||||
AuthURL: "https://www.facebook.com/v12.0/dialog/oauth",
|
||||
TokenURL: "https://graph.facebook.com/v12.0/oauth/access_token",
|
||||
UserInfoURL: "https://graph.facebook.com/me?fields=id,name,email",
|
||||
ProviderName: "facebook",
|
||||
})
|
||||
}
|
||||
|
||||
// NewMultiProviderAuthenticator creates a DatabaseAuthenticator with all major OAuth2 providers configured
|
||||
func NewMultiProviderAuthenticator(db *sql.DB, configs map[string]OAuth2Config) *DatabaseAuthenticator {
|
||||
auth := NewDatabaseAuthenticator(db)
|
||||
|
||||
//nolint:gocritic // OAuth2Config is copied but kept for API simplicity
|
||||
for _, cfg := range configs {
|
||||
auth.WithOAuth2(cfg)
|
||||
}
|
||||
|
||||
return auth
|
||||
}
|
||||
+917
@@ -0,0 +1,917 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuthServerConfig configures the MCP-standard OAuth2 authorization server.
|
||||
type OAuthServerConfig struct {
|
||||
// Issuer is the public base URL of this server (e.g. "https://api.example.com").
|
||||
// Used in /.well-known/oauth-authorization-server and to build endpoint URLs.
|
||||
Issuer string
|
||||
|
||||
// ProviderCallbackPath is the path on this server that external OAuth2 providers
|
||||
// redirect back to. Defaults to "/oauth/provider/callback".
|
||||
ProviderCallbackPath string
|
||||
|
||||
// LoginTitle is shown on the built-in login form when the server acts as its own
|
||||
// identity provider. Defaults to "Sign in".
|
||||
LoginTitle string
|
||||
|
||||
// PersistClients stores registered clients in the database when a DatabaseAuthenticator is provided.
|
||||
// Clients registered during a session survive server restarts.
|
||||
PersistClients bool
|
||||
|
||||
// PersistCodes stores authorization codes in the database.
|
||||
// Useful for multi-instance deployments. Defaults to in-memory.
|
||||
PersistCodes bool
|
||||
|
||||
// DefaultScopes lists scopes advertised in server metadata. Defaults to ["openid","profile","email"].
|
||||
DefaultScopes []string
|
||||
|
||||
// AccessTokenTTL is the issued token lifetime. Defaults to 24h.
|
||||
AccessTokenTTL time.Duration
|
||||
|
||||
// AuthCodeTTL is the auth code lifetime. Defaults to 2 minutes.
|
||||
AuthCodeTTL time.Duration
|
||||
}
|
||||
|
||||
// oauthClient is a dynamically registered OAuth2 client (RFC 7591).
|
||||
type oauthClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
AllowedScopes []string `json:"allowed_scopes,omitempty"`
|
||||
}
|
||||
|
||||
// pendingAuth tracks an in-progress authorization code exchange.
|
||||
type pendingAuth struct {
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
ClientState string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
ProviderName string // empty = password login
|
||||
ExpiresAt time.Time
|
||||
SessionToken string // set after authentication completes
|
||||
RefreshToken string // set after authentication completes when refresh tokens are issued
|
||||
Scopes []string // requested scopes
|
||||
}
|
||||
|
||||
// externalProvider pairs a DatabaseAuthenticator with its provider name.
|
||||
type externalProvider struct {
|
||||
auth *DatabaseAuthenticator
|
||||
providerName string
|
||||
}
|
||||
|
||||
// OAuthServer implements the MCP-standard OAuth2 authorization server (OAuth 2.1 + PKCE).
|
||||
//
|
||||
// It can act as both:
|
||||
// - A direct identity provider using DatabaseAuthenticator username/password login
|
||||
// - A federation layer that delegates authentication to external OAuth2 providers
|
||||
// (Google, GitHub, Microsoft, etc.) registered via RegisterExternalProvider
|
||||
//
|
||||
// The server exposes these RFC-compliant endpoints:
|
||||
//
|
||||
// GET /.well-known/oauth-authorization-server RFC 8414 — server metadata discovery
|
||||
// POST /oauth/register RFC 7591 — dynamic client registration
|
||||
// GET /oauth/authorize OAuth 2.1 + PKCE — start authorization
|
||||
// POST /oauth/authorize Direct login form submission
|
||||
// POST /oauth/token Token exchange and refresh
|
||||
// POST /oauth/revoke RFC 7009 — token revocation
|
||||
// POST /oauth/introspect RFC 7662 — token introspection
|
||||
// GET {ProviderCallbackPath} Internal — external provider callback
|
||||
type OAuthServer struct {
|
||||
cfg OAuthServerConfig
|
||||
auth *DatabaseAuthenticator // nil = only external providers
|
||||
providers []externalProvider
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[string]*oauthClient
|
||||
pending map[string]*pendingAuth // provider_state → pending (external flow)
|
||||
codes map[string]*pendingAuth // auth_code → pending (post-auth)
|
||||
|
||||
done chan struct{} // closed by Close() to stop background goroutines
|
||||
}
|
||||
|
||||
// NewOAuthServer creates a new MCP OAuth2 authorization server.
|
||||
//
|
||||
// Pass a DatabaseAuthenticator to enable direct username/password login (the server
|
||||
// acts as its own identity provider). Pass nil to use only external providers.
|
||||
// External providers are added separately via RegisterExternalProvider.
|
||||
//
|
||||
// Call Close() to stop background goroutines when the server is no longer needed.
|
||||
func NewOAuthServer(cfg OAuthServerConfig, auth *DatabaseAuthenticator) *OAuthServer {
|
||||
if cfg.ProviderCallbackPath == "" {
|
||||
cfg.ProviderCallbackPath = "/oauth/provider/callback"
|
||||
}
|
||||
if cfg.LoginTitle == "" {
|
||||
cfg.LoginTitle = "Sign in"
|
||||
}
|
||||
if len(cfg.DefaultScopes) == 0 {
|
||||
cfg.DefaultScopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
if cfg.AccessTokenTTL == 0 {
|
||||
cfg.AccessTokenTTL = 24 * time.Hour
|
||||
}
|
||||
if cfg.AuthCodeTTL == 0 {
|
||||
cfg.AuthCodeTTL = 2 * time.Minute
|
||||
}
|
||||
// Normalize issuer: remove trailing slash to ensure consistent endpoint URL construction.
|
||||
cfg.Issuer = strings.TrimSuffix(cfg.Issuer, "/")
|
||||
s := &OAuthServer{
|
||||
cfg: cfg,
|
||||
auth: auth,
|
||||
clients: make(map[string]*oauthClient),
|
||||
pending: make(map[string]*pendingAuth),
|
||||
codes: make(map[string]*pendingAuth),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go s.cleanupExpired()
|
||||
return s
|
||||
}
|
||||
|
||||
// Close stops the background goroutines started by NewOAuthServer.
|
||||
// It is safe to call Close multiple times.
|
||||
func (s *OAuthServer) Close() {
|
||||
select {
|
||||
case <-s.done:
|
||||
// already closed
|
||||
default:
|
||||
close(s.done)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterExternalProvider adds an external OAuth2 provider (Google, GitHub, Microsoft, etc.)
|
||||
// that handles user authentication via redirect. The DatabaseAuthenticator must have been
|
||||
// configured with WithOAuth2(providerName, ...) before calling this.
|
||||
// Multiple providers can be registered; the first is used as the default.
|
||||
// All providers must be registered before the server starts serving requests.
|
||||
func (s *OAuthServer) RegisterExternalProvider(auth *DatabaseAuthenticator, providerName string) {
|
||||
s.mu.Lock()
|
||||
s.providers = append(s.providers, externalProvider{auth: auth, providerName: providerName})
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// ProviderCallbackPath returns the configured path for external provider callbacks.
|
||||
func (s *OAuthServer) ProviderCallbackPath() string {
|
||||
return s.cfg.ProviderCallbackPath
|
||||
}
|
||||
|
||||
// HTTPHandler returns an http.Handler that serves all RFC-required OAuth2 endpoints.
|
||||
// Mount it at the root of your HTTP server alongside the MCP transport.
|
||||
//
|
||||
// mux := http.NewServeMux()
|
||||
// mux.Handle("/", oauthServer.HTTPHandler())
|
||||
// mux.Handle("/mcp/", mcpTransport)
|
||||
func (s *OAuthServer) HTTPHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", s.metadataHandler)
|
||||
mux.HandleFunc("/oauth/register", s.registerHandler)
|
||||
mux.HandleFunc("/oauth/authorize", s.authorizeHandler)
|
||||
mux.HandleFunc("/oauth/token", s.tokenHandler)
|
||||
mux.HandleFunc("/oauth/revoke", s.revokeHandler)
|
||||
mux.HandleFunc("/oauth/introspect", s.introspectHandler)
|
||||
mux.HandleFunc(s.cfg.ProviderCallbackPath, s.providerCallbackHandler)
|
||||
return mux
|
||||
}
|
||||
|
||||
// cleanupExpired removes stale pending auths and codes every 5 minutes.
|
||||
func (s *OAuthServer) cleanupExpired() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
for k, p := range s.pending {
|
||||
if now.After(p.ExpiresAt) {
|
||||
delete(s.pending, k)
|
||||
}
|
||||
}
|
||||
for k, p := range s.codes {
|
||||
if now.After(p.ExpiresAt) {
|
||||
delete(s.codes, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// RFC 8414 — Server metadata
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) metadataHandler(w http.ResponseWriter, r *http.Request) {
|
||||
issuer := s.cfg.Issuer
|
||||
meta := map[string]interface{}{
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": issuer + "/oauth/authorize",
|
||||
"token_endpoint": issuer + "/oauth/token",
|
||||
"registration_endpoint": issuer + "/oauth/register",
|
||||
"revocation_endpoint": issuer + "/oauth/revoke",
|
||||
"introspection_endpoint": issuer + "/oauth/introspect",
|
||||
"scopes_supported": s.cfg.DefaultScopes,
|
||||
"response_types_supported": []string{"code"},
|
||||
"grant_types_supported": []string{"authorization_code", "refresh_token"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"token_endpoint_auth_methods_supported": []string{"none"},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(meta) //nolint:errcheck
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// RFC 7591 — Dynamic client registration
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientName string `json:"client_name"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
AllowedScopes []string `json:"allowed_scopes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeOAuthError(w, "invalid_request", "malformed JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.RedirectURIs) == 0 {
|
||||
writeOAuthError(w, "invalid_request", "redirect_uris required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
grantTypes := req.GrantTypes
|
||||
if len(grantTypes) == 0 {
|
||||
grantTypes = []string{"authorization_code"}
|
||||
}
|
||||
allowedScopes := req.AllowedScopes
|
||||
if len(allowedScopes) == 0 {
|
||||
allowedScopes = s.cfg.DefaultScopes
|
||||
}
|
||||
clientID, err := randomOAuthToken()
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
client := &oauthClient{
|
||||
ClientID: clientID,
|
||||
RedirectURIs: req.RedirectURIs,
|
||||
ClientName: req.ClientName,
|
||||
GrantTypes: grantTypes,
|
||||
AllowedScopes: allowedScopes,
|
||||
}
|
||||
|
||||
if s.cfg.PersistClients && s.auth != nil {
|
||||
dbClient := &OAuthServerClient{
|
||||
ClientID: client.ClientID,
|
||||
RedirectURIs: client.RedirectURIs,
|
||||
ClientName: client.ClientName,
|
||||
GrantTypes: client.GrantTypes,
|
||||
AllowedScopes: client.AllowedScopes,
|
||||
}
|
||||
if _, err := s.auth.OAuthRegisterClient(r.Context(), dbClient); err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.clients[clientID] = client
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(client) //nolint:errcheck
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Authorization endpoint — GET + POST /oauth/authorize
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) authorizeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.authorizeGet(w, r)
|
||||
case http.MethodPost:
|
||||
s.authorizePost(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeGet validates the request and either:
|
||||
// - Redirects to an external provider (if providers are registered)
|
||||
// - Renders a login form (if the server is its own identity provider)
|
||||
func (s *OAuthServer) authorizeGet(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
clientID := q.Get("client_id")
|
||||
redirectURI := q.Get("redirect_uri")
|
||||
clientState := q.Get("state")
|
||||
codeChallenge := q.Get("code_challenge")
|
||||
codeChallengeMethod := q.Get("code_challenge_method")
|
||||
providerName := q.Get("provider")
|
||||
scopeStr := q.Get("scope")
|
||||
var scopes []string
|
||||
if scopeStr != "" {
|
||||
scopes = strings.Fields(scopeStr)
|
||||
}
|
||||
|
||||
if q.Get("response_type") != "code" {
|
||||
writeOAuthError(w, "unsupported_response_type", "only 'code' is supported", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if codeChallenge == "" {
|
||||
writeOAuthError(w, "invalid_request", "code_challenge required (PKCE S256)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if codeChallengeMethod != "" && codeChallengeMethod != "S256" {
|
||||
writeOAuthError(w, "invalid_request", "only S256 code_challenge_method is supported", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
client, ok := s.lookupOrFetchClient(r.Context(), clientID)
|
||||
if !ok {
|
||||
writeOAuthError(w, "invalid_client", "unknown client_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !oauthSliceContains(client.RedirectURIs, redirectURI) {
|
||||
writeOAuthError(w, "invalid_request", "redirect_uri not registered", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// External provider path
|
||||
if len(s.providers) > 0 {
|
||||
s.redirectToExternalProvider(w, r, clientID, redirectURI, clientState, codeChallenge, codeChallengeMethod, providerName, scopes)
|
||||
return
|
||||
}
|
||||
|
||||
// Direct login form path (server is its own identity provider)
|
||||
if s.auth == nil {
|
||||
http.Error(w, "no authentication provider configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.renderLoginForm(w, r, clientID, redirectURI, clientState, codeChallenge, codeChallengeMethod, scopeStr, "")
|
||||
}
|
||||
|
||||
// authorizePost handles login form submission for the direct login flow.
|
||||
func (s *OAuthServer) authorizePost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := r.FormValue("client_id")
|
||||
redirectURI := r.FormValue("redirect_uri")
|
||||
clientState := r.FormValue("client_state")
|
||||
codeChallenge := r.FormValue("code_challenge")
|
||||
codeChallengeMethod := r.FormValue("code_challenge_method")
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
scopeStr := r.FormValue("scope")
|
||||
var scopes []string
|
||||
if scopeStr != "" {
|
||||
scopes = strings.Fields(scopeStr)
|
||||
}
|
||||
|
||||
client, ok := s.lookupOrFetchClient(r.Context(), clientID)
|
||||
if !ok || !oauthSliceContains(client.RedirectURIs, redirectURI) {
|
||||
http.Error(w, "invalid client or redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.auth == nil {
|
||||
http.Error(w, "no authentication provider configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
loginResp, err := s.auth.Login(r.Context(), LoginRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
s.renderLoginForm(w, r, clientID, redirectURI, clientState, codeChallenge, codeChallengeMethod, scopeStr, "Invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
s.issueCodeAndRedirect(w, r, loginResp.Token, loginResp.RefreshToken, clientID, redirectURI, clientState, codeChallenge, codeChallengeMethod, "", scopes)
|
||||
}
|
||||
|
||||
// redirectToExternalProvider stores the pending auth and redirects to the configured provider.
|
||||
func (s *OAuthServer) redirectToExternalProvider(w http.ResponseWriter, r *http.Request, clientID, redirectURI, clientState, codeChallenge, codeChallengeMethod, providerName string, scopes []string) {
|
||||
var provider *externalProvider
|
||||
if providerName != "" {
|
||||
for i := range s.providers {
|
||||
if s.providers[i].providerName == providerName {
|
||||
provider = &s.providers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if provider == nil {
|
||||
http.Error(w, fmt.Sprintf("provider %q not found", providerName), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
provider = &s.providers[0]
|
||||
}
|
||||
|
||||
providerState, err := randomOAuthToken()
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pending := &pendingAuth{
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
ClientState: clientState,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
ProviderName: provider.providerName,
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
Scopes: scopes,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.pending[providerState] = pending
|
||||
s.mu.Unlock()
|
||||
|
||||
authURL, err := provider.auth.OAuth2GetAuthURL(provider.providerName, providerState)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// External provider callback — GET {ProviderCallbackPath}
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) providerCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
providerState := r.URL.Query().Get("state")
|
||||
|
||||
if code == "" {
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
pending, ok := s.pending[providerState]
|
||||
if ok {
|
||||
delete(s.pending, providerState)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if !ok || time.Now().After(pending.ExpiresAt) {
|
||||
http.Error(w, "invalid or expired state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
provider := s.providerByName(pending.ProviderName)
|
||||
if provider == nil {
|
||||
http.Error(w, fmt.Sprintf("provider %q not found", pending.ProviderName), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
loginResp, err := provider.auth.OAuth2HandleCallback(r.Context(), pending.ProviderName, code, providerState)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
s.issueCodeAndRedirect(w, r, loginResp.Token, loginResp.RefreshToken,
|
||||
pending.ClientID, pending.RedirectURI, pending.ClientState,
|
||||
pending.CodeChallenge, pending.CodeChallengeMethod, pending.ProviderName, pending.Scopes)
|
||||
}
|
||||
|
||||
// issueCodeAndRedirect generates a short-lived auth code and redirects to the MCP client.
|
||||
func (s *OAuthServer) issueCodeAndRedirect(w http.ResponseWriter, r *http.Request, sessionToken, refreshToken, clientID, redirectURI, clientState, codeChallenge, codeChallengeMethod, providerName string, scopes []string) {
|
||||
authCode, err := randomOAuthToken()
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pending := &pendingAuth{
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
ClientState: clientState,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
ProviderName: providerName,
|
||||
SessionToken: sessionToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: time.Now().Add(s.cfg.AuthCodeTTL),
|
||||
Scopes: scopes,
|
||||
}
|
||||
|
||||
if s.cfg.PersistCodes && s.auth != nil {
|
||||
oauthCode := &OAuthCode{
|
||||
Code: authCode,
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
ClientState: clientState,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
SessionToken: sessionToken,
|
||||
RefreshToken: refreshToken,
|
||||
Scopes: scopes,
|
||||
ExpiresAt: pending.ExpiresAt,
|
||||
}
|
||||
if err := s.auth.OAuthSaveCode(r.Context(), oauthCode); err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
s.mu.Lock()
|
||||
s.codes[authCode] = pending
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
redirectURL, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid redirect_uri", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
qp := redirectURL.Query()
|
||||
qp.Set("code", authCode)
|
||||
if clientState != "" {
|
||||
qp.Set("state", clientState)
|
||||
}
|
||||
redirectURL.RawQuery = qp.Encode()
|
||||
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Token endpoint — POST /oauth/token
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) tokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeOAuthError(w, "invalid_request", "cannot parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch r.FormValue("grant_type") {
|
||||
case "authorization_code":
|
||||
s.handleAuthCodeGrant(w, r)
|
||||
case "refresh_token":
|
||||
s.handleRefreshGrant(w, r)
|
||||
default:
|
||||
writeOAuthError(w, "unsupported_grant_type", "", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OAuthServer) handleAuthCodeGrant(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.FormValue("code")
|
||||
redirectURI := r.FormValue("redirect_uri")
|
||||
clientID := r.FormValue("client_id")
|
||||
codeVerifier := r.FormValue("code_verifier")
|
||||
|
||||
if code == "" || codeVerifier == "" {
|
||||
writeOAuthError(w, "invalid_request", "code and code_verifier required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var sessionToken string
|
||||
var refreshToken string
|
||||
var scopes []string
|
||||
|
||||
if s.cfg.PersistCodes && s.auth != nil {
|
||||
oauthCode, err := s.auth.OAuthExchangeCode(r.Context(), code)
|
||||
if err != nil {
|
||||
writeOAuthError(w, "invalid_grant", "code expired or invalid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if oauthCode.ClientID != clientID {
|
||||
writeOAuthError(w, "invalid_client", "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if oauthCode.RedirectURI != redirectURI {
|
||||
writeOAuthError(w, "invalid_grant", "redirect_uri mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !validatePKCESHA256(oauthCode.CodeChallenge, codeVerifier) {
|
||||
writeOAuthError(w, "invalid_grant", "code_verifier invalid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sessionToken = oauthCode.SessionToken
|
||||
refreshToken = oauthCode.RefreshToken
|
||||
scopes = oauthCode.Scopes
|
||||
} else {
|
||||
s.mu.Lock()
|
||||
pending, ok := s.codes[code]
|
||||
if ok {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if !ok || time.Now().After(pending.ExpiresAt) {
|
||||
writeOAuthError(w, "invalid_grant", "code expired or invalid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if pending.ClientID != clientID {
|
||||
writeOAuthError(w, "invalid_client", "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if pending.RedirectURI != redirectURI {
|
||||
writeOAuthError(w, "invalid_grant", "redirect_uri mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !validatePKCESHA256(pending.CodeChallenge, codeVerifier) {
|
||||
writeOAuthError(w, "invalid_grant", "code_verifier invalid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sessionToken = pending.SessionToken
|
||||
refreshToken = pending.RefreshToken
|
||||
scopes = pending.Scopes
|
||||
}
|
||||
|
||||
s.writeOAuthToken(w, sessionToken, refreshToken, scopes)
|
||||
}
|
||||
|
||||
func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request) {
|
||||
refreshToken := r.FormValue("refresh_token")
|
||||
providerName := r.FormValue("provider")
|
||||
if refreshToken == "" {
|
||||
writeOAuthError(w, "invalid_request", "refresh_token required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Try external providers first, then fall back to DatabaseAuthenticator
|
||||
provider := s.providerByName(providerName)
|
||||
if provider != nil {
|
||||
loginResp, err := provider.auth.OAuth2RefreshToken(r.Context(), refreshToken, providerName)
|
||||
if err != nil {
|
||||
writeOAuthError(w, "invalid_grant", err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.writeOAuthToken(w, loginResp.Token, loginResp.RefreshToken, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if s.auth != nil {
|
||||
loginResp, err := s.auth.RefreshToken(r.Context(), refreshToken)
|
||||
if err != nil {
|
||||
writeOAuthError(w, "invalid_grant", err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.writeOAuthToken(w, loginResp.Token, loginResp.RefreshToken, nil)
|
||||
return
|
||||
}
|
||||
|
||||
writeOAuthError(w, "invalid_grant", "no provider available for refresh", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// RFC 7009 — Token revocation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) revokeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
token := r.FormValue("token")
|
||||
if token == "" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if s.auth != nil {
|
||||
s.auth.OAuthRevokeToken(r.Context(), token) //nolint:errcheck
|
||||
} else {
|
||||
// In external-provider-only mode, attempt revocation via the first provider's auth.
|
||||
s.mu.RLock()
|
||||
var providerAuth *DatabaseAuthenticator
|
||||
if len(s.providers) > 0 {
|
||||
providerAuth = s.providers[0].auth
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if providerAuth != nil {
|
||||
providerAuth.OAuthRevokeToken(r.Context(), token) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// RFC 7662 — Token introspection
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) introspectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"active":false}`)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
token := r.FormValue("token")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if token == "" {
|
||||
w.Write([]byte(`{"active":false}`)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the authenticator to use: prefer the primary auth, then the first provider's auth.
|
||||
authToUse := s.auth
|
||||
if authToUse == nil {
|
||||
s.mu.RLock()
|
||||
if len(s.providers) > 0 {
|
||||
authToUse = s.providers[0].auth
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
if authToUse == nil {
|
||||
w.Write([]byte(`{"active":false}`)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
info, err := authToUse.OAuthIntrospectToken(r.Context(), token)
|
||||
if err != nil {
|
||||
w.Write([]byte(`{"active":false}`)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(info) //nolint:errcheck
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Login form (direct identity provider mode)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func (s *OAuthServer) renderLoginForm(w http.ResponseWriter, r *http.Request, clientID, redirectURI, clientState, codeChallenge, codeChallengeMethod, scope, errMsg string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
errHTML := ""
|
||||
if errMsg != "" {
|
||||
errHTML = `<p style="color:red">` + errMsg + `</p>`
|
||||
}
|
||||
fmt.Fprintf(w, loginFormHTML,
|
||||
s.cfg.LoginTitle,
|
||||
s.cfg.LoginTitle,
|
||||
errHTML,
|
||||
clientID,
|
||||
htmlEscape(redirectURI),
|
||||
htmlEscape(clientState),
|
||||
htmlEscape(codeChallenge),
|
||||
htmlEscape(codeChallengeMethod),
|
||||
htmlEscape(scope),
|
||||
)
|
||||
}
|
||||
|
||||
const loginFormHTML = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>%s</title>
|
||||
<style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5}
|
||||
.card{background:#fff;padding:2rem;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.15);width:320px}
|
||||
h2{margin:0 0 1.5rem;font-size:1.25rem}
|
||||
label{display:block;margin-bottom:.25rem;font-size:.875rem;color:#555}
|
||||
input[type=text],input[type=password]{width:100%%;box-sizing:border-box;padding:.5rem;border:1px solid #ccc;border-radius:4px;margin-bottom:1rem;font-size:1rem}
|
||||
button{width:100%%;padding:.6rem;background:#0070f3;color:#fff;border:none;border-radius:4px;font-size:1rem;cursor:pointer}
|
||||
button:hover{background:#005fd4}.err{color:#d32f2f;margin-bottom:1rem;font-size:.875rem}</style>
|
||||
</head><body><div class="card">
|
||||
<h2>%s</h2>%s
|
||||
<form method="POST" action="authorize">
|
||||
<input type="hidden" name="client_id" value="%s">
|
||||
<input type="hidden" name="redirect_uri" value="%s">
|
||||
<input type="hidden" name="client_state" value="%s">
|
||||
<input type="hidden" name="code_challenge" value="%s">
|
||||
<input type="hidden" name="code_challenge_method" value="%s">
|
||||
<input type="hidden" name="scope" value="%s">
|
||||
<label>Username</label><input type="text" name="username" autofocus autocomplete="username">
|
||||
<label>Password</label><input type="password" name="password" autocomplete="current-password">
|
||||
<button type="submit">Sign in</button>
|
||||
</form></div></body></html>`
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// lookupOrFetchClient checks in-memory first, then DB if PersistClients is enabled.
|
||||
func (s *OAuthServer) lookupOrFetchClient(ctx context.Context, clientID string) (*oauthClient, bool) {
|
||||
s.mu.RLock()
|
||||
c, ok := s.clients[clientID]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
return c, true
|
||||
}
|
||||
|
||||
if !s.cfg.PersistClients || s.auth == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
dbClient, err := s.auth.OAuthGetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
c = &oauthClient{
|
||||
ClientID: dbClient.ClientID,
|
||||
RedirectURIs: dbClient.RedirectURIs,
|
||||
ClientName: dbClient.ClientName,
|
||||
GrantTypes: dbClient.GrantTypes,
|
||||
AllowedScopes: dbClient.AllowedScopes,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.clients[clientID] = c
|
||||
s.mu.Unlock()
|
||||
return c, true
|
||||
}
|
||||
|
||||
func (s *OAuthServer) providerByName(name string) *externalProvider {
|
||||
for i := range s.providers {
|
||||
if s.providers[i].providerName == name {
|
||||
return &s.providers[i]
|
||||
}
|
||||
}
|
||||
// If name is empty and only one provider exists, return it
|
||||
if name == "" && len(s.providers) == 1 {
|
||||
return &s.providers[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePKCESHA256(challenge, verifier string) bool {
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:]) == challenge
|
||||
}
|
||||
|
||||
func randomOAuthToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func oauthSliceContains(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *OAuthServer) writeOAuthToken(w http.ResponseWriter, accessToken, refreshToken string, scopes []string) {
|
||||
expiresIn := int64(s.cfg.AccessTokenTTL.Seconds())
|
||||
resp := map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": expiresIn,
|
||||
}
|
||||
if refreshToken != "" {
|
||||
resp["refresh_token"] = refreshToken
|
||||
}
|
||||
if len(scopes) > 0 {
|
||||
resp["scope"] = strings.Join(scopes, " ")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
json.NewEncoder(w).Encode(resp) //nolint:errcheck
|
||||
}
|
||||
|
||||
func writeOAuthError(w http.ResponseWriter, errCode, description string, status int) {
|
||||
resp := map[string]string{"error": errCode}
|
||||
if description != "" {
|
||||
resp["error_description"] = description
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(resp) //nolint:errcheck
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuthServerClient is a persisted RFC 7591 registered OAuth2 client.
|
||||
type OAuthServerClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
AllowedScopes []string `json:"allowed_scopes,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthCode is a short-lived authorization code.
|
||||
type OAuthCode struct {
|
||||
Code string `json:"code"`
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ClientState string `json:"client_state,omitempty"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
CodeChallengeMethod string `json:"code_challenge_method"`
|
||||
SessionToken string `json:"session_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// OAuthTokenInfo is the RFC 7662 token introspection response.
|
||||
type OAuthTokenInfo struct {
|
||||
Active bool `json:"active"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
UserLevel int `json:"user_level,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Exp int64 `json:"exp,omitempty"`
|
||||
Iat int64 `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthRegisterClient persists an OAuth2 client registration.
|
||||
func (a *DatabaseAuthenticator) OAuthRegisterClient(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) {
|
||||
input, err := json.Marshal(client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal client: %w", err)
|
||||
}
|
||||
|
||||
var success bool
|
||||
var errMsg *string
|
||||
var data []byte
|
||||
|
||||
err = a.db.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error, p_data::text
|
||||
FROM %s($1::jsonb)
|
||||
`, a.sqlNames.OAuthRegisterClient), input).Scan(&success, &errMsg, &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register client: %w", err)
|
||||
}
|
||||
if !success {
|
||||
if errMsg != nil {
|
||||
return nil, fmt.Errorf("%s", *errMsg)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to register client")
|
||||
}
|
||||
|
||||
var result OAuthServerClient
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse registered client: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// OAuthGetClient retrieves a registered client by ID.
|
||||
func (a *DatabaseAuthenticator) OAuthGetClient(ctx context.Context, clientID string) (*OAuthServerClient, error) {
|
||||
var success bool
|
||||
var errMsg *string
|
||||
var data []byte
|
||||
|
||||
err := a.db.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error, p_data::text
|
||||
FROM %s($1)
|
||||
`, a.sqlNames.OAuthGetClient), clientID).Scan(&success, &errMsg, &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
if !success {
|
||||
if errMsg != nil {
|
||||
return nil, fmt.Errorf("%s", *errMsg)
|
||||
}
|
||||
return nil, fmt.Errorf("client not found")
|
||||
}
|
||||
|
||||
var result OAuthServerClient
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse client: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// OAuthSaveCode persists an authorization code.
|
||||
func (a *DatabaseAuthenticator) OAuthSaveCode(ctx context.Context, code *OAuthCode) error {
|
||||
input, err := json.Marshal(code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal code: %w", err)
|
||||
}
|
||||
|
||||
var success bool
|
||||
var errMsg *string
|
||||
|
||||
err = a.db.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error
|
||||
FROM %s($1::jsonb)
|
||||
`, a.sqlNames.OAuthSaveCode), input).Scan(&success, &errMsg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save code: %w", err)
|
||||
}
|
||||
if !success {
|
||||
if errMsg != nil {
|
||||
return fmt.Errorf("%s", *errMsg)
|
||||
}
|
||||
return fmt.Errorf("failed to save code")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OAuthExchangeCode retrieves and deletes an authorization code (single use).
|
||||
func (a *DatabaseAuthenticator) OAuthExchangeCode(ctx context.Context, code string) (*OAuthCode, error) {
|
||||
var success bool
|
||||
var errMsg *string
|
||||
var data []byte
|
||||
|
||||
err := a.db.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error, p_data::text
|
||||
FROM %s($1)
|
||||
`, a.sqlNames.OAuthExchangeCode), code).Scan(&success, &errMsg, &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
if !success {
|
||||
if errMsg != nil {
|
||||
return nil, fmt.Errorf("%s", *errMsg)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid or expired code")
|
||||
}
|
||||
|
||||
var result OAuthCode
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse code data: %w", err)
|
||||
}
|
||||
result.Code = code
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// OAuthIntrospectToken validates a token and returns its metadata (RFC 7662).
|
||||
func (a *DatabaseAuthenticator) OAuthIntrospectToken(ctx context.Context, token string) (*OAuthTokenInfo, error) {
|
||||
var success bool
|
||||
var errMsg *string
|
||||
var data []byte
|
||||
|
||||
err := a.db.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error, p_data::text
|
||||
FROM %s($1)
|
||||
`, a.sqlNames.OAuthIntrospect), token).Scan(&success, &errMsg, &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to introspect token: %w", err)
|
||||
}
|
||||
if !success {
|
||||
if errMsg != nil {
|
||||
return nil, fmt.Errorf("%s", *errMsg)
|
||||
}
|
||||
return nil, fmt.Errorf("introspection failed")
|
||||
}
|
||||
|
||||
var result OAuthTokenInfo
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token info: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// OAuthRevokeToken revokes a token by deleting the session (RFC 7009).
|
||||
func (a *DatabaseAuthenticator) OAuthRevokeToken(ctx context.Context, token string) error {
|
||||
var success bool
|
||||
var errMsg *string
|
||||
|
||||
err := a.db.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT p_success, p_error
|
||||
FROM %s($1)
|
||||
`, a.sqlNames.OAuthRevoke), token).Scan(&success, &errMsg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to revoke token: %w", err)
|
||||
}
|
||||
if !success {
|
||||
if errMsg != nil {
|
||||
return fmt.Errorf("%s", *errMsg)
|
||||
}
|
||||
return fmt.Errorf("failed to revoke token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PasskeyCredential represents a stored WebAuthn/FIDO2 credential
|
||||
type PasskeyCredential struct {
|
||||
ID string `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
CredentialID []byte `json:"credential_id"` // Raw credential ID from authenticator
|
||||
PublicKey []byte `json:"public_key"` // COSE public key
|
||||
AttestationType string `json:"attestation_type"` // none, indirect, direct
|
||||
AAGUID []byte `json:"aaguid"` // Authenticator AAGUID
|
||||
SignCount uint32 `json:"sign_count"` // Signature counter
|
||||
CloneWarning bool `json:"clone_warning"` // True if cloning detected
|
||||
Transports []string `json:"transports,omitempty"` // usb, nfc, ble, internal
|
||||
BackupEligible bool `json:"backup_eligible"` // Credential can be backed up
|
||||
BackupState bool `json:"backup_state"` // Credential is currently backed up
|
||||
Name string `json:"name,omitempty"` // User-friendly name
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
// PasskeyRegistrationOptions contains options for beginning passkey registration
|
||||
type PasskeyRegistrationOptions struct {
|
||||
Challenge []byte `json:"challenge"`
|
||||
RelyingParty PasskeyRelyingParty `json:"rp"`
|
||||
User PasskeyUser `json:"user"`
|
||||
PubKeyCredParams []PasskeyCredentialParam `json:"pubKeyCredParams"`
|
||||
Timeout int64 `json:"timeout,omitempty"` // Milliseconds
|
||||
ExcludeCredentials []PasskeyCredentialDescriptor `json:"excludeCredentials,omitempty"`
|
||||
AuthenticatorSelection *PasskeyAuthenticatorSelection `json:"authenticatorSelection,omitempty"`
|
||||
Attestation string `json:"attestation,omitempty"` // none, indirect, direct, enterprise
|
||||
Extensions map[string]any `json:"extensions,omitempty"`
|
||||
}
|
||||
|
||||
// PasskeyAuthenticationOptions contains options for beginning passkey authentication
|
||||
type PasskeyAuthenticationOptions struct {
|
||||
Challenge []byte `json:"challenge"`
|
||||
Timeout int64 `json:"timeout,omitempty"`
|
||||
RelyingPartyID string `json:"rpId,omitempty"`
|
||||
AllowCredentials []PasskeyCredentialDescriptor `json:"allowCredentials,omitempty"`
|
||||
UserVerification string `json:"userVerification,omitempty"` // required, preferred, discouraged
|
||||
Extensions map[string]any `json:"extensions,omitempty"`
|
||||
}
|
||||
|
||||
// PasskeyRelyingParty identifies the relying party
|
||||
type PasskeyRelyingParty struct {
|
||||
ID string `json:"id"` // Domain (e.g., "example.com")
|
||||
Name string `json:"name"` // Display name
|
||||
}
|
||||
|
||||
// PasskeyUser identifies the user
|
||||
type PasskeyUser struct {
|
||||
ID []byte `json:"id"` // User handle (unique, persistent)
|
||||
Name string `json:"name"` // Username
|
||||
DisplayName string `json:"displayName"` // Display name
|
||||
}
|
||||
|
||||
// PasskeyCredentialParam specifies supported public key algorithm
|
||||
type PasskeyCredentialParam struct {
|
||||
Type string `json:"type"` // "public-key"
|
||||
Alg int `json:"alg"` // COSE algorithm identifier (e.g., -7 for ES256, -257 for RS256)
|
||||
}
|
||||
|
||||
// PasskeyCredentialDescriptor describes a credential
|
||||
type PasskeyCredentialDescriptor struct {
|
||||
Type string `json:"type"` // "public-key"
|
||||
ID []byte `json:"id"` // Credential ID
|
||||
Transports []string `json:"transports,omitempty"` // usb, nfc, ble, internal
|
||||
}
|
||||
|
||||
// PasskeyAuthenticatorSelection specifies authenticator requirements
|
||||
type PasskeyAuthenticatorSelection struct {
|
||||
AuthenticatorAttachment string `json:"authenticatorAttachment,omitempty"` // platform, cross-platform
|
||||
RequireResidentKey bool `json:"requireResidentKey,omitempty"`
|
||||
ResidentKey string `json:"residentKey,omitempty"` // discouraged, preferred, required
|
||||
UserVerification string `json:"userVerification,omitempty"` // required, preferred, discouraged
|
||||
}
|
||||
|
||||
// PasskeyRegistrationResponse contains the client's registration response
|
||||
type PasskeyRegistrationResponse struct {
|
||||
ID string `json:"id"` // Base64URL encoded credential ID
|
||||
RawID []byte `json:"rawId"` // Raw credential ID
|
||||
Type string `json:"type"` // "public-key"
|
||||
Response PasskeyAuthenticatorAttestationResponse `json:"response"`
|
||||
ClientExtensionResults map[string]any `json:"clientExtensionResults,omitempty"`
|
||||
Transports []string `json:"transports,omitempty"`
|
||||
}
|
||||
|
||||
// PasskeyAuthenticatorAttestationResponse contains attestation data
|
||||
type PasskeyAuthenticatorAttestationResponse struct {
|
||||
ClientDataJSON []byte `json:"clientDataJSON"`
|
||||
AttestationObject []byte `json:"attestationObject"`
|
||||
Transports []string `json:"transports,omitempty"`
|
||||
}
|
||||
|
||||
// PasskeyAuthenticationResponse contains the client's authentication response
|
||||
type PasskeyAuthenticationResponse struct {
|
||||
ID string `json:"id"` // Base64URL encoded credential ID
|
||||
RawID []byte `json:"rawId"` // Raw credential ID
|
||||
Type string `json:"type"` // "public-key"
|
||||
Response PasskeyAuthenticatorAssertionResponse `json:"response"`
|
||||
ClientExtensionResults map[string]any `json:"clientExtensionResults,omitempty"`
|
||||
}
|
||||
|
||||
// PasskeyAuthenticatorAssertionResponse contains assertion data
|
||||
type PasskeyAuthenticatorAssertionResponse struct {
|
||||
ClientDataJSON []byte `json:"clientDataJSON"`
|
||||
AuthenticatorData []byte `json:"authenticatorData"`
|
||||
Signature []byte `json:"signature"`
|
||||
UserHandle []byte `json:"userHandle,omitempty"`
|
||||
}
|
||||
|
||||
// PasskeyProvider handles passkey registration and authentication
|
||||
type PasskeyProvider interface {
|
||||
// BeginRegistration creates registration options for a new passkey
|
||||
BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error)
|
||||
|
||||
// CompleteRegistration verifies and stores a new passkey credential
|
||||
CompleteRegistration(ctx context.Context, userID int, response PasskeyRegistrationResponse, expectedChallenge []byte) (*PasskeyCredential, error)
|
||||
|
||||
// BeginAuthentication creates authentication options for passkey login
|
||||
BeginAuthentication(ctx context.Context, username string) (*PasskeyAuthenticationOptions, error)
|
||||
|
||||
// CompleteAuthentication verifies a passkey assertion and returns the user
|
||||
CompleteAuthentication(ctx context.Context, response PasskeyAuthenticationResponse, expectedChallenge []byte) (int, error)
|
||||
|
||||
// GetCredentials returns all passkey credentials for a user
|
||||
GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error)
|
||||
|
||||
// DeleteCredential removes a passkey credential
|
||||
DeleteCredential(ctx context.Context, userID int, credentialID string) error
|
||||
|
||||
// UpdateCredentialName updates the friendly name of a credential
|
||||
UpdateCredentialName(ctx context.Context, userID int, credentialID string, name string) error
|
||||
}
|
||||
|
||||
// PasskeyLoginRequest contains passkey authentication data
|
||||
type PasskeyLoginRequest struct {
|
||||
Response PasskeyAuthenticationResponse `json:"response"`
|
||||
ExpectedChallenge []byte `json:"expected_challenge"`
|
||||
Claims map[string]any `json:"claims"` // Additional login data
|
||||
}
|
||||
|
||||
// PasskeyRegisterRequest contains passkey registration data
|
||||
type PasskeyRegisterRequest struct {
|
||||
UserID int `json:"user_id"`
|
||||
Response PasskeyRegistrationResponse `json:"response"`
|
||||
ExpectedChallenge []byte `json:"expected_challenge"`
|
||||
CredentialName string `json:"credential_name,omitempty"`
|
||||
}
|
||||
|
||||
// PasskeyBeginRegistrationRequest contains options for starting passkey registration
|
||||
type PasskeyBeginRegistrationRequest struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// PasskeyBeginAuthenticationRequest contains options for starting passkey authentication
|
||||
type PasskeyBeginAuthenticationRequest struct {
|
||||
Username string `json:"username,omitempty"` // Optional for resident key flow
|
||||
}
|
||||
|
||||
// ParsePasskeyRegistrationResponse parses a JSON passkey registration response
|
||||
func ParsePasskeyRegistrationResponse(data []byte) (*PasskeyRegistrationResponse, error) {
|
||||
var response PasskeyRegistrationResponse
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// ParsePasskeyAuthenticationResponse parses a JSON passkey authentication response
|
||||
func ParsePasskeyAuthenticationResponse(data []byte) (*PasskeyAuthenticationResponse, error) {
|
||||
var response PasskeyAuthenticationResponse
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// PasskeyAuthenticationExample demonstrates passkey (WebAuthn/FIDO2) authentication
|
||||
func PasskeyAuthenticationExample() {
|
||||
// Setup database connection
|
||||
db, _ := sql.Open("postgres", "postgres://user:pass@localhost/db")
|
||||
|
||||
// Create passkey provider
|
||||
passkeyProvider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
|
||||
RPID: "example.com", // Your domain
|
||||
RPName: "Example Application", // Display name
|
||||
RPOrigin: "https://example.com", // Expected origin
|
||||
Timeout: 60000, // 60 seconds
|
||||
})
|
||||
|
||||
// Create authenticator with passkey support
|
||||
// Option 1: Pass during creation
|
||||
_ = NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{
|
||||
PasskeyProvider: passkeyProvider,
|
||||
})
|
||||
|
||||
// Option 2: Use WithPasskey method
|
||||
auth := NewDatabaseAuthenticator(db).WithPasskey(passkeyProvider)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// === REGISTRATION FLOW ===
|
||||
|
||||
// Step 1: Begin registration
|
||||
regOptions, _ := auth.BeginPasskeyRegistration(ctx, PasskeyBeginRegistrationRequest{
|
||||
UserID: 1,
|
||||
Username: "alice",
|
||||
DisplayName: "Alice Smith",
|
||||
})
|
||||
|
||||
// Send regOptions to client as JSON
|
||||
// Client will call navigator.credentials.create() with these options
|
||||
_ = regOptions
|
||||
|
||||
// Step 2: Complete registration (after client returns credential)
|
||||
// This would come from the client's navigator.credentials.create() response
|
||||
clientResponse := PasskeyRegistrationResponse{
|
||||
ID: "base64-credential-id",
|
||||
RawID: []byte("raw-credential-id"),
|
||||
Type: "public-key",
|
||||
Response: PasskeyAuthenticatorAttestationResponse{
|
||||
ClientDataJSON: []byte("..."),
|
||||
AttestationObject: []byte("..."),
|
||||
},
|
||||
Transports: []string{"internal"},
|
||||
}
|
||||
|
||||
credential, _ := auth.CompletePasskeyRegistration(ctx, PasskeyRegisterRequest{
|
||||
UserID: 1,
|
||||
Response: clientResponse,
|
||||
ExpectedChallenge: regOptions.Challenge,
|
||||
CredentialName: "My iPhone",
|
||||
})
|
||||
|
||||
fmt.Printf("Registered credential: %s\n", credential.ID)
|
||||
|
||||
// === AUTHENTICATION FLOW ===
|
||||
|
||||
// Step 1: Begin authentication
|
||||
authOptions, _ := auth.BeginPasskeyAuthentication(ctx, PasskeyBeginAuthenticationRequest{
|
||||
Username: "alice", // Optional - omit for resident key flow
|
||||
})
|
||||
|
||||
// Send authOptions to client as JSON
|
||||
// Client will call navigator.credentials.get() with these options
|
||||
_ = authOptions
|
||||
|
||||
// Step 2: Complete authentication (after client returns assertion)
|
||||
// This would come from the client's navigator.credentials.get() response
|
||||
clientAssertion := PasskeyAuthenticationResponse{
|
||||
ID: "base64-credential-id",
|
||||
RawID: []byte("raw-credential-id"),
|
||||
Type: "public-key",
|
||||
Response: PasskeyAuthenticatorAssertionResponse{
|
||||
ClientDataJSON: []byte("..."),
|
||||
AuthenticatorData: []byte("..."),
|
||||
Signature: []byte("..."),
|
||||
},
|
||||
}
|
||||
|
||||
loginResponse, _ := auth.LoginWithPasskey(ctx, PasskeyLoginRequest{
|
||||
Response: clientAssertion,
|
||||
ExpectedChallenge: authOptions.Challenge,
|
||||
Claims: map[string]any{
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
},
|
||||
})
|
||||
|
||||
fmt.Printf("Logged in user: %s with token: %s\n",
|
||||
loginResponse.User.UserName, loginResponse.Token)
|
||||
|
||||
// === CREDENTIAL MANAGEMENT ===
|
||||
|
||||
// Get all credentials for a user
|
||||
credentials, _ := auth.GetPasskeyCredentials(ctx, 1)
|
||||
for i := range credentials {
|
||||
fmt.Printf("Credential: %s (created: %s, last used: %s)\n",
|
||||
credentials[i].Name, credentials[i].CreatedAt, credentials[i].LastUsedAt)
|
||||
}
|
||||
|
||||
// Update credential name
|
||||
_ = auth.UpdatePasskeyCredentialName(ctx, 1, credential.ID, "My New iPhone")
|
||||
|
||||
// Delete credential
|
||||
_ = auth.DeletePasskeyCredential(ctx, 1, credential.ID)
|
||||
}
|
||||
|
||||
// PasskeyHTTPHandlersExample shows HTTP handlers for passkey authentication
|
||||
func PasskeyHTTPHandlersExample(auth *DatabaseAuthenticator) {
|
||||
// Store challenges in session/cache in production
|
||||
challenges := make(map[string][]byte)
|
||||
|
||||
// Begin registration endpoint
|
||||
http.HandleFunc("/api/passkey/register/begin", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
options, err := auth.BeginPasskeyRegistration(r.Context(), PasskeyBeginRegistrationRequest{
|
||||
UserID: req.UserID,
|
||||
Username: req.Username,
|
||||
DisplayName: req.DisplayName,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Store challenge for verification (use session ID as key in production)
|
||||
sessionID := "session-123"
|
||||
challenges[sessionID] = options.Challenge
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(options)
|
||||
})
|
||||
|
||||
// Complete registration endpoint
|
||||
http.HandleFunc("/api/passkey/register/complete", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
UserID int `json:"user_id"`
|
||||
Response PasskeyRegistrationResponse `json:"response"`
|
||||
CredentialName string `json:"credential_name"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Get stored challenge (from session in production)
|
||||
sessionID := "session-123"
|
||||
challenge := challenges[sessionID]
|
||||
delete(challenges, sessionID)
|
||||
|
||||
credential, err := auth.CompletePasskeyRegistration(r.Context(), PasskeyRegisterRequest{
|
||||
UserID: req.UserID,
|
||||
Response: req.Response,
|
||||
ExpectedChallenge: challenge,
|
||||
CredentialName: req.CredentialName,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(credential)
|
||||
})
|
||||
|
||||
// Begin authentication endpoint
|
||||
http.HandleFunc("/api/passkey/login/begin", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"` // Optional
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
options, err := auth.BeginPasskeyAuthentication(r.Context(), PasskeyBeginAuthenticationRequest{
|
||||
Username: req.Username,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Store challenge for verification (use session ID as key in production)
|
||||
sessionID := "session-456"
|
||||
challenges[sessionID] = options.Challenge
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(options)
|
||||
})
|
||||
|
||||
// Complete authentication endpoint
|
||||
http.HandleFunc("/api/passkey/login/complete", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Response PasskeyAuthenticationResponse `json:"response"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Get stored challenge (from session in production)
|
||||
sessionID := "session-456"
|
||||
challenge := challenges[sessionID]
|
||||
delete(challenges, sessionID)
|
||||
|
||||
loginResponse, err := auth.LoginWithPasskey(r.Context(), PasskeyLoginRequest{
|
||||
Response: req.Response,
|
||||
ExpectedChallenge: challenge,
|
||||
Claims: map[string]any{
|
||||
"ip_address": r.RemoteAddr,
|
||||
"user_agent": r.UserAgent(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Set session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: loginResponse.Token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(loginResponse)
|
||||
})
|
||||
|
||||
// List credentials endpoint
|
||||
http.HandleFunc("/api/passkey/credentials", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get user from authenticated session
|
||||
userCtx, err := auth.Authenticate(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
credentials, err := auth.GetPasskeyCredentials(r.Context(), userCtx.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(credentials)
|
||||
})
|
||||
|
||||
// Delete credential endpoint
|
||||
http.HandleFunc("/api/passkey/credentials/delete", func(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx, err := auth.Authenticate(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CredentialID string `json:"credential_id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
err = auth.DeletePasskeyCredential(r.Context(), userCtx.UserID, req.CredentialID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
// PasskeyClientSideExample shows the client-side JavaScript code needed
|
||||
func PasskeyClientSideExample() string {
|
||||
return `
|
||||
// === CLIENT-SIDE JAVASCRIPT FOR PASSKEY AUTHENTICATION ===
|
||||
|
||||
// Helper function to convert base64 to ArrayBuffer
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// Helper function to convert ArrayBuffer to base64
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
// === REGISTRATION ===
|
||||
|
||||
async function registerPasskey(userId, username, displayName) {
|
||||
// Step 1: Get registration options from server
|
||||
const optionsResponse = await fetch('/api/passkey/register/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, username, display_name: displayName })
|
||||
});
|
||||
const options = await optionsResponse.json();
|
||||
|
||||
// Convert base64 strings to ArrayBuffers
|
||||
options.challenge = base64ToArrayBuffer(options.challenge);
|
||||
options.user.id = base64ToArrayBuffer(options.user.id);
|
||||
if (options.excludeCredentials) {
|
||||
options.excludeCredentials = options.excludeCredentials.map(cred => ({
|
||||
...cred,
|
||||
id: base64ToArrayBuffer(cred.id)
|
||||
}));
|
||||
}
|
||||
|
||||
// Step 2: Create credential using WebAuthn API
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: options
|
||||
});
|
||||
|
||||
// Step 3: Send credential to server
|
||||
const credentialResponse = {
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||
attestationObject: arrayBufferToBase64(credential.response.attestationObject)
|
||||
},
|
||||
transports: credential.response.getTransports ? credential.response.getTransports() : []
|
||||
};
|
||||
|
||||
const completeResponse = await fetch('/api/passkey/register/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
response: credentialResponse,
|
||||
credential_name: 'My Device'
|
||||
})
|
||||
});
|
||||
|
||||
return await completeResponse.json();
|
||||
}
|
||||
|
||||
// === AUTHENTICATION ===
|
||||
|
||||
async function loginWithPasskey(username) {
|
||||
// Step 1: Get authentication options from server
|
||||
const optionsResponse = await fetch('/api/passkey/login/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
const options = await optionsResponse.json();
|
||||
|
||||
// Convert base64 strings to ArrayBuffers
|
||||
options.challenge = base64ToArrayBuffer(options.challenge);
|
||||
if (options.allowCredentials) {
|
||||
options.allowCredentials = options.allowCredentials.map(cred => ({
|
||||
...cred,
|
||||
id: base64ToArrayBuffer(cred.id)
|
||||
}));
|
||||
}
|
||||
|
||||
// Step 2: Get credential using WebAuthn API
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: options
|
||||
});
|
||||
|
||||
// Step 3: Send assertion to server
|
||||
const assertionResponse = {
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||
authenticatorData: arrayBufferToBase64(credential.response.authenticatorData),
|
||||
signature: arrayBufferToBase64(credential.response.signature),
|
||||
userHandle: credential.response.userHandle ? arrayBufferToBase64(credential.response.userHandle) : null
|
||||
}
|
||||
};
|
||||
|
||||
const loginResponse = await fetch('/api/passkey/login/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ response: assertionResponse })
|
||||
});
|
||||
|
||||
return await loginResponse.json();
|
||||
}
|
||||
|
||||
// === USAGE ===
|
||||
|
||||
// Register a new passkey
|
||||
document.getElementById('register-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
const result = await registerPasskey(1, 'alice', 'Alice Smith');
|
||||
console.log('Passkey registered:', result);
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Login with passkey
|
||||
document.getElementById('login-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
const result = await loginWithPasskey('alice');
|
||||
console.log('Logged in:', result);
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
}
|
||||
});
|
||||
`
|
||||
}
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DatabasePasskeyProvider implements PasskeyProvider using database storage
|
||||
// Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults)
|
||||
type DatabasePasskeyProvider struct {
|
||||
db *sql.DB
|
||||
dbMu sync.RWMutex
|
||||
dbFactory func() (*sql.DB, error)
|
||||
rpID string // Relying Party ID (domain)
|
||||
rpName string // Relying Party display name
|
||||
rpOrigin string // Expected origin for WebAuthn
|
||||
timeout int64 // Timeout in milliseconds (default: 60000)
|
||||
sqlNames *SQLNames
|
||||
}
|
||||
|
||||
// DatabasePasskeyProviderOptions configures the passkey provider
|
||||
type DatabasePasskeyProviderOptions struct {
|
||||
// RPID is the Relying Party ID (typically your domain, e.g., "example.com")
|
||||
RPID string
|
||||
// RPName is the display name for your relying party
|
||||
RPName string
|
||||
// RPOrigin is the expected origin (e.g., "https://example.com")
|
||||
RPOrigin string
|
||||
// Timeout is the timeout for operations in milliseconds (default: 60000)
|
||||
Timeout int64
|
||||
// SQLNames provides custom SQL procedure/function names. If nil, uses DefaultSQLNames().
|
||||
SQLNames *SQLNames
|
||||
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
|
||||
// If nil, reconnection is disabled.
|
||||
DBFactory func() (*sql.DB, error)
|
||||
}
|
||||
|
||||
// NewDatabasePasskeyProvider creates a new database-backed passkey provider
|
||||
func NewDatabasePasskeyProvider(db *sql.DB, opts DatabasePasskeyProviderOptions) *DatabasePasskeyProvider {
|
||||
if opts.Timeout == 0 {
|
||||
opts.Timeout = 60000 // 60 seconds default
|
||||
}
|
||||
|
||||
sqlNames := MergeSQLNames(DefaultSQLNames(), opts.SQLNames)
|
||||
|
||||
return &DatabasePasskeyProvider{
|
||||
db: db,
|
||||
dbFactory: opts.DBFactory,
|
||||
rpID: opts.RPID,
|
||||
rpName: opts.RPName,
|
||||
rpOrigin: opts.RPOrigin,
|
||||
timeout: opts.Timeout,
|
||||
sqlNames: sqlNames,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DatabasePasskeyProvider) getDB() *sql.DB {
|
||||
p.dbMu.RLock()
|
||||
defer p.dbMu.RUnlock()
|
||||
return p.db
|
||||
}
|
||||
|
||||
func (p *DatabasePasskeyProvider) reconnectDB() error {
|
||||
if p.dbFactory == nil {
|
||||
return fmt.Errorf("no db factory configured for reconnect")
|
||||
}
|
||||
newDB, err := p.dbFactory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.dbMu.Lock()
|
||||
p.db = newDB
|
||||
p.dbMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeginRegistration creates registration options for a new passkey
|
||||
func (p *DatabasePasskeyProvider) BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error) {
|
||||
// Generate challenge
|
||||
challenge := make([]byte, 32)
|
||||
if _, err := rand.Read(challenge); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate challenge: %w", err)
|
||||
}
|
||||
|
||||
// Get existing credentials to exclude
|
||||
credentials, err := p.GetCredentials(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get existing credentials: %w", err)
|
||||
}
|
||||
|
||||
excludeCredentials := make([]PasskeyCredentialDescriptor, 0, len(credentials))
|
||||
for i := range credentials {
|
||||
excludeCredentials = append(excludeCredentials, PasskeyCredentialDescriptor{
|
||||
Type: "public-key",
|
||||
ID: credentials[i].CredentialID,
|
||||
Transports: credentials[i].Transports,
|
||||
})
|
||||
}
|
||||
|
||||
// Create user handle (persistent user ID)
|
||||
userHandle := []byte(fmt.Sprintf("user_%d", userID))
|
||||
|
||||
return &PasskeyRegistrationOptions{
|
||||
Challenge: challenge,
|
||||
RelyingParty: PasskeyRelyingParty{
|
||||
ID: p.rpID,
|
||||
Name: p.rpName,
|
||||
},
|
||||
User: PasskeyUser{
|
||||
ID: userHandle,
|
||||
Name: username,
|
||||
DisplayName: displayName,
|
||||
},
|
||||
PubKeyCredParams: []PasskeyCredentialParam{
|
||||
{Type: "public-key", Alg: -7}, // ES256 (ECDSA with SHA-256)
|
||||
{Type: "public-key", Alg: -257}, // RS256 (RSASSA-PKCS1-v1_5 with SHA-256)
|
||||
},
|
||||
Timeout: p.timeout,
|
||||
ExcludeCredentials: excludeCredentials,
|
||||
AuthenticatorSelection: &PasskeyAuthenticatorSelection{
|
||||
RequireResidentKey: false,
|
||||
ResidentKey: "preferred",
|
||||
UserVerification: "preferred",
|
||||
},
|
||||
Attestation: "none",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CompleteRegistration verifies and stores a new passkey credential
|
||||
// NOTE: This is a simplified implementation. In production, you should use a WebAuthn library
|
||||
// like github.com/go-webauthn/webauthn to properly verify attestation and parse credentials.
|
||||
func (p *DatabasePasskeyProvider) CompleteRegistration(ctx context.Context, userID int, response PasskeyRegistrationResponse, expectedChallenge []byte) (*PasskeyCredential, error) {
|
||||
// TODO: Implement full WebAuthn verification
|
||||
// 1. Verify clientDataJSON contains correct challenge and origin
|
||||
// 2. Parse and verify attestationObject
|
||||
// 3. Extract public key and credential ID
|
||||
// 4. Verify attestation signature (if not "none")
|
||||
|
||||
// For now, this is a placeholder that stores the credential data
|
||||
// In production, you MUST use a proper WebAuthn library
|
||||
|
||||
credData := map[string]any{
|
||||
"user_id": userID,
|
||||
"credential_id": base64.StdEncoding.EncodeToString(response.RawID),
|
||||
"public_key": base64.StdEncoding.EncodeToString(response.Response.AttestationObject),
|
||||
"attestation_type": "none",
|
||||
"sign_count": 0,
|
||||
"transports": response.Transports,
|
||||
"backup_eligible": false,
|
||||
"backup_state": false,
|
||||
"name": "Passkey",
|
||||
}
|
||||
|
||||
credJSON, err := json.Marshal(credData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal credential data: %w", err)
|
||||
}
|
||||
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var credentialID sql.NullInt64
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_credential_id FROM %s($1::jsonb)`, p.sqlNames.PasskeyStoreCredential)
|
||||
err = p.getDB().QueryRowContext(ctx, query, string(credJSON)).Scan(&success, &errorMsg, &credentialID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store credential: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return nil, fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to store credential")
|
||||
}
|
||||
|
||||
return &PasskeyCredential{
|
||||
ID: fmt.Sprintf("%d", credentialID.Int64),
|
||||
UserID: userID,
|
||||
CredentialID: response.RawID,
|
||||
PublicKey: response.Response.AttestationObject,
|
||||
AttestationType: "none",
|
||||
Transports: response.Transports,
|
||||
CreatedAt: time.Now(),
|
||||
LastUsedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BeginAuthentication creates authentication options for passkey login
|
||||
func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, username string) (*PasskeyAuthenticationOptions, error) {
|
||||
// Generate challenge
|
||||
challenge := make([]byte, 32)
|
||||
if _, err := rand.Read(challenge); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate challenge: %w", err)
|
||||
}
|
||||
|
||||
// If username is provided, get user's credentials
|
||||
var allowCredentials []PasskeyCredentialDescriptor
|
||||
if username != "" {
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var userID sql.NullInt64
|
||||
var credentialsJSON sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_user_id, p_credentials::text FROM %s($1)`, p.sqlNames.PasskeyGetCredsByUsername)
|
||||
err := p.getDB().QueryRowContext(ctx, query, username).Scan(&success, &errorMsg, &userID, &credentialsJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get credentials: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return nil, fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get credentials")
|
||||
}
|
||||
|
||||
// Parse credentials
|
||||
var creds []struct {
|
||||
ID string `json:"credential_id"`
|
||||
Transports []string `json:"transports"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(credentialsJSON.String), &creds); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse credentials: %w", err)
|
||||
}
|
||||
|
||||
allowCredentials = make([]PasskeyCredentialDescriptor, 0, len(creds))
|
||||
for _, cred := range creds {
|
||||
credID, err := base64.StdEncoding.DecodeString(cred.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
allowCredentials = append(allowCredentials, PasskeyCredentialDescriptor{
|
||||
Type: "public-key",
|
||||
ID: credID,
|
||||
Transports: cred.Transports,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &PasskeyAuthenticationOptions{
|
||||
Challenge: challenge,
|
||||
Timeout: p.timeout,
|
||||
RelyingPartyID: p.rpID,
|
||||
AllowCredentials: allowCredentials,
|
||||
UserVerification: "preferred",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CompleteAuthentication verifies a passkey assertion and returns the user ID
|
||||
// NOTE: This is a simplified implementation. In production, you should use a WebAuthn library
|
||||
// like github.com/go-webauthn/webauthn to properly verify the assertion signature.
|
||||
func (p *DatabasePasskeyProvider) CompleteAuthentication(ctx context.Context, response PasskeyAuthenticationResponse, expectedChallenge []byte) (int, error) {
|
||||
// TODO: Implement full WebAuthn verification
|
||||
// 1. Verify clientDataJSON contains correct challenge and origin
|
||||
// 2. Verify authenticatorData
|
||||
// 3. Verify signature using stored public key
|
||||
// 4. Update sign counter and check for cloning
|
||||
|
||||
// Get credential from database
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var credentialJSON sql.NullString
|
||||
|
||||
runQuery := func() error {
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_credential::text FROM %s($1)`, p.sqlNames.PasskeyGetCredential)
|
||||
return p.getDB().QueryRowContext(ctx, query, response.RawID).Scan(&success, &errorMsg, &credentialJSON)
|
||||
}
|
||||
err := runQuery()
|
||||
if isDBClosed(err) {
|
||||
if reconnErr := p.reconnectDB(); reconnErr == nil {
|
||||
err = runQuery()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get credential: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return 0, fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return 0, fmt.Errorf("credential not found")
|
||||
}
|
||||
|
||||
// Parse credential
|
||||
var cred struct {
|
||||
UserID int `json:"user_id"`
|
||||
SignCount uint32 `json:"sign_count"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(credentialJSON.String), &cred); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse credential: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Verify signature here
|
||||
// For now, we'll just update the counter as a placeholder
|
||||
|
||||
// Update counter (in production, this should be done after successful verification)
|
||||
newCounter := cred.SignCount + 1
|
||||
var updateSuccess bool
|
||||
var updateError sql.NullString
|
||||
var cloneWarning sql.NullBool
|
||||
|
||||
updateQuery := fmt.Sprintf(`SELECT p_success, p_error, p_clone_warning FROM %s($1, $2)`, p.sqlNames.PasskeyUpdateCounter)
|
||||
err = p.getDB().QueryRowContext(ctx, updateQuery, response.RawID, newCounter).Scan(&updateSuccess, &updateError, &cloneWarning)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to update counter: %w", err)
|
||||
}
|
||||
|
||||
if cloneWarning.Valid && cloneWarning.Bool {
|
||||
return 0, fmt.Errorf("credential cloning detected")
|
||||
}
|
||||
|
||||
return cred.UserID, nil
|
||||
}
|
||||
|
||||
// GetCredentials returns all passkey credentials for a user
|
||||
func (p *DatabasePasskeyProvider) GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) {
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var credentialsJSON sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_credentials::text FROM %s($1)`, p.sqlNames.PasskeyGetUserCredentials)
|
||||
err := p.getDB().QueryRowContext(ctx, query, userID).Scan(&success, &errorMsg, &credentialsJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get credentials: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return nil, fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get credentials")
|
||||
}
|
||||
|
||||
// Parse credentials
|
||||
var rawCreds []struct {
|
||||
ID int `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
CredentialID string `json:"credential_id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
AttestationType string `json:"attestation_type"`
|
||||
AAGUID string `json:"aaguid"`
|
||||
SignCount uint32 `json:"sign_count"`
|
||||
CloneWarning bool `json:"clone_warning"`
|
||||
Transports []string `json:"transports"`
|
||||
BackupEligible bool `json:"backup_eligible"`
|
||||
BackupState bool `json:"backup_state"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(credentialsJSON.String), &rawCreds); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse credentials: %w", err)
|
||||
}
|
||||
|
||||
credentials := make([]PasskeyCredential, 0, len(rawCreds))
|
||||
for i := range rawCreds {
|
||||
raw := rawCreds[i]
|
||||
credID, err := base64.StdEncoding.DecodeString(raw.CredentialID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pubKey, err := base64.StdEncoding.DecodeString(raw.PublicKey)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
aaguid, _ := base64.StdEncoding.DecodeString(raw.AAGUID)
|
||||
|
||||
credentials = append(credentials, PasskeyCredential{
|
||||
ID: fmt.Sprintf("%d", raw.ID),
|
||||
UserID: raw.UserID,
|
||||
CredentialID: credID,
|
||||
PublicKey: pubKey,
|
||||
AttestationType: raw.AttestationType,
|
||||
AAGUID: aaguid,
|
||||
SignCount: raw.SignCount,
|
||||
CloneWarning: raw.CloneWarning,
|
||||
Transports: raw.Transports,
|
||||
BackupEligible: raw.BackupEligible,
|
||||
BackupState: raw.BackupState,
|
||||
Name: raw.Name,
|
||||
CreatedAt: raw.CreatedAt,
|
||||
LastUsedAt: raw.LastUsedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
// DeleteCredential removes a passkey credential
|
||||
func (p *DatabasePasskeyProvider) DeleteCredential(ctx context.Context, userID int, credentialID string) error {
|
||||
credID, err := base64.StdEncoding.DecodeString(credentialID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid credential ID: %w", err)
|
||||
}
|
||||
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2)`, p.sqlNames.PasskeyDeleteCredential)
|
||||
err = p.getDB().QueryRowContext(ctx, query, userID, credID).Scan(&success, &errorMsg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete credential: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return fmt.Errorf("failed to delete credential")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCredentialName updates the friendly name of a credential
|
||||
func (p *DatabasePasskeyProvider) UpdateCredentialName(ctx context.Context, userID int, credentialID string, name string) error {
|
||||
credID, err := base64.StdEncoding.DecodeString(credentialID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid credential ID: %w", err)
|
||||
}
|
||||
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2, $3)`, p.sqlNames.PasskeyUpdateName)
|
||||
err = p.getDB().QueryRowContext(ctx, query, userID, credID, name).Scan(&success, &errorMsg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update credential name: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return fmt.Errorf("failed to update credential name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
type ColumnSecurity struct {
|
||||
Schema string `json:"schema"`
|
||||
Tablename string `json:"tablename"`
|
||||
Path []string `json:"path"`
|
||||
ExtraFilters map[string]string `json:"extra_filters"`
|
||||
UserID int `json:"user_id"`
|
||||
Accesstype string `json:"accesstype"`
|
||||
MaskStart int `json:"mask_start"`
|
||||
MaskEnd int `json:"mask_end"`
|
||||
MaskInvert bool `json:"mask_invert"`
|
||||
MaskChar string `json:"mask_char"`
|
||||
Control string `json:"control"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type RowSecurity struct {
|
||||
Schema string `json:"schema"`
|
||||
Tablename string `json:"tablename"`
|
||||
Template string `json:"template"`
|
||||
HasBlock bool `json:"has_block"`
|
||||
UserID int `json:"user_id"`
|
||||
}
|
||||
|
||||
func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string {
|
||||
str := m.Template
|
||||
str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName)
|
||||
str = strings.ReplaceAll(str, "{TableName}", m.Tablename)
|
||||
str = strings.ReplaceAll(str, "{SchemaName}", m.Schema)
|
||||
str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%d", m.UserID))
|
||||
return str
|
||||
}
|
||||
|
||||
// SecurityList manages security state and caching
|
||||
// It wraps a SecurityProvider and provides caching and utility methods
|
||||
type SecurityList struct {
|
||||
provider SecurityProvider
|
||||
|
||||
ColumnSecurityMutex sync.RWMutex
|
||||
ColumnSecurity map[string][]ColumnSecurity
|
||||
RowSecurityMutex sync.RWMutex
|
||||
RowSecurity map[string]RowSecurity
|
||||
}
|
||||
|
||||
// NewSecurityList creates a new security list with the given provider
|
||||
func NewSecurityList(provider SecurityProvider) (*SecurityList, error) {
|
||||
if provider == nil {
|
||||
return nil, fmt.Errorf("security provider cannot be nil")
|
||||
}
|
||||
|
||||
return &SecurityList{
|
||||
provider: provider,
|
||||
ColumnSecurity: make(map[string][]ColumnSecurity),
|
||||
RowSecurity: make(map[string]RowSecurity),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Provider returns the underlying security provider
|
||||
func (m *SecurityList) Provider() SecurityProvider {
|
||||
return m.provider
|
||||
}
|
||||
|
||||
type CONTEXT_KEY string
|
||||
|
||||
const SECURITY_CONTEXT_KEY CONTEXT_KEY = "SecurityList"
|
||||
|
||||
func maskString(pString string, maskStart, maskEnd int, maskChar string, invert bool) string {
|
||||
strLen := len(pString)
|
||||
middleIndex := (strLen / 2)
|
||||
newStr := ""
|
||||
if maskStart == 0 && maskEnd == 0 {
|
||||
maskStart = strLen
|
||||
maskEnd = strLen
|
||||
}
|
||||
if maskEnd > strLen {
|
||||
maskEnd = strLen
|
||||
}
|
||||
if maskStart > strLen {
|
||||
maskStart = strLen
|
||||
}
|
||||
if maskChar == "" {
|
||||
maskChar = "*"
|
||||
}
|
||||
for index, char := range pString {
|
||||
if invert && index >= middleIndex-maskStart && index <= middleIndex {
|
||||
newStr += maskChar
|
||||
continue
|
||||
}
|
||||
if invert && index <= middleIndex+maskEnd && index >= middleIndex {
|
||||
newStr += maskChar
|
||||
continue
|
||||
}
|
||||
if !invert && index <= maskStart {
|
||||
newStr += maskChar
|
||||
continue
|
||||
}
|
||||
if !invert && index >= strLen-1-maskEnd {
|
||||
newStr += maskChar
|
||||
continue
|
||||
}
|
||||
newStr += string(char)
|
||||
}
|
||||
|
||||
return newStr
|
||||
}
|
||||
|
||||
func (m *SecurityList) ColumSecurityApplyOnRecord(prevRecord reflect.Value, newRecord reflect.Value, modelType reflect.Type, pUserID int, pSchema, pTablename string) ([]string, error) {
|
||||
cols := make([]string, 0)
|
||||
if m.ColumnSecurity == nil {
|
||||
return cols, fmt.Errorf("security not initialized")
|
||||
}
|
||||
|
||||
if prevRecord.Type() != newRecord.Type() {
|
||||
logger.Error("prev:%s and new:%s record type mismatch", prevRecord.Type(), newRecord.Type())
|
||||
return cols, fmt.Errorf("prev and new record type mismatch")
|
||||
}
|
||||
|
||||
m.ColumnSecurityMutex.RLock()
|
||||
defer m.ColumnSecurityMutex.RUnlock()
|
||||
|
||||
colsecList, ok := m.ColumnSecurity[fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)]
|
||||
if !ok || colsecList == nil {
|
||||
return cols, fmt.Errorf("no column security data")
|
||||
}
|
||||
|
||||
for i := range colsecList {
|
||||
colsec := &colsecList[i]
|
||||
if !strings.EqualFold(colsec.Accesstype, "mask") && !strings.EqualFold(colsec.Accesstype, "hide") {
|
||||
continue
|
||||
}
|
||||
lastRecords := interateStruct(prevRecord)
|
||||
newRecords := interateStruct(newRecord)
|
||||
var lastLoopField, lastLoopNewField reflect.Value
|
||||
pathLen := len(colsec.Path)
|
||||
for i, path := range colsec.Path {
|
||||
var nameType, fieldName string
|
||||
if len(newRecords) == 0 {
|
||||
if lastLoopNewField.IsValid() && lastLoopField.IsValid() && i < pathLen-1 {
|
||||
lastLoopNewField.Set(lastLoopField)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for ri := range newRecords {
|
||||
if !newRecords[ri].IsValid() || !lastRecords[ri].IsValid() {
|
||||
break
|
||||
}
|
||||
var field, oldField reflect.Value
|
||||
|
||||
columnData := reflection.GetModelColumnDetail(newRecords[ri])
|
||||
lastColumnData := reflection.GetModelColumnDetail(lastRecords[ri])
|
||||
for i, cols := range columnData {
|
||||
if cols.SQLName != "" && strings.EqualFold(cols.SQLName, path) {
|
||||
nameType = "sql"
|
||||
fieldName = cols.SQLName
|
||||
field = cols.FieldValue
|
||||
oldField = lastColumnData[i].FieldValue
|
||||
break
|
||||
}
|
||||
if cols.Name != "" && strings.EqualFold(cols.Name, path) {
|
||||
nameType = "struct"
|
||||
fieldName = cols.Name
|
||||
field = cols.FieldValue
|
||||
oldField = lastColumnData[i].FieldValue
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !field.IsValid() || !oldField.IsValid() {
|
||||
break
|
||||
}
|
||||
lastLoopField = oldField
|
||||
lastLoopNewField = field
|
||||
|
||||
if i == pathLen-1 {
|
||||
if strings.Contains(strings.ToLower(fieldName), "json") {
|
||||
prevSrc := oldField.Bytes()
|
||||
newSrc := field.Bytes()
|
||||
pathstr := strings.Join(colsec.Path, ".")
|
||||
prevPathValue := gjson.GetBytes(prevSrc, pathstr)
|
||||
newBytes, err := sjson.SetBytes(newSrc, pathstr, prevPathValue.Str)
|
||||
if err == nil {
|
||||
if field.CanSet() {
|
||||
field.SetBytes(newBytes)
|
||||
} else {
|
||||
logger.Warn("Value not settable: %v", field)
|
||||
cols = append(cols, pathstr)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if nameType == "sql" {
|
||||
if strings.EqualFold(colsec.Accesstype, "mask") || strings.EqualFold(colsec.Accesstype, "hide") {
|
||||
field.Set(oldField)
|
||||
cols = append(cols, strings.Join(colsec.Path, "."))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
lastRecords = interateStruct(field)
|
||||
newRecords = interateStruct(oldField)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cols, nil
|
||||
}
|
||||
|
||||
func interateStruct(val reflect.Value) []reflect.Value {
|
||||
list := make([]reflect.Value, 0)
|
||||
|
||||
switch val.Kind() {
|
||||
case reflect.Pointer, reflect.Interface:
|
||||
elem := val.Elem()
|
||||
if elem.IsValid() {
|
||||
list = append(list, interateStruct(elem)...)
|
||||
}
|
||||
return list
|
||||
case reflect.Array, reflect.Slice:
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
elem := val.Index(i)
|
||||
if !elem.IsValid() {
|
||||
continue
|
||||
}
|
||||
list = append(list, interateStruct(elem)...)
|
||||
}
|
||||
return list
|
||||
case reflect.Struct:
|
||||
list = append(list, val)
|
||||
return list
|
||||
default:
|
||||
return list
|
||||
}
|
||||
}
|
||||
|
||||
func setColSecValue(fieldsrc reflect.Value, colsec ColumnSecurity, fieldTypeName string) (int, reflect.Value) {
|
||||
fieldval := fieldsrc
|
||||
if fieldsrc.Kind() == reflect.Pointer || fieldsrc.Kind() == reflect.Interface {
|
||||
fieldval = fieldval.Elem()
|
||||
}
|
||||
|
||||
fieldKindLower := strings.ToLower(fieldval.Kind().String())
|
||||
switch {
|
||||
case strings.Contains(fieldKindLower, "int") &&
|
||||
(strings.EqualFold(colsec.Accesstype, "mask") || strings.EqualFold(colsec.Accesstype, "hide")):
|
||||
if fieldval.CanInt() && fieldval.CanSet() {
|
||||
fieldval.SetInt(0)
|
||||
}
|
||||
case (strings.Contains(fieldKindLower, "time") || strings.Contains(fieldKindLower, "date")) &&
|
||||
(strings.EqualFold(colsec.Accesstype, "mask") || strings.EqualFold(colsec.Accesstype, "hide")):
|
||||
fieldval.SetZero()
|
||||
case strings.Contains(fieldKindLower, "string"):
|
||||
strVal := fieldval.String()
|
||||
if strings.EqualFold(colsec.Accesstype, "mask") {
|
||||
fieldval.SetString(maskString(strVal, colsec.MaskStart, colsec.MaskEnd, colsec.MaskChar, colsec.MaskInvert))
|
||||
} else if strings.EqualFold(colsec.Accesstype, "hide") {
|
||||
fieldval.SetString("")
|
||||
}
|
||||
case strings.Contains(fieldTypeName, "json") &&
|
||||
(strings.EqualFold(colsec.Accesstype, "mask") || strings.EqualFold(colsec.Accesstype, "hide")):
|
||||
if len(colsec.Path) < 2 {
|
||||
return 1, fieldval
|
||||
}
|
||||
pathstr := strings.Join(colsec.Path, ".")
|
||||
src := fieldval.Bytes()
|
||||
pathValue := gjson.GetBytes(src, pathstr)
|
||||
strValue := pathValue.String()
|
||||
if strings.EqualFold(colsec.Accesstype, "mask") {
|
||||
strValue = maskString(strValue, colsec.MaskStart, colsec.MaskEnd, colsec.MaskChar, colsec.MaskInvert)
|
||||
} else if strings.EqualFold(colsec.Accesstype, "hide") {
|
||||
strValue = ""
|
||||
}
|
||||
newBytes, err := sjson.SetBytes(src, pathstr, strValue)
|
||||
if err == nil {
|
||||
fieldval.SetBytes(newBytes)
|
||||
}
|
||||
}
|
||||
return 0, fieldsrc
|
||||
}
|
||||
|
||||
func (m *SecurityList) ApplyColumnSecurity(records reflect.Value, modelType reflect.Type, pUserID int, pSchema, pTablename string) (reflect.Value, error) {
|
||||
defer logger.CatchPanic("ApplyColumnSecurity")()
|
||||
|
||||
if m.ColumnSecurity == nil {
|
||||
return records, fmt.Errorf("security not initialized")
|
||||
}
|
||||
|
||||
m.ColumnSecurityMutex.RLock()
|
||||
defer m.ColumnSecurityMutex.RUnlock()
|
||||
|
||||
colsecList, ok := m.ColumnSecurity[fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)]
|
||||
if !ok || colsecList == nil {
|
||||
return records, fmt.Errorf("nocolumn security data")
|
||||
}
|
||||
|
||||
for i := range colsecList {
|
||||
colsec := &colsecList[i]
|
||||
if !strings.EqualFold(colsec.Accesstype, "mask") && !strings.EqualFold(colsec.Accesstype, "hide") {
|
||||
continue
|
||||
}
|
||||
|
||||
if records.Kind() == reflect.Array || records.Kind() == reflect.Slice {
|
||||
for i := 0; i < records.Len(); i++ {
|
||||
record := records.Index(i)
|
||||
if !record.IsValid() {
|
||||
continue
|
||||
}
|
||||
|
||||
lastRecord := interateStruct(record)
|
||||
pathLen := len(colsec.Path)
|
||||
for i, path := range colsec.Path {
|
||||
var field reflect.Value
|
||||
var nameType, fieldName string
|
||||
if len(lastRecord) == 0 {
|
||||
break
|
||||
}
|
||||
columnData := reflection.GetModelColumnDetail(lastRecord[0])
|
||||
for _, cols := range columnData {
|
||||
if cols.SQLName != "" && strings.EqualFold(cols.SQLName, path) {
|
||||
nameType = "sql"
|
||||
fieldName = cols.SQLName
|
||||
field = cols.FieldValue
|
||||
break
|
||||
}
|
||||
if cols.Name != "" && strings.EqualFold(cols.Name, path) {
|
||||
nameType = "struct"
|
||||
fieldName = cols.Name
|
||||
field = cols.FieldValue
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if i == pathLen-1 {
|
||||
if nameType == "sql" || nameType == "struct" {
|
||||
setColSecValue(field, *colsec, fieldName)
|
||||
}
|
||||
break
|
||||
}
|
||||
if field.IsValid() {
|
||||
lastRecord = interateStruct(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (m *SecurityList) LoadColumnSecurity(ctx context.Context, pUserID int, pSchema, pTablename string, pOverwrite bool) error {
|
||||
if m.provider == nil {
|
||||
return fmt.Errorf("security provider not set")
|
||||
}
|
||||
|
||||
m.ColumnSecurityMutex.Lock()
|
||||
defer m.ColumnSecurityMutex.Unlock()
|
||||
|
||||
if m.ColumnSecurity == nil {
|
||||
m.ColumnSecurity = make(map[string][]ColumnSecurity, 0)
|
||||
}
|
||||
secKey := fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)
|
||||
|
||||
if pOverwrite || m.ColumnSecurity[secKey] == nil {
|
||||
m.ColumnSecurity[secKey] = make([]ColumnSecurity, 0)
|
||||
}
|
||||
|
||||
// Call the provider to load security rules
|
||||
colSecList, err := m.provider.GetColumnSecurity(ctx, pUserID, pSchema, pTablename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetColumnSecurity failed: %v", err)
|
||||
}
|
||||
|
||||
m.ColumnSecurity[secKey] = colSecList
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SecurityList) ClearSecurity(pUserID int, pSchema, pTablename string) error {
|
||||
var filtered []ColumnSecurity
|
||||
m.ColumnSecurityMutex.Lock()
|
||||
defer m.ColumnSecurityMutex.Unlock()
|
||||
|
||||
secKey := fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)
|
||||
list, ok := m.ColumnSecurity[secKey]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range list {
|
||||
cs := &list[i]
|
||||
if cs.Schema != pSchema && cs.Tablename != pTablename && cs.UserID != pUserID {
|
||||
filtered = append(filtered, *cs)
|
||||
}
|
||||
}
|
||||
|
||||
m.ColumnSecurity[secKey] = filtered
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) {
|
||||
if m.provider == nil {
|
||||
return RowSecurity{}, fmt.Errorf("security provider not set")
|
||||
}
|
||||
|
||||
m.RowSecurityMutex.Lock()
|
||||
defer m.RowSecurityMutex.Unlock()
|
||||
|
||||
if m.RowSecurity == nil {
|
||||
m.RowSecurity = make(map[string]RowSecurity, 0)
|
||||
}
|
||||
secKey := fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)
|
||||
|
||||
// Call the provider to load security rules
|
||||
record, err := m.provider.GetRowSecurity(ctx, pUserID, pSchema, pTablename)
|
||||
if err != nil {
|
||||
return RowSecurity{}, fmt.Errorf("GetRowSecurity failed: %v", err)
|
||||
}
|
||||
|
||||
m.RowSecurity[secKey] = record
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename string) (RowSecurity, error) {
|
||||
defer logger.CatchPanic("GetRowSecurityTemplate")()
|
||||
|
||||
if m.RowSecurity == nil {
|
||||
return RowSecurity{}, fmt.Errorf("security not initialized")
|
||||
}
|
||||
|
||||
m.RowSecurityMutex.RLock()
|
||||
defer m.RowSecurityMutex.RUnlock()
|
||||
|
||||
rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)]
|
||||
if !ok {
|
||||
return RowSecurity{}, fmt.Errorf("no row security data")
|
||||
}
|
||||
|
||||
return rowSec, nil
|
||||
}
|
||||
+1145
File diff suppressed because it is too large
Load Diff
+267
@@ -0,0 +1,267 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var validSQLIdentifier = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||
|
||||
// SQLNames defines all configurable SQL stored procedure and table names
|
||||
// used by the security package. Override individual fields to remap
|
||||
// to custom database objects. Use DefaultSQLNames() for baseline defaults,
|
||||
// and MergeSQLNames() to apply partial overrides.
|
||||
type SQLNames struct {
|
||||
// Auth procedures (DatabaseAuthenticator)
|
||||
Login string // default: "resolvespec_login"
|
||||
Register string // default: "resolvespec_register"
|
||||
Logout string // default: "resolvespec_logout"
|
||||
Session string // default: "resolvespec_session"
|
||||
SessionUpdate string // default: "resolvespec_session_update"
|
||||
RefreshToken string // default: "resolvespec_refresh_token"
|
||||
|
||||
// JWT procedures (JWTAuthenticator)
|
||||
JWTLogin string // default: "resolvespec_jwt_login"
|
||||
JWTLogout string // default: "resolvespec_jwt_logout"
|
||||
|
||||
// Security policy procedures
|
||||
ColumnSecurity string // default: "resolvespec_column_security"
|
||||
RowSecurity string // default: "resolvespec_row_security"
|
||||
|
||||
// TOTP procedures (DatabaseTwoFactorProvider)
|
||||
TOTPEnable string // default: "resolvespec_totp_enable"
|
||||
TOTPDisable string // default: "resolvespec_totp_disable"
|
||||
TOTPGetStatus string // default: "resolvespec_totp_get_status"
|
||||
TOTPGetSecret string // default: "resolvespec_totp_get_secret"
|
||||
TOTPRegenerateBackup string // default: "resolvespec_totp_regenerate_backup_codes"
|
||||
TOTPValidateBackupCode string // default: "resolvespec_totp_validate_backup_code"
|
||||
|
||||
// Passkey procedures (DatabasePasskeyProvider)
|
||||
PasskeyStoreCredential string // default: "resolvespec_passkey_store_credential"
|
||||
PasskeyGetCredsByUsername string // default: "resolvespec_passkey_get_credentials_by_username"
|
||||
PasskeyGetCredential string // default: "resolvespec_passkey_get_credential"
|
||||
PasskeyUpdateCounter string // default: "resolvespec_passkey_update_counter"
|
||||
PasskeyGetUserCredentials string // default: "resolvespec_passkey_get_user_credentials"
|
||||
PasskeyDeleteCredential string // default: "resolvespec_passkey_delete_credential"
|
||||
PasskeyUpdateName string // default: "resolvespec_passkey_update_name"
|
||||
PasskeyLogin string // default: "resolvespec_passkey_login"
|
||||
|
||||
// Password reset procedures (DatabaseAuthenticator)
|
||||
PasswordResetRequest string // default: "resolvespec_password_reset_request"
|
||||
PasswordResetComplete string // default: "resolvespec_password_reset"
|
||||
|
||||
// OAuth2 procedures (DatabaseAuthenticator OAuth2 methods)
|
||||
OAuthGetOrCreateUser string // default: "resolvespec_oauth_getorcreateuser"
|
||||
OAuthCreateSession string // default: "resolvespec_oauth_createsession"
|
||||
OAuthGetRefreshToken string // default: "resolvespec_oauth_getrefreshtoken"
|
||||
OAuthUpdateRefreshToken string // default: "resolvespec_oauth_updaterefreshtoken"
|
||||
OAuthGetUser string // default: "resolvespec_oauth_getuser"
|
||||
|
||||
// OAuth2 server procedures (OAuthServer persistence)
|
||||
OAuthRegisterClient string // default: "resolvespec_oauth_register_client"
|
||||
OAuthGetClient string // default: "resolvespec_oauth_get_client"
|
||||
OAuthSaveCode string // default: "resolvespec_oauth_save_code"
|
||||
OAuthExchangeCode string // default: "resolvespec_oauth_exchange_code"
|
||||
OAuthIntrospect string // default: "resolvespec_oauth_introspect"
|
||||
OAuthRevoke string // default: "resolvespec_oauth_revoke"
|
||||
}
|
||||
|
||||
// DefaultSQLNames returns an SQLNames with all default resolvespec_* values.
|
||||
func DefaultSQLNames() *SQLNames {
|
||||
return &SQLNames{
|
||||
Login: "resolvespec_login",
|
||||
Register: "resolvespec_register",
|
||||
Logout: "resolvespec_logout",
|
||||
Session: "resolvespec_session",
|
||||
SessionUpdate: "resolvespec_session_update",
|
||||
RefreshToken: "resolvespec_refresh_token",
|
||||
|
||||
JWTLogin: "resolvespec_jwt_login",
|
||||
JWTLogout: "resolvespec_jwt_logout",
|
||||
|
||||
ColumnSecurity: "resolvespec_column_security",
|
||||
RowSecurity: "resolvespec_row_security",
|
||||
|
||||
TOTPEnable: "resolvespec_totp_enable",
|
||||
TOTPDisable: "resolvespec_totp_disable",
|
||||
TOTPGetStatus: "resolvespec_totp_get_status",
|
||||
TOTPGetSecret: "resolvespec_totp_get_secret",
|
||||
TOTPRegenerateBackup: "resolvespec_totp_regenerate_backup_codes",
|
||||
TOTPValidateBackupCode: "resolvespec_totp_validate_backup_code",
|
||||
|
||||
PasskeyStoreCredential: "resolvespec_passkey_store_credential",
|
||||
PasskeyGetCredsByUsername: "resolvespec_passkey_get_credentials_by_username",
|
||||
PasskeyGetCredential: "resolvespec_passkey_get_credential",
|
||||
PasskeyUpdateCounter: "resolvespec_passkey_update_counter",
|
||||
PasskeyGetUserCredentials: "resolvespec_passkey_get_user_credentials",
|
||||
PasskeyDeleteCredential: "resolvespec_passkey_delete_credential",
|
||||
PasskeyUpdateName: "resolvespec_passkey_update_name",
|
||||
PasskeyLogin: "resolvespec_passkey_login",
|
||||
|
||||
PasswordResetRequest: "resolvespec_password_reset_request",
|
||||
PasswordResetComplete: "resolvespec_password_reset",
|
||||
|
||||
OAuthGetOrCreateUser: "resolvespec_oauth_getorcreateuser",
|
||||
OAuthCreateSession: "resolvespec_oauth_createsession",
|
||||
OAuthGetRefreshToken: "resolvespec_oauth_getrefreshtoken",
|
||||
OAuthUpdateRefreshToken: "resolvespec_oauth_updaterefreshtoken",
|
||||
OAuthGetUser: "resolvespec_oauth_getuser",
|
||||
|
||||
OAuthRegisterClient: "resolvespec_oauth_register_client",
|
||||
OAuthGetClient: "resolvespec_oauth_get_client",
|
||||
OAuthSaveCode: "resolvespec_oauth_save_code",
|
||||
OAuthExchangeCode: "resolvespec_oauth_exchange_code",
|
||||
OAuthIntrospect: "resolvespec_oauth_introspect",
|
||||
OAuthRevoke: "resolvespec_oauth_revoke",
|
||||
}
|
||||
}
|
||||
|
||||
// MergeSQLNames returns a copy of base with any non-empty fields from override applied.
|
||||
// If override is nil, a copy of base is returned.
|
||||
func MergeSQLNames(base, override *SQLNames) *SQLNames {
|
||||
if override == nil {
|
||||
copied := *base
|
||||
return &copied
|
||||
}
|
||||
merged := *base
|
||||
if override.Login != "" {
|
||||
merged.Login = override.Login
|
||||
}
|
||||
if override.Register != "" {
|
||||
merged.Register = override.Register
|
||||
}
|
||||
if override.Logout != "" {
|
||||
merged.Logout = override.Logout
|
||||
}
|
||||
if override.Session != "" {
|
||||
merged.Session = override.Session
|
||||
}
|
||||
if override.SessionUpdate != "" {
|
||||
merged.SessionUpdate = override.SessionUpdate
|
||||
}
|
||||
if override.RefreshToken != "" {
|
||||
merged.RefreshToken = override.RefreshToken
|
||||
}
|
||||
if override.JWTLogin != "" {
|
||||
merged.JWTLogin = override.JWTLogin
|
||||
}
|
||||
if override.JWTLogout != "" {
|
||||
merged.JWTLogout = override.JWTLogout
|
||||
}
|
||||
if override.ColumnSecurity != "" {
|
||||
merged.ColumnSecurity = override.ColumnSecurity
|
||||
}
|
||||
if override.RowSecurity != "" {
|
||||
merged.RowSecurity = override.RowSecurity
|
||||
}
|
||||
if override.TOTPEnable != "" {
|
||||
merged.TOTPEnable = override.TOTPEnable
|
||||
}
|
||||
if override.TOTPDisable != "" {
|
||||
merged.TOTPDisable = override.TOTPDisable
|
||||
}
|
||||
if override.TOTPGetStatus != "" {
|
||||
merged.TOTPGetStatus = override.TOTPGetStatus
|
||||
}
|
||||
if override.TOTPGetSecret != "" {
|
||||
merged.TOTPGetSecret = override.TOTPGetSecret
|
||||
}
|
||||
if override.TOTPRegenerateBackup != "" {
|
||||
merged.TOTPRegenerateBackup = override.TOTPRegenerateBackup
|
||||
}
|
||||
if override.TOTPValidateBackupCode != "" {
|
||||
merged.TOTPValidateBackupCode = override.TOTPValidateBackupCode
|
||||
}
|
||||
if override.PasskeyStoreCredential != "" {
|
||||
merged.PasskeyStoreCredential = override.PasskeyStoreCredential
|
||||
}
|
||||
if override.PasskeyGetCredsByUsername != "" {
|
||||
merged.PasskeyGetCredsByUsername = override.PasskeyGetCredsByUsername
|
||||
}
|
||||
if override.PasskeyGetCredential != "" {
|
||||
merged.PasskeyGetCredential = override.PasskeyGetCredential
|
||||
}
|
||||
if override.PasskeyUpdateCounter != "" {
|
||||
merged.PasskeyUpdateCounter = override.PasskeyUpdateCounter
|
||||
}
|
||||
if override.PasskeyGetUserCredentials != "" {
|
||||
merged.PasskeyGetUserCredentials = override.PasskeyGetUserCredentials
|
||||
}
|
||||
if override.PasskeyDeleteCredential != "" {
|
||||
merged.PasskeyDeleteCredential = override.PasskeyDeleteCredential
|
||||
}
|
||||
if override.PasskeyUpdateName != "" {
|
||||
merged.PasskeyUpdateName = override.PasskeyUpdateName
|
||||
}
|
||||
if override.PasskeyLogin != "" {
|
||||
merged.PasskeyLogin = override.PasskeyLogin
|
||||
}
|
||||
if override.PasswordResetRequest != "" {
|
||||
merged.PasswordResetRequest = override.PasswordResetRequest
|
||||
}
|
||||
if override.PasswordResetComplete != "" {
|
||||
merged.PasswordResetComplete = override.PasswordResetComplete
|
||||
}
|
||||
if override.OAuthGetOrCreateUser != "" {
|
||||
merged.OAuthGetOrCreateUser = override.OAuthGetOrCreateUser
|
||||
}
|
||||
if override.OAuthCreateSession != "" {
|
||||
merged.OAuthCreateSession = override.OAuthCreateSession
|
||||
}
|
||||
if override.OAuthGetRefreshToken != "" {
|
||||
merged.OAuthGetRefreshToken = override.OAuthGetRefreshToken
|
||||
}
|
||||
if override.OAuthUpdateRefreshToken != "" {
|
||||
merged.OAuthUpdateRefreshToken = override.OAuthUpdateRefreshToken
|
||||
}
|
||||
if override.OAuthGetUser != "" {
|
||||
merged.OAuthGetUser = override.OAuthGetUser
|
||||
}
|
||||
if override.OAuthRegisterClient != "" {
|
||||
merged.OAuthRegisterClient = override.OAuthRegisterClient
|
||||
}
|
||||
if override.OAuthGetClient != "" {
|
||||
merged.OAuthGetClient = override.OAuthGetClient
|
||||
}
|
||||
if override.OAuthSaveCode != "" {
|
||||
merged.OAuthSaveCode = override.OAuthSaveCode
|
||||
}
|
||||
if override.OAuthExchangeCode != "" {
|
||||
merged.OAuthExchangeCode = override.OAuthExchangeCode
|
||||
}
|
||||
if override.OAuthIntrospect != "" {
|
||||
merged.OAuthIntrospect = override.OAuthIntrospect
|
||||
}
|
||||
if override.OAuthRevoke != "" {
|
||||
merged.OAuthRevoke = override.OAuthRevoke
|
||||
}
|
||||
return &merged
|
||||
}
|
||||
|
||||
// ValidateSQLNames checks that all non-empty fields in names are valid SQL identifiers.
|
||||
// Returns an error if any field contains invalid characters.
|
||||
func ValidateSQLNames(names *SQLNames) error {
|
||||
v := reflect.ValueOf(names).Elem()
|
||||
typ := v.Type()
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
if field.Kind() != reflect.String {
|
||||
continue
|
||||
}
|
||||
val := field.String()
|
||||
if val != "" && !validSQLIdentifier.MatchString(val) {
|
||||
return fmt.Errorf("SQLNames.%s contains invalid characters: %q", typ.Field(i).Name, val)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSQLNames merges an optional override with defaults.
|
||||
// Used by constructors that accept variadic *SQLNames.
|
||||
func resolveSQLNames(override ...*SQLNames) *SQLNames {
|
||||
if len(override) > 0 && override[0] != nil {
|
||||
return MergeSQLNames(DefaultSQLNames(), override[0])
|
||||
}
|
||||
return DefaultSQLNames()
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TwoFactorAuthProvider defines interface for 2FA operations
|
||||
type TwoFactorAuthProvider interface {
|
||||
// Generate2FASecret creates a new secret for a user
|
||||
Generate2FASecret(userID int, issuer, accountName string) (*TwoFactorSecret, error)
|
||||
|
||||
// Validate2FACode verifies a TOTP code
|
||||
Validate2FACode(secret string, code string) (bool, error)
|
||||
|
||||
// Enable2FA activates 2FA for a user (store secret in your database)
|
||||
Enable2FA(userID int, secret string, backupCodes []string) error
|
||||
|
||||
// Disable2FA deactivates 2FA for a user
|
||||
Disable2FA(userID int) error
|
||||
|
||||
// Get2FAStatus checks if user has 2FA enabled
|
||||
Get2FAStatus(userID int) (bool, error)
|
||||
|
||||
// Get2FASecret retrieves the user's 2FA secret
|
||||
Get2FASecret(userID int) (string, error)
|
||||
|
||||
// GenerateBackupCodes creates backup codes for 2FA
|
||||
GenerateBackupCodes(userID int, count int) ([]string, error)
|
||||
|
||||
// ValidateBackupCode checks and consumes a backup code
|
||||
ValidateBackupCode(userID int, code string) (bool, error)
|
||||
}
|
||||
|
||||
// TwoFactorSecret contains 2FA setup information
|
||||
type TwoFactorSecret struct {
|
||||
Secret string `json:"secret"` // Base32 encoded secret
|
||||
QRCodeURL string `json:"qr_code_url"` // URL for QR code generation
|
||||
BackupCodes []string `json:"backup_codes"` // One-time backup codes
|
||||
Issuer string `json:"issuer"` // Application name
|
||||
AccountName string `json:"account_name"` // User identifier (email/username)
|
||||
}
|
||||
|
||||
// TwoFactorConfig holds TOTP configuration
|
||||
type TwoFactorConfig struct {
|
||||
Algorithm string // SHA1, SHA256, SHA512
|
||||
Digits int // Number of digits in code (6 or 8)
|
||||
Period int // Time step in seconds (default 30)
|
||||
SkewWindow int // Number of time steps to check before/after (default 1)
|
||||
}
|
||||
|
||||
// DefaultTwoFactorConfig returns standard TOTP configuration
|
||||
func DefaultTwoFactorConfig() *TwoFactorConfig {
|
||||
return &TwoFactorConfig{
|
||||
Algorithm: "SHA1",
|
||||
Digits: 6,
|
||||
Period: 30,
|
||||
SkewWindow: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// TOTPGenerator handles TOTP code generation and validation
|
||||
type TOTPGenerator struct {
|
||||
config *TwoFactorConfig
|
||||
}
|
||||
|
||||
// NewTOTPGenerator creates a new TOTP generator with config
|
||||
func NewTOTPGenerator(config *TwoFactorConfig) *TOTPGenerator {
|
||||
if config == nil {
|
||||
config = DefaultTwoFactorConfig()
|
||||
}
|
||||
return &TOTPGenerator{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSecret creates a random base32-encoded secret
|
||||
func (t *TOTPGenerator) GenerateSecret() (string, error) {
|
||||
secret := make([]byte, 20)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random secret: %w", err)
|
||||
}
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret), nil
|
||||
}
|
||||
|
||||
// GenerateQRCodeURL creates a URL for QR code generation
|
||||
func (t *TOTPGenerator) GenerateQRCodeURL(secret, issuer, accountName string) string {
|
||||
params := url.Values{}
|
||||
params.Set("secret", secret)
|
||||
params.Set("issuer", issuer)
|
||||
params.Set("algorithm", t.config.Algorithm)
|
||||
params.Set("digits", fmt.Sprintf("%d", t.config.Digits))
|
||||
params.Set("period", fmt.Sprintf("%d", t.config.Period))
|
||||
|
||||
label := url.PathEscape(fmt.Sprintf("%s:%s", issuer, accountName))
|
||||
return fmt.Sprintf("otpauth://totp/%s?%s", label, params.Encode())
|
||||
}
|
||||
|
||||
// GenerateCode creates a TOTP code for a given time
|
||||
func (t *TOTPGenerator) GenerateCode(secret string, timestamp time.Time) (string, error) {
|
||||
// Decode secret
|
||||
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid secret: %w", err)
|
||||
}
|
||||
|
||||
// Calculate counter (time steps since Unix epoch)
|
||||
counter := uint64(timestamp.Unix()) / uint64(t.config.Period)
|
||||
|
||||
// Generate HMAC
|
||||
h := t.getHashFunc()
|
||||
mac := hmac.New(h, key)
|
||||
|
||||
// Convert counter to 8-byte array
|
||||
buf := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(buf, counter)
|
||||
mac.Write(buf)
|
||||
|
||||
sum := mac.Sum(nil)
|
||||
|
||||
// Dynamic truncation
|
||||
offset := sum[len(sum)-1] & 0x0f
|
||||
truncated := binary.BigEndian.Uint32(sum[offset:]) & 0x7fffffff
|
||||
|
||||
// Generate code with specified digits
|
||||
code := truncated % uint32(math.Pow10(t.config.Digits))
|
||||
|
||||
format := fmt.Sprintf("%%0%dd", t.config.Digits)
|
||||
return fmt.Sprintf(format, code), nil
|
||||
}
|
||||
|
||||
// ValidateCode checks if a code is valid for the secret
|
||||
func (t *TOTPGenerator) ValidateCode(secret, code string) (bool, error) {
|
||||
now := time.Now()
|
||||
|
||||
// Check current time and skew window
|
||||
for i := -t.config.SkewWindow; i <= t.config.SkewWindow; i++ {
|
||||
timestamp := now.Add(time.Duration(i*t.config.Period) * time.Second)
|
||||
expected, err := t.GenerateCode(secret, timestamp)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if code == expected {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// getHashFunc returns the hash function based on algorithm
|
||||
func (t *TOTPGenerator) getHashFunc() func() hash.Hash {
|
||||
switch strings.ToUpper(t.config.Algorithm) {
|
||||
case "SHA256":
|
||||
return sha256.New
|
||||
case "SHA512":
|
||||
return sha512.New
|
||||
default:
|
||||
return sha1.New
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateBackupCodes creates random backup codes
|
||||
func GenerateBackupCodes(count int) ([]string, error) {
|
||||
codes := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
code := make([]byte, 4)
|
||||
_, err := rand.Read(code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate backup code: %w", err)
|
||||
}
|
||||
codes[i] = fmt.Sprintf("%08X", binary.BigEndian.Uint32(code))
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// TwoFactorAuthenticator wraps an Authenticator and adds 2FA support
|
||||
type TwoFactorAuthenticator struct {
|
||||
baseAuth Authenticator
|
||||
totp *TOTPGenerator
|
||||
provider TwoFactorAuthProvider
|
||||
}
|
||||
|
||||
// NewTwoFactorAuthenticator creates a new 2FA-enabled authenticator
|
||||
func NewTwoFactorAuthenticator(baseAuth Authenticator, provider TwoFactorAuthProvider, config *TwoFactorConfig) *TwoFactorAuthenticator {
|
||||
if config == nil {
|
||||
config = DefaultTwoFactorConfig()
|
||||
}
|
||||
return &TwoFactorAuthenticator{
|
||||
baseAuth: baseAuth,
|
||||
totp: NewTOTPGenerator(config),
|
||||
provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
// Login authenticates with 2FA support
|
||||
func (t *TwoFactorAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
// First, perform standard authentication
|
||||
resp, err := t.baseAuth.Login(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if user has 2FA enabled
|
||||
if resp.User == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
has2FA, err := t.provider.Get2FAStatus(resp.User.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check 2FA status: %w", err)
|
||||
}
|
||||
|
||||
if !has2FA {
|
||||
// User doesn't have 2FA enabled, return normal response
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// User has 2FA enabled
|
||||
if req.TwoFactorCode == "" {
|
||||
// No 2FA code provided, require it
|
||||
resp.Requires2FA = true
|
||||
resp.Token = "" // Don't return token until 2FA is verified
|
||||
resp.RefreshToken = ""
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Validate 2FA code
|
||||
secret, err := t.provider.Get2FASecret(resp.User.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get 2FA secret: %w", err)
|
||||
}
|
||||
|
||||
// Try TOTP code first
|
||||
valid, err := t.totp.ValidateCode(secret, req.TwoFactorCode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to validate 2FA code: %w", err)
|
||||
}
|
||||
|
||||
if !valid {
|
||||
// Try backup code
|
||||
valid, err = t.provider.ValidateBackupCode(resp.User.UserID, req.TwoFactorCode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to validate backup code: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !valid {
|
||||
return nil, fmt.Errorf("invalid 2FA code")
|
||||
}
|
||||
|
||||
// 2FA verified, return full response with token
|
||||
resp.User.TwoFactorEnabled = true
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Logout delegates to base authenticator
|
||||
func (t *TwoFactorAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
|
||||
return t.baseAuth.Logout(ctx, req)
|
||||
}
|
||||
|
||||
// Authenticate delegates to base authenticator
|
||||
func (t *TwoFactorAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
|
||||
return t.baseAuth.Authenticate(r)
|
||||
}
|
||||
|
||||
// Setup2FA initiates 2FA setup for a user
|
||||
func (t *TwoFactorAuthenticator) Setup2FA(userID int, issuer, accountName string) (*TwoFactorSecret, error) {
|
||||
return t.provider.Generate2FASecret(userID, issuer, accountName)
|
||||
}
|
||||
|
||||
// Enable2FA completes 2FA setup after user confirms with a valid code
|
||||
func (t *TwoFactorAuthenticator) Enable2FA(userID int, secret, verificationCode string) error {
|
||||
// Verify the code before enabling
|
||||
valid, err := t.totp.ValidateCode(secret, verificationCode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate code: %w", err)
|
||||
}
|
||||
|
||||
if !valid {
|
||||
return fmt.Errorf("invalid verification code")
|
||||
}
|
||||
|
||||
// Generate backup codes
|
||||
backupCodes, err := t.provider.GenerateBackupCodes(userID, 10)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate backup codes: %w", err)
|
||||
}
|
||||
|
||||
// Enable 2FA
|
||||
return t.provider.Enable2FA(userID, secret, backupCodes)
|
||||
}
|
||||
|
||||
// Disable2FA removes 2FA from a user account
|
||||
func (t *TwoFactorAuthenticator) Disable2FA(userID int) error {
|
||||
return t.provider.Disable2FA(userID)
|
||||
}
|
||||
|
||||
// RegenerateBackupCodes creates new backup codes for a user
|
||||
func (t *TwoFactorAuthenticator) RegenerateBackupCodes(userID int, count int) ([]string, error) {
|
||||
return t.provider.GenerateBackupCodes(userID, count)
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DatabaseTwoFactorProvider implements TwoFactorAuthProvider using PostgreSQL stored procedures
|
||||
// Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults)
|
||||
// See totp_database_schema.sql for procedure definitions
|
||||
type DatabaseTwoFactorProvider struct {
|
||||
db *sql.DB
|
||||
totpGen *TOTPGenerator
|
||||
sqlNames *SQLNames
|
||||
}
|
||||
|
||||
// NewDatabaseTwoFactorProvider creates a new database-backed 2FA provider
|
||||
func NewDatabaseTwoFactorProvider(db *sql.DB, config *TwoFactorConfig, names ...*SQLNames) *DatabaseTwoFactorProvider {
|
||||
if config == nil {
|
||||
config = DefaultTwoFactorConfig()
|
||||
}
|
||||
return &DatabaseTwoFactorProvider{
|
||||
db: db,
|
||||
totpGen: NewTOTPGenerator(config),
|
||||
sqlNames: resolveSQLNames(names...),
|
||||
}
|
||||
}
|
||||
|
||||
// Generate2FASecret creates a new secret for a user
|
||||
func (p *DatabaseTwoFactorProvider) Generate2FASecret(userID int, issuer, accountName string) (*TwoFactorSecret, error) {
|
||||
secret, err := p.totpGen.GenerateSecret()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate secret: %w", err)
|
||||
}
|
||||
|
||||
qrURL := p.totpGen.GenerateQRCodeURL(secret, issuer, accountName)
|
||||
|
||||
backupCodes, err := GenerateBackupCodes(10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate backup codes: %w", err)
|
||||
}
|
||||
|
||||
return &TwoFactorSecret{
|
||||
Secret: secret,
|
||||
QRCodeURL: qrURL,
|
||||
BackupCodes: backupCodes,
|
||||
Issuer: issuer,
|
||||
AccountName: accountName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate2FACode verifies a TOTP code
|
||||
func (p *DatabaseTwoFactorProvider) Validate2FACode(secret string, code string) (bool, error) {
|
||||
return p.totpGen.ValidateCode(secret, code)
|
||||
}
|
||||
|
||||
// Enable2FA activates 2FA for a user
|
||||
func (p *DatabaseTwoFactorProvider) Enable2FA(userID int, secret string, backupCodes []string) error {
|
||||
// Hash backup codes for secure storage
|
||||
hashedCodes := make([]string, len(backupCodes))
|
||||
for i, code := range backupCodes {
|
||||
hash := sha256.Sum256([]byte(code))
|
||||
hashedCodes[i] = hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// Convert to JSON array
|
||||
codesJSON, err := json.Marshal(hashedCodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal backup codes: %w", err)
|
||||
}
|
||||
|
||||
// Call stored procedure
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2, $3::jsonb)`, p.sqlNames.TOTPEnable)
|
||||
err = p.db.QueryRow(query, userID, secret, string(codesJSON)).Scan(&success, &errorMsg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enable 2FA query failed: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return fmt.Errorf("failed to enable 2FA")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disable2FA deactivates 2FA for a user
|
||||
func (p *DatabaseTwoFactorProvider) Disable2FA(userID int) error {
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1)`, p.sqlNames.TOTPDisable)
|
||||
err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("disable 2FA query failed: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return fmt.Errorf("failed to disable 2FA")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get2FAStatus checks if user has 2FA enabled
|
||||
func (p *DatabaseTwoFactorProvider) Get2FAStatus(userID int) (bool, error) {
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var enabled bool
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_enabled FROM %s($1)`, p.sqlNames.TOTPGetStatus)
|
||||
err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg, &enabled)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get 2FA status query failed: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return false, fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return false, fmt.Errorf("failed to get 2FA status")
|
||||
}
|
||||
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// Get2FASecret retrieves the user's 2FA secret
|
||||
func (p *DatabaseTwoFactorProvider) Get2FASecret(userID int) (string, error) {
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var secret sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_secret FROM %s($1)`, p.sqlNames.TOTPGetSecret)
|
||||
err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg, &secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get 2FA secret query failed: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return "", fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return "", fmt.Errorf("failed to get 2FA secret")
|
||||
}
|
||||
|
||||
if !secret.Valid {
|
||||
return "", fmt.Errorf("2FA secret not found")
|
||||
}
|
||||
|
||||
return secret.String, nil
|
||||
}
|
||||
|
||||
// GenerateBackupCodes creates backup codes for 2FA
|
||||
func (p *DatabaseTwoFactorProvider) GenerateBackupCodes(userID int, count int) ([]string, error) {
|
||||
codes, err := GenerateBackupCodes(count)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate backup codes: %w", err)
|
||||
}
|
||||
|
||||
// Hash backup codes for storage
|
||||
hashedCodes := make([]string, len(codes))
|
||||
for i, code := range codes {
|
||||
hash := sha256.Sum256([]byte(code))
|
||||
hashedCodes[i] = hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// Convert to JSON array
|
||||
codesJSON, err := json.Marshal(hashedCodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal backup codes: %w", err)
|
||||
}
|
||||
|
||||
// Call stored procedure
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2::jsonb)`, p.sqlNames.TOTPRegenerateBackup)
|
||||
err = p.db.QueryRow(query, userID, string(codesJSON)).Scan(&success, &errorMsg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("regenerate backup codes query failed: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return nil, fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to regenerate backup codes")
|
||||
}
|
||||
|
||||
// Return unhashed codes to user (only time they see them)
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// ValidateBackupCode checks and consumes a backup code
|
||||
func (p *DatabaseTwoFactorProvider) ValidateBackupCode(userID int, code string) (bool, error) {
|
||||
// Hash the code
|
||||
hash := sha256.Sum256([]byte(code))
|
||||
codeHash := hex.EncodeToString(hash[:])
|
||||
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var valid bool
|
||||
|
||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_valid FROM %s($1, $2)`, p.sqlNames.TOTPValidateBackupCode)
|
||||
err := p.db.QueryRow(query, userID, codeHash).Scan(&success, &errorMsg, &valid)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validate backup code query failed: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
if errorMsg.Valid {
|
||||
return false, fmt.Errorf("%s", errorMsg.String)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return valid, nil
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MemoryTwoFactorProvider is an in-memory implementation of TwoFactorAuthProvider for testing/examples
|
||||
type MemoryTwoFactorProvider struct {
|
||||
mu sync.RWMutex
|
||||
secrets map[int]string // userID -> secret
|
||||
backupCodes map[int]map[string]bool // userID -> backup codes (code -> used)
|
||||
totpGen *TOTPGenerator
|
||||
}
|
||||
|
||||
// NewMemoryTwoFactorProvider creates a new in-memory 2FA provider
|
||||
func NewMemoryTwoFactorProvider(config *TwoFactorConfig) *MemoryTwoFactorProvider {
|
||||
if config == nil {
|
||||
config = DefaultTwoFactorConfig()
|
||||
}
|
||||
return &MemoryTwoFactorProvider{
|
||||
secrets: make(map[int]string),
|
||||
backupCodes: make(map[int]map[string]bool),
|
||||
totpGen: NewTOTPGenerator(config),
|
||||
}
|
||||
}
|
||||
|
||||
// Generate2FASecret creates a new secret for a user
|
||||
func (m *MemoryTwoFactorProvider) Generate2FASecret(userID int, issuer, accountName string) (*TwoFactorSecret, error) {
|
||||
secret, err := m.totpGen.GenerateSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
qrURL := m.totpGen.GenerateQRCodeURL(secret, issuer, accountName)
|
||||
|
||||
backupCodes, err := GenerateBackupCodes(10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TwoFactorSecret{
|
||||
Secret: secret,
|
||||
QRCodeURL: qrURL,
|
||||
BackupCodes: backupCodes,
|
||||
Issuer: issuer,
|
||||
AccountName: accountName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate2FACode verifies a TOTP code
|
||||
func (m *MemoryTwoFactorProvider) Validate2FACode(secret string, code string) (bool, error) {
|
||||
return m.totpGen.ValidateCode(secret, code)
|
||||
}
|
||||
|
||||
// Enable2FA activates 2FA for a user
|
||||
func (m *MemoryTwoFactorProvider) Enable2FA(userID int, secret string, backupCodes []string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.secrets[userID] = secret
|
||||
|
||||
// Store backup codes
|
||||
if m.backupCodes[userID] == nil {
|
||||
m.backupCodes[userID] = make(map[string]bool)
|
||||
}
|
||||
|
||||
for _, code := range backupCodes {
|
||||
// Hash backup codes for security
|
||||
hash := sha256.Sum256([]byte(code))
|
||||
m.backupCodes[userID][hex.EncodeToString(hash[:])] = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disable2FA deactivates 2FA for a user
|
||||
func (m *MemoryTwoFactorProvider) Disable2FA(userID int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
delete(m.secrets, userID)
|
||||
delete(m.backupCodes, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get2FAStatus checks if user has 2FA enabled
|
||||
func (m *MemoryTwoFactorProvider) Get2FAStatus(userID int) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
_, exists := m.secrets[userID]
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// Get2FASecret retrieves the user's 2FA secret
|
||||
func (m *MemoryTwoFactorProvider) Get2FASecret(userID int) (string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
secret, exists := m.secrets[userID]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("user does not have 2FA enabled")
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// GenerateBackupCodes creates backup codes for 2FA
|
||||
func (m *MemoryTwoFactorProvider) GenerateBackupCodes(userID int, count int) ([]string, error) {
|
||||
codes, err := GenerateBackupCodes(count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Clear old backup codes and store new ones
|
||||
m.backupCodes[userID] = make(map[string]bool)
|
||||
for _, code := range codes {
|
||||
hash := sha256.Sum256([]byte(code))
|
||||
m.backupCodes[userID][hex.EncodeToString(hash[:])] = false
|
||||
}
|
||||
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// ValidateBackupCode checks and consumes a backup code
|
||||
func (m *MemoryTwoFactorProvider) ValidateBackupCode(userID int, code string) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
userCodes, exists := m.backupCodes[userID]
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Hash the provided code
|
||||
hash := sha256.Sum256([]byte(code))
|
||||
hashStr := hex.EncodeToString(hash[:])
|
||||
|
||||
used, exists := userCodes[hashStr]
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if used {
|
||||
return false, fmt.Errorf("backup code already used")
|
||||
}
|
||||
|
||||
// Mark as used
|
||||
userCodes[hashStr] = true
|
||||
return true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user