629 lines
21 KiB
Go
629 lines
21 KiB
Go
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) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
|
|
var setupCompleted string
|
|
var signupsEnabled int
|
|
if err := r.db.QueryRowContext(ctx, `
|
|
SELECT COALESCE(setup_completed_at, ''), signups_enabled
|
|
FROM instance_settings
|
|
WHERE id = 1
|
|
`).Scan(&setupCompleted, &signupsEnabled); err != nil {
|
|
return InstanceSettings{}, fmt.Errorf("get instance settings: %w", err)
|
|
}
|
|
return InstanceSettings{
|
|
SetupComplete: setupCompleted != "",
|
|
SignupsEnabled: signupsEnabled == 1,
|
|
}, nil
|
|
}
|
|
|
|
func (r *Repository) CompleteInitialSetup(ctx context.Context, user User, signupsEnabled bool) (User, error) {
|
|
now := time.Now().UTC()
|
|
if user.ID == "" {
|
|
user.ID = "user:" + user.Email
|
|
}
|
|
if user.DisplayName == "" {
|
|
user.DisplayName = user.Email
|
|
}
|
|
|
|
tx, err := r.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("begin initial setup: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
var setupCompleted string
|
|
if err := tx.QueryRowContext(ctx, `
|
|
SELECT COALESCE(setup_completed_at, '')
|
|
FROM instance_settings
|
|
WHERE id = 1
|
|
`).Scan(&setupCompleted); err != nil {
|
|
return User{}, fmt.Errorf("get initial setup state: %w", err)
|
|
}
|
|
if setupCompleted != "" {
|
|
return User{}, fmt.Errorf("initial setup has already been completed")
|
|
}
|
|
|
|
var userCount int
|
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
|
return User{}, fmt.Errorf("count users during initial setup: %w", err)
|
|
}
|
|
if userCount != 0 {
|
|
return User{}, fmt.Errorf("initial setup requires an empty user database")
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO users (id, email, display_name, password_hash, role, created_at, last_seen_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, user.ID, user.Email, user.DisplayName, nullString(user.PasswordHash), string(RoleAdmin), now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
|
|
return User{}, fmt.Errorf("create initial admin: %w", err)
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE instance_settings
|
|
SET setup_completed_at = ?, signups_enabled = ?, updated_at = ?
|
|
WHERE id = 1 AND setup_completed_at IS NULL
|
|
`, now.Format(time.RFC3339), boolInt(signupsEnabled), now.Format(time.RFC3339)); err != nil {
|
|
return User{}, fmt.Errorf("complete initial setup: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return User{}, fmt.Errorf("commit initial setup: %w", err)
|
|
}
|
|
return r.GetUserByEmail(ctx, user.Email)
|
|
}
|
|
|
|
func (r *Repository) UpdateSignupsEnabled(ctx context.Context, enabled bool) error {
|
|
result, err := r.db.ExecContext(ctx, `
|
|
UPDATE instance_settings
|
|
SET signups_enabled = ?, updated_at = ?
|
|
WHERE id = 1 AND setup_completed_at IS NOT NULL
|
|
`, boolInt(enabled), time.Now().UTC().Format(time.RFC3339))
|
|
if err != nil {
|
|
return fmt.Errorf("update signup setting: %w", err)
|
|
}
|
|
if rows, _ := result.RowsAffected(); rows == 0 {
|
|
return fmt.Errorf("initial setup is required")
|
|
}
|
|
return 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 boolInt(value bool) int {
|
|
if value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
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)
|
|
}
|