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