// 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 }