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.
 
 
 

345 lines
10 KiB

// Package auth manages persistent users and server-side sessions.
package auth
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"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")
)
// Role controls a user's access level.
type Role string
const (
// RoleAdmin can manage users in the later administration phase.
RoleAdmin Role = "admin"
// RoleUser can browse configured roots after authentication.
RoleUser Role = "user"
)
// User is the safe, password-hash-free representation of an account.
type User struct {
ID int64
Email string
Role Role
MustChangePassword 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
return user, nil
}
// 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 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[:])
}