package auth import ( "context" "database/sql" "errors" "fmt" "regexp" "strings" "time" ) var ( // ErrUserExists means the username is taken (case-insensitive). ErrUserExists = errors.New("auth: username already taken") // ErrUserNotFound means no user matched. ErrUserNotFound = errors.New("auth: user not found") // ErrInvalidUsername means the username failed validation. ErrInvalidUsername = errors.New("auth: invalid username") // ErrInvalidCredentials is returned for both unknown users and bad // passwords, so callers cannot distinguish the two. ErrInvalidCredentials = errors.New("auth: invalid credentials") ) // usernamePattern keeps usernames URL- and log-safe, and avoids the // confusable-character mess of allowing arbitrary Unicode identifiers. var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{2,31}$`) // User is an account. The password hash is never serialized to a client. type User struct { ID int64 Username string DisplayName string PasswordHash string IsAdmin bool CreatedAt time.Time } // ValidateUsername enforces the username policy. func ValidateUsername(username string) error { if !usernamePattern.MatchString(username) { return fmt.Errorf("%w: must be 3-32 characters, start with a letter or digit, and contain only letters, digits, dot, underscore or hyphen", ErrInvalidUsername) } return nil } // CreateUser hashes the password and inserts the account. func CreateUser(ctx context.Context, db *sql.DB, username, displayName, password string, isAdmin bool) (User, error) { if err := ValidateUsername(username); err != nil { return User{}, err } if password == "" { return User{}, errors.New("auth: password must not be empty") } if displayName == "" { displayName = username } hash, err := HashPassword(password) if err != nil { return User{}, err } u := User{Username: username, DisplayName: displayName, IsAdmin: isAdmin} var created string err = db.QueryRowContext(ctx, ` INSERT INTO users (username, display_name, password_hash, is_admin) VALUES (?, ?, ?, ?) RETURNING id, created_at`, username, displayName, hash, boolToInt(isAdmin), ).Scan(&u.ID, &created) if err != nil { if isUniqueViolation(err) { return User{}, ErrUserExists } return User{}, fmt.Errorf("auth: create user: %w", err) } u.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) u.PasswordHash = hash return u, nil } // GetUserByUsername does a case-insensitive lookup (the column is COLLATE NOCASE). func GetUserByUsername(ctx context.Context, db *sql.DB, username string) (User, error) { return scanUser(db.QueryRowContext(ctx, userSelect+` WHERE username = ?`, username)) } // GetUserByID loads one user by primary key. func GetUserByID(ctx context.Context, db *sql.DB, id int64) (User, error) { return scanUser(db.QueryRowContext(ctx, userSelect+` WHERE id = ?`, id)) } // ListUsers returns every account, oldest first. func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) { rows, err := db.QueryContext(ctx, userSelect+` ORDER BY id`) if err != nil { return nil, fmt.Errorf("auth: list users: %w", err) } defer rows.Close() var out []User for rows.Next() { u, err := scanUserRow(rows) if err != nil { return nil, err } out = append(out, u) } return out, rows.Err() } // CountUsers reports how many accounts exist. Used to decide whether to // bootstrap the first admin. func CountUsers(ctx context.Context, db *sql.DB) (int, error) { var n int err := db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n) return n, err } // SetPassword replaces a user's password and logs out all their sessions. // Logging out everywhere is deliberate: a password change is often a response // to a suspected compromise. func SetPassword(ctx context.Context, db *sql.DB, userID int64, password string) error { if password == "" { return errors.New("auth: password must not be empty") } hash, err := HashPassword(password) if err != nil { return err } tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() //nolint:errcheck if _, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, userID); err != nil { return fmt.Errorf("auth: set password: %w", err) } if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil { return fmt.Errorf("auth: clear sessions: %w", err) } return tx.Commit() } // ── Authentication ───────────────────────────────────────────────────── // Authenticate verifies a username/password pair and returns the user. // // It hashes even when the user does not exist, so response time does not // reveal whether a username is registered. func Authenticate(ctx context.Context, db *sql.DB, username, password string) (User, error) { u, err := GetUserByUsername(ctx, db, username) if errors.Is(err, sql.ErrNoRows) { _, _ = HashPassword(password) // burn equivalent time return User{}, ErrInvalidCredentials } if err != nil { return User{}, err } ok, err := VerifyPassword(password, u.PasswordHash) if err != nil { // A malformed stored hash is an operational error, not a bad login. return User{}, fmt.Errorf("auth: verify password for %q: %w", u.Username, err) } if !ok { return User{}, ErrInvalidCredentials } return u, nil } // ── Library grants ───────────────────────────────────────────────────── // Grant is one user's access to one library. type Grant struct { LibraryID int64 CanRead bool CanDownload bool } // GrantLibrary upserts a user's access to a library. func GrantLibrary(ctx context.Context, db *sql.DB, userID, libraryID int64, canRead, canDownload bool) error { _, err := db.ExecContext(ctx, ` INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, ?, ?) ON CONFLICT (user_id, library_id) DO UPDATE SET can_read = excluded.can_read, can_download = excluded.can_download`, userID, libraryID, boolToInt(canRead), boolToInt(canDownload)) if err != nil { return fmt.Errorf("auth: grant library: %w", err) } return nil } // RevokeLibrary removes a user's access to a library. func RevokeLibrary(ctx context.Context, db *sql.DB, userID, libraryID int64) error { _, err := db.ExecContext(ctx, `DELETE FROM library_grants WHERE user_id = ? AND library_id = ?`, userID, libraryID) if err != nil { return fmt.Errorf("auth: revoke library: %w", err) } return nil } // LibraryGrants lists a user's explicit grants. func LibraryGrants(ctx context.Context, db *sql.DB, userID int64) ([]Grant, error) { rows, err := db.QueryContext(ctx, `SELECT library_id, can_read, can_download FROM library_grants WHERE user_id = ?`, userID) if err != nil { return nil, fmt.Errorf("auth: list grants: %w", err) } defer rows.Close() var out []Grant for rows.Next() { var g Grant var read, download int if err := rows.Scan(&g.LibraryID, &read, &download); err != nil { return nil, err } g.CanRead, g.CanDownload = read == 1, download == 1 out = append(out, g) } return out, rows.Err() } // VisibleLibraryIDs returns the libraries a user may read. // // This is the single access-control chokepoint. Admins see every library; // everyone else sees exactly their grants with can_read set. Every endpoint // that returns media must filter through this or CanAccessLibrary. func VisibleLibraryIDs(ctx context.Context, db *sql.DB, u User) ([]int64, error) { if u.IsAdmin { rows, err := db.QueryContext(ctx, `SELECT id FROM libraries ORDER BY id`) if err != nil { return nil, fmt.Errorf("auth: list all libraries: %w", err) } defer rows.Close() var out []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } out = append(out, id) } return out, rows.Err() } rows, err := db.QueryContext(ctx, `SELECT library_id FROM library_grants WHERE user_id = ? AND can_read = 1 ORDER BY library_id`, u.ID) if err != nil { return nil, fmt.Errorf("auth: list visible libraries: %w", err) } defer rows.Close() var out []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } out = append(out, id) } return out, rows.Err() } // CanAccessLibrary reports whether a user may read one library. func CanAccessLibrary(ctx context.Context, db *sql.DB, u User, libraryID int64) (bool, error) { if u.IsAdmin { return true, nil } var n int err := db.QueryRowContext(ctx, `SELECT count(*) FROM library_grants WHERE user_id = ? AND library_id = ? AND can_read = 1`, u.ID, libraryID).Scan(&n) if err != nil { return false, fmt.Errorf("auth: check library access: %w", err) } return n > 0, nil } // ── helpers ──────────────────────────────────────────────────────────── const userSelect = `SELECT id, username, display_name, password_hash, is_admin, created_at FROM users` type rowScanner interface { Scan(dest ...any) error } func scanUser(row rowScanner) (User, error) { u, err := scanUserRow(row) if errors.Is(err, sql.ErrNoRows) { return User{}, sql.ErrNoRows } return u, err } func scanUserRow(row rowScanner) (User, error) { var u User var isAdmin int var created string if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &isAdmin, &created); err != nil { return User{}, err } u.IsAdmin = isAdmin == 1 u.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) return u, nil } func boolToInt(b bool) int { if b { return 1 } return 0 } // isUniqueViolation sniffs the SQLite error for a uniqueness constraint. // modernc.org/sqlite does not export a typed error for this, so string // matching on the driver message is the pragmatic option. func isUniqueViolation(err error) bool { if err == nil { return false } msg := strings.ToUpper(err.Error()) return strings.Contains(msg, "UNIQUE CONSTRAINT FAILED") || strings.Contains(msg, "CONSTRAINT FAILED: UNIQUE") }