Browse Source

feat(auth): require login and password change

main
Domagoj Zecevic 1 month ago
parent
commit
c5db1b9d85
  1. 28
      .ai/HANDOFF.md
  2. 22
      .ai/REVIEW.md
  3. 2
      .ai/TASKS.md
  4. 6
      Dockerfile
  5. 29
      README.md
  6. 24
      cmd/webfilebrowser/main.go
  7. 4
      compose.yaml
  8. 18
      go.mod
  9. 49
      go.sum
  10. 345
      internal/auth/store.go
  11. 116
      internal/auth/store_test.go
  12. 215
      internal/web/app.go
  13. 155
      internal/web/app_test.go

28
.ai/HANDOFF.md

@ -78,3 +78,31 @@ Each entry uses this exact structure. Omit fields marked as role-specific when t
| Next Role | none |
---
### T-002 — dev — 2026-09-24T09:37:48Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added persistent SQLite authentication, secure sliding sessions, login/logout, and the mandatory password-change gate. |
| Files Changed | Dockerfile, README.md, cmd/webfilebrowser/main.go, compose.yaml, go.mod, go.sum, internal/auth/store.go, internal/auth/store_test.go, internal/web/app.go, internal/web/app_test.go, .ai/TASKS.md, .ai/REVIEW.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, and `go test ./...` passed in `golang:1.24`; Docker image build and named-volume startup smoke test passed; E2E not configured. |
| Commit | `feat(auth): require login and password change` |
| Verdict | PASS |
| Blocking Findings | none |
| Next Role | human approval |
---
### T-002 — commit_task — 2026-09-24T09:40:46Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Approved the reviewed T-002 authentication foundation and prepared its single task commit. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | Review validation recorded in `.ai/REVIEW.md`. |
| Commit | `feat(auth): require login and password change` |
| Next Role | none |
---

22
.ai/REVIEW.md

@ -27,3 +27,25 @@ The implementation matches T-001: it supplies a Go module and HTTP-server
skeleton, validated file/environment root loading, an example Compose setup and
Docker image, and operator documentation for root configuration and restart
requirements.
## T-002 — 2026-09-24T09:37:48Z
Verdict: PASS
Findings: none.
Validation:
- `go fmt ./...` (in `golang:1.24`): PASS
- `go vet ./...` (in `golang:1.24`): PASS
- `go test ./...` (in `golang:1.24`): PASS
- `docker build --tag webfilebrowser:t002 .`: PASS
- Container startup with a fresh named SQLite volume: PASS; it loaded the
sample root and began serving on `:8080` as the non-root application user.
- E2E: NOT_CONFIGURED; project documentation declares no E2E command.
The review found that the SQLite migration seeds the temporary administrator
only on initial database creation, credentials use bcrypt, only hashed session
tokens are stored, and every current/future protected path is authenticated
before route handling. The HTTP integration tests cover the forced password
change gate for the root, file, download, stream, and admin paths.

2
.ai/TASKS.md

