Phase 1: Control Service skeleton (Go, SQLite, session login, Docker, Caddy)
- Go service with its own multi-stage Dockerfile and docker-compose.yml; runs as container webrestrict-control on port 8791. - SQLite schema for the full AGENTS.md §7 data model via versioned migrations (PRAGMA user_version); audit log is append-only. - Configuration from the process environment; the real env file is gitignored and a template is committed. No secrets in the repo. - Separate session login (signed HMAC cookie + CSRF) for Sam. - GET /health; HTMX + server-rendered dashboard styled with the Family Home Lab tokens.css (copied) and Inter fonts; dark mode; phone-friendly. - Caddy vhost for webrestrict.lab.audasmedia.com.au applied on .35 (Let's Encrypt cert issued); route recorded in deploy/caddy/webrestrict.caddy. - Unit tests for config, auth and store. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
207
internal/auth/auth.go
Normal file
207
internal/auth/auth.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Package auth implements the separate session login for the Control Service
|
||||
// (AGENTS.md §9 Auth). Sessions are stateless, signed cookies. A random CSRF
|
||||
// cookie uses the double-submit pattern for forms.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "control_session"
|
||||
csrfCookie = "control_csrf"
|
||||
)
|
||||
|
||||
// Manager issues and validates sessions.
|
||||
type Manager struct {
|
||||
// Secret signs every cookie. Required.
|
||||
Secret []byte
|
||||
// TTL is the session lifetime.
|
||||
TTL time.Duration
|
||||
// Secure sets the Secure cookie attribute. Keep true behind HTTPS.
|
||||
Secure bool
|
||||
// Username is the single allowed login name.
|
||||
Username string
|
||||
// passwordHash is a bcrypt hash, or nil when only a plaintext password is set.
|
||||
passwordHash []byte
|
||||
// plaintext is the fallback password, or empty when a hash is set.
|
||||
plaintext string
|
||||
}
|
||||
|
||||
// New builds a Manager. Exactly one of bcryptHash or plaintext must be set.
|
||||
func New(secret []byte, ttl time.Duration, secure bool, username, bcryptHash, plaintext string) (*Manager, error) {
|
||||
if len(secret) < 16 {
|
||||
return nil, errors.New("session secret too short")
|
||||
}
|
||||
m := &Manager{Secret: secret, TTL: ttl, Secure: secure, Username: username}
|
||||
switch {
|
||||
case bcryptHash != "":
|
||||
if _, err := bcrypt.Cost([]byte(bcryptHash)); err != nil {
|
||||
return nil, fmt.Errorf("CONTROL_ADMIN_PASSWORD_HASH is not a valid bcrypt hash: %w", err)
|
||||
}
|
||||
m.passwordHash = []byte(bcryptHash)
|
||||
case plaintext != "":
|
||||
m.plaintext = plaintext
|
||||
default:
|
||||
return nil, errors.New("no admin password configured")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Hashed reports whether the manager verifies against a bcrypt hash.
|
||||
func (m *Manager) Hashed() bool { return m.passwordHash != nil }
|
||||
|
||||
// CheckCredentials validates a username and password in constant time where
|
||||
// possible. It never reveals which part was wrong.
|
||||
func (m *Manager) CheckCredentials(username, password string) bool {
|
||||
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(m.Username)) == 1
|
||||
passOK := false
|
||||
if m.passwordHash != nil {
|
||||
passOK = bcrypt.CompareHashAndPassword(m.passwordHash, []byte(password)) == nil
|
||||
} else {
|
||||
passOK = subtle.ConstantTimeCompare([]byte(password), []byte(m.plaintext)) == 1
|
||||
}
|
||||
return userOK && passOK
|
||||
}
|
||||
|
||||
// Issue writes a signed session cookie and the matching CSRF cookie.
|
||||
func (m *Manager) Issue(w http.ResponseWriter) {
|
||||
csrf := randomToken()
|
||||
expires := time.Now().Add(m.TTL).Unix()
|
||||
payload := strings.Join([]string{m.Username, csrf, strconv.FormatInt(expires, 10)}, "\n")
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: m.sign(payload),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: m.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(expires, 0),
|
||||
MaxAge: int(m.TTL.Seconds()),
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookie,
|
||||
Value: csrf,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: m.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(expires, 0),
|
||||
MaxAge: int(m.TTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// Clear removes the session and CSRF cookies.
|
||||
func (m *Manager) Clear(w http.ResponseWriter) {
|
||||
for _, name := range []string{sessionCookie, csrfCookie} {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: m.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated reports whether the request carries a valid, unexpired session.
|
||||
func (m *Manager) Authenticated(r *http.Request) bool {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
payload, ok := m.verify(c.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(payload, "\n")
|
||||
if len(parts) != 3 || parts[0] != m.Username {
|
||||
return false
|
||||
}
|
||||
expires, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil || time.Now().Unix() > expires {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EnsureCSRF returns the CSRF token for a form, setting the cookie if absent.
|
||||
func (m *Manager) EnsureCSRF(w http.ResponseWriter, r *http.Request) string {
|
||||
if c, err := r.Cookie(csrfCookie); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
}
|
||||
token := randomToken()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: m.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(m.TTL.Seconds()),
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
// CheckCSRF compares the submitted form token with the CSRF cookie.
|
||||
func (m *Manager) CheckCSRF(r *http.Request) bool {
|
||||
c, err := r.Cookie(csrfCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return false
|
||||
}
|
||||
form := r.PostFormValue("csrf")
|
||||
return subtle.ConstantTimeCompare([]byte(form), []byte(c.Value)) == 1
|
||||
}
|
||||
|
||||
func (m *Manager) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, m.Secret)
|
||||
mac.Write([]byte(payload))
|
||||
sig := mac.Sum(nil)
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
func (m *Manager) verify(token string) (string, bool) {
|
||||
encoded, sigEncoded, ok := strings.Cut(token, ".")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
gotSig, err := base64.RawURLEncoding.DecodeString(sigEncoded)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
mac := hmac.New(sha256.New, m.Secret)
|
||||
mac.Write(payload)
|
||||
if !hmac.Equal(gotSig, mac.Sum(nil)) {
|
||||
return "", false
|
||||
}
|
||||
return string(payload), true
|
||||
}
|
||||
|
||||
func randomToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("auth: random source failed: " + err.Error())
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
112
internal/auth/auth_test.go
Normal file
112
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testManager(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
m, err := New([]byte("0123456789abcdef0123456789abcdef"), time.Hour, false, "sam", "", "hunter2")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestCredentials(t *testing.T) {
|
||||
m := testManager(t)
|
||||
if !m.CheckCredentials("sam", "hunter2") {
|
||||
t.Error("valid credentials rejected")
|
||||
}
|
||||
if m.CheckCredentials("sam", "wrong") {
|
||||
t.Error("wrong password accepted")
|
||||
}
|
||||
if m.CheckCredentials("finn", "hunter2") {
|
||||
t.Error("wrong username accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRoundTripAndTamper(t *testing.T) {
|
||||
m := testManager(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
m.Issue(rec)
|
||||
res := rec.Result()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
for _, c := range res.Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
if !m.Authenticated(req) {
|
||||
t.Fatal("issued session is not authenticated")
|
||||
}
|
||||
|
||||
// Tamper with the session value: the HMAC must reject it.
|
||||
tampered := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
for _, c := range res.Cookies() {
|
||||
if c.Name == sessionCookie {
|
||||
c.Value = c.Value[:len(c.Value)-2] + "xx"
|
||||
}
|
||||
tampered.AddCookie(c)
|
||||
}
|
||||
if m.Authenticated(tampered) {
|
||||
t.Fatal("tampered session accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRF(t *testing.T) {
|
||||
m := testManager(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
token := m.EnsureCSRF(rec, req)
|
||||
if token == "" {
|
||||
t.Fatal("empty csrf token")
|
||||
}
|
||||
|
||||
// The token is delivered as a cookie; a matching form value passes.
|
||||
form := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
form.PostForm = map[string][]string{"csrf": {token}}
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
form.AddCookie(c)
|
||||
}
|
||||
if !m.CheckCSRF(form) {
|
||||
t.Error("matching csrf rejected")
|
||||
}
|
||||
|
||||
// A different form value fails.
|
||||
bad := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
bad.PostForm = map[string][]string{"csrf": {"not-the-token"}}
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
bad.AddCookie(c)
|
||||
}
|
||||
if m.CheckCSRF(bad) {
|
||||
t.Error("mismatched csrf accepted")
|
||||
}
|
||||
|
||||
// No cookie at all fails.
|
||||
none := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
none.PostForm = map[string][]string{"csrf": {token}}
|
||||
if m.CheckCSRF(none) {
|
||||
t.Error("csrf accepted without cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredSession(t *testing.T) {
|
||||
m, err := New([]byte("0123456789abcdef0123456789abcdef"), -time.Minute, false, "sam", "", "hunter2")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
m.Issue(rec)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
if m.Authenticated(req) {
|
||||
t.Error("expired session accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user