13 changed files with 1006 additions and 7 deletions
@ -1,3 +1,21 @@ |
|||
module webfilebrowser |
|||
|
|||
go 1.24.0 |
|||
|
|||
require ( |
|||
golang.org/x/crypto v0.36.0 |
|||
modernc.org/sqlite v1.36.3 |
|||
) |
|||
|
|||
require ( |
|||
github.com/dustin/go-humanize v1.0.1 // indirect |
|||
github.com/google/uuid v1.6.0 // indirect |
|||
github.com/mattn/go-isatty v0.0.20 // indirect |
|||
github.com/ncruces/go-strftime v0.1.9 // indirect |
|||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
|||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect |
|||
golang.org/x/sys v0.31.0 // indirect |
|||
modernc.org/libc v1.61.13 // indirect |
|||
modernc.org/mathutil v1.7.1 // indirect |
|||
modernc.org/memory v1.8.2 // indirect |
|||
) |
|||
|
|||
@ -0,0 +1,49 @@ |
|||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
|||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
|||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
|||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
|||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
|||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
|||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
|||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= |
|||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
|||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
|||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
|||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= |
|||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= |
|||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= |
|||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= |
|||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= |
|||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= |
|||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= |
|||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= |
|||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= |
|||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= |
|||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= |
|||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= |
|||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= |
|||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= |
|||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= |
|||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= |
|||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= |
|||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= |
|||
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= |
|||
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
|||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= |
|||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= |
|||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
|||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
|||
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= |
|||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= |
|||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= |
|||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
|||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
|||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
|||
modernc.org/sqlite v1.36.3 h1:qYMYlFR+rtLDUzuXoST1SDIdEPbX8xzuhdF90WsX1ss= |
|||
modernc.org/sqlite v1.36.3/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws= |
|||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
|||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
|||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
|||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
|||
@ -0,0 +1,345 @@ |
|||
// 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[:]) |
|||
} |
|||
@ -0,0 +1,116 @@ |
|||
package auth |
|||
|
|||
import ( |
|||
"errors" |
|||
"path/filepath" |
|||
"testing" |
|||
"time" |
|||
) |
|||
|
|||
func TestSeedAdminAuthenticatesAndRequiresPasswordChange(t *testing.T) { |
|||
store := openTestStore(t, t.TempDir()) |
|||
|
|||
user, err := store.Authenticate(SeedAdminEmail, SeedAdminPassword) |
|||
if err != nil { |
|||
t.Fatalf("Authenticate(seed admin) error = %v", err) |
|||
} |
|||
if user.Role != RoleAdmin { |
|||
t.Errorf("seed admin role = %q, want %q", user.Role, RoleAdmin) |
|||
} |
|||
if !user.MustChangePassword { |
|||
t.Error("seed admin MustChangePassword = false, want true") |
|||
} |
|||
|
|||
if _, err := store.Authenticate(SeedAdminEmail, "wrong-password"); !errors.Is(err, ErrInvalidCredentials) { |
|||
t.Errorf("Authenticate(wrong password) error = %v, want ErrInvalidCredentials", err) |
|||
} |
|||
} |
|||
|
|||
func TestPasswordHashingAndChangePassword(t *testing.T) { |
|||
password := "Start1234!" |
|||
hash, err := HashPassword(password) |
|||
if err != nil { |
|||
t.Fatalf("HashPassword() error = %v", err) |
|||
} |
|||
if hash == password { |
|||
t.Error("HashPassword() returned the plaintext password") |
|||
} |
|||
if !CheckPassword(hash, password) { |
|||
t.Error("CheckPassword() = false for the password used to create the hash") |
|||
} |
|||
if CheckPassword(hash, "different-password") { |
|||
t.Error("CheckPassword() = true for a different password") |
|||
} |
|||
|
|||
store := openTestStore(t, t.TempDir()) |
|||
user, err := store.Authenticate(SeedAdminEmail, SeedAdminPassword) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
if err := store.ChangePassword(user.ID, SeedAdminPassword, SeedAdminPassword); !errors.Is(err, ErrPasswordUnchanged) { |
|||
t.Errorf("ChangePassword(same password) error = %v, want ErrPasswordUnchanged", err) |
|||
} |
|||
|
|||
const newPassword = "A-new-password-123!" |
|||
if err := store.ChangePassword(user.ID, SeedAdminPassword, newPassword); err != nil { |
|||
t.Fatalf("ChangePassword() error = %v", err) |
|||
} |
|||
if _, err := store.Authenticate(SeedAdminEmail, SeedAdminPassword); !errors.Is(err, ErrInvalidCredentials) { |
|||
t.Errorf("Authenticate(old password) error = %v, want ErrInvalidCredentials", err) |
|||
} |
|||
updatedUser, err := store.Authenticate(SeedAdminEmail, newPassword) |
|||
if err != nil { |
|||
t.Fatalf("Authenticate(new password) error = %v", err) |
|||
} |
|||
if updatedUser.MustChangePassword { |
|||
t.Error("MustChangePassword remains true after a password change") |
|||
} |
|||
} |
|||
|
|||
func TestSessionsPersistAcrossStoreRestartAndSlideExpiry(t *testing.T) { |
|||
now := time.Date(2026, time.September, 24, 12, 0, 0, 0, time.UTC) |
|||
clock := func() time.Time { return now } |
|||
databasePath := filepath.Join(t.TempDir(), "webfilebrowser.db") |
|||
|
|||
store, err := Open(databasePath, Options{Clock: clock, SessionLifetime: 7 * 24 * time.Hour}) |
|||
if err != nil { |
|||
t.Fatalf("Open() error = %v", err) |
|||
} |
|||
user, err := store.Authenticate(SeedAdminEmail, SeedAdminPassword) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
token, err := store.CreateSession(user.ID) |
|||
if err != nil { |
|||
t.Fatalf("CreateSession() error = %v", err) |
|||
} |
|||
if err := store.Close(); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
|
|||
now = now.Add(6 * 24 * time.Hour) |
|||
reopenedStore, err := Open(databasePath, Options{Clock: clock, SessionLifetime: 7 * 24 * time.Hour}) |
|||
if err != nil { |
|||
t.Fatalf("Open() after restart error = %v", err) |
|||
} |
|||
t.Cleanup(func() { _ = reopenedStore.Close() }) |
|||
|
|||
if _, err := reopenedStore.LookupSession(token); err != nil { |
|||
t.Fatalf("LookupSession() after restart error = %v", err) |
|||
} |
|||
|
|||
now = now.Add(2 * 24 * time.Hour) |
|||
if _, err := reopenedStore.LookupSession(token); err != nil { |
|||
t.Fatalf("LookupSession() after sliding expiry error = %v", err) |
|||
} |
|||
} |
|||
|
|||
func openTestStore(t *testing.T, directory string) *Store { |
|||
t.Helper() |
|||
store, err := Open(filepath.Join(directory, "webfilebrowser.db"), Options{}) |
|||
if err != nil { |
|||
t.Fatalf("Open() error = %v", err) |
|||
} |
|||
t.Cleanup(func() { _ = store.Close() }) |
|||
return store |
|||
} |
|||
@ -0,0 +1,215 @@ |
|||
// Package web provides the login-gated HTTP surface for the application.
|
|||
package web |
|||
|
|||
import ( |
|||
"errors" |
|||
"fmt" |
|||
"html/template" |
|||
"net/http" |
|||
"time" |
|||
|
|||
"webfilebrowser/internal/auth" |
|||
) |
|||
|
|||
const ( |
|||
loginPath = "/login" |
|||
logoutPath = "/logout" |
|||
changePasswordPath = "/change-password" |
|||
sessionCookieName = "webfilebrowser_session" |
|||
) |
|||
|
|||
var ( |
|||
loginTemplate = template.Must(template.New("login").Parse(`<!doctype html> |
|||
<html lang="en"><head><meta charset="utf-8"><title>Sign in</title></head> |
|||
<body><main><h1>Sign in</h1>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}} |
|||
<form method="post" action="/login"><label>Email <input type="email" name="email" required></label> |
|||
<label>Password <input type="password" name="password" required></label><button type="submit">Sign in</button></form></main></body></html>`)) |
|||
changePasswordTemplate = template.Must(template.New("change-password").Parse(`<!doctype html> |
|||
<html lang="en"><head><meta charset="utf-8"><title>Change password</title></head> |
|||
<body><main><h1>Change your password</h1><p>You must choose a new password before continuing.</p>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}} |
|||
<form method="post" action="/change-password"><label>Current password <input type="password" name="current_password" required></label> |
|||
<label>New password <input type="password" name="new_password" required></label><button type="submit">Change password</button></form></main></body></html>`)) |
|||
) |
|||
|
|||
// New returns an HTTP handler that requires a valid server-side session for
|
|||
// every route other than the login endpoint.
|
|||
func New(store *auth.Store) http.Handler { |
|||
return &application{store: store} |
|||
} |
|||
|
|||
type application struct { |
|||
store *auth.Store |
|||
} |
|||
|
|||
func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
|||
if request.URL.Path == loginPath { |
|||
app.handleLogin(writer, request) |
|||
return |
|||
} |
|||
|
|||
user, token, ok := app.currentUser(writer, request) |
|||
if !ok { |
|||
return |
|||
} |
|||
if request.URL.Path == logoutPath { |
|||
app.handleLogout(writer, request, token) |
|||
return |
|||
} |
|||
if user.MustChangePassword { |
|||
if request.URL.Path != changePasswordPath { |
|||
http.Redirect(writer, request, changePasswordPath, http.StatusSeeOther) |
|||
return |
|||
} |
|||
app.handleChangePassword(writer, request, user) |
|||
return |
|||
} |
|||
if request.URL.Path == changePasswordPath { |
|||
http.Redirect(writer, request, "/", http.StatusSeeOther) |
|||
return |
|||
} |
|||
if request.URL.Path == "/" { |
|||
app.handleHome(writer, request, user) |
|||
return |
|||
} |
|||
|
|||
http.NotFound(writer, request) |
|||
} |
|||
|
|||
func (app *application) handleLogin(writer http.ResponseWriter, request *http.Request) { |
|||
switch request.Method { |
|||
case http.MethodGet: |
|||
renderLogin(writer, http.StatusOK, "") |
|||
case http.MethodPost: |
|||
if err := request.ParseForm(); err != nil { |
|||
renderLogin(writer, http.StatusBadRequest, "Unable to read sign-in details.") |
|||
return |
|||
} |
|||
user, err := app.store.Authenticate(request.FormValue("email"), request.FormValue("password")) |
|||
if err != nil { |
|||
renderLogin(writer, http.StatusUnauthorized, "Invalid email or password.") |
|||
return |
|||
} |
|||
token, err := app.store.CreateSession(user.ID) |
|||
if err != nil { |
|||
http.Error(writer, "Unable to create session.", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
app.setSessionCookie(writer, token) |
|||
destination := "/" |
|||
if user.MustChangePassword { |
|||
destination = changePasswordPath |
|||
} |
|||
http.Redirect(writer, request, destination, http.StatusSeeOther) |
|||
default: |
|||
writer.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) |
|||
http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) |
|||
} |
|||
} |
|||
|
|||
func (app *application) handleChangePassword(writer http.ResponseWriter, request *http.Request, user auth.User) { |
|||
switch request.Method { |
|||
case http.MethodGet: |
|||
renderChangePassword(writer, http.StatusOK, "") |
|||
case http.MethodPost: |
|||
if err := request.ParseForm(); err != nil { |
|||
renderChangePassword(writer, http.StatusBadRequest, "Unable to read password details.") |
|||
return |
|||
} |
|||
err := app.store.ChangePassword(user.ID, request.FormValue("current_password"), request.FormValue("new_password")) |
|||
if errors.Is(err, auth.ErrPasswordUnchanged) { |
|||
renderChangePassword(writer, http.StatusBadRequest, "Your new password must differ from the current password.") |
|||
return |
|||
} |
|||
if errors.Is(err, auth.ErrInvalidCredentials) { |
|||
renderChangePassword(writer, http.StatusBadRequest, "Your current password is incorrect.") |
|||
return |
|||
} |
|||
if err != nil { |
|||
http.Error(writer, "Unable to change password.", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
http.Redirect(writer, request, "/", http.StatusSeeOther) |
|||
default: |
|||
writer.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) |
|||
http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) |
|||
} |
|||
} |
|||
|
|||
func (app *application) handleLogout(writer http.ResponseWriter, request *http.Request, token string) { |
|||
if request.Method != http.MethodPost { |
|||
writer.Header().Set("Allow", http.MethodPost) |
|||
http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) |
|||
return |
|||
} |
|||
if err := app.store.DeleteSession(token); err != nil { |
|||
http.Error(writer, "Unable to end session.", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
http.SetCookie(writer, &http.Cookie{ |
|||
Name: sessionCookieName, |
|||
Value: "", |
|||
Path: "/", |
|||
MaxAge: -1, |
|||
HttpOnly: true, |
|||
Secure: true, |
|||
SameSite: http.SameSiteLaxMode, |
|||
}) |
|||
http.Redirect(writer, request, loginPath, http.StatusSeeOther) |
|||
} |
|||
|
|||
func (app *application) handleHome(writer http.ResponseWriter, _ *http.Request, user auth.User) { |
|||
writer.Header().Set("Content-Type", "text/html; charset=utf-8") |
|||
_, _ = fmt.Fprintf(writer, "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>webFileBrowser</title></head><body><main><h1>webFileBrowser</h1><p>File browser is being prepared.</p><p>Signed in as %s.</p><form method=\"post\" action=\"/logout\"><button type=\"submit\">Sign out</button></form></main></body></html>", template.HTMLEscapeString(user.Email)) |
|||
} |
|||
|
|||
func (app *application) currentUser(writer http.ResponseWriter, request *http.Request) (auth.User, string, bool) { |
|||
cookie, err := request.Cookie(sessionCookieName) |
|||
if err != nil { |
|||
http.Redirect(writer, request, loginPath, http.StatusSeeOther) |
|||
return auth.User{}, "", false |
|||
} |
|||
user, err := app.store.LookupSession(cookie.Value) |
|||
if err != nil { |
|||
app.clearSessionCookie(writer) |
|||
http.Redirect(writer, request, loginPath, http.StatusSeeOther) |
|||
return auth.User{}, "", false |
|||
} |
|||
app.setSessionCookie(writer, cookie.Value) |
|||
return user, cookie.Value, true |
|||
} |
|||
|
|||
func (app *application) setSessionCookie(writer http.ResponseWriter, token string) { |
|||
http.SetCookie(writer, &http.Cookie{ |
|||
Name: sessionCookieName, |
|||
Value: token, |
|||
Path: "/", |
|||
Expires: time.Now().Add(app.store.SessionLifetime()), |
|||
HttpOnly: true, |
|||
Secure: true, |
|||
SameSite: http.SameSiteLaxMode, |
|||
}) |
|||
} |
|||
|
|||
func (app *application) clearSessionCookie(writer http.ResponseWriter) { |
|||
http.SetCookie(writer, &http.Cookie{ |
|||
Name: sessionCookieName, |
|||
Value: "", |
|||
Path: "/", |
|||
MaxAge: -1, |
|||
HttpOnly: true, |
|||
Secure: true, |
|||
SameSite: http.SameSiteLaxMode, |
|||
}) |
|||
} |
|||
|
|||
func renderLogin(writer http.ResponseWriter, status int, message string) { |
|||
writer.Header().Set("Content-Type", "text/html; charset=utf-8") |
|||
writer.WriteHeader(status) |
|||
_ = loginTemplate.Execute(writer, struct{ Error string }{Error: message}) |
|||
} |
|||
|
|||
func renderChangePassword(writer http.ResponseWriter, status int, message string) { |
|||
writer.Header().Set("Content-Type", "text/html; charset=utf-8") |
|||
writer.WriteHeader(status) |
|||
_ = changePasswordTemplate.Execute(writer, struct{ Error string }{Error: message}) |
|||
} |
|||
@ -0,0 +1,155 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"bytes" |
|||
"io" |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"net/url" |
|||
"path/filepath" |
|||
"strings" |
|||
"testing" |
|||
|
|||
"webfilebrowser/internal/auth" |
|||
) |
|||
|
|||
func TestUnauthenticatedRequestsNeverReachProtectedRoutes(t *testing.T) { |
|||
_, handler := newTestApplication(t) |
|||
|
|||
for _, target := range []string{"/", "/files/media", "/download/media/report.pdf", "/stream/media/video.mp4", "/admin/users"} { |
|||
t.Run(target, func(t *testing.T) { |
|||
response := httptest.NewRecorder() |
|||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, target, nil)) |
|||
|
|||
if response.Code != http.StatusSeeOther { |
|||
t.Errorf("GET %s status = %d, want %d", target, response.Code, http.StatusSeeOther) |
|||
} |
|||
if location := response.Header().Get("Location"); location != loginPath { |
|||
t.Errorf("GET %s Location = %q, want %q", target, location, loginPath) |
|||
} |
|||
if strings.Contains(response.Body.String(), "File browser is being prepared") { |
|||
t.Errorf("GET %s returned protected content", target) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestLoginForcesPasswordChangeBeforeAccessingOtherRoutes(t *testing.T) { |
|||
_, handler := newTestApplication(t) |
|||
|
|||
loginResponse := postForm(handler, loginPath, url.Values{ |
|||
"email": {auth.SeedAdminEmail}, |
|||
"password": {auth.SeedAdminPassword}, |
|||
}, nil) |
|||
if loginResponse.Code != http.StatusSeeOther { |
|||
t.Fatalf("POST %s status = %d, want %d", loginPath, loginResponse.Code, http.StatusSeeOther) |
|||
} |
|||
if location := loginResponse.Header().Get("Location"); location != changePasswordPath { |
|||
t.Fatalf("POST %s Location = %q, want %q", loginPath, location, changePasswordPath) |
|||
} |
|||
cookie := responseCookie(t, loginResponse, sessionCookieName) |
|||
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode { |
|||
t.Errorf("session cookie security attributes = HttpOnly:%t Secure:%t SameSite:%v, want true:true:Lax", cookie.HttpOnly, cookie.Secure, cookie.SameSite) |
|||
} |
|||
|
|||
for _, target := range []string{"/", "/files/media", "/download/media/report.pdf", "/stream/media/video.mp4", "/admin/users"} { |
|||
response := httptest.NewRecorder() |
|||
request := httptest.NewRequest(http.MethodGet, target, nil) |
|||
request.AddCookie(cookie) |
|||
handler.ServeHTTP(response, request) |
|||
|
|||
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != changePasswordPath { |
|||
t.Errorf("GET %s during forced change = (%d, %q), want (%d, %q)", target, response.Code, response.Header().Get("Location"), http.StatusSeeOther, changePasswordPath) |
|||
} |
|||
} |
|||
|
|||
samePasswordResponse := postForm(handler, changePasswordPath, url.Values{ |
|||
"current_password": {auth.SeedAdminPassword}, |
|||
"new_password": {auth.SeedAdminPassword}, |
|||
}, cookie) |
|||
if samePasswordResponse.Code != http.StatusBadRequest { |
|||
t.Errorf("POST %s with unchanged password status = %d, want %d", changePasswordPath, samePasswordResponse.Code, http.StatusBadRequest) |
|||
} |
|||
|
|||
changedPasswordResponse := postForm(handler, changePasswordPath, url.Values{ |
|||
"current_password": {auth.SeedAdminPassword}, |
|||
"new_password": {"A-new-password-123!"}, |
|||
}, cookie) |
|||
if changedPasswordResponse.Code != http.StatusSeeOther || changedPasswordResponse.Header().Get("Location") != "/" { |
|||
t.Errorf("POST %s status/location = (%d, %q), want (%d, %q)", changePasswordPath, changedPasswordResponse.Code, changedPasswordResponse.Header().Get("Location"), http.StatusSeeOther, "/") |
|||
} |
|||
|
|||
rootResponse := httptest.NewRecorder() |
|||
rootRequest := httptest.NewRequest(http.MethodGet, "/", nil) |
|||
rootRequest.AddCookie(cookie) |
|||
handler.ServeHTTP(rootResponse, rootRequest) |
|||
if rootResponse.Code != http.StatusOK { |
|||
t.Errorf("GET / after changing password status = %d, want %d", rootResponse.Code, http.StatusOK) |
|||
} |
|||
} |
|||
|
|||
func TestLogoutDeletesTheServerSession(t *testing.T) { |
|||
_, handler := newTestApplication(t) |
|||
cookie := loginSeedAdmin(t, handler) |
|||
|
|||
logoutResponse := postForm(handler, logoutPath, nil, cookie) |
|||
if logoutResponse.Code != http.StatusSeeOther || logoutResponse.Header().Get("Location") != loginPath { |
|||
t.Fatalf("POST %s status/location = (%d, %q), want (%d, %q)", logoutPath, logoutResponse.Code, logoutResponse.Header().Get("Location"), http.StatusSeeOther, loginPath) |
|||
} |
|||
|
|||
protectedResponse := httptest.NewRecorder() |
|||
protectedRequest := httptest.NewRequest(http.MethodGet, "/", nil) |
|||
protectedRequest.AddCookie(cookie) |
|||
handler.ServeHTTP(protectedResponse, protectedRequest) |
|||
if protectedResponse.Code != http.StatusSeeOther || protectedResponse.Header().Get("Location") != loginPath { |
|||
t.Errorf("GET / after logout status/location = (%d, %q), want (%d, %q)", protectedResponse.Code, protectedResponse.Header().Get("Location"), http.StatusSeeOther, loginPath) |
|||
} |
|||
} |
|||
|
|||
func newTestApplication(t *testing.T) (*auth.Store, http.Handler) { |
|||
t.Helper() |
|||
store, err := auth.Open(filepath.Join(t.TempDir(), "webfilebrowser.db"), auth.Options{}) |
|||
if err != nil { |
|||
t.Fatalf("Open() error = %v", err) |
|||
} |
|||
t.Cleanup(func() { _ = store.Close() }) |
|||
return store, New(store) |
|||
} |
|||
|
|||
func loginSeedAdmin(t *testing.T, handler http.Handler) *http.Cookie { |
|||
t.Helper() |
|||
response := postForm(handler, loginPath, url.Values{ |
|||
"email": {auth.SeedAdminEmail}, |
|||
"password": {auth.SeedAdminPassword}, |
|||
}, nil) |
|||
if response.Code != http.StatusSeeOther { |
|||
t.Fatalf("POST %s status = %d, want %d", loginPath, response.Code, http.StatusSeeOther) |
|||
} |
|||
return responseCookie(t, response, sessionCookieName) |
|||
} |
|||
|
|||
func postForm(handler http.Handler, target string, values url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { |
|||
var body io.Reader |
|||
if values != nil { |
|||
body = bytes.NewBufferString(values.Encode()) |
|||
} |
|||
request := httptest.NewRequest(http.MethodPost, target, body) |
|||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
|||
if cookie != nil { |
|||
request.AddCookie(cookie) |
|||
} |
|||
response := httptest.NewRecorder() |
|||
handler.ServeHTTP(response, request) |
|||
return response |
|||
} |
|||
|
|||
func responseCookie(t *testing.T, response *httptest.ResponseRecorder, name string) *http.Cookie { |
|||
t.Helper() |
|||
for _, cookie := range response.Result().Cookies() { |
|||
if cookie.Name == name { |
|||
return cookie |
|||
} |
|||
} |
|||
t.Fatalf("response did not set cookie %q", name) |
|||
return nil |
|||
} |
|||
Loading…
Reference in new issue