server auth
This commit is contained in:
546
apps/server/internal/auth/service.go
Normal file
546
apps/server/internal/auth/service.go
Normal file
@@ -0,0 +1,546 @@
|
||||
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
|
||||
devicePollSeconds = 5
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
webauthn *webauthn.WebAuthn
|
||||
publicOrigin string
|
||||
}
|
||||
|
||||
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) RegisterPasswordUser(ctx context.Context, email, displayName, password, role string) (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")
|
||||
}
|
||||
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
|
||||
}
|
||||
userCount, err := s.repo.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
normalizedRole := RoleViewer
|
||||
if userCount == 0 {
|
||||
normalizedRole = RoleAdmin
|
||||
} else if strings.EqualFold(role, string(RoleViewer)) {
|
||||
normalizedRole = RoleViewer
|
||||
}
|
||||
return s.repo.UpsertUser(ctx, User{
|
||||
Email: email,
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
PasswordHash: passwordHash,
|
||||
Role: normalizedRole,
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||
if err != nil {
|
||||
if !isNoRows(err) {
|
||||
return nil, "", err
|
||||
}
|
||||
userCount, err := s.repo.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
normalizedRole := RoleViewer
|
||||
if userCount == 0 {
|
||||
normalizedRole = RoleAdmin
|
||||
}
|
||||
user, err = s.repo.UpsertUser(ctx, User{
|
||||
Email: normalizeEmail(email),
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
Role: normalizedRole,
|
||||
})
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
|
||||
if !Allows(actor, ScopeAdmin) {
|
||||
return User{}, fmt.Errorf("admin role required")
|
||||
}
|
||||
normalizedRole := Role(strings.ToLower(strings.TrimSpace(role)))
|
||||
switch normalizedRole {
|
||||
case RoleViewer, RoleEditor, RoleAdmin:
|
||||
default:
|
||||
return User{}, fmt.Errorf("invalid role")
|
||||
}
|
||||
user, err := s.repo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if user.Role == RoleAdmin && normalizedRole != RoleAdmin {
|
||||
admins, err := s.repo.CountAdmins(ctx)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if admins <= 1 {
|
||||
return User{}, fmt.Errorf("cannot demote the last admin account")
|
||||
}
|
||||
}
|
||||
if err := s.repo.UpdateUserRole(ctx, user.ID, normalizedRole); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
s.repo.Audit(ctx, actor.UserID, "auth.user.role.update", "user", user.ID, "", "", map[string]any{"role": normalizedRole})
|
||||
return s.repo.GetUserByID(ctx, user.ID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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) {
|
||||
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 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)
|
||||
}
|
||||
Reference in New Issue
Block a user