4f949e6d8f
API keys were never validated — the middleware only checked session tokens, so an X-API-Key request always 401'd. Add auth.Service.ValidateAPIKey (looks up the key hash, enforces enabled/revoked/expiry, loads the user + roles, stamps last_used_utc) and route X-API-Key / bearer auth through it. Add per-key scopes (migration 006): "read" (GET/HEAD only) or "readwrite" (full access, default). The middleware rejects mutating requests from a read-scoped key with 403. Create accepts a scope; list and create responses include it; the UI create dialog has a scope selector and the list shows a scope chip. Disable/re-enable and revoke (permanent) were already correct. Verified live: RW key GET/POST ok; read key GET ok, POST 403; disable→401, re-enable→200; revoke→401 and re-enable blocked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
364 lines
9.9 KiB
Go
364 lines
9.9 KiB
Go
// Package auth provides authentication and authorization services
|
|
package auth
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/crypto"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/models"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
|
ErrUserDisabled = errors.New("user account is disabled")
|
|
ErrUserNotFound = errors.New("user not found")
|
|
ErrSessionExpired = errors.New("session has expired")
|
|
ErrSessionRevoked = errors.New("session has been revoked")
|
|
ErrPasswordTooShort = errors.New("new password is too short")
|
|
ErrPasswordUnchanged = errors.New("new password must differ from the current password")
|
|
ErrUsernameTaken = errors.New("a different account already uses this username")
|
|
)
|
|
|
|
// MinPasswordLength is the enforced minimum length for passwords set through
|
|
// the self-service change-password flow. This is intentionally modest so the
|
|
// bootstrap admin can trivially rotate the default, but tight enough that
|
|
// trivial values like "abc" cannot be used.
|
|
const MinPasswordLength = 8
|
|
|
|
// Service provides authentication functionality
|
|
type Service struct {
|
|
db *sql.DB
|
|
userRepo *repository.UserRepository
|
|
}
|
|
|
|
// NewService creates a new auth service
|
|
func NewService(db *sql.DB) *Service {
|
|
return &Service{
|
|
db: db,
|
|
userRepo: repository.NewUserRepository(db),
|
|
}
|
|
}
|
|
|
|
// LoginResult represents a successful login
|
|
type LoginResult struct {
|
|
User *models.User
|
|
SessionToken string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// Login authenticates a user with username and password
|
|
func (s *Service) Login(username, password string) (*LoginResult, error) {
|
|
user, err := s.userRepo.GetByUsername(username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
|
|
if !user.IsActive {
|
|
return nil, ErrUserDisabled
|
|
}
|
|
|
|
if user.PasswordHash == nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
|
|
valid, err := crypto.VerifyPassword(password, *user.PasswordHash)
|
|
if err != nil || !valid {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
|
|
return s.createSession(user)
|
|
}
|
|
|
|
// createSession issues a new session for an already-authenticated user, updates
|
|
// last-login, loads roles, and returns the session token. Shared by local and
|
|
// OIDC login.
|
|
func (s *Service) createSession(user *models.User) (*LoginResult, error) {
|
|
sessionToken, err := crypto.GenerateRandomKey(32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tokenHash := crypto.HashAPIKey(sessionToken)
|
|
sessionID := uuid.New().String()
|
|
expiresAt := time.Now().UTC().Add(24 * time.Hour)
|
|
now := time.Now().UTC()
|
|
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO sessions (id, user_id, token_hash, expires_utc, created_utc)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, sessionID, user.ID, tokenHash, expiresAt.Format(time.RFC3339), now.Format(time.RFC3339))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.userRepo.UpdateLastLogin(user.ID)
|
|
user.Roles, _ = s.GetUserRoles(user.ID)
|
|
|
|
return &LoginResult{
|
|
User: user,
|
|
SessionToken: sessionToken,
|
|
ExpiresAt: expiresAt,
|
|
}, nil
|
|
}
|
|
|
|
// OIDCIdentity carries the claims extracted from a validated ID token.
|
|
type OIDCIdentity struct {
|
|
ProviderID string
|
|
Subject string
|
|
Username string
|
|
Email string
|
|
DisplayName string
|
|
DefaultRole string // role name to grant a newly provisioned user (optional)
|
|
}
|
|
|
|
// LoginOIDC signs in a federated user. It links by the stable (provider,
|
|
// subject) pair; a first-time subject provisions a new active OIDC user (no
|
|
// local password). To avoid silent account takeover it refuses to reuse a
|
|
// username already held by a different (e.g. local) account.
|
|
func (s *Service) LoginOIDC(id OIDCIdentity) (*LoginResult, error) {
|
|
if id.Subject == "" || id.Username == "" {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
|
|
user, err := s.userRepo.GetByOIDCSubject(id.ProviderID, id.Subject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user != nil {
|
|
if !user.IsActive {
|
|
return nil, ErrUserDisabled
|
|
}
|
|
return s.createSession(user)
|
|
}
|
|
|
|
// First login for this subject: the username must not already belong to a
|
|
// different account.
|
|
if existing, err := s.userRepo.GetByUsername(id.Username); err != nil {
|
|
return nil, err
|
|
} else if existing != nil {
|
|
return nil, ErrUsernameTaken
|
|
}
|
|
|
|
newUser := &models.User{
|
|
Username: id.Username,
|
|
Email: strOrNil(id.Email),
|
|
DisplayName: strOrNil(id.DisplayName),
|
|
IsActive: true,
|
|
IsOIDCUser: true,
|
|
OIDCProviderID: strOrNil(id.ProviderID),
|
|
OIDCSubject: strOrNil(id.Subject),
|
|
}
|
|
if err := s.userRepo.Create(newUser); err != nil {
|
|
return nil, err
|
|
}
|
|
if id.DefaultRole != "" {
|
|
if err := s.assignRoleByName(newUser.ID, id.DefaultRole); err != nil {
|
|
// Non-fatal: the user is created but without the default role.
|
|
_ = err
|
|
}
|
|
}
|
|
return s.createSession(newUser)
|
|
}
|
|
|
|
// assignRoleByName grants the named role to a user, if the role exists.
|
|
func (s *Service) assignRoleByName(userID, roleName string) error {
|
|
var roleID string
|
|
err := s.db.QueryRow(`SELECT id FROM roles WHERE name = ?`, roleName).Scan(&roleID)
|
|
if err == sql.ErrNoRows {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.Exec(`
|
|
INSERT OR IGNORE INTO user_roles (user_id, role_id, created_utc)
|
|
VALUES (?, ?, ?)
|
|
`, userID, roleID, time.Now().UTC().Format(time.RFC3339))
|
|
return err
|
|
}
|
|
|
|
func strOrNil(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
|
|
// ValidateSession validates a session token and returns the user
|
|
func (s *Service) ValidateSession(token string) (*models.User, error) {
|
|
tokenHash := crypto.HashAPIKey(token)
|
|
|
|
var session models.Session
|
|
var expiresStr, revokedStr sql.NullString
|
|
|
|
err := s.db.QueryRow(`
|
|
SELECT id, user_id, expires_utc, revoked_utc
|
|
FROM sessions WHERE token_hash = ?
|
|
`, tokenHash).Scan(&session.ID, &session.UserID, &expiresStr, &revokedStr)
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if revokedStr.Valid {
|
|
return nil, ErrSessionRevoked
|
|
}
|
|
|
|
expires, _ := time.Parse(time.RFC3339, expiresStr.String)
|
|
if time.Now().UTC().After(expires) {
|
|
return nil, ErrSessionExpired
|
|
}
|
|
|
|
user, err := s.userRepo.GetByID(session.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
|
|
if !user.IsActive {
|
|
return nil, ErrUserDisabled
|
|
}
|
|
|
|
user.Roles, _ = s.GetUserRoles(user.ID)
|
|
return user, nil
|
|
}
|
|
|
|
// ValidateAPIKey resolves an API key (as sent in the X-API-Key header) to its
|
|
// owning user and scope ("read" or "readwrite"), enforcing the key's
|
|
// enabled/revoked/expiry state, and best-effort stamps last_used_utc. Mirrors
|
|
// ValidateSession for the API-key auth path.
|
|
func (s *Service) ValidateAPIKey(token string) (*models.User, string, error) {
|
|
keyHash := crypto.HashAPIKey(token)
|
|
|
|
var id, userID string
|
|
var isEnabled int
|
|
var expiresStr, revokedStr, scopeStr sql.NullString
|
|
err := s.db.QueryRow(`
|
|
SELECT id, user_id, is_enabled, expires_utc, revoked_utc, scope
|
|
FROM api_keys WHERE key_hash = ?
|
|
`, keyHash).Scan(&id, &userID, &isEnabled, &expiresStr, &revokedStr, &scopeStr)
|
|
if err == sql.ErrNoRows {
|
|
return nil, "", ErrInvalidCredentials
|
|
}
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if revokedStr.Valid {
|
|
return nil, "", ErrSessionRevoked
|
|
}
|
|
if isEnabled == 0 {
|
|
return nil, "", ErrSessionRevoked
|
|
}
|
|
if expiresStr.Valid && expiresStr.String != "" {
|
|
if expires, perr := time.Parse(time.RFC3339, expiresStr.String); perr == nil && time.Now().UTC().After(expires) {
|
|
return nil, "", ErrSessionExpired
|
|
}
|
|
}
|
|
|
|
user, err := s.userRepo.GetByID(userID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if user == nil {
|
|
return nil, "", ErrUserNotFound
|
|
}
|
|
if !user.IsActive {
|
|
return nil, "", ErrUserDisabled
|
|
}
|
|
|
|
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_utc = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id)
|
|
|
|
user.Roles, _ = s.GetUserRoles(user.ID)
|
|
scope := scopeStr.String
|
|
if scope == "" {
|
|
scope = "readwrite"
|
|
}
|
|
return user, scope, nil
|
|
}
|
|
|
|
// ChangePassword verifies the user's current password and, on success,
|
|
// replaces it with newPassword while clearing the password_reset_required
|
|
// flag atomically through UserRepository.UpdatePassword.
|
|
//
|
|
// Returns ErrInvalidCredentials if currentPassword does not verify,
|
|
// ErrUserNotFound if the user does not exist, ErrPasswordTooShort if
|
|
// newPassword is below MinPasswordLength, and ErrPasswordUnchanged if the
|
|
// caller is trying to reuse the existing password.
|
|
func (s *Service) ChangePassword(userID, currentPassword, newPassword string) error {
|
|
if len(newPassword) < MinPasswordLength {
|
|
return ErrPasswordTooShort
|
|
}
|
|
if newPassword == currentPassword {
|
|
return ErrPasswordUnchanged
|
|
}
|
|
|
|
user, err := s.userRepo.GetByID(userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user == nil {
|
|
return ErrUserNotFound
|
|
}
|
|
if user.PasswordHash == nil {
|
|
return ErrInvalidCredentials
|
|
}
|
|
|
|
valid, err := crypto.VerifyPassword(currentPassword, *user.PasswordHash)
|
|
if err != nil || !valid {
|
|
return ErrInvalidCredentials
|
|
}
|
|
|
|
newHash, err := crypto.HashPassword(newPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.userRepo.UpdatePassword(userID, newHash)
|
|
}
|
|
|
|
// Logout revokes a session
|
|
func (s *Service) Logout(token string) error {
|
|
tokenHash := crypto.HashAPIKey(token)
|
|
now := time.Now().UTC()
|
|
_, err := s.db.Exec(`
|
|
UPDATE sessions SET revoked_utc = ? WHERE token_hash = ?
|
|
`, now.Format(time.RFC3339), tokenHash)
|
|
return err
|
|
}
|
|
|
|
// GetUserRoles retrieves roles for a user
|
|
func (s *Service) GetUserRoles(userID string) ([]models.Role, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT r.id, r.name, r.description, r.is_system_role
|
|
FROM roles r
|
|
INNER JOIN user_roles ur ON r.id = ur.role_id
|
|
WHERE ur.user_id = ?
|
|
`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var roles []models.Role
|
|
for rows.Next() {
|
|
var role models.Role
|
|
var isSystem int
|
|
if err := rows.Scan(&role.ID, &role.Name, &role.Description, &isSystem); err != nil {
|
|
return nil, err
|
|
}
|
|
role.IsSystemRole = isSystem != 0
|
|
roles = append(roles, role)
|
|
}
|
|
return roles, rows.Err()
|
|
}
|