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.
326 lines
10 KiB
Go
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")
|
|
}
|