// Package auth manages persistent users and server-side sessions. package auth import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "errors" "fmt" "strings" "time" "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" ) const ( // SeedAdminEmail is the administrator account created with a new database. SeedAdminEmail = "boss@zecevic-ninja.com" // SeedAdminPassword is a temporary password that must be changed on first login. SeedAdminPassword = "Start1234!" defaultSessionLifetime = 7 * 24 * time.Hour ) var ( // ErrInvalidCredentials intentionally covers missing, disabled, and invalid users. ErrInvalidCredentials = errors.New("invalid credentials") // ErrPasswordUnchanged prevents retaining a temporary password. ErrPasswordUnchanged = errors.New("new password must differ from the current password") // ErrSessionNotFound indicates an expired, deleted, or unknown session. ErrSessionNotFound = errors.New("session not found") // ErrUserNotFound indicates that an administrator targeted an unknown user. ErrUserNotFound = errors.New("user not found") // ErrInvalidUser indicates incomplete or unsupported account details. ErrInvalidUser = errors.New("invalid user details") // ErrEmailInUse indicates that another account already uses the email address. ErrEmailInUse = errors.New("email address is already in use") ) // Role controls a user's access level. type Role string const ( // RoleAdmin can manage user accounts. RoleAdmin Role = "admin" // RoleUser can browse configured roots after authentication. RoleUser Role = "user" ) // User is the safe, password-hash-free representation of an account and its status. type User struct { ID int64 Email string Role Role MustChangePassword bool Disabled bool } // Options customizes the persistent authentication store. type Options struct { Clock func() time.Time SessionLifetime time.Duration } // Store holds SQLite-backed accounts and server-side sessions. type Store struct { db *sql.DB clock func() time.Time sessionLifetime time.Duration } // Open migrates the SQLite database and seeds the initial administrator when // the database is created for the first time. func Open(databasePath string, options Options) (*Store, error) { clock := options.Clock if clock == nil { clock = time.Now } sessionLifetime := options.SessionLifetime if sessionLifetime <= 0 { sessionLifetime = defaultSessionLifetime } database, err := sql.Open("sqlite", databasePath) if err != nil { return nil, fmt.Errorf("open authentication database: %w", err) } database.SetMaxOpenConns(1) if err := database.Ping(); err != nil { _ = database.Close() return nil, fmt.Errorf("connect to authentication database: %w", err) } if _, err := database.Exec("PRAGMA foreign_keys = ON"); err != nil { _ = database.Close() return nil, fmt.Errorf("enable SQLite foreign keys: %w", err) } store := &Store{db: database, clock: clock, sessionLifetime: sessionLifetime} if err := store.migrate(); err != nil { _ = database.Close() return nil, err } return store, nil } // Close releases the database connection. func (store *Store) Close() error { return store.db.Close() } // SessionLifetime returns the configured server-side sliding-session lifetime. func (store *Store) SessionLifetime() time.Duration { return store.sessionLifetime } // Authenticate validates an enabled account without exposing its password hash. func (store *Store) Authenticate(email, password string) (User, error) { var user User var passwordHash string var mustChangePassword int var disabled int err := store.db.QueryRow(` SELECT id, email, password_hash, role, must_change_password, disabled FROM users WHERE email = ? COLLATE NOCASE `, email).Scan(&user.ID, &user.Email, &passwordHash, &user.Role, &mustChangePassword, &disabled) if errors.Is(err, sql.ErrNoRows) || disabled != 0 { return User{}, ErrInvalidCredentials } if err != nil { return User{}, fmt.Errorf("look up user: %w", err) } if !CheckPassword(passwordHash, password) { return User{}, ErrInvalidCredentials } user.MustChangePassword = mustChangePassword != 0 user.Disabled = disabled != 0 return user, nil } // ListUsers returns all accounts, including disabled accounts, for an // administrator to manage. func (store *Store) ListUsers() ([]User, error) { rows, err := store.db.Query(` SELECT id, email, role, must_change_password, disabled FROM users ORDER BY email COLLATE NOCASE `) if err != nil { return nil, fmt.Errorf("list users: %w", err) } defer func() { _ = rows.Close() }() users := make([]User, 0) for rows.Next() { var user User var mustChangePassword, disabled int if err := rows.Scan(&user.ID, &user.Email, &user.Role, &mustChangePassword, &disabled); err != nil { return nil, fmt.Errorf("scan user: %w", err) } user.MustChangePassword = mustChangePassword != 0 user.Disabled = disabled != 0 users = append(users, user) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate users: %w", err) } return users, nil } // CreateUser creates an enabled account whose temporary password must be // changed before it can access the application. func (store *Store) CreateUser(email, temporaryPassword string, role Role) (User, error) { email = strings.TrimSpace(email) if email == "" || temporaryPassword == "" || !validRole(role) { return User{}, ErrInvalidUser } passwordHash, err := HashPassword(temporaryPassword) if err != nil { return User{}, fmt.Errorf("hash temporary password: %w", err) } now := store.nowUnix() result, err := store.db.Exec(` INSERT INTO users (email, password_hash, role, must_change_password, disabled, created_at, updated_at) VALUES (?, ?, ?, 1, 0, ?, ?) `, email, passwordHash, role, now, now) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed: users.email") { return User{}, ErrEmailInUse } return User{}, fmt.Errorf("create user: %w", err) } userID, err := result.LastInsertId() if err != nil { return User{}, fmt.Errorf("read created user ID: %w", err) } return User{ID: userID, Email: email, Role: role, MustChangePassword: true}, nil } // ResetPassword replaces a user's password with a new temporary password and // invalidates every existing session for that user. func (store *Store) ResetPassword(userID int64, temporaryPassword string) error { if userID <= 0 || temporaryPassword == "" { return ErrInvalidUser } passwordHash, err := HashPassword(temporaryPassword) if err != nil { return fmt.Errorf("hash temporary password: %w", err) } transaction, err := store.db.Begin() if err != nil { return fmt.Errorf("begin password reset: %w", err) } defer func() { _ = transaction.Rollback() }() result, err := transaction.Exec(` UPDATE users SET password_hash = ?, must_change_password = 1, updated_at = ? WHERE id = ? `, passwordHash, store.nowUnix(), userID) if err != nil { return fmt.Errorf("reset password: %w", err) } if err := requireAffectedUser(result); err != nil { return err } if _, err := transaction.Exec("DELETE FROM sessions WHERE user_id = ?", userID); err != nil { return fmt.Errorf("remove reset user sessions: %w", err) } if err := transaction.Commit(); err != nil { return fmt.Errorf("commit password reset: %w", err) } return nil } // DisableUser prevents sign-in and invalidates every active session for the // account while preserving it for later administrative review or deletion. func (store *Store) DisableUser(userID int64) error { if userID <= 0 { return ErrInvalidUser } transaction, err := store.db.Begin() if err != nil { return fmt.Errorf("begin user disable: %w", err) } defer func() { _ = transaction.Rollback() }() result, err := transaction.Exec("UPDATE users SET disabled = 1, updated_at = ? WHERE id = ?", store.nowUnix(), userID) if err != nil { return fmt.Errorf("disable user: %w", err) } if err := requireAffectedUser(result); err != nil { return err } if _, err := transaction.Exec("DELETE FROM sessions WHERE user_id = ?", userID); err != nil { return fmt.Errorf("remove disabled user sessions: %w", err) } if err := transaction.Commit(); err != nil { return fmt.Errorf("commit user disable: %w", err) } return nil } // DeleteUser permanently removes an account and its server-side sessions. func (store *Store) DeleteUser(userID int64) error { if userID <= 0 { return ErrInvalidUser } result, err := store.db.Exec("DELETE FROM users WHERE id = ?", userID) if err != nil { return fmt.Errorf("delete user: %w", err) } return requireAffectedUser(result) } // ChangePassword verifies the current password, rejects reuse, and clears the // forced-password-change flag. func (store *Store) ChangePassword(userID int64, currentPassword, newPassword string) error { var passwordHash string err := store.db.QueryRow("SELECT password_hash FROM users WHERE id = ? AND disabled = 0", userID).Scan(&passwordHash) if errors.Is(err, sql.ErrNoRows) { return ErrInvalidCredentials } if err != nil { return fmt.Errorf("look up password hash: %w", err) } if !CheckPassword(passwordHash, currentPassword) { return ErrInvalidCredentials } if CheckPassword(passwordHash, newPassword) { return ErrPasswordUnchanged } newHash, err := HashPassword(newPassword) if err != nil { return fmt.Errorf("hash new password: %w", err) } if _, err := store.db.Exec(` UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = ? WHERE id = ? `, newHash, store.nowUnix(), userID); err != nil { return fmt.Errorf("update password: %w", err) } return nil } // CreateSession stores a new random session and returns its cookie value. func (store *Store) CreateSession(userID int64) (string, error) { token, err := randomToken() if err != nil { return "", fmt.Errorf("generate session token: %w", err) } now := store.nowUnix() _, err = store.db.Exec(` INSERT INTO sessions (token_hash, user_id, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?) `, tokenHash(token), userID, now+int64(store.sessionLifetime.Seconds()), now, now) if err != nil { return "", fmt.Errorf("create session: %w", err) } return token, nil } // LookupSession returns the signed-in user and refreshes the server-side // expiry, creating a seven-day sliding session by default. func (store *Store) LookupSession(token string) (User, error) { if token == "" { return User{}, ErrSessionNotFound } var user User var mustChangePassword int var expiresAt int64 err := store.db.QueryRow(` SELECT users.id, users.email, users.role, users.must_change_password, sessions.expires_at FROM sessions JOIN users ON users.id = sessions.user_id WHERE sessions.token_hash = ? AND users.disabled = 0 `, tokenHash(token)).Scan(&user.ID, &user.Email, &user.Role, &mustChangePassword, &expiresAt) if errors.Is(err, sql.ErrNoRows) { return User{}, ErrSessionNotFound } if err != nil { return User{}, fmt.Errorf("look up session: %w", err) } now := store.nowUnix() if expiresAt <= now { if _, err := store.db.Exec("DELETE FROM sessions WHERE token_hash = ?", tokenHash(token)); err != nil { return User{}, fmt.Errorf("remove expired session: %w", err) } return User{}, ErrSessionNotFound } if _, err := store.db.Exec(` UPDATE sessions SET expires_at = ?, updated_at = ? WHERE token_hash = ? `, now+int64(store.sessionLifetime.Seconds()), now, tokenHash(token)); err != nil { return User{}, fmt.Errorf("refresh session: %w", err) } user.MustChangePassword = mustChangePassword != 0 return user, nil } // DeleteSession invalidates a cookie's session server-side. func (store *Store) DeleteSession(token string) error { if token == "" { return nil } if _, err := store.db.Exec("DELETE FROM sessions WHERE token_hash = ?", tokenHash(token)); err != nil { return fmt.Errorf("delete session: %w", err) } return nil } // HashPassword creates a bcrypt hash suitable for persistent storage. func HashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } return string(hash), nil } // CheckPassword reports whether a bcrypt hash matches a supplied password. func CheckPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } func (store *Store) migrate() error { transaction, err := store.db.Begin() if err != nil { return fmt.Errorf("begin database migration: %w", err) } defer func() { _ = transaction.Rollback() }() if _, err := transaction.Exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY ) `); err != nil { return fmt.Errorf("create migration table: %w", err) } var version int if err := transaction.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&version); err != nil { return fmt.Errorf("read schema version: %w", err) } if version < 1 { if err := applyInitialSchema(transaction); err != nil { return err } if err := seedAdmin(transaction, store.nowUnix()); err != nil { return err } if _, err := transaction.Exec("INSERT INTO schema_migrations (version) VALUES (1)"); err != nil { return fmt.Errorf("record initial schema migration: %w", err) } } if err := transaction.Commit(); err != nil { return fmt.Errorf("commit database migration: %w", err) } return nil } func applyInitialSchema(transaction *sql.Tx) error { statements := []string{ `CREATE TABLE users ( id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('admin', 'user')), must_change_password INTEGER NOT NULL CHECK(must_change_password IN (0, 1)), disabled INTEGER NOT NULL DEFAULT 0 CHECK(disabled IN (0, 1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )`, `CREATE TABLE sessions ( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )`, "CREATE INDEX sessions_expires_at_idx ON sessions(expires_at)", } for _, statement := range statements { if _, err := transaction.Exec(statement); err != nil { return fmt.Errorf("apply initial schema: %w", err) } } return nil } func seedAdmin(transaction *sql.Tx, now int64) error { passwordHash, err := HashPassword(SeedAdminPassword) if err != nil { return fmt.Errorf("hash seed administrator password: %w", err) } if _, err := transaction.Exec(` INSERT INTO users (email, password_hash, role, must_change_password, disabled, created_at, updated_at) VALUES (?, ?, ?, 1, 0, ?, ?) `, SeedAdminEmail, passwordHash, RoleAdmin, now, now); err != nil { return fmt.Errorf("seed administrator: %w", err) } return nil } func (store *Store) nowUnix() int64 { return store.clock().UTC().Unix() } func validRole(role Role) bool { return role == RoleAdmin || role == RoleUser } func requireAffectedUser(result sql.Result) error { affected, err := result.RowsAffected() if err != nil { return fmt.Errorf("read affected users: %w", err) } if affected == 0 { return ErrUserNotFound } return nil } func randomToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(bytes), nil } func tokenHash(token string) string { sum := sha256.Sum256([]byte(token)) return base64.RawURLEncoding.EncodeToString(sum[:]) }