Files
cairnquire/apps/server/internal/auth/service.go
2026-06-04 08:50:34 -04:00

727 lines
21 KiB
Go

package auth
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const (
SessionCookieName = "cairnquire_session"
sessionTTL = 24 * time.Hour
challengeTTL = 5 * time.Minute
deviceCodeTTL = 15 * time.Minute
passwordResetTTL = time.Hour
devicePollSeconds = 5
)
type EmailSender interface {
Send(ctx context.Context, to []string, subject, body string) error
}
type Service struct {
repo *Repository
webauthn *webauthn.WebAuthn
publicOrigin string
email EmailSender
}
func NewService(repo *Repository, publicOrigin string) (*Service, error) {
if publicOrigin == "" {
publicOrigin = "http://localhost:8080"
}
parsed, err := url.Parse(publicOrigin)
if err != nil {
return nil, fmt.Errorf("parse public origin: %w", err)
}
rpID := parsed.Hostname()
if rpID == "" {
return nil, fmt.Errorf("public origin must include host")
}
web, err := webauthn.New(&webauthn.Config{
RPID: rpID,
RPDisplayName: "Cairnquire",
RPOrigins: []string{publicOrigin},
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementPreferred,
UserVerification: protocol.VerificationPreferred,
},
AttestationPreference: protocol.PreferNoAttestation,
})
if err != nil {
return nil, err
}
return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil
}
func (s *Service) SetEmailSender(sender EmailSender) {
s.email = sender
}
func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
return s.repo.GetInstanceSettings(ctx)
}
func (s *Service) CompleteInitialSetup(ctx context.Context, email, displayName, password string, signupsEnabled bool) (User, error) {
email = normalizeEmail(email)
if email == "" {
return User{}, fmt.Errorf("email is required")
}
if len(password) < 12 {
return User{}, fmt.Errorf("password must be at least 12 characters")
}
passwordHash, err := hashPassword(password)
if err != nil {
return User{}, err
}
return s.repo.CompleteInitialSetup(ctx, User{
Email: email,
DisplayName: strings.TrimSpace(displayName),
PasswordHash: passwordHash,
Role: RoleAdmin,
}, signupsEnabled)
}
func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName, password, role string) (User, error) {
if err := s.requirePublicSignups(ctx); err != nil {
return User{}, err
}
email = normalizeEmail(email)
if email == "" {
return User{}, fmt.Errorf("email is required")
}
if len(password) < 12 {
return User{}, fmt.Errorf("password must be at least 12 characters")
}
if _, err := s.repo.GetUserByEmail(ctx, email); err == nil {
return User{}, fmt.Errorf("user already exists")
} else if err != nil && !isNoRows(err) {
return User{}, err
}
passwordHash, err := hashPassword(password)
if err != nil {
return User{}, err
}
return s.repo.UpsertUser(ctx, User{
Email: email,
DisplayName: strings.TrimSpace(displayName),
PasswordHash: passwordHash,
Role: RoleViewer,
})
}
func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAgent string) (Principal, string, error) {
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
if err != nil {
if isNoRows(err) {
_, _ = verifyPassword(password, mustDummyPasswordHash())
}
return Principal{}, "", fmt.Errorf("invalid credentials")
}
if user.PasswordHash == "" {
return Principal{}, "", fmt.Errorf("invalid credentials")
}
ok, err := verifyPassword(password, user.PasswordHash)
if err != nil || !ok || user.Disabled {
return Principal{}, "", fmt.Errorf("invalid credentials")
}
principal, token, err := s.createSession(ctx, user, ip, userAgent)
if err == nil {
s.repo.Audit(ctx, user.ID, "auth.password.login", "user", user.ID, ip, userAgent, nil)
}
return principal, token, err
}
func (s *Service) BeginPasskeyRegistration(ctx context.Context, email, displayName, role string) (any, string, error) {
if err := s.requirePublicSignups(ctx); err != nil {
return nil, "", err
}
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
if err != nil {
if !isNoRows(err) {
return nil, "", err
}
user, err = s.repo.UpsertUser(ctx, User{
Email: normalizeEmail(email),
DisplayName: strings.TrimSpace(displayName),
Role: RoleViewer,
})
if err != nil {
return nil, "", err
}
} else {
return nil, "", fmt.Errorf("user already exists")
}
creation, session, err := s.webauthn.BeginRegistration(user)
if err != nil {
return nil, "", err
}
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL)
if err != nil {
return nil, "", err
}
return creation, challengeID, nil
}
func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID string, r *http.Request) (User, error) {
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration")
if err != nil {
return User{}, err
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return User{}, err
}
credential, err := s.webauthn.FinishRegistration(user, session, r)
if err != nil {
return User{}, err
}
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
return User{}, err
}
s.repo.Audit(ctx, user.ID, "auth.passkey.register", "user", user.ID, clientIP(r), r.UserAgent(), nil)
return s.repo.GetUserByID(ctx, user.ID)
}
func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) {
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
if err != nil {
return nil, "", err
}
if user.Disabled {
return nil, "", fmt.Errorf("invalid credentials")
}
assertion, session, err := s.webauthn.BeginLogin(user)
if err != nil {
return nil, "", err
}
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_login", *session, challengeTTL)
if err != nil {
return nil, "", err
}
return assertion, challengeID, nil
}
func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r *http.Request) (Principal, string, error) {
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_login")
if err != nil {
return Principal{}, "", err
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return Principal{}, "", err
}
if user.Disabled {
return Principal{}, "", fmt.Errorf("invalid credentials")
}
credential, err := s.webauthn.FinishLogin(user, session, r)
if err != nil {
return Principal{}, "", err
}
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
return Principal{}, "", err
}
principal, token, err := s.createSession(ctx, user, clientIP(r), r.UserAgent())
if err == nil {
s.repo.Audit(ctx, user.ID, "auth.passkey.login", "user", user.ID, clientIP(r), r.UserAgent(), nil)
}
return principal, token, err
}
func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Principal, error) {
session, user, err := s.repo.ValidateSession(ctx, hashSecret(token))
if err != nil {
return Principal{}, err
}
if user.Disabled {
return Principal{}, fmt.Errorf("account disabled")
}
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), nil
}
func (s *Service) RevokeSession(ctx context.Context, sessionID string) error {
return s.repo.RevokeSession(ctx, sessionID)
}
func (s *Service) ChangePassword(ctx context.Context, principal Principal, currentPassword, newPassword string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
if len(newPassword) < 12 {
return fmt.Errorf("password must be at least 12 characters")
}
user, err := s.repo.GetUserByID(ctx, principal.UserID)
if err != nil {
return err
}
if user.PasswordHash != "" {
ok, err := verifyPassword(currentPassword, user.PasswordHash)
if err != nil || !ok {
return fmt.Errorf("invalid credentials")
}
}
passwordHash, err := hashPassword(newPassword)
if err != nil {
return err
}
if err := s.repo.UpdatePasswordHash(ctx, user.ID, passwordHash); err != nil {
return err
}
s.repo.Audit(ctx, user.ID, "auth.password.change", "user", user.ID, "", "", nil)
return nil
}
func (s *Service) DeleteAccount(ctx context.Context, principal Principal, currentPassword string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
user, err := s.repo.GetUserByID(ctx, principal.UserID)
if err != nil {
return err
}
if user.Role == RoleAdmin {
admins, err := s.repo.CountAdmins(ctx)
if err != nil {
return err
}
if admins <= 1 {
return fmt.Errorf("cannot delete the last admin account")
}
}
if user.PasswordHash != "" {
ok, err := verifyPassword(currentPassword, user.PasswordHash)
if err != nil || !ok {
return fmt.Errorf("invalid credentials")
}
}
s.repo.Audit(ctx, user.ID, "auth.account.delete", "user", user.ID, "", "", nil)
return s.repo.DeleteUser(ctx, user.ID)
}
func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
return s.repo.ListUsers(ctx)
}
func (s *Service) UpdateSignupsEnabled(ctx context.Context, actor Principal, enabled bool) (InstanceSettings, error) {
if !Allows(actor, ScopeAdmin) {
return InstanceSettings{}, fmt.Errorf("admin role required")
}
if err := s.repo.UpdateSignupsEnabled(ctx, enabled); err != nil {
return InstanceSettings{}, err
}
return s.repo.GetInstanceSettings(ctx)
}
func (s *Service) EnsureDevUser(ctx context.Context) (User, error) {
const email = "dev@localhost"
settings, err := s.repo.GetInstanceSettings(ctx)
if err != nil {
return User{}, err
}
if !settings.SetupComplete {
return s.CompleteInitialSetup(ctx, email, "Development Admin", "development-only-password", true)
}
user, err := s.repo.GetUserByEmail(ctx, email)
if err == nil {
return user, nil
}
if !isNoRows(err) {
return User{}, err
}
return s.repo.UpsertUser(ctx, User{Email: email, DisplayName: "Development Admin", Role: RoleAdmin})
}
func (s *Service) PrincipalForUser(ctx context.Context, userID string) (Principal, error) {
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return Principal{}, err
}
if user.Disabled {
return Principal{}, fmt.Errorf("account disabled")
}
return principalFromUser(user, "dev", "", "", nil, time.Time{}), nil
}
func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return User{}, err
}
users, err := s.UpdateUserAccess(ctx, actor, []UserAccessUpdate{{
ID: userID,
Role: Role(role),
Disabled: user.Disabled,
}})
if err != nil {
return User{}, err
}
return users[0], nil
}
func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates []UserAccessUpdate) ([]User, error) {
if !Allows(actor, ScopeAdmin) {
return nil, fmt.Errorf("admin role required")
}
if len(updates) == 0 {
return nil, fmt.Errorf("at least one user update is required")
}
normalized := make([]UserAccessUpdate, 0, len(updates))
seen := make(map[string]struct{}, len(updates))
for _, update := range updates {
update.ID = strings.TrimSpace(update.ID)
if update.ID == "" {
return nil, fmt.Errorf("user id is required")
}
if _, ok := seen[update.ID]; ok {
return nil, fmt.Errorf("duplicate user update")
}
seen[update.ID] = struct{}{}
update.Role = Role(strings.ToLower(strings.TrimSpace(string(update.Role))))
switch update.Role {
case RoleViewer, RoleEditor, RoleAdmin:
default:
return nil, fmt.Errorf("invalid role")
}
normalized = append(normalized, update)
}
if err := s.repo.UpdateUserAccess(ctx, normalized); err != nil {
return nil, err
}
users := make([]User, 0, len(normalized))
for _, update := range normalized {
user, err := s.repo.GetUserByID(ctx, update.ID)
if err != nil {
return nil, err
}
s.repo.Audit(ctx, actor.UserID, "auth.user.access.update", "user", user.ID, "", "", map[string]any{
"role": user.Role,
"disabled": user.Disabled,
})
users = append(users, user)
}
return users, nil
}
func (s *Service) SendPasswordReset(ctx context.Context, actor Principal, userID string) error {
if !Allows(actor, ScopeAdmin) {
return fmt.Errorf("admin role required")
}
if s.email == nil {
return fmt.Errorf("email sender is not configured")
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return err
}
secret, err := randomToken(32)
if err != nil {
return err
}
id, err := randomToken(18)
if err != nil {
return err
}
now := time.Now().UTC()
if err := s.repo.CreatePasswordResetToken(ctx, PasswordResetToken{
ID: "reset:" + id,
UserID: user.ID,
TokenHash: hashSecret(secret),
CreatedAt: now,
ExpiresAt: now.Add(passwordResetTTL),
InitiatedBy: actor.UserID,
}); err != nil {
return err
}
link := s.publicOrigin + "/password-reset?token=" + url.QueryEscape(secret)
body := fmt.Sprintf("A Cairnquire administrator requested a password reset for your account.\n\nSet a new password within one hour:\n%s\n\nIf you did not expect this message, contact your administrator.", link)
if err := s.email.Send(ctx, []string{user.Email}, "Reset your Cairnquire password", body); err != nil {
s.repo.ExpirePasswordResetToken(ctx, hashSecret(secret))
return err
}
s.repo.Audit(ctx, actor.UserID, "auth.password.reset.request", "user", user.ID, "", "", nil)
return nil
}
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
if len(newPassword) < 12 {
return fmt.Errorf("password must be at least 12 characters")
}
if strings.TrimSpace(token) == "" {
return fmt.Errorf("password reset token is required")
}
passwordHash, err := hashPassword(newPassword)
if err != nil {
return err
}
userID, err := s.repo.CompletePasswordReset(ctx, hashSecret(token), passwordHash)
if err != nil {
return err
}
s.repo.Audit(ctx, userID, "auth.password.reset.complete", "user", userID, "", "", nil)
return nil
}
func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (CreatedAPIKey, error) {
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return CreatedAPIKey{}, err
}
if strings.TrimSpace(name) == "" {
name = "API token"
}
tokenID, err := randomHex(12)
if err != nil {
return CreatedAPIKey{}, err
}
secret, err := randomHex(32)
if err != nil {
return CreatedAPIKey{}, err
}
now := time.Now().UTC()
keyID := "api:" + tokenID
token := "cq_pat_" + tokenID + "_" + secret
record := APIKey{
ID: keyID,
UserID: user.ID,
Name: strings.TrimSpace(name),
KeyHash: hashSecret(secret),
Scopes: normalizeScopes(scopes),
CreatedAt: now,
ExpiresAt: expiresAt,
}
if err := s.repo.CreateAPIKey(ctx, record); err != nil {
return CreatedAPIKey{}, err
}
return CreatedAPIKey{Record: record, Token: token}, nil
}
func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Principal, error) {
keyID, secret, err := parseAPIToken(token)
if err != nil {
return Principal{}, err
}
key, user, err := s.repo.ValidateAPIKey(ctx, keyID, hashSecret(secret))
if err != nil {
return Principal{}, err
}
if user.Disabled {
return Principal{}, fmt.Errorf("account disabled")
}
expiresAt := time.Time{}
if key.ExpiresAt != nil {
expiresAt = *key.ExpiresAt
}
return principalFromUser(user, "api_token", "", key.ID, key.Scopes, expiresAt), nil
}
func (s *Service) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) {
return s.repo.ListAPIKeys(ctx, userID)
}
func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
return s.repo.RevokeAPIKey(ctx, userID, keyID)
}
func (s *Service) StartDeviceFlow(ctx context.Context, name string, scopes []Scope) (DeviceStart, error) {
deviceSecret, err := randomToken(32)
if err != nil {
return DeviceStart{}, err
}
rawUserCode, err := randomToken(5)
if err != nil {
return DeviceStart{}, err
}
userCode := formatUserCode(rawUserCode)
idPart, err := randomToken(12)
if err != nil {
return DeviceStart{}, err
}
now := time.Now().UTC()
device := DeviceCode{
ID: "dev:" + idPart,
DeviceCodeHash: hashSecret(deviceSecret),
UserCodeHash: hashSecret(normalizeUserCode(userCode)),
UserCodeDisplay: userCode,
Name: strings.TrimSpace(name),
Scopes: normalizeScopes(scopes),
Status: "pending",
CreatedAt: now,
ExpiresAt: now.Add(deviceCodeTTL),
}
if device.Name == "" {
device.Name = "Device"
}
if err := s.repo.CreateDeviceCode(ctx, device); err != nil {
return DeviceStart{}, err
}
verifyURI := strings.TrimRight(s.publicOrigin, "/") + "/device/verify"
return DeviceStart{
DeviceCode: deviceSecret,
UserCode: userCode,
VerificationURI: verifyURI,
VerificationURIComplete: verifyURI + "?user_code=" + url.QueryEscape(userCode),
ExpiresIn: int(deviceCodeTTL.Seconds()),
Interval: devicePollSeconds,
}, nil
}
func (s *Service) ApproveDeviceFlow(ctx context.Context, principal Principal, userCode string) (DeviceCode, error) {
if principal.UserID == "" {
return DeviceCode{}, fmt.Errorf("authentication required")
}
return s.repo.ApproveDeviceCode(ctx, hashSecret(normalizeUserCode(userCode)), principal.UserID)
}
func (s *Service) PollDeviceFlow(ctx context.Context, deviceCode string) (CreatedAPIKey, string, error) {
device, err := s.repo.GetDeviceCodeByDeviceCode(ctx, hashSecret(deviceCode))
if err != nil {
if errorsIsNoRows(err) {
return CreatedAPIKey{}, "invalid_device_code", err
}
return CreatedAPIKey{}, "", err
}
s.repo.TouchDevicePoll(ctx, device.ID)
switch device.Status {
case "pending":
return CreatedAPIKey{}, "authorization_pending", sql.ErrNoRows
case "denied", "expired":
return CreatedAPIKey{}, device.Status, fmt.Errorf("device flow %s", device.Status)
case "approved":
created, err := s.CreateAPIKey(ctx, device.UserID, device.Name, device.Scopes, nil)
if err == nil {
s.repo.FinishDeviceCode(ctx, device.ID)
}
return created, "", err
default:
return CreatedAPIKey{}, "invalid_state", fmt.Errorf("invalid device status")
}
}
func (s *Service) createSession(ctx context.Context, user User, ip, userAgent string) (Principal, string, error) {
if user.Disabled {
return Principal{}, "", fmt.Errorf("account disabled")
}
token, err := randomToken(32)
if err != nil {
return Principal{}, "", err
}
session, err := s.repo.CreateSession(ctx, user.ID, hashSecret(token), ip, userAgent, sessionTTL)
if err != nil {
return Principal{}, "", err
}
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), token, nil
}
func (s *Service) requirePublicSignups(ctx context.Context) error {
settings, err := s.repo.GetInstanceSettings(ctx)
if err != nil {
return err
}
if !settings.SetupComplete {
return fmt.Errorf("initial setup is required")
}
if !settings.SignupsEnabled {
return fmt.Errorf("signups are disabled")
}
return nil
}
func principalFromUser(user User, method, sessionID, apiKeyID string, scopes []Scope, expiresAt time.Time) Principal {
return Principal{
UserID: user.ID,
Email: user.Email,
DisplayName: user.DisplayName,
Role: user.Role,
Scopes: scopes,
Method: method,
SessionID: sessionID,
APIKeyID: apiKeyID,
ExpiresAt: expiresAt,
}
}
func parseAPIToken(token string) (string, string, error) {
parts := strings.SplitN(token, "_", 4)
if len(parts) != 4 || parts[0] != "cq" || parts[1] != "pat" {
return "", "", fmt.Errorf("invalid api token")
}
return "api:" + parts[2], parts[3], nil
}
func normalizeEmail(email string) string {
return strings.TrimSpace(strings.ToLower(email))
}
func formatUserCode(raw string) string {
clean := strings.ToUpper(strings.NewReplacer("-", "", "_", "").Replace(raw))
if len(clean) > 8 {
clean = clean[:8]
}
if len(clean) > 4 {
return clean[:4] + "-" + clean[4:]
}
return clean
}
func normalizeUserCode(code string) string {
return strings.ToUpper(strings.NewReplacer("-", "", " ", "", "_", "").Replace(strings.TrimSpace(code)))
}
func errorsIsNoRows(err error) bool {
return err == sql.ErrNoRows
}
var dummyPasswordHash string
func mustDummyPasswordHash() string {
if dummyPasswordHash == "" {
hash, err := hashPassword("not-the-password")
if err != nil {
panic(err)
}
dummyPasswordHash = hash
}
return dummyPasswordHash
}
func clientIP(r *http.Request) string {
if r == nil {
return ""
}
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
parts := strings.Split(forwarded, ",")
return strings.TrimSpace(parts[0])
}
return r.RemoteAddr
}
func RoleAllows(role Role, required Scope) bool {
switch required {
case ScopeDocsRead, ScopeSyncRead:
return role == RoleViewer || role == RoleEditor || role == RoleAdmin
case ScopeDocsWrite, ScopeSyncWrite:
return role == RoleEditor || role == RoleAdmin
case ScopeAdmin:
return role == RoleAdmin
default:
return false
}
}
func Allows(principal Principal, required Scope) bool {
if principal.UserID == "" {
return false
}
if len(principal.Scopes) > 0 && !hasScope(principal.Scopes, required) {
return false
}
return RoleAllows(principal.Role, required)
}