server auth
This commit is contained in:
171
apps/server/internal/auth/crypto.go
Normal file
171
apps/server/internal/auth/crypto.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
passwordTime uint32 = 3
|
||||
passwordMemory uint32 = 64 * 1024
|
||||
passwordThreads uint8 = 4
|
||||
passwordKeyLen uint32 = 32
|
||||
passwordSaltLen = 16
|
||||
)
|
||||
|
||||
func randomBytes(length int) ([]byte, error) {
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func randomToken(length int) (string, error) {
|
||||
buf, err := randomBytes(length)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func randomHex(length int) (string, error) {
|
||||
buf, err := randomBytes(length)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func hashSecret(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func constantTimeEqualHex(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
salt, err := randomBytes(passwordSaltLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, passwordTime, passwordMemory, passwordThreads, passwordKeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
passwordMemory,
|
||||
passwordTime,
|
||||
passwordThreads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
), nil
|
||||
}
|
||||
|
||||
func verifyPassword(password, encoded string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, fmt.Errorf("invalid password hash")
|
||||
}
|
||||
|
||||
var memory uint64
|
||||
var timeCost uint64
|
||||
var threads uint64
|
||||
for _, param := range strings.Split(parts[3], ",") {
|
||||
keyValue := strings.SplitN(param, "=", 2)
|
||||
if len(keyValue) != 2 {
|
||||
return false, fmt.Errorf("invalid password hash parameters")
|
||||
}
|
||||
value, err := strconv.ParseUint(keyValue[1], 10, 32)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse password hash parameter: %w", err)
|
||||
}
|
||||
switch keyValue[0] {
|
||||
case "m":
|
||||
memory = value
|
||||
case "t":
|
||||
timeCost = value
|
||||
case "p":
|
||||
threads = value
|
||||
}
|
||||
}
|
||||
if memory == 0 || timeCost == 0 || threads == 0 {
|
||||
return false, fmt.Errorf("missing password hash parameters")
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode password salt: %w", err)
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode password hash: %w", err)
|
||||
}
|
||||
|
||||
actual := argon2.IDKey([]byte(password), salt, uint32(timeCost), uint32(memory), uint8(threads), uint32(len(expected)))
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1, nil
|
||||
}
|
||||
|
||||
func normalizeScopes(scopes []Scope) []Scope {
|
||||
if len(scopes) == 0 {
|
||||
return []Scope{ScopeDocsRead, ScopeDocsWrite, ScopeSyncRead, ScopeSyncWrite}
|
||||
}
|
||||
seen := make(map[Scope]struct{}, len(scopes))
|
||||
var normalized []Scope
|
||||
for _, scope := range scopes {
|
||||
switch scope {
|
||||
case ScopeDocsRead, ScopeDocsWrite, ScopeSyncRead, ScopeSyncWrite, ScopeAdmin:
|
||||
if _, ok := seen[scope]; !ok {
|
||||
seen[scope] = struct{}{}
|
||||
normalized = append(normalized, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return []Scope{ScopeDocsRead}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func scopesToString(scopes []Scope) string {
|
||||
normalized := normalizeScopes(scopes)
|
||||
parts := make([]string, 0, len(normalized))
|
||||
for _, scope := range normalized {
|
||||
parts = append(parts, string(scope))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func scopesFromString(raw string) []Scope {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
scopes := make([]Scope, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
scopes = append(scopes, Scope(trimmed))
|
||||
}
|
||||
}
|
||||
return normalizeScopes(scopes)
|
||||
}
|
||||
|
||||
func hasScope(scopes []Scope, required Scope) bool {
|
||||
for _, scope := range scopes {
|
||||
if scope == ScopeAdmin || scope == required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
534
apps/server/internal/auth/repository.go
Normal file
534
apps/server/internal/auth/repository.go
Normal file
@@ -0,0 +1,534 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertUser(ctx context.Context, user User) (User, error) {
|
||||
now := time.Now().UTC()
|
||||
if user.ID == "" {
|
||||
user.ID = "user:" + user.Email
|
||||
}
|
||||
if user.Role == "" {
|
||||
user.Role = RoleViewer
|
||||
}
|
||||
if user.DisplayName == "" {
|
||||
user.DisplayName = user.Email
|
||||
}
|
||||
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO users (id, email, display_name, password_hash, role, created_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
password_hash = COALESCE(excluded.password_hash, users.password_hash),
|
||||
role = excluded.role,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
`, user.ID, user.Email, user.DisplayName, nullString(user.PasswordHash), string(user.Role), now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
|
||||
return User{}, fmt.Errorf("upsert user: %w", err)
|
||||
}
|
||||
|
||||
return r.GetUserByEmail(ctx, user.Email)
|
||||
}
|
||||
|
||||
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
var user User
|
||||
var created, lastSeen, passwordHash, role string
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
|
||||
FROM users
|
||||
WHERE email = ?
|
||||
`, email).Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash, &role, &created, &lastSeen)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user.PasswordHash = passwordHash
|
||||
user.Role = Role(role)
|
||||
user.CreatedAt, err = time.Parse(time.RFC3339, created)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("parse user created_at: %w", err)
|
||||
}
|
||||
if lastSeen != "" {
|
||||
user.LastSeenAt, err = time.Parse(time.RFC3339, lastSeen)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("parse user last_seen_at: %w", err)
|
||||
}
|
||||
}
|
||||
user.Credentials, err = r.ListCredentials(ctx, user.ID)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetUserByID(ctx context.Context, userID string) (User, error) {
|
||||
var email string
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT email FROM users WHERE id = ?`, userID).Scan(&email); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return r.GetUserByEmail(ctx, email)
|
||||
}
|
||||
|
||||
func (r *Repository) CountUsers(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CountAdmins(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
var user User
|
||||
var created, lastSeen, role string
|
||||
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &role, &created, &lastSeen); err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
user.Role = Role(role)
|
||||
user.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
if lastSeen != "" {
|
||||
user.LastSeenAt, _ = time.Parse(time.RFC3339, lastSeen)
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) UpdatePasswordHash(ctx context.Context, userID, passwordHash string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = ?, last_seen_at = ?
|
||||
WHERE id = ?
|
||||
`, passwordHash, time.Now().UTC().Format(time.RFC3339), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUserRole(ctx context.Context, userID string, role Role) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET role = ?, last_seen_at = ?
|
||||
WHERE id = ?
|
||||
`, string(role), time.Now().UTC().Format(time.RFC3339), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteUser(ctx context.Context, userID string) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) SaveCredential(ctx context.Context, userID string, credential webauthn.Credential) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
payload, err := json.Marshal(credential)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal credential: %w", err)
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO webauthn_credentials (id, user_id, credential_id, credential_json, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(credential_id) DO UPDATE SET
|
||||
credential_json = excluded.credential_json,
|
||||
last_used_at = excluded.last_used_at
|
||||
`, "cred:"+hashSecret(string(credential.ID))[:24], userID, credential.ID, string(payload), now, now); err != nil {
|
||||
return fmt.Errorf("save credential: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListCredentials(ctx context.Context, userID string) ([]webauthn.Credential, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT credential_json
|
||||
FROM webauthn_credentials
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list credentials: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var credentials []webauthn.Credential
|
||||
for rows.Next() {
|
||||
var raw string
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, fmt.Errorf("scan credential: %w", err)
|
||||
}
|
||||
var credential webauthn.Credential
|
||||
if err := json.Unmarshal([]byte(raw), &credential); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal credential: %w", err)
|
||||
}
|
||||
credentials = append(credentials, credential)
|
||||
}
|
||||
return credentials, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) SaveChallenge(ctx context.Context, userID, challengeType string, session webauthn.SessionData, ttl time.Duration) (string, error) {
|
||||
idPart, err := randomToken(18)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal challenge: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
id := "chal:" + idPart
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO auth_challenges (id, user_id, type, session_json, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, id, userID, challengeType, string(payload), now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339)); err != nil {
|
||||
return "", fmt.Errorf("save challenge: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ConsumeChallenge(ctx context.Context, id, challengeType string) (webauthn.SessionData, string, error) {
|
||||
var raw, userID, expires, used string
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT session_json, user_id, expires_at, COALESCE(used_at, '')
|
||||
FROM auth_challenges
|
||||
WHERE id = ? AND type = ?
|
||||
`, id, challengeType).Scan(&raw, &userID, &expires, &used)
|
||||
if err != nil {
|
||||
return webauthn.SessionData{}, "", err
|
||||
}
|
||||
if used != "" {
|
||||
return webauthn.SessionData{}, "", fmt.Errorf("challenge already used")
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, expires)
|
||||
if err != nil {
|
||||
return webauthn.SessionData{}, "", fmt.Errorf("parse challenge expiry: %w", err)
|
||||
}
|
||||
if time.Now().UTC().After(expiresAt) {
|
||||
return webauthn.SessionData{}, "", fmt.Errorf("challenge expired")
|
||||
}
|
||||
var session webauthn.SessionData
|
||||
if err := json.Unmarshal([]byte(raw), &session); err != nil {
|
||||
return webauthn.SessionData{}, "", fmt.Errorf("unmarshal challenge: %w", err)
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx, `UPDATE auth_challenges SET used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id); err != nil {
|
||||
return webauthn.SessionData{}, "", fmt.Errorf("consume challenge: %w", err)
|
||||
}
|
||||
return session, userID, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateSession(ctx context.Context, userID, tokenHash, ip, userAgent string, ttl time.Duration) (Session, error) {
|
||||
idPart, err := randomToken(18)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
session := Session{
|
||||
ID: "sess:" + idPart,
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at, last_seen_at, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, session.ID, session.UserID, session.TokenHash, session.CreatedAt.Format(time.RFC3339), session.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339), ip, userAgent); err != nil {
|
||||
return Session{}, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ValidateSession(ctx context.Context, tokenHash string) (Session, User, error) {
|
||||
var session Session
|
||||
var created, expires, revoked string
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, token_hash, created_at, expires_at, COALESCE(revoked_at, '')
|
||||
FROM sessions
|
||||
WHERE token_hash = ?
|
||||
`, tokenHash).Scan(&session.ID, &session.UserID, &session.TokenHash, &created, &expires, &revoked)
|
||||
if err != nil {
|
||||
return Session{}, User{}, err
|
||||
}
|
||||
session.CreatedAt, err = time.Parse(time.RFC3339, created)
|
||||
if err != nil {
|
||||
return Session{}, User{}, err
|
||||
}
|
||||
session.ExpiresAt, err = time.Parse(time.RFC3339, expires)
|
||||
if err != nil {
|
||||
return Session{}, User{}, err
|
||||
}
|
||||
if revoked != "" {
|
||||
return Session{}, User{}, fmt.Errorf("session revoked")
|
||||
}
|
||||
if time.Now().UTC().After(session.ExpiresAt) {
|
||||
return Session{}, User{}, fmt.Errorf("session expired")
|
||||
}
|
||||
user, err := r.GetUserByID(ctx, session.UserID)
|
||||
if err != nil {
|
||||
return Session{}, User{}, err
|
||||
}
|
||||
_, _ = r.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), session.ID)
|
||||
return session, user, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeSession(ctx context.Context, sessionID string) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE sessions SET revoked_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CreateAPIKey(ctx context.Context, key APIKey) error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO api_keys (id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at, revoked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, key.ID, key.UserID, key.Name, key.KeyHash, scopesToString(key.Scopes), key.CreatedAt.Format(time.RFC3339), timePtrString(key.ExpiresAt), timePtrString(key.LastUsedAt), timePtrString(key.RevokedAt)); err != nil {
|
||||
return fmt.Errorf("create api key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ValidateAPIKey(ctx context.Context, keyID, keyHash string) (APIKey, User, error) {
|
||||
var key APIKey
|
||||
var scopesRaw, created, expires, lastUsed, revoked string
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, name, key_hash, scopes, created_at, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), COALESCE(revoked_at, '')
|
||||
FROM api_keys
|
||||
WHERE id = ?
|
||||
`, keyID).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyHash, &scopesRaw, &created, &expires, &lastUsed, &revoked)
|
||||
if err != nil {
|
||||
return APIKey{}, User{}, err
|
||||
}
|
||||
if !constantTimeEqualHex(key.KeyHash, keyHash) {
|
||||
return APIKey{}, User{}, sql.ErrNoRows
|
||||
}
|
||||
key.Scopes = scopesFromString(scopesRaw)
|
||||
key.CreatedAt, err = time.Parse(time.RFC3339, created)
|
||||
if err != nil {
|
||||
return APIKey{}, User{}, err
|
||||
}
|
||||
if expires != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, expires)
|
||||
if err != nil {
|
||||
return APIKey{}, User{}, err
|
||||
}
|
||||
if time.Now().UTC().After(parsed) {
|
||||
return APIKey{}, User{}, fmt.Errorf("api key expired")
|
||||
}
|
||||
key.ExpiresAt = &parsed
|
||||
}
|
||||
if revoked != "" {
|
||||
return APIKey{}, User{}, fmt.Errorf("api key revoked")
|
||||
}
|
||||
if lastUsed != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, lastUsed)
|
||||
if err == nil {
|
||||
key.LastUsedAt = &parsed
|
||||
}
|
||||
}
|
||||
user, err := r.GetUserByID(ctx, key.UserID)
|
||||
if err != nil {
|
||||
return APIKey{}, User{}, err
|
||||
}
|
||||
_, _ = r.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), key.ID)
|
||||
return key, user, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, user_id, name, scopes, created_at, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), COALESCE(revoked_at, '')
|
||||
FROM api_keys
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []APIKey
|
||||
for rows.Next() {
|
||||
var key APIKey
|
||||
var scopesRaw, created, expires, lastUsed, revoked string
|
||||
if err := rows.Scan(&key.ID, &key.UserID, &key.Name, &scopesRaw, &created, &expires, &lastUsed, &revoked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key.Scopes = scopesFromString(scopesRaw)
|
||||
key.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
if expires != "" {
|
||||
parsed, _ := time.Parse(time.RFC3339, expires)
|
||||
key.ExpiresAt = &parsed
|
||||
}
|
||||
if lastUsed != "" {
|
||||
parsed, _ := time.Parse(time.RFC3339, lastUsed)
|
||||
key.LastUsedAt = &parsed
|
||||
}
|
||||
if revoked != "" {
|
||||
parsed, _ := time.Parse(time.RFC3339, revoked)
|
||||
key.RevokedAt = &parsed
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ?`, time.Now().UTC().Format(time.RFC3339), keyID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateDeviceCode(ctx context.Context, device DeviceCode) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO device_codes (id, device_code_hash, user_code_hash, user_code_display, name, scopes, status, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, device.ID, device.DeviceCodeHash, device.UserCodeHash, device.UserCodeDisplay, device.Name, scopesToString(device.Scopes), device.Status, device.CreatedAt.Format(time.RFC3339), device.ExpiresAt.Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) GetDeviceCodeByUserCode(ctx context.Context, userCodeHash string) (DeviceCode, error) {
|
||||
return r.getDeviceCode(ctx, `user_code_hash = ?`, userCodeHash)
|
||||
}
|
||||
|
||||
func (r *Repository) GetDeviceCodeByDeviceCode(ctx context.Context, deviceCodeHash string) (DeviceCode, error) {
|
||||
return r.getDeviceCode(ctx, `device_code_hash = ?`, deviceCodeHash)
|
||||
}
|
||||
|
||||
func (r *Repository) getDeviceCode(ctx context.Context, predicate string, value string) (DeviceCode, error) {
|
||||
var device DeviceCode
|
||||
var scopesRaw, created, expires, approved, lastPolled, userID string
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, COALESCE(user_id, ''), device_code_hash, user_code_hash, user_code_display, name, scopes, status, created_at, expires_at, COALESCE(approved_at, ''), COALESCE(last_polled_at, '')
|
||||
FROM device_codes
|
||||
WHERE `+predicate, value)
|
||||
if err := row.Scan(&device.ID, &userID, &device.DeviceCodeHash, &device.UserCodeHash, &device.UserCodeDisplay, &device.Name, &scopesRaw, &device.Status, &created, &expires, &approved, &lastPolled); err != nil {
|
||||
return DeviceCode{}, err
|
||||
}
|
||||
device.UserID = userID
|
||||
device.Scopes = scopesFromString(scopesRaw)
|
||||
device.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
device.ExpiresAt, _ = time.Parse(time.RFC3339, expires)
|
||||
if approved != "" {
|
||||
parsed, _ := time.Parse(time.RFC3339, approved)
|
||||
device.ApprovedAt = &parsed
|
||||
}
|
||||
if lastPolled != "" {
|
||||
parsed, _ := time.Parse(time.RFC3339, lastPolled)
|
||||
device.LastPolledAt = &parsed
|
||||
}
|
||||
if time.Now().UTC().After(device.ExpiresAt) && device.Status == "pending" {
|
||||
device.Status = "expired"
|
||||
_, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET status = 'expired' WHERE id = ?`, device.ID)
|
||||
}
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ApproveDeviceCode(ctx context.Context, userCodeHash, userID string) (DeviceCode, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE device_codes
|
||||
SET status = 'approved', user_id = ?, approved_at = ?
|
||||
WHERE user_code_hash = ? AND status = 'pending'
|
||||
`, userID, now, userCodeHash)
|
||||
if err != nil {
|
||||
return DeviceCode{}, err
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||
return DeviceCode{}, sql.ErrNoRows
|
||||
}
|
||||
return r.GetDeviceCodeByUserCode(ctx, userCodeHash)
|
||||
}
|
||||
|
||||
func (r *Repository) TouchDevicePoll(ctx context.Context, id string) {
|
||||
_, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET last_polled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id)
|
||||
}
|
||||
|
||||
func (r *Repository) FinishDeviceCode(ctx context.Context, id string) {
|
||||
_, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET status = 'expired', last_polled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id)
|
||||
}
|
||||
|
||||
func (r *Repository) Audit(ctx context.Context, actorUserID, eventType, resourceType, resourceID, ip, userAgent string, metadata map[string]any) {
|
||||
idPart, err := randomToken(18)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if metadata == nil {
|
||||
metadata = map[string]any{}
|
||||
}
|
||||
payload, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
payload = []byte("{}")
|
||||
}
|
||||
_, _ = r.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_log (id, actor_user_id, event_type, resource_type, resource_id, ip_address, user_agent, created_at, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "audit:"+idPart, nullString(actorUserID), eventType, nullString(resourceType), nullString(resourceID), ip, userAgent, time.Now().UTC().Format(time.RFC3339), string(payload))
|
||||
}
|
||||
|
||||
func nullString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func timePtrString(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func isNoRows(err error) bool {
|
||||
return errors.Is(err, sql.ErrNoRows)
|
||||
}
|
||||
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)
|
||||
}
|
||||
181
apps/server/internal/auth/service_test.go
Normal file
181
apps/server/internal/auth/service_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||
)
|
||||
|
||||
func setupAuthTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("libsql", "file:"+t.TempDir()+"/auth.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
service, err := NewService(NewRepository(db), "http://localhost:8080")
|
||||
if err != nil {
|
||||
t.Fatalf("new auth service: %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func TestPasswordLoginCreatesValidSession(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", "editor")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
if user.Email != "dev@example.com" {
|
||||
t.Fatalf("email = %q, want normalized", user.Email)
|
||||
}
|
||||
|
||||
principal, token, err := service.LoginPassword(ctx, "dev@example.com", "correct horse battery staple", "127.0.0.1", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("LoginPassword() error = %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session token")
|
||||
}
|
||||
if principal.Role != RoleAdmin {
|
||||
t.Fatalf("role = %s, want first registered user to bootstrap as admin", principal.Role)
|
||||
}
|
||||
|
||||
validated, err := service.ValidateSessionToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateSessionToken() error = %v", err)
|
||||
}
|
||||
if validated.UserID != principal.UserID {
|
||||
t.Fatalf("validated user = %q, want %q", validated.UserID, principal.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
expires := time.Now().UTC().Add(time.Hour)
|
||||
created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIKey() error = %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(created.Token, "cq_pat_") {
|
||||
t.Fatalf("token prefix = %q, want cq_pat_", created.Token)
|
||||
}
|
||||
if created.Record.KeyHash != "" && strings.Contains(created.Record.KeyHash, created.Token) {
|
||||
t.Fatal("record contains raw token")
|
||||
}
|
||||
|
||||
principal, err := service.ValidateBearerToken(ctx, created.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateBearerToken() error = %v", err)
|
||||
}
|
||||
if principal.APIKeyID != created.Record.ID {
|
||||
t.Fatalf("api key id = %q, want %q", principal.APIKeyID, created.Record.ID)
|
||||
}
|
||||
if !Allows(principal, ScopeDocsRead) {
|
||||
t.Fatal("expected docs:read to be allowed")
|
||||
}
|
||||
if Allows(principal, ScopeAdmin) {
|
||||
t.Fatal("did not expect admin scope to be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead})
|
||||
if err != nil {
|
||||
t.Fatalf("StartDeviceFlow() error = %v", err)
|
||||
}
|
||||
|
||||
if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "authorization_pending" || err == nil {
|
||||
t.Fatalf("PollDeviceFlow() before approval code=%q err=%v, want authorization_pending", code, err)
|
||||
}
|
||||
|
||||
_, err = service.ApproveDeviceFlow(ctx, principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)), start.UserCode)
|
||||
if err != nil {
|
||||
t.Fatalf("ApproveDeviceFlow() error = %v", err)
|
||||
}
|
||||
created, code, err := service.PollDeviceFlow(ctx, start.DeviceCode)
|
||||
if err != nil {
|
||||
t.Fatalf("PollDeviceFlow() after approval code=%q err=%v", code, err)
|
||||
}
|
||||
if created.Token == "" {
|
||||
t.Fatal("expected device flow to mint api token")
|
||||
}
|
||||
if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "expired" || err == nil {
|
||||
t.Fatalf("PollDeviceFlow() after token issuance code=%q err=%v, want expired", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordChangeInvalidatesOldPassword(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||
if err := service.ChangePassword(ctx, principal, "correct horse battery staple", "new correct horse battery staple"); err != nil {
|
||||
t.Fatalf("ChangePassword() error = %v", err)
|
||||
}
|
||||
if _, _, err := service.LoginPassword(ctx, "admin@example.com", "correct horse battery staple", "127.0.0.1", "test"); err == nil {
|
||||
t.Fatal("old password still works")
|
||||
}
|
||||
if _, _, err := service.LoginPassword(ctx, "admin@example.com", "new correct horse battery staple", "127.0.0.1", "test"); err != nil {
|
||||
t.Fatalf("new password login error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||
if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil {
|
||||
t.Fatal("expected demoting last admin to fail")
|
||||
}
|
||||
if err := service.DeleteAccount(ctx, principal, "correct horse battery staple"); err == nil {
|
||||
t.Fatal("expected deleting last admin to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "another correct horse", "viewer"); err == nil {
|
||||
t.Fatal("expected duplicate password registration to fail")
|
||||
}
|
||||
if _, _, err := service.BeginPasskeyRegistration(ctx, "dev@example.com", "Dev", "viewer"); err == nil {
|
||||
t.Fatal("expected public passkey registration for existing account to fail")
|
||||
}
|
||||
}
|
||||
117
apps/server/internal/auth/types.go
Normal file
117
apps/server/internal/auth/types.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleViewer Role = "viewer"
|
||||
RoleEditor Role = "editor"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
ScopeDocsRead Scope = "docs:read"
|
||||
ScopeDocsWrite Scope = "docs:write"
|
||||
ScopeSyncRead Scope = "sync:read"
|
||||
ScopeSyncWrite Scope = "sync:write"
|
||||
ScopeAdmin Scope = "admin"
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role Role `json:"role"`
|
||||
Scopes []Scope `json:"scopes,omitempty"`
|
||||
Method string `json:"method"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
DisplayName string
|
||||
Role Role
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
Credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u User) WebAuthnID() []byte {
|
||||
return []byte(u.ID)
|
||||
}
|
||||
|
||||
func (u User) WebAuthnName() string {
|
||||
return u.Email
|
||||
}
|
||||
|
||||
func (u User) WebAuthnDisplayName() string {
|
||||
if u.DisplayName != "" {
|
||||
return u.DisplayName
|
||||
}
|
||||
return u.Email
|
||||
}
|
||||
|
||||
func (u User) WebAuthnCredentials() []webauthn.Credential {
|
||||
return u.Credentials
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
UserID string
|
||||
TokenHash string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
RevokedAt *time.Time
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
KeyHash string `json:"-"`
|
||||
Scopes []Scope `json:"scopes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||
}
|
||||
|
||||
type CreatedAPIKey struct {
|
||||
Record APIKey `json:"record"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type DeviceCode struct {
|
||||
ID string
|
||||
UserID string
|
||||
DeviceCodeHash string
|
||||
UserCodeHash string
|
||||
UserCodeDisplay string
|
||||
Name string
|
||||
Scopes []Scope
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ApprovedAt *time.Time
|
||||
LastPolledAt *time.Time
|
||||
}
|
||||
|
||||
type DeviceStart struct {
|
||||
DeviceCode string `json:"deviceCode"`
|
||||
UserCode string `json:"userCode"`
|
||||
VerificationURI string `json:"verificationUri"`
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
Reference in New Issue
Block a user