Vellum/internal/auth/store.go
mithral 9772e55c73 Phase 1: accounts, sessions, per-library grants
Multi-user from the first migration, as planned.

- auth: argon2id password hashing (PHC encoded, parameters travel with the
  hash), opaque session tokens stored only as SHA-256, login rate limiting,
  and account enumeration resistance (unknown user hashes anyway)
- sessions: cookie or Bearer auth, HttpOnly + SameSite=Lax, revocation on
  password change
- grants: library_grants is the single access-control chokepoint. Admins see
  every library; everyone else sees exactly their can_read grants
- bootstrap: VELLUM_ADMIN_USER/PASSWORD create the first admin only on an
  empty database. No default password is ever invented.
- httpapi: mounts modules on a shared mux, session middleware innermost
- httpx: shared JSON helpers, strict body decoding

Gate: two users see different libraries. Tests cover that, plus session
expiry, token-at-rest, grant upsert, and the full login/me/logout flow.
2026-10-09 16:31:24 -04:00

326 lines
10 KiB
Go

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")
}