Phase 1 of AGENTS.md §11: the app runs on .13 in its own container on port 8792, behind Caddy at school.lab.audasmedia.com.au, with its own session login and GET /health. - Go service (Go 1.25, modernc SQLite, no cgo) with its own image. - SQLite schema per AGENTS.md §7: children, items, scans, notifications, session (internal/store/migrations/001_init.sql). - Separate session login (signed cookie, CSRF double-submit), not SSO. - HTMX server-rendered shell using the Family Home Lab tokens.css: canvas #f6f5f4, action blue #0075de, Inter, 8px grid, hairline borders, pill CTAs, dark mode, phone-friendly. - Fail loudly (FR-12): a missing or failed scan shows as an explicit warning or error banner, never as an empty day. - Dockerfile + docker-compose.yml (own container, own port 8792; uses network_mode bridge because .13 has no free default Docker address pool). - Caddy snippet for .35 in deploy/caddy/, deployment notes, and deploy/verify-phase1.sh. No school scan in this phase; the Compass login and fetch are Phase 2. Co-Authored-By: Paperclip <noreply@paperclip.ing>
208 lines
5.7 KiB
Go
208 lines
5.7 KiB
Go
// Package auth implements the service's own session login (AGENTS.md §9 Auth:
|
|
// a separate session login, not SSO). 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 = "school_session"
|
|
csrfCookie = "school_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("SCHOOL_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)
|
|
}
|