Initial project foundation #1

Merged
tony merged 1 commits from codex/initial-project-foundation AGit into main 2026-08-06 22:08:00 +02:00
19 changed files with 1713 additions and 2 deletions
+96 -1
View File
@@ -27,6 +27,102 @@ Read `README.md` and the relevant documents under `docs/` before changing implem
- Add negative tests for authorization, path validation, module capabilities, archive extraction, and agent operation scope.
- Report what was validated and what still needs physical or integration testing.
## Codex operating workflow
These rules are permanent repository policy. A `codex exec` prompt should name
the objective and constraints specific to the task, then rely on this file
instead of repeating the repository workflow.
### Git, branches and releases
- Start by reading the current branch and working-tree state. Treat existing
changes as user-owned and do not overwrite, discard, stage or reformat
unrelated work.
- Git commands required by the current task are allowed on working branches.
Before any Git write, verify the active branch. Never switch to, modify,
commit on, merge into, rebase, reset, delete or push `main`; if work starts
on `main`, stop before writing and use or request a working branch.
- Commits and pushes on non-`main` branches are allowed only when they are
necessary for the stated task. Do not infer that implementation alone
requires publication, and always report the operations performed.
- Do not perform merges or create merge/pull requests in Gitea. When either is
needed, give the user the procedure to complete it in Gitea.
- Do not alter remotes, credentials or repository-wide Git configuration
unless the task explicitly requires it.
- Never use destructive recovery commands such as `git reset --hard`,
`git clean`, or checkout-based file restoration without explicit approval
and a verified target list.
### Security and scope
- Work only inside this repository unless the task explicitly names another
location. Do not expose credentials, tokens, cookies, database contents or
private keys in commands, logs, fixtures or reports.
- Preserve every trust boundary and non-negotiable product rule above. Stop and
report a conflict instead of weakening authentication, authorization,
validation, isolation, redaction or data-preservation behavior.
- Inspect before editing, make the smallest coherent change, and preserve
user-owned changes. Do not modify generated or synchronized files unless the
repository documents that workflow.
- Do not install system packages, start persistent services, modify host
configuration or use elevated privileges without explicit approval.
### Network and dependencies
- Treat network access as opt-in. Use it only when the task requires current
primary documentation or dependency retrieval.
- Prefer existing pinned dependencies and repository tools. Review changes to
`go.mod` and `go.sum`; do not add an unrelated dependency or execute code
fetched from an untrusted source.
- Never send repository contents, secrets or local data to external services.
Record any validation skipped because the network was unavailable.
### Caches and temporary files
- Keep Codex-created caches and temporary artifacts outside tracked source
paths, preferably under `.cache/codex/` or an OS temporary directory.
- Reuse caches when safe. Do not delete or purge shared Go, Python, linter,
container or user caches unless explicitly requested.
- Do not leave binaries, databases, coverage files, logs or temporary patches
in the repository. Before finishing, remove only artifacts created by the
current task and confirmed safe to remove.
### Required validation
For Go implementation changes, run the applicable complete validation set from
the repository root:
```sh
gofmt -w <changed-go-files>
go mod tidy
go test ./...
CGO_ENABLED=0 go build ./...
go test -race ./...
go vet ./...
staticcheck ./...
golangci-lint run
python3 tools/validate_spec.py
git diff --check
```
Use already installed tools when possible. If a required tool or Python module
is unavailable, do not silently install it or omit the check: report the exact
blocker and request approval when installation or network access is needed.
Run narrower tests during development, but run the full applicable set before
declaring completion. A passing build does not replace tests, race detection,
static analysis or specification validation.
### Completion report
- Summarize behavior changed and list every modified, created or removed file.
- List each validation command with pass, fail or not-run status and the exact
blocker for anything incomplete.
- Report the final branch and working-tree state, while distinguishing changes
made by Codex from changes that were already present.
- State explicitly whether commits, tags, pushes, branch changes, external
writes or persistent host changes occurred. Never claim success for a check
that was not run to completion.
## Definition of done for a change
- Relevant requirements and acceptance criteria are satisfied.
@@ -35,4 +131,3 @@ Read `README.md` and the relevant documents under `docs/` before changing implem
- Failure and rollback behavior is covered.
- Documentation and machine-readable examples agree.
- Tests cover success, denial, and interruption paths.
+1 -1
View File
@@ -72,7 +72,7 @@ Only the main application's HTTP port is published. The agent and game-managemen
## Status
Specification baseline. The roadmap and V1 acceptance criteria define the implementation order and completion boundary.
The first roadmap foundation is implemented: the main Go binary, embedded server-rendered UI, SQLite migrations, first-administrator bootstrap, and local session authentication. Later roadmap components, including the restricted Docker agent and WebAssembly runtime, are not implemented yet.
## Validate the specification
+73
View File
@@ -0,0 +1,73 @@
// Command dogama runs the DoGaMa main application.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("application stopped", "event", "application.failed", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
listenAddress := environment("DOGAMA_LISTEN_ADDRESS", ":8080")
databasePath := environment("DOGAMA_DATABASE_PATH", "dogama.db")
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := sqlite.Open(ctx, databasePath)
if err != nil {
return err
}
defer db.Close()
handler, err := web.NewHandler(auth.New(db), logger)
if err != nil {
return err
}
server := &http.Server{
Addr: listenAddress,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
logger.Info("application listening", "event", "application.started", "address", listenAddress)
errCh <- server.ListenAndServe()
}()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return server.Shutdown(shutdownCtx)
}
}
func environment(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
+10
View File
@@ -26,6 +26,16 @@ docs/
tests/integration/
```
## Initial application development
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads only bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`) and `DOGAMA_DATABASE_PATH` (default `dogama.db`). Run it with:
```sh
go run ./cmd/dogama
```
Browser sessions always use `Secure`, `HttpOnly`, and `SameSite=Strict` cookies. Place the application behind a trusted TLS reverse proxy for browser use, including development environments. The application does not accept forwarded client addresses as authoritative for authentication throttling.
## Contract changes
Template schema, manifest schema, normalized module API and agent deployment plan are versioned contracts.
+20
View File
@@ -0,0 +1,20 @@
module git.zaynet.fr/DoGaMa/DoGaMa-serv
go 1.25.0
require (
golang.org/x/crypto v0.53.0
modernc.org/sqlite v1.56.0
)
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.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+52
View File
@@ -0,0 +1,52 @@
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-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/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.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
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=
+317
View File
@@ -0,0 +1,317 @@
// Package auth implements local identities, sessions, and login throttling.
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"net/netip"
"strings"
"time"
"golang.org/x/crypto/argon2"
)
var (
// ErrBootstrapComplete is returned when a first administrator already exists.
ErrBootstrapComplete = errors.New("bootstrap is already complete")
// ErrInvalidCredentials deliberately does not reveal whether an account exists.
ErrInvalidCredentials = errors.New("invalid credentials")
// ErrRateLimited is returned while authentication backoff is active.
ErrRateLimited = errors.New("authentication temporarily unavailable")
// ErrInvalidSession is returned for absent, expired, or revoked sessions.
ErrInvalidSession = errors.New("invalid session")
)
const (
absoluteLifetime = 24 * time.Hour
idleLifetime = 30 * time.Minute
attemptWindow = 15 * time.Minute
)
// User is the authenticated principal exposed to application handlers.
type User struct {
ID string
Username string
Role string
}
// Session contains a new opaque browser credential and CSRF token.
type Session struct {
Token string
CSRFToken string
ExpiresAt time.Time
}
// Service owns authentication persistence.
type Service struct {
db *sql.DB
now func() time.Time
dummyPasswordHash string
}
// New constructs an authentication service.
func New(db *sql.DB) *Service {
salt := make([]byte, 16)
return &Service{db: db, now: time.Now, dummyPasswordHash: encodePassword("not a real account password", salt)}
}
// BootstrapRequired reports whether the one-time administrator setup is pending.
func (s *Service) BootstrapRequired(ctx context.Context) (bool, error) {
var completed sql.NullString
if err := s.db.QueryRowContext(ctx, "SELECT bootstrap_completed_at FROM system_state WHERE singleton = 1").Scan(&completed); err != nil {
return false, fmt.Errorf("read bootstrap state: %w", err)
}
return !completed.Valid, nil
}
// BootstrapAdmin creates exactly one initial administrator in a transaction.
func (s *Service) BootstrapAdmin(ctx context.Context, username, password string) error {
username = strings.TrimSpace(username)
if err := validateCredentials(username, password); err != nil {
return err
}
hash, err := hashPassword(password)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin bootstrap: %w", err)
}
defer func() { _ = tx.Rollback() }()
var completed sql.NullString
if err := tx.QueryRowContext(ctx, "SELECT bootstrap_completed_at FROM system_state WHERE singleton = 1").Scan(&completed); err != nil {
return fmt.Errorf("read bootstrap state: %w", err)
}
if completed.Valid {
return ErrBootstrapComplete
}
now := s.now().UTC().Format(time.RFC3339Nano)
if _, err := tx.ExecContext(ctx, "INSERT INTO users(id, username, password_hash, global_role, created_at) VALUES (?, ?, ?, 'admin', ?)", randomToken(18), username, hash, now); err != nil {
return fmt.Errorf("create administrator: %w", err)
}
result, err := tx.ExecContext(ctx, "UPDATE system_state SET bootstrap_completed_at = ? WHERE singleton = 1 AND bootstrap_completed_at IS NULL", now)
if err != nil {
return fmt.Errorf("complete bootstrap: %w", err)
}
changed, err := result.RowsAffected()
if err != nil || changed != 1 {
return ErrBootstrapComplete
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit bootstrap: %w", err)
}
return nil
}
// Login checks backoff and credentials, then creates a rotated session.
func (s *Service) Login(ctx context.Context, username, password, remoteAddr string) (Session, error) {
key := attemptKey(username, remoteAddr)
now := s.now().UTC()
if blocked, err := s.blocked(ctx, key, now); err != nil {
return Session{}, err
} else if blocked {
return Session{}, ErrRateLimited
}
var user User
var passwordHash string
err := s.db.QueryRowContext(ctx, `SELECT id, username, global_role, password_hash
FROM users WHERE username = ? AND disabled_at IS NULL`, strings.TrimSpace(username)).Scan(&user.ID, &user.Username, &user.Role, &passwordHash)
accountExists := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return Session{}, fmt.Errorf("load login account: %w", err)
}
if !accountExists {
passwordHash = s.dummyPasswordHash
}
valid := verifyPassword(password, passwordHash) && accountExists
if !valid {
if err := s.recordFailure(ctx, key, now); err != nil {
return Session{}, err
}
return Session{}, ErrInvalidCredentials
}
if _, err := s.db.ExecContext(ctx, "DELETE FROM authentication_attempts WHERE attempt_key = ?", key); err != nil {
return Session{}, fmt.Errorf("clear authentication attempts: %w", err)
}
return s.createSession(ctx, user.ID, now)
}
// Authenticate resolves and refreshes a valid opaque session.
func (s *Service) Authenticate(ctx context.Context, token string) (User, error) {
if token == "" {
return User{}, ErrInvalidSession
}
now := s.now().UTC()
var user User
var expiresAt, lastSeenAt string
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.global_role, s.expires_at, s.last_seen_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Role, &expiresAt, &lastSeenAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrInvalidSession
}
if err != nil {
return User{}, fmt.Errorf("load session: %w", err)
}
expires, err1 := time.Parse(time.RFC3339Nano, expiresAt)
lastSeen, err2 := time.Parse(time.RFC3339Nano, lastSeenAt)
if err1 != nil || err2 != nil || !now.Before(expires) || now.Sub(lastSeen) > idleLifetime {
_ = s.Revoke(ctx, token)
return User{}, ErrInvalidSession
}
if _, err := s.db.ExecContext(ctx, "UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?", now.Format(time.RFC3339Nano), digest(token)); err != nil {
return User{}, fmt.Errorf("refresh session: %w", err)
}
return user, nil
}
// ValidateCSRF checks that a token belongs to the current session.
func (s *Service) ValidateCSRF(ctx context.Context, sessionToken, csrfToken string) bool {
if sessionToken == "" || csrfToken == "" {
return false
}
var expected []byte
if err := s.db.QueryRowContext(ctx, "SELECT csrf_hash FROM sessions WHERE id_hash = ?", digest(sessionToken)).Scan(&expected); err != nil {
return false
}
actual := digest(csrfToken)
return subtle.ConstantTimeCompare(expected, actual) == 1
}
// Revoke deletes a browser session.
func (s *Service) Revoke(ctx context.Context, token string) error {
if token == "" {
return nil
}
if _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE id_hash = ?", digest(token)); err != nil {
return fmt.Errorf("revoke session: %w", err)
}
return nil
}
func (s *Service) createSession(ctx context.Context, userID string, now time.Time) (Session, error) {
token, csrf := randomToken(32), randomToken(32)
expires := now.Add(absoluteLifetime)
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id_hash, user_id, csrf_hash, created_at, expires_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?)`, digest(token), userID, digest(csrf), now.Format(time.RFC3339Nano), expires.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
if err != nil {
return Session{}, fmt.Errorf("create session: %w", err)
}
return Session{Token: token, CSRFToken: csrf, ExpiresAt: expires}, nil
}
func (s *Service) blocked(ctx context.Context, key string, now time.Time) (bool, error) {
var blockedUntil sql.NullString
err := s.db.QueryRowContext(ctx, "SELECT blocked_until FROM authentication_attempts WHERE attempt_key = ?", key).Scan(&blockedUntil)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("read authentication attempts: %w", err)
}
if !blockedUntil.Valid {
return false, nil
}
until, err := time.Parse(time.RFC3339Nano, blockedUntil.String)
return err == nil && now.Before(until), nil
}
func (s *Service) recordFailure(ctx context.Context, key string, now time.Time) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin authentication attempt update: %w", err)
}
defer func() { _ = tx.Rollback() }()
var failures int
var updated string
err = tx.QueryRowContext(ctx, "SELECT failures, updated_at FROM authentication_attempts WHERE attempt_key = ?", key).Scan(&failures, &updated)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("read authentication attempts: %w", err)
}
if parsed, parseErr := time.Parse(time.RFC3339Nano, updated); parseErr != nil || now.Sub(parsed) > attemptWindow {
failures = 0
}
failures++
var blocked any
if failures >= 5 {
delay := time.Duration(1<<min(failures-5, 6)) * time.Minute
blocked = now.Add(delay).Format(time.RFC3339Nano)
}
_, err = tx.ExecContext(ctx, `INSERT INTO authentication_attempts(attempt_key, failures, blocked_until, updated_at)
VALUES (?, ?, ?, ?) ON CONFLICT(attempt_key) DO UPDATE SET failures=excluded.failures, blocked_until=excluded.blocked_until, updated_at=excluded.updated_at`, key, failures, blocked, now.Format(time.RFC3339Nano))
if err != nil {
return fmt.Errorf("record authentication attempt: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit authentication attempt: %w", err)
}
return nil
}
func validateCredentials(username, password string) error {
if len(username) < 3 || len(username) > 64 || len(password) < 12 || len(password) > 1024 {
return errors.New("username must be 3-64 characters and password at least 12 characters")
}
for _, character := range username {
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '.' && character != '_' && character != '-' {
return errors.New("username may contain only letters, numbers, dots, underscores, and hyphens")
}
}
return nil
}
func hashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
}
return encodePassword(password, salt), nil
}
func encodePassword(password string, salt []byte) string {
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 2, 32)
return fmt.Sprintf("$argon2id$v=19$m=65536,t=3,p=2$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash))
}
func verifyPassword(password, encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" || parts[2] != "v=19" || parts[3] != "m=65536,t=3,p=2" {
return false
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
expected, err2 := base64.RawStdEncoding.DecodeString(parts[5])
if err1 != nil || err2 != nil || len(salt) != 16 || len(expected) != 32 {
return false
}
actual := argon2.IDKey([]byte(password), salt, 3, 64*1024, 2, 32)
return subtle.ConstantTimeCompare(actual, expected) == 1
}
func attemptKey(username, remoteAddr string) string {
host := remoteAddr
if address, err := netip.ParseAddrPort(remoteAddr); err == nil {
host = address.Addr().String()
}
value := strings.ToLower(strings.TrimSpace(username)) + "\x00" + host
return base64.RawURLEncoding.EncodeToString(digest(value))
}
func randomToken(size int) string {
value := make([]byte, size)
if _, err := rand.Read(value); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(value)
}
func digest(value string) []byte {
sum := sha256.Sum256([]byte(value))
return sum[:]
}
+189
View File
@@ -0,0 +1,189 @@
package auth
import (
"context"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestBootstrapLoginSessionAndRevocation(t *testing.T) {
service := testService(t)
ctx := context.Background()
required, err := service.BootstrapRequired(ctx)
if err != nil || !required {
t.Fatalf("bootstrap required = %v, error = %v", required, err)
}
if err := service.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
if err := service.BootstrapAdmin(ctx, "second", "correct horse battery staple"); !errors.Is(err, ErrBootstrapComplete) {
t.Fatalf("second bootstrap error = %v", err)
}
if _, err := service.Login(ctx, "admin", "wrong password", "192.0.2.1:1234"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("bad login error = %v", err)
}
session, err := service.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
user, err := service.Authenticate(ctx, session.Token)
if err != nil || user.Role != "admin" {
t.Fatalf("authenticated user = %#v, error = %v", user, err)
}
if !service.ValidateCSRF(ctx, session.Token, session.CSRFToken) || service.ValidateCSRF(ctx, session.Token, "wrong") {
t.Fatal("unexpected CSRF validation result")
}
if err := service.Revoke(ctx, session.Token); err != nil {
t.Fatal(err)
}
if _, err := service.Authenticate(ctx, session.Token); !errors.Is(err, ErrInvalidSession) {
t.Fatalf("revoked session error = %v", err)
}
}
func TestBootstrapRejectsInvalidCredentials(t *testing.T) {
service := testService(t)
for _, test := range []struct {
username string
password string
}{
{username: "a", password: "correct horse battery staple"},
{username: "admin name", password: "correct horse battery staple"},
{username: "admin", password: "short"},
} {
if err := service.BootstrapAdmin(context.Background(), test.username, test.password); err == nil {
t.Fatalf("BootstrapAdmin(%q) unexpectedly succeeded", test.username)
}
}
}
func TestBootstrapAdminIsAtomicUnderConcurrency(t *testing.T) {
service := testService(t)
start := make(chan struct{})
results := make(chan error, 2)
var wait sync.WaitGroup
for _, username := range []string{"first", "second"} {
wait.Add(1)
go func() {
defer wait.Done()
<-start
results <- service.BootstrapAdmin(context.Background(), username, "correct horse battery staple")
}()
}
close(start)
wait.Wait()
close(results)
var succeeded, completed int
for err := range results {
switch {
case err == nil:
succeeded++
case errors.Is(err, ErrBootstrapComplete):
completed++
default:
t.Fatalf("concurrent bootstrap error = %v", err)
}
}
if succeeded != 1 || completed != 1 {
t.Fatalf("concurrent bootstrap results: succeeded=%d complete=%d", succeeded, completed)
}
var users int
if err := service.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&users); err != nil || users != 1 {
t.Fatalf("user count = %d, %v; want 1", users, err)
}
}
func TestLoginRateLimitAndIdleExpiry(t *testing.T) {
service := testService(t)
ctx := context.Background()
if err := service.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < 5; attempt++ {
_, err := service.Login(ctx, "admin", "wrong password", "192.0.2.1:1234")
if !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("attempt %d error = %v", attempt, err)
}
}
if _, err := service.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("rate limited login error = %v", err)
}
now := time.Now().UTC()
service.now = func() time.Time { return now }
session, err := service.Login(ctx, "admin", "correct horse battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return now.Add(idleLifetime + time.Second) }
if _, err := service.Authenticate(ctx, session.Token); !errors.Is(err, ErrInvalidSession) {
t.Fatalf("idle session error = %v", err)
}
}
func TestRecordFailureSerializesConcurrentUpdates(t *testing.T) {
service := testService(t)
now := time.Now().UTC()
const attempts = 20
start := make(chan struct{})
errorsChannel := make(chan error, attempts)
var wait sync.WaitGroup
for range attempts {
wait.Add(1)
go func() {
defer wait.Done()
<-start
errorsChannel <- service.recordFailure(context.Background(), "shared-key", now)
}()
}
close(start)
wait.Wait()
close(errorsChannel)
for err := range errorsChannel {
if err != nil {
t.Fatal(err)
}
}
var failures int
if err := service.db.QueryRow("SELECT failures FROM authentication_attempts WHERE attempt_key = 'shared-key'").Scan(&failures); err != nil {
t.Fatal(err)
}
if failures != attempts {
t.Fatalf("recorded failures = %d, want %d", failures, attempts)
}
}
func TestVerifyPasswordRejectsMalformedArgon2idEncodings(t *testing.T) {
valid := encodePassword("password", make([]byte, 16))
if !verifyPassword("password", valid) {
t.Fatal("valid password encoding was rejected")
}
for _, encoded := range []string{
strings.Replace(valid, "v=19", "v=16", 1),
strings.Replace(valid, "m=65536,t=3,p=2", "m=8,t=1,p=1", 1),
"$argon2id$v=19$m=65536,t=3,p=2$AA$AA",
"$argon2id$v=19$m=65536,t=3,p=2$$",
"not-an-argon2-hash",
} {
if verifyPassword("password", encoded) {
t.Fatalf("malformed encoding unexpectedly verified: %q", encoded)
}
}
}
func testService(t *testing.T) *Service {
t.Helper()
db, err := sqlite.Open(context.Background(), filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return New(db)
}
+247
View File
@@ -0,0 +1,247 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
)
func TestDriverPreservesNullAndEmptyValues(t *testing.T) {
db := openDriverDatabase(t)
if _, err := db.Exec(`CREATE TABLE values_test (text_value TEXT, blob_value BLOB)`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO values_test VALUES (?, ?), (?, ?), (?, ?)`, nil, nil, "", []byte{}, "value", []byte{0, 1}); err != nil {
t.Fatal(err)
}
rows, err := db.Query(`SELECT text_value, blob_value, typeof(text_value), typeof(blob_value) FROM values_test ORDER BY rowid`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var text, blob any
var textType, blobType string
if !rows.Next() || rows.Scan(&text, &blob, &textType, &blobType) != nil {
t.Fatal("read NULL row")
}
if text != nil || blob != nil || textType != "null" || blobType != "null" {
t.Fatalf("NULL row = %#v, %#v, %q, %q", text, blob, textType, blobType)
}
if !rows.Next() {
t.Fatal("missing empty row")
}
var emptyText string
var emptyBlob []byte
if err := rows.Scan(&emptyText, &emptyBlob, &textType, &blobType); err != nil {
t.Fatal(err)
}
if emptyText != "" || len(emptyBlob) != 0 || textType != "text" || blobType != "blob" {
t.Fatalf("empty row = %q, %#v, %q, %q", emptyText, emptyBlob, textType, blobType)
}
}
func TestDriverExecConsumesReturningRows(t *testing.T) {
db := openDriverDatabase(t)
if _, err := db.Exec(`CREATE TABLE returning_test (id INTEGER PRIMARY KEY, value TEXT)`); err != nil {
t.Fatal(err)
}
result, err := db.Exec(`INSERT INTO returning_test(value) VALUES ('one'), ('two') RETURNING id`)
if err != nil {
t.Fatal(err)
}
changed, err := result.RowsAffected()
if err != nil || changed != 2 {
t.Fatalf("RowsAffected = %d, %v; want 2", changed, err)
}
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM returning_test`).Scan(&count); err != nil || count != 2 {
t.Fatalf("row count = %d, %v; want 2", count, err)
}
}
func TestDriverReportsLastInsertIDAndRowsAffected(t *testing.T) {
db := openDriverDatabase(t)
if _, err := db.Exec(`CREATE TABLE result_test (id INTEGER PRIMARY KEY, value TEXT)`); err != nil {
t.Fatal(err)
}
insert, err := db.Exec(`INSERT INTO result_test(value) VALUES (?)`, "one")
if err != nil {
t.Fatal(err)
}
lastID, err := insert.LastInsertId()
if err != nil || lastID != 1 {
t.Fatalf("LastInsertId = %d, %v; want 1", lastID, err)
}
changed, err := insert.RowsAffected()
if err != nil || changed != 1 {
t.Fatalf("insert RowsAffected = %d, %v; want 1", changed, err)
}
update, err := db.Exec(`UPDATE result_test SET value = ? WHERE id = ?`, "two", lastID)
if err != nil {
t.Fatal(err)
}
changed, err = update.RowsAffected()
if err != nil || changed != 1 {
t.Fatalf("update RowsAffected = %d, %v; want 1", changed, err)
}
}
func TestDriverValidatesArgumentsAndSupportsNamedValues(t *testing.T) {
db := openDriverDatabase(t)
if _, err := db.Exec(`SELECT ?, ?`, 1); err == nil {
t.Fatal("too few arguments unexpectedly succeeded")
}
var value string
if err := db.QueryRow(`SELECT :value`, sql.Named("value", "named")).Scan(&value); err != nil {
t.Fatal(err)
}
if value != "named" {
t.Fatalf("named value = %q", value)
}
}
func TestDriverPrepareValidatesSQLAndCanBeReused(t *testing.T) {
db := openDriverDatabase(t)
if _, err := db.Prepare(`definitely not SQL`); err == nil {
t.Fatal("invalid prepared statement unexpectedly succeeded")
}
statement, err := db.Prepare(`SELECT ?`)
if err != nil {
t.Fatal(err)
}
defer statement.Close()
for _, expected := range []int{1, 2} {
var actual int
if err := statement.QueryRow(expected).Scan(&actual); err != nil {
t.Fatal(err)
}
if actual != expected {
t.Fatalf("prepared value = %d, want %d", actual, expected)
}
}
}
func TestDriverExecutesStatementBatch(t *testing.T) {
db := openDriverDatabase(t)
if _, err := db.Exec(`CREATE TABLE batch_test (value INTEGER); INSERT INTO batch_test VALUES (1), (2)`); err != nil {
t.Fatal(err)
}
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM batch_test`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("batch row count = %d, want 2", count)
}
}
func TestDriverCloseWithOutstandingRows(t *testing.T) {
db := openDriverDatabase(t)
rows, err := db.QueryContext(context.Background(), "SELECT 1")
if err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatalf("close with outstanding statement: %v", err)
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
}
func TestDriverCommitAndRollback(t *testing.T) {
db := openDriverDatabase(t)
if _, err := db.Exec(`CREATE TABLE transaction_test (value TEXT)`); err != nil {
t.Fatal(err)
}
rolledBack, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if _, err := rolledBack.Exec(`INSERT INTO transaction_test VALUES ('rolled back')`); err != nil {
t.Fatal(err)
}
if err := rolledBack.Rollback(); err != nil {
t.Fatal(err)
}
committed, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if _, err := committed.Exec(`INSERT INTO transaction_test VALUES ('committed')`); err != nil {
t.Fatal(err)
}
if err := committed.Commit(); err != nil {
t.Fatal(err)
}
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM transaction_test`).Scan(&count); err != nil || count != 1 {
t.Fatalf("transaction row count = %d, %v; want 1", count, err)
}
}
func TestDriverHonorsCanceledContextBeforeStep(t *testing.T) {
db := openDriverDatabase(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := db.ExecContext(ctx, "SELECT 1")
if !errors.Is(err, context.Canceled) {
t.Fatalf("ExecContext error = %v, want context cancellation", err)
}
}
func TestDriverHonorsBusyTimeout(t *testing.T) {
path := "file:" + filepath.Join(t.TempDir(), "busy.db")
first := openDriverPath(t, path)
second := openDriverPath(t, path)
if _, err := first.Exec("CREATE TABLE busy_test (value INTEGER)"); err != nil {
t.Fatal(err)
}
if _, err := second.Exec("PRAGMA busy_timeout = 100"); err != nil {
t.Fatal(err)
}
tx, err := first.Begin()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = tx.Rollback() })
if _, err := tx.Exec("INSERT INTO busy_test VALUES (1)"); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
started := time.Now()
_, err = second.ExecContext(ctx, "INSERT INTO busy_test VALUES (2)")
if err == nil {
t.Fatal("busy ExecContext unexpectedly succeeded")
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("busy ExecContext exceeded configured timeout: %v", elapsed)
}
}
func openDriverDatabase(t *testing.T) *sql.DB {
t.Helper()
return openDriverPath(t, ":memory:")
}
func openDriverPath(t *testing.T, path string) *sql.DB {
t.Helper()
db, err := sql.Open(driverName, path)
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close database: %v", err)
}
})
return db
}
+104
View File
@@ -0,0 +1,104 @@
// Package sqlite owns the SQLite implementation of application persistence.
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"sort"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/migrations"
_ "modernc.org/sqlite"
)
const driverName = "sqlite"
// Open opens a SQLite database and applies all pending migrations.
func Open(ctx context.Context, path string) (*sql.DB, error) {
dsn := path
if path != ":memory:" && !strings.HasPrefix(path, "file:") {
dsn = "file:" + path
}
db, err := sql.Open(driverName, dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
if err := configure(ctx, db); err != nil {
db.Close()
return nil, err
}
if err := migrate(ctx, db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func configure(ctx context.Context, db *sql.DB) error {
for _, statement := range []string{
"PRAGMA foreign_keys = ON",
"PRAGMA journal_mode = WAL",
"PRAGMA busy_timeout = 5000",
} {
if _, err := db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("configure sqlite: %w", err)
}
}
return nil
}
func migrate(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("create migration ledger: %w", err)
}
entries, err := fs.Glob(migrations.Files, "*.sql")
if err != nil {
return fmt.Errorf("list migrations: %w", err)
}
sort.Strings(entries)
for _, name := range entries {
var present int
err := db.QueryRowContext(ctx, "SELECT 1 FROM schema_migrations WHERE version = ?", name).Scan(&present)
if err == nil {
continue
}
if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("check migration %s: %w", name, err)
}
body, err := migrations.Files.ReadFile(name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
for _, statement := range strings.Split(string(body), ";") {
if strings.TrimSpace(statement) == "" {
continue
}
if _, err = tx.ExecContext(ctx, statement); err != nil {
break
}
}
if err == nil {
_, err = tx.ExecContext(ctx, "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", name, time.Now().UTC().Format(time.RFC3339Nano))
}
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package sqlite_test
import (
"context"
"path/filepath"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
path := filepath.Join(t.TempDir(), "dogama.db")
db, err := sqlite.Open(context.Background(), path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("got %d migrations, want 1", count)
}
var foreignKeys, busyTimeout int
var journalMode string
if err := db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
t.Fatal(err)
}
if err := db.QueryRow("PRAGMA busy_timeout").Scan(&busyTimeout); err != nil {
t.Fatal(err)
}
if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
t.Fatal(err)
}
if foreignKeys != 1 || busyTimeout != 5000 || journalMode != "wal" {
t.Fatalf("unexpected pragmas: foreign_keys=%d busy_timeout=%d journal_mode=%s", foreignKeys, busyTimeout, journalMode)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
db, err = sqlite.Open(context.Background(), path)
if err != nil {
t.Fatalf("reopen migrated database: %v", err)
}
defer db.Close()
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("reopened database has %d migrations, want 1", count)
}
}
+304
View File
@@ -0,0 +1,304 @@
// Package web serves DoGaMa's embedded, server-rendered interface.
package web
import (
"crypto/rand"
"crypto/subtle"
"embed"
"encoding/base64"
"errors"
"html/template"
"io"
"log/slog"
"net/http"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
)
const (
sessionCookie = "dogama_session"
csrfCookie = "dogama_csrf"
maxFormBytes = 64 << 10
)
//go:embed templates/*.html static/*.css
var assets embed.FS
var englishMessages = map[string]string{
"brand": "DoGaMa",
"setup.title": "Create administrator",
"setup.heading": "Welcome to DoGaMa",
"setup.introduction": "Create the first administrator to finish setup.",
"setup.submit": "Create administrator",
"login.title": "Sign in",
"login.heading": "Sign in to DoGaMa",
"login.submit": "Sign in",
"logout.submit": "Sign out",
"dashboard.title": "Dashboard",
"dashboard.ready": "The initial application foundation is ready.",
"field.username": "Username",
"field.password": "Password",
"error.csrf": "Request verification failed.",
"error.form": "Invalid form submission.",
"error.internal": "The request could not be completed.",
"error.credentials": "Invalid username or password.",
"dashboard.signed_in": "Signed in as",
}
type server struct {
auth *auth.Service
templates *template.Template
logger *slog.Logger
}
type pageData struct {
Title string
CSRFToken string
Error string
User auth.User
}
// NewHandler constructs the complete HTTP application.
func NewHandler(authService *auth.Service, logger *slog.Logger) (http.Handler, error) {
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html")
if err != nil {
return nil, err
}
s := &server{auth: authService, templates: templates, logger: logger}
mux := http.NewServeMux()
mux.HandleFunc("GET /static/app.v1.css", s.stylesheet)
mux.HandleFunc("GET /setup", s.setupForm)
mux.HandleFunc("POST /setup", s.setupSubmit)
mux.HandleFunc("GET /login", s.loginForm)
mux.HandleFunc("POST /login", s.loginSubmit)
mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("GET /", s.home)
return s.securityHeaders(mux), nil
}
func (s *server) setupForm(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, true) {
return
}
token := s.anonymousCSRF(w, r)
s.render(w, http.StatusOK, "setup.html", pageData{Title: message("setup.title"), CSRFToken: token})
}
func (s *server) setupSubmit(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, true) {
return
}
if !s.parseForm(w, r) || !validAnonymousCSRF(r) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
err := s.auth.BootstrapAdmin(r.Context(), r.FormValue("username"), r.FormValue("password"))
if err != nil {
if errors.Is(err, auth.ErrBootstrapComplete) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
token := s.anonymousCSRF(w, r)
s.render(w, http.StatusUnprocessableEntity, "setup.html", pageData{Title: message("setup.title"), CSRFToken: token, Error: err.Error()})
return
}
clearCookie(w, csrfCookie)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *server) loginForm(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
if _, err := s.currentUser(r); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
token := s.anonymousCSRF(w, r)
s.render(w, http.StatusOK, "login.html", pageData{Title: message("login.title"), CSRFToken: token})
}
func (s *server) loginSubmit(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
if !s.parseForm(w, r) || !validAnonymousCSRF(r) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
session, err := s.auth.Login(r.Context(), r.FormValue("username"), r.FormValue("password"), r.RemoteAddr)
if err != nil {
status := http.StatusUnauthorized
if errors.Is(err, auth.ErrRateLimited) {
status = http.StatusTooManyRequests
w.Header().Set("Retry-After", "60")
}
token := s.anonymousCSRF(w, r)
s.render(w, status, "login.html", pageData{Title: message("login.title"), CSRFToken: token, Error: message("error.credentials")})
return
}
if cookie, cookieErr := r.Cookie(sessionCookie); cookieErr == nil {
if revokeErr := s.auth.Revoke(r.Context(), cookie.Value); revokeErr != nil {
_ = s.auth.Revoke(r.Context(), session.Token)
s.logger.Error("session rotation failed", "event", "auth.session.rotation.failed", "error", revokeErr)
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
}
setCookie(w, sessionCookie, session.Token, session.ExpiresAt, true)
setCookie(w, csrfCookie, session.CSRFToken, session.ExpiresAt, true)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) logout(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
session, err := r.Cookie(sessionCookie)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !s.parseForm(w, r) || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
if err := s.auth.Revoke(r.Context(), session.Value); err != nil {
s.logger.Error("session revocation failed", "event", "auth.logout.failed", "error", err)
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
clearCookie(w, sessionCookie)
clearCookie(w, csrfCookie)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *server) home(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
user, err := s.currentUser(r)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
csrf, err := r.Cookie(csrfCookie)
if err != nil {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
s.render(w, http.StatusOK, "home.html", pageData{Title: message("dashboard.title"), User: user, CSRFToken: csrf.Value})
}
func (s *server) currentUser(r *http.Request) (auth.User, error) {
cookie, err := r.Cookie(sessionCookie)
if err != nil {
return auth.User{}, auth.ErrInvalidSession
}
return s.auth.Authenticate(r.Context(), cookie.Value)
}
func (s *server) requireBootstrap(w http.ResponseWriter, r *http.Request, setupRoute bool) bool {
required, err := s.auth.BootstrapRequired(r.Context())
if err != nil {
s.logger.Error("bootstrap state failed", "event", "bootstrap.state.failed", "error", err)
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return false
}
if required != setupRoute {
if required {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
} else {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
return false
}
return true
}
func (s *server) anonymousCSRF(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(csrfCookie); err == nil && cookie.Value != "" {
return cookie.Value
}
token := randomToken()
setCookie(w, csrfCookie, token, time.Now().Add(time.Hour), true)
return token
}
func validAnonymousCSRF(r *http.Request) bool {
cookie, err := r.Cookie(csrfCookie)
provided := r.FormValue("csrf_token")
if err != nil || cookie.Value == "" || provided == "" || len(cookie.Value) != len(provided) {
return false
}
return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(provided)) == 1
}
func (s *server) parseForm(w http.ResponseWriter, r *http.Request) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
s.problem(w, http.StatusBadRequest, message("error.form"))
return false
}
return true
}
func (s *server) render(w http.ResponseWriter, status int, name string, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
s.logger.Error("template rendering failed", "event", "http.render.failed", "template", name, "error", err)
}
}
func (s *server) stylesheet(w http.ResponseWriter, _ *http.Request) {
body, err := assets.Open("static/app.v1.css")
if err != nil {
http.Error(w, "Not found.", http.StatusNotFound)
return
}
defer body.Close()
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
_, _ = io.Copy(w, body)
}
func (s *server) problem(w http.ResponseWriter, status int, message string) {
http.Error(w, message, status)
}
func (s *server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
func setCookie(w http.ResponseWriter, name, value string, expires time.Time, httpOnly bool) {
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", Expires: expires, MaxAge: int(time.Until(expires).Seconds()), HttpOnly: httpOnly, Secure: true, SameSite: http.SameSiteStrictMode})
}
func clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode})
}
func randomToken() string {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(value)
}
func message(key string) string {
if value, ok := englishMessages[key]; ok {
return value
}
return key
}
+182
View File
@@ -0,0 +1,182 @@
package web
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
handler := testHandler(t)
response := request(t, handler, http.MethodGet, "/", nil)
assertStatus(t, response, http.StatusSeeOther)
if location := response.Header().Get("Location"); location != "/setup" {
t.Fatalf("pre-bootstrap location = %q", location)
}
setupPage := request(t, handler, http.MethodGet, "/setup", nil)
assertStatus(t, setupPage, http.StatusOK)
csrf := namedCookie(t, setupPage, csrfCookie)
setup := formRequest(t, handler, "/setup", url.Values{
"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"},
}, csrf)
assertStatus(t, setup, http.StatusSeeOther)
closedSetup := request(t, handler, http.MethodGet, "/setup", nil)
assertStatus(t, closedSetup, http.StatusSeeOther)
if location := closedSetup.Header().Get("Location"); location != "/login" {
t.Fatalf("post-bootstrap setup location = %q", location)
}
loginPage := request(t, handler, http.MethodGet, "/login", nil)
csrf = namedCookie(t, loginPage, csrfCookie)
badCSRF := formRequest(t, handler, "/login", url.Values{
"csrf_token": {"wrong"}, "username": {"admin"}, "password": {"correct horse battery staple"},
}, csrf)
assertStatus(t, badCSRF, http.StatusForbidden)
login := formRequest(t, handler, "/login", url.Values{
"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"},
}, csrf)
assertStatus(t, login, http.StatusSeeOther)
session := namedCookie(t, login, sessionCookie)
sessionCSRF := namedCookie(t, login, csrfCookie)
for _, cookie := range []*http.Cookie{session, sessionCSRF} {
if !cookie.Secure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("insecure cookie attributes: %#v", cookie)
}
}
home := request(t, handler, http.MethodGet, "/", []*http.Cookie{session, sessionCSRF})
assertStatus(t, home, http.StatusOK)
if !strings.Contains(home.Body.String(), "Signed in as <strong>admin</strong>") {
t.Fatalf("protected page did not identify user: %s", home.Body.String())
}
if home.Header().Get("Content-Security-Policy") == "" || home.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers missing")
}
deniedLogout := formRequest(t, handler, "/logout", url.Values{"csrf_token": {"wrong"}}, session, sessionCSRF)
assertStatus(t, deniedLogout, http.StatusForbidden)
logout := formRequest(t, handler, "/logout", url.Values{"csrf_token": {sessionCSRF.Value}}, session, sessionCSRF)
assertStatus(t, logout, http.StatusSeeOther)
afterLogout := request(t, handler, http.MethodGet, "/", []*http.Cookie{session, sessionCSRF})
assertStatus(t, afterLogout, http.StatusSeeOther)
}
func TestLoginReturnsGenericFailureAndRateLimits(t *testing.T) {
handler := testHandler(t)
setupPage := request(t, handler, http.MethodGet, "/setup", nil)
csrf := namedCookie(t, setupPage, csrfCookie)
setup := formRequest(t, handler, "/setup", url.Values{"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
assertStatus(t, setup, http.StatusSeeOther)
loginPage := request(t, handler, http.MethodGet, "/login", nil)
csrf = namedCookie(t, loginPage, csrfCookie)
for attempt := 0; attempt < 5; attempt++ {
response := formRequest(t, handler, "/login", url.Values{"csrf_token": {csrf.Value}, "username": {"unknown"}, "password": {"incorrect password"}}, csrf)
assertStatus(t, response, http.StatusUnauthorized)
if !strings.Contains(response.Body.String(), "Invalid username or password.") {
t.Fatal("login failure was not generic")
}
}
limited := formRequest(t, handler, "/login", url.Values{"csrf_token": {csrf.Value}, "username": {"unknown"}, "password": {"incorrect password"}}, csrf)
assertStatus(t, limited, http.StatusTooManyRequests)
}
func TestFailedLoginKeepsCurrentSessionAndSuccessfulLoginRotatesIt(t *testing.T) {
handler := testHandler(t)
setupPage := request(t, handler, http.MethodGet, "/setup", nil)
csrf := namedCookie(t, setupPage, csrfCookie)
setup := formRequest(t, handler, "/setup", url.Values{"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
assertStatus(t, setup, http.StatusSeeOther)
loginPage := request(t, handler, http.MethodGet, "/login", nil)
csrf = namedCookie(t, loginPage, csrfCookie)
login := formRequest(t, handler, "/login", url.Values{"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
assertStatus(t, login, http.StatusSeeOther)
oldSession := namedCookie(t, login, sessionCookie)
oldCSRF := namedCookie(t, login, csrfCookie)
failed := formRequest(t, handler, "/login", url.Values{"csrf_token": {oldCSRF.Value}, "username": {"admin"}, "password": {"wrong password"}}, oldSession, oldCSRF)
assertStatus(t, failed, http.StatusUnauthorized)
stillAuthenticated := request(t, handler, http.MethodGet, "/", []*http.Cookie{oldSession, oldCSRF})
assertStatus(t, stillAuthenticated, http.StatusOK)
rotated := formRequest(t, handler, "/login", url.Values{"csrf_token": {oldCSRF.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, oldSession, oldCSRF)
assertStatus(t, rotated, http.StatusSeeOther)
newSession := namedCookie(t, rotated, sessionCookie)
newCSRF := namedCookie(t, rotated, csrfCookie)
if newSession.Value == oldSession.Value || newCSRF.Value == oldCSRF.Value {
t.Fatal("successful login did not rotate session credentials")
}
oldCredentials := request(t, handler, http.MethodGet, "/", []*http.Cookie{oldSession, oldCSRF})
assertStatus(t, oldCredentials, http.StatusSeeOther)
newCredentials := request(t, handler, http.MethodGet, "/", []*http.Cookie{newSession, newCSRF})
assertStatus(t, newCredentials, http.StatusOK)
}
func testHandler(t *testing.T) http.Handler {
t.Helper()
db, err := sqlite.Open(context.Background(), filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
handler, err := NewHandler(auth.New(db), slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
return handler
}
func request(t *testing.T, handler http.Handler, method, target string, cookies []*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, target, nil)
for _, cookie := range cookies {
request.AddCookie(cookie)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func formRequest(t *testing.T, handler http.Handler, target string, values url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, target, strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, cookie := range cookies {
request.AddCookie(cookie)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func namedCookie(t *testing.T, response *httptest.ResponseRecorder, name string) *http.Cookie {
t.Helper()
for _, cookie := range response.Result().Cookies() {
if cookie.Name == name && cookie.MaxAge >= 0 {
return cookie
}
}
t.Fatalf("cookie %q not found", name)
return nil
}
func assertStatus(t *testing.T, response *httptest.ResponseRecorder, expected int) {
t.Helper()
if response.Code != expected {
t.Fatalf("status = %d, want %d; body = %s", response.Code, expected, response.Body.String())
}
}
+11
View File
@@ -0,0 +1,11 @@
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; }
body { margin: 0; background: #eef2f7; color: #172033; }
main { width: min(32rem, calc(100% - 2rem)); margin: 8vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: white; }
form { display: grid; gap: 1rem; }
header form { display: block; }
label { display: grid; gap: .35rem; font-weight: 600; }
input, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
button { border: 0; background: #3157d5; color: white; font-weight: 700; cursor: pointer; }
.error { padding: .75rem; border-left: .25rem solid #b42318; background: #fee4e2; color: #7a271a; }
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input { background: #111827; color: #e5e7eb; } }
+3
View File
@@ -0,0 +1,3 @@
{{define "home.html"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header><main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p></main></body></html>{{end}}
+3
View File
@@ -0,0 +1,3 @@
{{define "login.html"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body><main><h1>{{msg "login.heading"}}</h1>{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}<form method="post" action="/login"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{msg "field.username"}}<input name="username" autocomplete="username" required></label><label>{{msg "field.password"}}<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">{{msg "login.submit"}}</button></form></main></body></html>{{end}}
+3
View File
@@ -0,0 +1,3 @@
{{define "setup.html"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body><main><h1>{{msg "setup.heading"}}</h1><p>{{msg "setup.introduction"}}</p>{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}<form method="post" action="/setup"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{msg "field.username"}}<input name="username" minlength="3" maxlength="64" autocomplete="username" required></label><label>{{msg "field.password"}}<input type="password" name="password" minlength="12" maxlength="1024" autocomplete="new-password" required></label><button type="submit">{{msg "setup.submit"}}</button></form></main></body></html>{{end}}
+34
View File
@@ -0,0 +1,34 @@
CREATE TABLE system_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
bootstrap_completed_at TEXT
);
INSERT INTO system_state (singleton, bootstrap_completed_at) VALUES (1, NULL);
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
global_role TEXT NOT NULL CHECK (global_role IN ('admin', 'user')),
disabled_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE sessions (
id_hash BLOB PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_hash BLOB NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
);
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
CREATE INDEX sessions_expires_at_idx ON sessions(expires_at);
CREATE TABLE authentication_attempts (
attempt_key TEXT PRIMARY KEY,
failures INTEGER NOT NULL,
blocked_until TEXT,
updated_at TEXT NOT NULL
);
+9
View File
@@ -0,0 +1,9 @@
// Package migrations exposes the append-only SQLite migrations embedded in the binary.
package migrations
import "embed"
// Files contains every released SQL migration.
//
//go:embed *.sql
var Files embed.FS