@ -21,7 +21,7 @@ Command expectations:
| Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role |
| --- | --- | --- | --- | --- | --- |
| T-001 | Project scaffolding: Go module layout, root-config loading (file/env listing container paths + display names), Dockerfile skeleton, docker-compose example, `README.md` overview/setup skeleton | done | `go build ./...` succeeds; app boots and logs the configured roots from a sample config without panicking; `README.md` documents the config file/env format for declaring roots and the volume-mount + restart-to-add-root constraint | PASS: config source/precedence/validation tests; `go fmt ./...`, `go vet ./...`, `go test ./...`, and `go build -buildvcs=false ./...` in `golang:1.24`; Docker image build + sample-config startup-log smoke test | none |
| T-002 | Data & auth foundation: SQLite schema/migrations for users + sessions, bcrypt password hashing, seed admin `boss@zecevic-ninja.com` / `Start1234!` with `must_change_password=true` on first startup, server-side session-cookie middleware (HTTP-only/Secure/SameSite, sliding expiry), login/logout routes, forced password-change gate that blocks every other route until the password is changed | ready_for_implement | Unauthenticated request to any non-login route (incl. file listing, download, stream, admin) returns redirect/401, never content; login with seed credentials succeeds and is immediately forced to the change-password screen and cannot reach any other page until changed; new password must differ from the temporary one; sessions persist across process restart via SQLite; unit tests cover auth middleware, forced-change gate, and password hashing | n/a | implement |
| T-002 | Data & auth foundation: SQLite schema/migrations for users + sessions, bcrypt password hashing, seed admin `boss@zecevic-ninja.com` / `Start1234!` with `must_change_password=true` on first startup, server-side session-cookie middleware (HTTP-only/Secure/SameSite, sliding expiry), login/logout routes, forced password-change gate that blocks every other route until the password is changed | done | Unauthenticated request to any non-login route (incl. file listing, download, stream, admin) returns redirect/401, never content; login with seed credentials succeeds and is immediately forced to the change-password screen and cannot reach any other page until changed; new password must differ from the temporary one; sessions persist across process restart via SQLite; unit tests cover auth middleware, forced-change gate, and password hashing | PASS: auth storage/bcrypt/persistence/sliding-session and HTTP login/logout/forced-change/protected-route tests; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24`; Docker image build and named-volume startup smoke test | none |
| T-003 | Folder browsing UI: server-rendered root listing page, directory navigation with breadcrumbs, strict path confinement inside each configured root (block `..` traversal and symlink escapes), light/dark theme toggle, minimal vanilla JS/CSS (no SPA framework) | ready_for_implement | Authenticated user sees configured roots, can enter/exit subfolders via breadcrumbs; any crafted path that would resolve outside a root's filesystem boundary is rejected (test with `../` and a symlink pointing outside the root); theme toggle switches light/dark and persists per session/browser; `README.md` documents the browsing UI behavior | n/a | implement |
| T-004 | Download & video streaming: raw-byte download endpoint with correct `Content-Type`/`Content-Disposition`, full HTTP `Range` support for video files (seek/partial content/resume) with zero transcoding anywhere in the request path | ready_for_implement | Downloading any file returns identical bytes to the source file (checksum match); a `Range` request against a video file returns `206 Partial Content` with the correct byte range and headers; no encoding/thumbnailing/transcoding code path exists; `README.md` documents the reverse-proxy Range-header-passthrough requirement | n/a | implement |
| T-005 | Admin user management: admin-only screens to list users, create a user (temporary password, `must_change_password=true`), reset a user's password, disable/delete a user; role-based route protection (403 for authenticated non-admins, not a redirect that leaks page existence) | ready_for_implement | Non-admin authenticated user gets 403 on all admin routes; admin can create a user who is then forced through the change-password gate on first login; admin can reset/disable a user; `README.md` documents the admin user-management flow | n/a | implement |

6
Dockerfile

@ -2,7 +2,7 @@ FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
@ -10,7 +10,9 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/webfile
FROM alpine:3.21
RUN addgroup -S webfilebrowser && adduser -S -G webfilebrowser webfilebrowser
RUN addgroup -S webfilebrowser && adduser -S -G webfilebrowser webfilebrowser \
&& mkdir -p /var/lib/webfilebrowser \
&& chown webfilebrowser:webfilebrowser /var/lib/webfilebrowser
COPY --from=build /out/webfilebrowser /usr/local/bin/webfilebrowser

29
README.md

@ -23,8 +23,8 @@ Docker is the only prerequisite for the scaffold.
```
The server listens on `http://localhost:8080` and logs every configured root
at startup. The scaffold intentionally has no application page yet, so requests
currently receive `404 Not Found`.
at startup. `/login` is the only public application route; every other route
redirects to login until it has a valid server-side session.
## Root configuration
@ -55,6 +55,31 @@ add or remove a root, update both the configuration and the container's volume
mounts, then restart the container. Roots are intentionally fixed at startup;
they cannot be changed from the application.
## Authentication and persistent data
The application stores users and server-side sessions in SQLite. By default,
the database is `/var/lib/webfilebrowser/webfilebrowser.db`; set
`WEB_FILE_BROWSER_DATABASE_PATH` to use another path. The Compose example
uses a named volume for this directory, so users and active sessions survive a
container restart.
On the first database initialization, webFileBrowser creates this temporary
administrator account:
```text
Email: boss@zecevic-ninja.com
Password: Start1234!
```
That account must change its password immediately after the first successful
login. It cannot access browsing, download, streaming, or administration routes
until the new password differs from the temporary password. Sessions are stored
server-side, use a seven-day sliding expiry, and are invalidated on logout.
The session cookie is always `HttpOnly`, `Secure`, and `SameSite=Lax`.
Terminate TLS at a reverse proxy before exposing the service; browsers will not
send the secure cookie over plain HTTP.
## AI Workflow
This project includes the persistent planner/implementer/reviewer workflow with file-based coordination, plus the PO orchestration layer.

24
cmd/webfilebrowser/main.go

@ -7,10 +7,15 @@ import (
"net/http"
"os"
"webfilebrowser/internal/auth"
"webfilebrowser/internal/config"
"webfilebrowser/internal/web"
)
const defaultAddress = ":8080"
const (
defaultAddress = ":8080"
defaultDatabasePath = "/var/lib/webfilebrowser/webfilebrowser.db"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
@ -24,6 +29,21 @@ func main() {
logger.Info("configured browse root", "name", root.Name, "path", root.Path)
}
databasePath := os.Getenv("WEB_FILE_BROWSER_DATABASE_PATH")
if databasePath == "" {
databasePath = defaultDatabasePath
}
store, err := auth.Open(databasePath, auth.Options{})
if err != nil {
logger.Error("open authentication database", "error", err)
os.Exit(1)
}
defer func() {
if err := store.Close(); err != nil {
logger.Error("close authentication database", "error", err)
}
}()
address := os.Getenv("WEB_FILE_BROWSER_ADDR")
if address == "" {
address = defaultAddress
@ -31,7 +51,7 @@ func main() {
server := &http.Server{
Addr: address,
Handler: http.NewServeMux(),
Handler: web.New(store),
}
logger.Info("starting web file browser", "address", address)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {

4
compose.yaml

@ -8,3 +8,7 @@ services:
volumes:
- ./config/roots.example.json:/etc/webfilebrowser/roots.json:ro
- ${MEDIA_DIR:-./example-files}:/mnt/media:ro
- webfilebrowser-data:/var/lib/webfilebrowser
volumes:
webfilebrowser-data:

18
go.mod

@ -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
)

49
go.sum

@ -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=

345
internal/auth/store.go

@ -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[:])
}

116
internal/auth/store_test.go

@ -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
}

215
internal/web/app.go

@ -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})
}

155
internal/web/app_test.go

@ -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…
Cancel
Save