139 lines
3.4 KiB
Go
139 lines
3.4 KiB
Go
// Package auth provides MCIAS authentication integration with token caching.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCredentials = errors.New("auth: invalid credentials")
|
|
ErrInvalidToken = errors.New("auth: invalid token")
|
|
)
|
|
|
|
const tokenCacheTTL = 30 * time.Second
|
|
|
|
// TokenInfo holds validated token information.
|
|
type TokenInfo struct {
|
|
Username string
|
|
Roles []string
|
|
IsAdmin bool
|
|
}
|
|
|
|
// cachedClaims holds a cached token validation result.
|
|
type cachedClaims struct {
|
|
info *TokenInfo
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// Authenticator provides MCIAS-backed authentication.
|
|
type Authenticator struct {
|
|
client *mcias.Client
|
|
logger *slog.Logger
|
|
|
|
mu sync.RWMutex
|
|
cache map[string]*cachedClaims // keyed by SHA-256(token)
|
|
}
|
|
|
|
// NewAuthenticator creates a new authenticator with the given MCIAS client.
|
|
func NewAuthenticator(client *mcias.Client, logger *slog.Logger) *Authenticator {
|
|
return &Authenticator{
|
|
client: client,
|
|
logger: logger,
|
|
cache: make(map[string]*cachedClaims),
|
|
}
|
|
}
|
|
|
|
// Login authenticates a user via MCIAS and returns the token.
|
|
func (a *Authenticator) Login(username, password, totpCode string) (token string, expiresAt string, err error) {
|
|
a.logger.Debug("login attempt", "username", username)
|
|
tok, exp, err := a.client.Login(username, password, totpCode)
|
|
if err != nil {
|
|
var authErr *mcias.MciasAuthError
|
|
if errors.As(err, &authErr) {
|
|
a.logger.Debug("login failed: invalid credentials", "username", username)
|
|
return "", "", ErrInvalidCredentials
|
|
}
|
|
a.logger.Debug("login failed", "username", username, "error", err)
|
|
return "", "", err
|
|
}
|
|
a.logger.Debug("login succeeded", "username", username)
|
|
return tok, exp, nil
|
|
}
|
|
|
|
// ValidateToken validates a bearer token, using a short-lived cache.
|
|
func (a *Authenticator) ValidateToken(token string) (*TokenInfo, error) {
|
|
key := tokenHash(token)
|
|
|
|
// Check cache.
|
|
a.mu.RLock()
|
|
cached, ok := a.cache[key]
|
|
a.mu.RUnlock()
|
|
if ok && time.Now().Before(cached.expiresAt) {
|
|
a.logger.Debug("token validated from cache")
|
|
return cached.info, nil
|
|
}
|
|
|
|
a.logger.Debug("validating token with MCIAS")
|
|
// Validate with MCIAS.
|
|
claims, err := a.client.ValidateToken(token)
|
|
if err != nil {
|
|
a.logger.Debug("token validation failed", "error", err)
|
|
return nil, err
|
|
}
|
|
if !claims.Valid {
|
|
a.logger.Debug("token invalid per MCIAS")
|
|
return nil, ErrInvalidToken
|
|
}
|
|
|
|
info := &TokenInfo{
|
|
Username: claims.Sub,
|
|
Roles: claims.Roles,
|
|
IsAdmin: hasAdminRole(claims.Roles),
|
|
}
|
|
|
|
// Cache the result.
|
|
a.mu.Lock()
|
|
a.cache[key] = &cachedClaims{
|
|
info: info,
|
|
expiresAt: time.Now().Add(tokenCacheTTL),
|
|
}
|
|
a.mu.Unlock()
|
|
a.logger.Debug("token validated and cached", "username", info.Username, "is_admin", info.IsAdmin)
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// Logout invalidates a token via MCIAS. The client must have the token set.
|
|
func (a *Authenticator) Logout(client *mcias.Client) error {
|
|
return client.Logout()
|
|
}
|
|
|
|
// ClearCache removes all cached token validations.
|
|
func (a *Authenticator) ClearCache() {
|
|
a.logger.Debug("clearing token cache")
|
|
a.mu.Lock()
|
|
a.cache = make(map[string]*cachedClaims)
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func tokenHash(token string) string {
|
|
h := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func hasAdminRole(roles []string) bool {
|
|
for _, r := range roles {
|
|
if r == "admin" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|