Files
Behavision/server/internal/auth/auth.go
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

254 lines
9.3 KiB
Go

// Package auth turns a password into a session and a session back into a
// principal. It holds no database code: everything here is pure, so the rules
// that decide who gets in are testable without Postgres.
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
ErrBadCredentials = errors.New("email or password is incorrect")
ErrNoSession = errors.New("not signed in")
ErrExpired = errors.New("session expired")
ErrForbidden = errors.New("not allowed")
)
// Lifetimes. The access token is short because it is copied onto shop-floor
// PCs and into a tray app's config file; the refresh token is long because a
// store that reboots overnight must come back working rather than waiting for
// someone with a password to arrive in the morning.
const (
AccessTTL = 12 * time.Hour
RefreshTTL = 60 * 24 * time.Hour
)
// bcryptCost 12 is roughly 250 ms on the 2 vCPU box this runs on. Deliberately
// slow: login happens once a shift, and the cost is the entire defence if the
// hashes ever leak - and with the password floor now at 8 characters it is
// most of what stands between a leaked hash and a working credential.
//
// ProductionBcryptCost is the real one, kept as a const so a test can assert
// on it without depending on whatever the suite has temporarily set.
//
// A var, not a const, solely so the test suite can lower it. At cost 12 nearly
// every handler test pays ~500 ms for a hash and a verify, which under the race
// detector pushed the package past `go test`'s ten-minute default and turned a
// passing suite into a CI failure with no failing assertion in it. Nothing in
// production writes to this; UseTestCost exists to make that obvious at the
// call site.
const ProductionBcryptCost = 12
var bcryptCost = ProductionBcryptCost
// UseTestCost drops the hashing cost to bcrypt's minimum and returns a function
// restoring it.
//
// For tests only. Named so that a production caller reads as obviously wrong,
// rather than a bare exported knob somebody could set from config and quietly
// destroy the only defence a leaked hash has.
func UseTestCost() func() {
previous := bcryptCost
bcryptCost = bcrypt.MinCost
// DummyHash is generated once at startup at the old cost. Regenerate it, or
// the unknown-address path keeps burning 250 ms per attempt and the timing
// equivalence the login handler depends on is measured against the wrong
// number.
previousDummy := DummyHash
if h, err := bcrypt.GenerateFromPassword([]byte("no user"), bcrypt.MinCost); err == nil {
DummyHash = string(h)
}
return func() { bcryptCost = previous; DummyHash = previousDummy }
}
func HashPassword(plain string) (string, error) {
if err := CheckPasswordPolicy(plain); err != nil {
return "", err
}
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
return string(b), err
}
// VerifyPassword reports whether the password matches.
//
// It takes the same time whether the user exists or not — the caller passes
// DummyHash for an unknown address. Without that, response time alone tells an
// attacker which addresses are registered, which for a B2B product is a list
// of your customer's staff.
func VerifyPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
// DummyHash is a bcrypt hash, at the real cost, of a value nothing can match.
// Used to burn the same CPU on an unknown email as on a known one.
//
// Generated at startup rather than pasted in as a constant: a hardcoded string
// with a typo in it fails to parse, CompareHashAndPassword returns immediately,
// and the timing leak this exists to close is silently back — the one failure
// mode no test would notice.
var DummyHash = func() string {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
panic("auth: no entropy: " + err.Error())
}
h, err := bcrypt.GenerateFromPassword(b[:], bcryptCost)
if err != nil {
panic("auth: cannot build dummy hash: " + err.Error())
}
return string(h)
}()
// MinPasswordLength is the whole policy, alongside the 200-character ceiling.
//
// Set to 8 by the product owner. Recording the trade rather than the number:
// eight characters of anything is inside the reach of an offline attack on a
// leaked hash, and these accounts read customer face data. What stands between
// the two is bcrypt at cost 12 (~250 ms a guess, so an online list is
// hopeless) and the per-account throttle of 10 failures in 15 minutes. Those
// make ONLINE guessing impractical at any length; they do nothing if the
// hashes themselves ever leak.
const MinPasswordLength = 8
// CheckPasswordPolicy is length-only on purpose. Composition rules ("one
// capital, one symbol") push people towards Passw0rd! and are worse than
// length for the same annoyance.
func CheckPasswordPolicy(plain string) error {
if len(plain) < MinPasswordLength {
return fmt.Errorf("password must be at least %d characters", MinPasswordLength)
}
if len(plain) > 200 {
// bcrypt silently truncates at 72 bytes; a 4 KB password is either a
// mistake or an attempt to make us hash something enormous.
return errors.New("password must be at most 200 characters")
}
return nil
}
// Token is a freshly minted secret and the hash to store for it. The plaintext
// exists only in the response to the client; only Hash is ever persisted.
type Token struct {
Plain string
Hash []byte
}
// NewToken mints 256 bits from crypto/rand.
//
// Not a UUID: v4 gives 122 bits and, more importantly, uuid is the type used
// for row ids all over this schema, so a token that looks like one invites
// somebody to eventually store it in a uuid column where it would be logged,
// joined and pasted around like an identifier rather than a secret.
func NewToken() (Token, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return Token{}, fmt.Errorf("cannot generate token: %w", err)
}
plain := base64.RawURLEncoding.EncodeToString(b[:])
return Token{Plain: plain, Hash: HashToken(plain)}, nil
}
// HashToken is SHA-256, not bcrypt. The input is 256 bits of entropy, so there
// is no dictionary for a slow hash to protect against — only a per-request
// cost, paid on every authenticated call.
func HashToken(plain string) []byte {
sum := sha256.Sum256([]byte(plain))
return sum[:]
}
// Principal is who the request is. ClientID empty means a platform admin, who
// is the only kind of user not scoped to one tenant.
type Principal struct {
UserID string
SessionID string
ClientID string
ClientName string
Email string
FullName string
Role string
}
func (p Principal) IsAdmin() bool { return p.Role == "admin" }
// CanWriteProfiles gates the in-store customer form. Staff can fill it in —
// that is the job — but not everyone who can read a report should be able to
// attach a name and a phone number to a face.
func (p Principal) CanWriteProfiles() bool {
switch p.Role {
case "admin", "owner", "manager", "staff":
return true
}
return false
}
// CanManageSites gates enrolment tokens and site configuration.
func (p Principal) CanManageSites() bool {
switch p.Role {
case "admin", "owner", "manager":
return true
}
return false
}
// BearerToken pulls the credential out of an Authorization header.
//
// Header only, never a query parameter: URLs end up in access logs, proxy logs
// and browser history, and a session token in any of those is a session token
// leaked.
func BearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
const p = "Bearer "
if len(h) > len(p) && strings.EqualFold(h[:len(p)], p) {
return strings.TrimSpace(h[len(p):])
}
return ""
}
// NormalizeEmail lowercases and trims. The unique index is on lower(email), so
// anything reaching the database must already agree with it or the constraint
// silently stops meaning what it says.
func NormalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// NormalizeCode strips an operator's formatting from an enrolment code.
//
// Codes are read off a screen, dictated down a phone and typed in, so spaces,
// dashes and the shift key are presentation, not part of the secret. Both the
// side that issues a code and the side that redeems one must agree exactly on
// what gets hashed, which is why this is one function and not two.
func NormalizeCode(s string) string {
return strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(s))
}
// NewEnrolmentCode mints the code an installer types once: 120 bits of
// randomness, base32 so it survives being read down a phone line, grouped in
// sixes so it can be read aloud at all.
//
// Here rather than beside either caller because two of them mint codes now -
// the provisioning command and the API an owner uses to replace a shop PC - and
// a second implementation that formatted or cased a code differently would hash
// to something the redeemer never produces. Same reason NormalizeCode above is
// one function.
func NewEnrolmentCode() (string, error) {
b := make([]byte, 15)
if _, err := rand.Read(b); err != nil {
return "", err
}
raw := strings.ToUpper(base32.StdEncoding.WithPadding(base32.NoPadding).
EncodeToString(b))
var parts []string
for i := 0; i < len(raw); i += 6 {
parts = append(parts, raw[i:i+6])
}
return strings.Join(parts, "-"), nil
}