mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).
- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
parameter, JSON null arrays, expires_at timezone casts, passkey list
GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
655 lines
23 KiB
Go
655 lines
23 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/sync/singleflight"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/cache"
|
|
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/backends"
|
|
)
|
|
|
|
// Production-Ready Authenticators
|
|
// =================================
|
|
|
|
// maxAuthTokens caps the comma-separated credentials tried per request so one
|
|
// request cannot drive unbounded session lookups.
|
|
const maxAuthTokens = 4
|
|
|
|
// sessionActivityTimeout bounds the detached last-activity update.
|
|
const sessionActivityTimeout = 5 * time.Second
|
|
|
|
// sessionActivityInterval is the minimum gap between last-activity writes for
|
|
// one session token. Requests inside it skip the write.
|
|
const sessionActivityInterval = time.Minute
|
|
|
|
// activityThrottle remembers when each token's activity was last written.
|
|
type activityThrottle struct {
|
|
mu sync.Mutex
|
|
last map[string]time.Time
|
|
lastPrune time.Time
|
|
}
|
|
|
|
// allow reports whether token is due an activity write, and if so records it.
|
|
func (t *activityThrottle) allow(token string, now time.Time) bool {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if t.last == nil {
|
|
t.last = make(map[string]time.Time)
|
|
}
|
|
if prev, ok := t.last[token]; ok && now.Sub(prev) < sessionActivityInterval {
|
|
return false
|
|
}
|
|
t.last[token] = now
|
|
if now.Sub(t.lastPrune) > sessionActivityInterval {
|
|
t.lastPrune = now
|
|
for k, v := range t.last {
|
|
if now.Sub(v) >= sessionActivityInterval {
|
|
delete(t.last, k)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// DatabaseAuthenticator provides session-based authentication with database storage
|
|
// All database operations go through stored procedures for security and consistency
|
|
// Procedure names and modes are configured through lookup.Config (see lookup.DefaultProcNames)
|
|
// See lookup/database_schema.sql for procedure definitions
|
|
// Also supports multiple OAuth2 providers configured with WithOAuth2()
|
|
// Also supports passkey authentication configured with WithPasskey()
|
|
type DatabaseAuthenticator struct {
|
|
src *lookupSource
|
|
cache *cache.Cache
|
|
cacheTTL time.Duration
|
|
|
|
// activityWG tracks in-flight asynchronous session activity updates
|
|
activityWG sync.WaitGroup
|
|
// activityLimit throttles those updates to one per token per interval
|
|
activityLimit activityThrottle
|
|
// sessionLoads collapses concurrent session lookups for the same token
|
|
sessionLoads singleflight.Group
|
|
|
|
// Cookie session support (optional, gated by enableCookieSession)
|
|
enableCookieSession bool
|
|
cookieOptions SessionCookieOptions
|
|
|
|
// OAuth2 providers registry (multiple providers supported)
|
|
oauth2Providers map[string]*OAuth2Provider
|
|
oauth2ProvidersMutex sync.RWMutex
|
|
|
|
// Passkey provider (optional)
|
|
passkeyProvider PasskeyProvider
|
|
|
|
// Optional fallback called when primary authentication fails
|
|
authenticateCallback func(r *http.Request) (*UserContext, error)
|
|
}
|
|
|
|
// DatabaseAuthenticatorOptions configures the database authenticator
|
|
type DatabaseAuthenticatorOptions struct {
|
|
// CacheTTL is the duration to cache user contexts
|
|
// Default: 5 minutes
|
|
CacheTTL time.Duration
|
|
// Cache is an optional cache instance. If nil, uses the default cache
|
|
Cache *cache.Cache
|
|
// PasskeyProvider is an optional passkey provider for WebAuthn/FIDO2 authentication
|
|
PasskeyProvider PasskeyProvider
|
|
// Lookup selects dialect, query mode and procedure/table/column names.
|
|
// The zero value uses stored procedures on Postgres and direct SQL elsewhere.
|
|
Lookup lookup.Config
|
|
// LookupProvider, when set, is used instead of building one from Lookup and the db.
|
|
LookupProvider *lookup.Provider
|
|
// 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)
|
|
// EnableCookieSession enables cookie-based session management.
|
|
// When true, Authenticate reads the session token from the cookie named by
|
|
// CookieOptions.Name (default "session_token") in addition to the Authorization header,
|
|
// and LoginWithCookie / LogoutWithCookie automatically set / clear the cookie.
|
|
EnableCookieSession bool
|
|
// UpgradePasswordHash, when true, rewrites a legacy cleartext password as a
|
|
// bcrypt hash after a successful login. It is off by default and is never
|
|
// enabled automatically: legacy cleartext values are still accepted at login,
|
|
// but stored rows are left untouched unless this is set.
|
|
UpgradePasswordHash bool
|
|
// CookieOptions configures the session cookie written by LoginWithCookie.
|
|
// Only used when EnableCookieSession is true.
|
|
CookieOptions SessionCookieOptions
|
|
// AuthenticateCallback is a fallback called when the primary authentication (database
|
|
// session lookup) fails. If non-nil and the callback returns a non-nil UserContext,
|
|
// that result is used in place of the failure.
|
|
AuthenticateCallback func(r *http.Request) (*UserContext, error)
|
|
}
|
|
|
|
func NewDatabaseAuthenticator(db *sql.DB) *DatabaseAuthenticator {
|
|
return NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{
|
|
CacheTTL: 5 * time.Minute,
|
|
})
|
|
}
|
|
|
|
func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorOptions) *DatabaseAuthenticator {
|
|
if opts.CacheTTL == 0 {
|
|
opts.CacheTTL = 5 * time.Minute
|
|
}
|
|
|
|
cacheInstance := opts.Cache
|
|
if cacheInstance == nil {
|
|
cacheInstance = cache.GetDefaultCache()
|
|
}
|
|
|
|
src := newLookupSource(db)
|
|
src.cfg = opts.Lookup
|
|
src.provider = opts.LookupProvider
|
|
src.opts = backends.Options{DBFactory: opts.DBFactory, UpgradePasswordHash: opts.UpgradePasswordHash}
|
|
|
|
return &DatabaseAuthenticator{
|
|
src: src,
|
|
cache: cacheInstance,
|
|
cacheTTL: opts.CacheTTL,
|
|
passkeyProvider: opts.PasskeyProvider,
|
|
enableCookieSession: opts.EnableCookieSession,
|
|
cookieOptions: opts.CookieOptions,
|
|
authenticateCallback: opts.AuthenticateCallback,
|
|
}
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) auth() lookup.AuthStore { return a.src.get().Auth }
|
|
|
|
func (a *DatabaseAuthenticator) SetAuthenticateCallback(fn func(r *http.Request) (*UserContext, error)) {
|
|
a.authenticateCallback = fn
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
|
return a.auth().Login(ctx, req)
|
|
}
|
|
|
|
// LoginWithAPIKey implements APIKeyLoginable. It validates a raw header/generic
|
|
// API key and creates a session for the key's user. Unknown, expired and
|
|
// inactive keys all return errInvalidAPIKey; the raw key is never logged.
|
|
// Procedure-only: the key and user lookup live in resolvespec_login_api_key so
|
|
// the underlying schema can differ per database; there is no direct-SQL path.
|
|
func (a *DatabaseAuthenticator) LoginWithAPIKey(ctx context.Context, rawKey string, claims map[string]any) (*LoginResponse, error) {
|
|
return a.auth().LoginAPIKey(ctx, rawKey, claims)
|
|
}
|
|
|
|
// Register implements Registrable interface
|
|
func (a *DatabaseAuthenticator) Register(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
|
|
return a.auth().Register(ctx, req)
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
|
|
if err := a.auth().Logout(ctx, req); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Clear cache for this token
|
|
if req.Token != "" {
|
|
cacheKey := fmt.Sprintf("auth:session:%s", req.Token)
|
|
_ = a.cache.Delete(ctx, cacheKey)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// LoginWithCookie performs a login and, when EnableCookieSession is true, writes the
|
|
// session cookie to w using the configured CookieOptions. The LoginResponse is returned
|
|
// regardless of whether cookie sessions are enabled.
|
|
func (a *DatabaseAuthenticator) LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error) {
|
|
resp, err := a.Login(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if a.enableCookieSession {
|
|
SetSessionCookie(w, resp, a.cookieOptions)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// LogoutWithCookie performs a logout and, when EnableCookieSession is true, clears the
|
|
// session cookie on w. The logout itself is performed regardless of the cookie flag.
|
|
func (a *DatabaseAuthenticator) LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error {
|
|
err := a.Logout(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if a.enableCookieSession {
|
|
ClearSessionCookie(w, a.cookieOptions)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
|
|
// Extract session token from header or cookie
|
|
sessionToken := r.Header.Get("Authorization")
|
|
reference := "authenticate"
|
|
var tokens []string
|
|
|
|
if sessionToken == "" {
|
|
if a.enableCookieSession {
|
|
if token := GetSessionCookie(r, a.cookieOptions); token != "" {
|
|
tokens = []string{token}
|
|
reference = "cookie"
|
|
}
|
|
}
|
|
} else {
|
|
// Parse Authorization header which may contain multiple comma-separated tokens
|
|
// Format: "Token abc, Token def" or "Bearer abc" or just "abc"
|
|
rawTokens := strings.SplitN(sessionToken, ",", maxAuthTokens+2)
|
|
if len(rawTokens) > maxAuthTokens {
|
|
return nil, fmt.Errorf("too many authorization tokens")
|
|
}
|
|
for _, token := range rawTokens {
|
|
token = strings.TrimSpace(token)
|
|
// Remove "Bearer " prefix if present
|
|
token = strings.TrimPrefix(token, "Bearer ")
|
|
// Remove "Token " prefix if present
|
|
token = strings.TrimPrefix(token, "Token ")
|
|
token = strings.TrimSpace(token)
|
|
if token != "" {
|
|
tokens = append(tokens, token)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(tokens) == 0 {
|
|
if a.authenticateCallback != nil {
|
|
return a.authenticateCallback(r)
|
|
}
|
|
return nil, fmt.Errorf("session token required")
|
|
}
|
|
|
|
// Log warning if multiple tokens are provided
|
|
if len(tokens) > 1 {
|
|
logger.Warn("Multiple authentication tokens provided in Authorization header (%d tokens). This is unusual and may indicate a misconfigured client. Header: %s", len(tokens), sessionToken)
|
|
}
|
|
|
|
// Try each token until one succeeds
|
|
var lastErr error
|
|
for _, token := range tokens {
|
|
// Build cache key
|
|
cacheKey := fmt.Sprintf("auth:session:%s", token)
|
|
|
|
// Use cache.GetOrSet to get from cache or load from database
|
|
// Concurrent misses for the same token share one database lookup.
|
|
v, err, _ := a.sessionLoads.Do(cacheKey, func() (any, error) {
|
|
var loaded UserContext
|
|
err := a.cache.GetOrSet(r.Context(), cacheKey, &loaded, a.cacheTTL, func() (any, error) {
|
|
// This function is called only if cache miss
|
|
dbtrace.Raw(r.Context(), "auth.session")
|
|
|
|
return a.auth().Session(r.Context(), token, reference)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return loaded, nil
|
|
})
|
|
|
|
if err != nil {
|
|
lastErr = err
|
|
continue // Try next token
|
|
}
|
|
userCtx, _ := v.(UserContext)
|
|
|
|
// Authentication succeeded with this token
|
|
// Update last activity timestamp asynchronously
|
|
if a.activityLimit.allow(token, time.Now()) {
|
|
activityCtx := userCtx
|
|
// Detach from the request (it is cancelled when the handler returns) but
|
|
// keep a deadline, and never let a panic here take the process down.
|
|
detached, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), sessionActivityTimeout)
|
|
a.activityWG.Add(1)
|
|
go func(ctx context.Context, token string) {
|
|
defer a.activityWG.Done()
|
|
defer cancel()
|
|
defer logger.CatchPanic("updateSessionActivity")()
|
|
a.updateSessionActivity(ctx, token, &activityCtx)
|
|
}(detached, token)
|
|
|
|
}
|
|
|
|
return &userCtx, nil
|
|
}
|
|
|
|
// All tokens failed — try callback before returning error
|
|
if a.authenticateCallback != nil {
|
|
return a.authenticateCallback(r)
|
|
}
|
|
if lastErr != nil {
|
|
return nil, lastErr
|
|
}
|
|
return nil, fmt.Errorf("authentication failed for all provided tokens")
|
|
}
|
|
|
|
// ClearCache removes a specific token from the cache or clears all cache if token is empty
|
|
func (a *DatabaseAuthenticator) ClearCache(token string) error {
|
|
ctx := context.Background()
|
|
if token != "" {
|
|
cacheKey := fmt.Sprintf("auth:session:%s", token)
|
|
return a.cache.Delete(ctx, cacheKey)
|
|
}
|
|
// Clear all auth cache entries
|
|
return a.cache.DeleteByPattern(ctx, "auth:session:*")
|
|
}
|
|
|
|
// ClearUserCache removes all cache entries for a specific user ID
|
|
func (a *DatabaseAuthenticator) ClearUserCache(userID int) error {
|
|
ctx := context.Background()
|
|
// Clear all sessions for this user
|
|
pattern := "auth:session:*"
|
|
return a.cache.DeleteByPattern(ctx, pattern)
|
|
}
|
|
|
|
// updateSessionActivity updates the last activity timestamp for the session
|
|
func (a *DatabaseAuthenticator) updateSessionActivity(ctx context.Context, sessionToken string, userCtx *UserContext) {
|
|
dbtrace.Raw(ctx, "auth.activity")
|
|
|
|
_ = a.auth().TouchSession(ctx, sessionToken, userCtx)
|
|
}
|
|
|
|
// RefreshToken implements Refreshable interface
|
|
func (a *DatabaseAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*LoginResponse, error) {
|
|
return a.auth().Refresh(ctx, refreshToken)
|
|
}
|
|
|
|
// JWTAuthenticator provides JWT token-based authentication
|
|
// All database operations go through stored procedures
|
|
// Procedure names and modes are configured through lookup.Config (see lookup.DefaultProcNames)
|
|
// NOTE: JWT signing/verification requires github.com/golang-jwt/jwt/v5 to be installed and imported
|
|
type JWTAuthenticator struct {
|
|
secretKey []byte
|
|
src *lookupSource
|
|
}
|
|
|
|
// WithPasswordHashUpgrade explicitly enables (or disables) upgrading legacy
|
|
// cleartext passwords to bcrypt after a successful login. Off by default.
|
|
func (a *JWTAuthenticator) WithPasswordHashUpgrade(enabled bool) *JWTAuthenticator {
|
|
a.src.opts.UpgradePasswordHash = enabled
|
|
return a
|
|
}
|
|
|
|
func NewJWTAuthenticator(secretKey string, db *sql.DB) *JWTAuthenticator {
|
|
return &JWTAuthenticator{secretKey: []byte(secretKey), src: newLookupSource(db)}
|
|
}
|
|
|
|
// WithDBFactory configures a factory used to reopen the database connection if it is closed.
|
|
func (a *JWTAuthenticator) WithDBFactory(factory func() (*sql.DB, error)) *JWTAuthenticator {
|
|
a.src.opts.DBFactory = factory
|
|
return a
|
|
}
|
|
|
|
// WithLookup configures dialect, query mode and names. Call before first use.
|
|
func (a *JWTAuthenticator) WithLookup(cfg lookup.Config) *JWTAuthenticator {
|
|
a.src.cfg = cfg
|
|
return a
|
|
}
|
|
|
|
// WithLookupProvider uses an existing provider instead of building one.
|
|
func (a *JWTAuthenticator) WithLookupProvider(p *lookup.Provider) *JWTAuthenticator {
|
|
a.src.provider = p
|
|
return a
|
|
}
|
|
|
|
func (a *JWTAuthenticator) auth() lookup.AuthStore { return a.src.get().Auth }
|
|
|
|
func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
|
return a.auth().JWTLogin(ctx, req)
|
|
}
|
|
|
|
func (a *JWTAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
|
|
return a.auth().JWTLogout(ctx, req)
|
|
}
|
|
|
|
func (a *JWTAuthenticator) LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error) {
|
|
return a.Login(ctx, req)
|
|
}
|
|
|
|
func (a *JWTAuthenticator) LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error {
|
|
return a.Logout(ctx, req)
|
|
}
|
|
|
|
func (a *JWTAuthenticator) 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")
|
|
}
|
|
|
|
// TODO: Implement JWT parsing when library is available
|
|
return nil, fmt.Errorf("JWT parsing not implemented - install github.com/golang-jwt/jwt/v5")
|
|
}
|
|
|
|
// Production-Ready Security Providers
|
|
// ====================================
|
|
|
|
// DatabaseColumnSecurityProvider loads column security through the lookup package
|
|
// (stored procedure on Postgres by default, direct SQL elsewhere).
|
|
type DatabaseColumnSecurityProvider struct {
|
|
src *lookupSource
|
|
}
|
|
|
|
func NewDatabaseColumnSecurityProvider(db *sql.DB) *DatabaseColumnSecurityProvider {
|
|
return &DatabaseColumnSecurityProvider{src: newLookupSource(db)}
|
|
}
|
|
|
|
// WithLookup configures dialect, query mode and names. Call before first use.
|
|
func (p *DatabaseColumnSecurityProvider) WithLookup(cfg lookup.Config) *DatabaseColumnSecurityProvider {
|
|
p.src.cfg = cfg
|
|
return p
|
|
}
|
|
|
|
// WithLookupProvider uses an existing provider instead of building one.
|
|
func (p *DatabaseColumnSecurityProvider) WithLookupProvider(lp *lookup.Provider) *DatabaseColumnSecurityProvider {
|
|
p.src.provider = lp
|
|
return p
|
|
}
|
|
|
|
// WithNoGroupTables skips group membership when loading rules in direct mode.
|
|
func (p *DatabaseColumnSecurityProvider) WithNoGroupTables() *DatabaseColumnSecurityProvider {
|
|
p.src.opts.NoGroupTables = true
|
|
return p
|
|
}
|
|
|
|
func (p *DatabaseColumnSecurityProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseColumnSecurityProvider {
|
|
p.src.opts.DBFactory = factory
|
|
return p
|
|
}
|
|
|
|
func (p *DatabaseColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error) {
|
|
dbtrace.Raw(ctx, "security.column")
|
|
return p.src.get().Policy.ColumnSecurity(ctx, userID, schema, table)
|
|
}
|
|
|
|
// DatabaseRowSecurityProvider loads row security through the lookup package
|
|
// (stored procedure on Postgres by default, direct SQL elsewhere).
|
|
type DatabaseRowSecurityProvider struct {
|
|
src *lookupSource
|
|
}
|
|
|
|
func NewDatabaseRowSecurityProvider(db *sql.DB) *DatabaseRowSecurityProvider {
|
|
return &DatabaseRowSecurityProvider{src: newLookupSource(db)}
|
|
}
|
|
|
|
// WithLookup configures dialect, query mode and names. Call before first use.
|
|
func (p *DatabaseRowSecurityProvider) WithLookup(cfg lookup.Config) *DatabaseRowSecurityProvider {
|
|
p.src.cfg = cfg
|
|
return p
|
|
}
|
|
|
|
// WithLookupProvider uses an existing provider instead of building one.
|
|
func (p *DatabaseRowSecurityProvider) WithLookupProvider(lp *lookup.Provider) *DatabaseRowSecurityProvider {
|
|
p.src.provider = lp
|
|
return p
|
|
}
|
|
|
|
// WithNoGroupTables skips group membership when loading rules in direct mode.
|
|
func (p *DatabaseRowSecurityProvider) WithNoGroupTables() *DatabaseRowSecurityProvider {
|
|
p.src.opts.NoGroupTables = true
|
|
return p
|
|
}
|
|
|
|
func (p *DatabaseRowSecurityProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseRowSecurityProvider {
|
|
p.src.opts.DBFactory = factory
|
|
return p
|
|
}
|
|
|
|
func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
|
|
dbtrace.Raw(ctx, "security.row")
|
|
return p.src.get().Policy.RowSecurity(ctx, userRef, schema, table)
|
|
}
|
|
|
|
// Helper functions
|
|
// ================
|
|
|
|
func parseRoles(rolesStr string) []string {
|
|
if rolesStr == "" {
|
|
return []string{}
|
|
}
|
|
return strings.Split(rolesStr, ",")
|
|
}
|
|
|
|
func parseIntHeader(r *http.Request, key string, defaultVal int) int {
|
|
val := r.Header.Get(key)
|
|
if val == "" {
|
|
return defaultVal
|
|
}
|
|
intVal, err := strconv.Atoi(val)
|
|
if err != nil {
|
|
return defaultVal
|
|
}
|
|
return intVal
|
|
}
|
|
|
|
func generateRandomString(length int) string {
|
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
b := make([]byte, length)
|
|
for i := range b {
|
|
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// func getClaimString(claims map[string]any, key string) string {
|
|
// if claims == nil {
|
|
// return ""
|
|
// }
|
|
// if val, ok := claims[key]; ok {
|
|
// if str, ok := val.(string); ok {
|
|
// return str
|
|
// }
|
|
// }
|
|
// return ""
|
|
// }
|
|
|
|
// Password reset methods
|
|
// ======================
|
|
|
|
// RequestPasswordReset implements PasswordResettable. It calls the stored procedure
|
|
// resolvespec_password_reset_request and returns the reset token and expiry.
|
|
func (a *DatabaseAuthenticator) RequestPasswordReset(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) {
|
|
return a.auth().ResetRequest(ctx, req)
|
|
}
|
|
|
|
// CompletePasswordReset implements PasswordResettable. It validates the token and
|
|
// updates the user's password via resolvespec_password_reset.
|
|
func (a *DatabaseAuthenticator) CompletePasswordReset(ctx context.Context, req PasswordResetCompleteRequest) error {
|
|
return a.auth().ResetComplete(ctx, req)
|
|
}
|
|
|
|
// Passkey authentication methods
|
|
// ==============================
|
|
|
|
// WithPasskey configures the DatabaseAuthenticator with a passkey provider
|
|
func (a *DatabaseAuthenticator) WithPasskey(provider PasskeyProvider) *DatabaseAuthenticator {
|
|
a.passkeyProvider = provider
|
|
return a
|
|
}
|
|
|
|
// BeginPasskeyRegistration initiates passkey registration for a user
|
|
func (a *DatabaseAuthenticator) BeginPasskeyRegistration(ctx context.Context, req PasskeyBeginRegistrationRequest) (*PasskeyRegistrationOptions, error) {
|
|
if a.passkeyProvider == nil {
|
|
return nil, fmt.Errorf("passkey provider not configured")
|
|
}
|
|
return a.passkeyProvider.BeginRegistration(ctx, req.UserID, req.Username, req.DisplayName)
|
|
}
|
|
|
|
// CompletePasskeyRegistration completes passkey registration
|
|
func (a *DatabaseAuthenticator) CompletePasskeyRegistration(ctx context.Context, req PasskeyRegisterRequest) (*PasskeyCredential, error) {
|
|
if a.passkeyProvider == nil {
|
|
return nil, fmt.Errorf("passkey provider not configured")
|
|
}
|
|
|
|
cred, err := a.passkeyProvider.CompleteRegistration(ctx, req.UserID, req.Response, req.ExpectedChallenge)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update credential name if provided
|
|
if req.CredentialName != "" && cred.ID != "" {
|
|
_ = a.passkeyProvider.UpdateCredentialName(ctx, req.UserID, cred.ID, req.CredentialName)
|
|
}
|
|
|
|
return cred, nil
|
|
}
|
|
|
|
// BeginPasskeyAuthentication initiates passkey authentication
|
|
func (a *DatabaseAuthenticator) BeginPasskeyAuthentication(ctx context.Context, req PasskeyBeginAuthenticationRequest) (*PasskeyAuthenticationOptions, error) {
|
|
if a.passkeyProvider == nil {
|
|
return nil, fmt.Errorf("passkey provider not configured")
|
|
}
|
|
return a.passkeyProvider.BeginAuthentication(ctx, req.Username)
|
|
}
|
|
|
|
// LoginWithPasskey authenticates a user using a passkey and creates a session
|
|
func (a *DatabaseAuthenticator) LoginWithPasskey(ctx context.Context, req PasskeyLoginRequest) (*LoginResponse, error) {
|
|
if a.passkeyProvider == nil {
|
|
return nil, fmt.Errorf("passkey provider not configured")
|
|
}
|
|
|
|
// Verify passkey assertion
|
|
userID, err := a.passkeyProvider.CompleteAuthentication(ctx, req.Response, req.ExpectedChallenge)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("passkey authentication failed: %w", err)
|
|
}
|
|
|
|
return a.src.get().Passkey.Login(ctx, userID, req.Claims)
|
|
}
|
|
|
|
// GetPasskeyCredentials returns all passkey credentials for a user
|
|
func (a *DatabaseAuthenticator) GetPasskeyCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) {
|
|
if a.passkeyProvider == nil {
|
|
return nil, fmt.Errorf("passkey provider not configured")
|
|
}
|
|
return a.passkeyProvider.GetCredentials(ctx, userID)
|
|
}
|
|
|
|
// DeletePasskeyCredential removes a passkey credential
|
|
func (a *DatabaseAuthenticator) DeletePasskeyCredential(ctx context.Context, userID int, credentialID string) error {
|
|
if a.passkeyProvider == nil {
|
|
return fmt.Errorf("passkey provider not configured")
|
|
}
|
|
return a.passkeyProvider.DeleteCredential(ctx, userID, credentialID)
|
|
}
|
|
|
|
// UpdatePasskeyCredentialName updates the friendly name of a credential
|
|
func (a *DatabaseAuthenticator) UpdatePasskeyCredentialName(ctx context.Context, userID int, credentialID string, name string) error {
|
|
if a.passkeyProvider == nil {
|
|
return fmt.Errorf("passkey provider not configured")
|
|
}
|
|
return a.passkeyProvider.UpdateCredentialName(ctx, userID, credentialID, name)
|
|
}
|