Files
Alphaeus Mote 4f949e6d8f fix(auth): make API keys actually authenticate; add read/read-write scopes
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>
2026-09-02 19:01:30 -04:00

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()
}