feat: establish initial application foundation
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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[:]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user