You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
505 lines
15 KiB
505 lines
15 KiB
// 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[:])
|
|
}
|
|
|