fix(go.sum): update ResolveSpec dependency to v1.0.87
CI / build-and-test (push) Failing after 1s
Release / release (push) Failing after 19m26s

This commit is contained in:
Hein
2026-06-23 13:17:16 +02:00
parent 0227912325
commit 1adf50e3db
2436 changed files with 1078758 additions and 114 deletions
+153
View File
@@ -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
View File
@@ -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
@@ -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`.
@@ -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.**
@@ -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
View File
@@ -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}"}
```
File diff suppressed because it is too large Load Diff
@@ -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
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+385
View File
@@ -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
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
@@ -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
View File
@@ -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")
}
@@ -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
}
@@ -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;
$$;
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, `"`, "&#34;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
+204
View File
@@ -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
View File
@@ -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
}
@@ -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);
}
});
`
}
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+267
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
@@ -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
}
@@ -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
}