The flow this product is sold on is three tiers: the platform admin
registers a merchant, the merchant registers their sales staff, the
staff sign in on a phone. Tier 1 handed the new owner a password. Tier 2
could not - a manager could only mint an invitation code, which the
salesperson had to redeem themselves, on their own phone, choosing their
own password. Good practice, and no use to a manager setting somebody up
before their first shift with a card and a pen.
POST /api/team/members mirrors POST /api/admin/clients: generated
password unless one is given, returned exactly once, bcrypt-hashed on
the way in and not recoverable after. Same permission shape as an
invitation - manager and above, only an owner mints an owner, admin
refused - so a manager cannot do through one door what they are refused
at the other. The invitation path stays; it is the better one whenever
the salesperson has their phone.
POST /api/team/{id}/password is the everyday case on a shop floor:
they forgot it. It sets a new one AND revokes every session they hold,
in one transaction, because the other reason a manager resets a
password is a lost phone, and a reset that left that phone signed in
would look complete while fixing nothing. Tenant-scoped in the UPDATE
itself; another company's user id is 404, never 403. No self-service
and no reset-by-email, deliberately: a floor account often has no
mailbox anyone checks, and the person who can vouch for the salesperson
standing in front of them is their manager.
RandomPassword moves from a private helper in the store to auth, so the
admin path, the merchant path and the reset all mint the same 80-bit
credential - rather than someone later writing a shorter one for the
"less important" account.
Verified: eight handler tests, and two against a real Postgres for the
things a fake cannot see - the RETURNING list scans on a row with no
last_login_at, the tenant scope holds, and the sessions row is actually
revoked. The tenant cleanup from yesterday held throughout.
API.md now documents the chain with both paths, and the note saying a
merchant could not create a login directly is gone because it is no
longer true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
269 lines
9.9 KiB
Go
269 lines
9.9 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 }
|
|
}
|
|
|
|
// RandomPassword mints a credential for somebody else - a merchant owner
|
|
// created by the platform admin, a salesperson created by their manager, a
|
|
// reset. 80 bits as 16 lowercase base32 characters: long enough that guessing
|
|
// it is not a plan, and a shape a person can read down a phone line without
|
|
// spelling out case. One generator rather than one per caller, so nobody
|
|
// later writes a shorter one for the "less important" account.
|
|
func RandomPassword() (string, error) {
|
|
b := make([]byte, 10)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return strings.ToLower(base32.StdEncoding.
|
|
WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
|
}
|
|
|
|
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
|
|
}
|