Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89224d2824 |
@@ -21,13 +21,17 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/webauthn v0.10.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/luxfi/crypto v1.20.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/zap-proto/fiber/v3 v3.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
@@ -35,9 +39,12 @@ require (
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/evanw/esbuild v0.28.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/go-webauthn/x v0.1.9 // indirect
|
||||
github.com/gofiber/schema v1.7.1 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.4 // indirect
|
||||
github.com/google/go-tpm v0.9.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hanzoai/dbx v1.16.0 // indirect
|
||||
@@ -52,6 +59,7 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.47 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -59,7 +67,7 @@ require (
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.70.0 // indirect
|
||||
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/zap-proto/go v1.3.0 // indirect
|
||||
github.com/zap-proto/http v0.2.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
|
||||
@@ -4,6 +4,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -32,6 +34,10 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyL
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
|
||||
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
|
||||
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
|
||||
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
|
||||
@@ -40,6 +46,8 @@ github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2e
|
||||
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
||||
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -78,6 +86,8 @@ github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLG
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
@@ -85,6 +95,8 @@ github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJ
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
||||
+111
-9
@@ -129,6 +129,54 @@ var publicPaths = map[string]bool{
|
||||
"/v1/iam/oauth/logout": true, // end session
|
||||
"/v1/iam/get-app-login": true, // pre-login app config (secrets masked)
|
||||
"/v1/iam/auth/methods": true, // pre-login method list
|
||||
// The passkey SIGN-IN ceremony. A passkey is what the caller signs in WITH,
|
||||
// so no bearer can exist yet — gating these would make the credential
|
||||
// unusable. Both halves gate themselves on possession of the private key:
|
||||
// begin only issues a random challenge, and finish only completes when the
|
||||
// authenticator's signature verifies against a stored public key. They are
|
||||
// the passkey twin of /v1/iam/login, which is public for the same reason.
|
||||
// The REGISTRATION ceremony (webauthn/signup/*) is NOT here — it requires a
|
||||
// bearer, because it decides which account a new credential can open.
|
||||
"/v1/iam/webauthn/signin/begin": true,
|
||||
"/v1/iam/webauthn/signin/finish": true,
|
||||
}
|
||||
|
||||
// formPaths is the CLOSED set of gated routes whose authorization target rides in
|
||||
// the request FORM rather than a decoded JSON body — the frozen v1 MFA wire.
|
||||
// v1 reads every MFA parameter through c.Ctx.Request.Form (controllers/mfa.go:36),
|
||||
// the query merged with the body, and its two live clients disagree about which
|
||||
// they use: the hanzo.id portal posts multipart, the console's BFF sends the
|
||||
// query with an empty body. So these are raw handlers, and no decoded body ever
|
||||
// reaches the op-invoke seam.
|
||||
//
|
||||
// The Guard authorizes them here, through the SAME httpx.Form call the handler
|
||||
// binds (internal/mfa subject()) — one function over one buffered request. That
|
||||
// is what keeps invariant 2: the target authorized cannot diverge from the target
|
||||
// executed, because there is no second parse to diverge with.
|
||||
var formPaths = map[string]bool{
|
||||
"/v1/iam/mfa/setup/initiate": true,
|
||||
"/v1/iam/mfa/setup/verify": true,
|
||||
"/v1/iam/mfa/setup/enable": true,
|
||||
"/v1/iam/delete-mfa": true,
|
||||
"/v1/iam/set-preferred-mfa": true,
|
||||
}
|
||||
|
||||
// bearerBound is the CLOSED set of gated routes whose subject IS the verified
|
||||
// bearer: the handler reads it from From(ctx) and takes no (owner, name) at all,
|
||||
// so there is no request-supplied target and the Guard only authenticates.
|
||||
//
|
||||
// Passkey registration is self-only BY CONSTRUCTION, and that is the whole point
|
||||
// of binding it here. A target parameter would have to pass the tenant rule
|
||||
// below, which lets an ORG ADMIN act on any member — and an org admin who can
|
||||
// register a passkey onto a member's account owns that account silently, forever.
|
||||
// So the ceremony never learns a target: it can only ever add a credential to the
|
||||
// caller's own identity.
|
||||
//
|
||||
// A route belongs here ONLY if its handler resolves its subject from From(ctx)
|
||||
// and nowhere else.
|
||||
var bearerBound = map[string]bool{
|
||||
"/v1/iam/webauthn/signup/begin": true,
|
||||
"/v1/iam/webauthn/signup/finish": true,
|
||||
}
|
||||
|
||||
// isPublic reports whether path is in the public allowlist. A trailing slash is
|
||||
@@ -149,6 +197,29 @@ func isPublic(path string) bool {
|
||||
// body decoded once by the op and is authorized at that seam.
|
||||
func isRead(method string) bool { return method == "GET" || method == "HEAD" }
|
||||
|
||||
// guards reports whether the Guard itself authorizes this request's target. Two
|
||||
// classes address a target outside a decoded JSON body: a read (no body at all)
|
||||
// and a v1 form route (its target rides in the form). A bearer-bound route
|
||||
// carries no target to authorize. Everything else decodes a typed body, which
|
||||
// the op-invoke seam authorizes on the exact value the handler binds.
|
||||
func guards(method, path string) bool {
|
||||
if bearerBound[path] {
|
||||
return false
|
||||
}
|
||||
return isRead(method) || formPaths[path]
|
||||
}
|
||||
|
||||
// target returns the (owner, name) a Guard-authorized request addresses, read
|
||||
// exactly where its handler reads it: a read's rides in the query string; a v1
|
||||
// form route's rides in the request form (query ∪ body), through the same
|
||||
// httpx.Form the handler binds, so the two cannot come apart.
|
||||
func target(c *zip.Ctx) (owner, name string) {
|
||||
if formPaths[c.Path()] {
|
||||
return httpx.Form(c, "owner"), httpx.Form(c, "name")
|
||||
}
|
||||
return c.Query("owner"), c.Query("name")
|
||||
}
|
||||
|
||||
// Guard is the AUTHENTICATION middleware. Mount it ONCE and FIRST, via app.Use,
|
||||
// so it wraps every route — the typed CRUD handlers and the framework's /mcp and
|
||||
// /openapi surfaces alike. Public routes pass straight through; every other route
|
||||
@@ -167,8 +238,11 @@ func Guard(db orm.DB) zip.Handler {
|
||||
if err != nil {
|
||||
return zip.ErrUnauthorized("authentication required")
|
||||
}
|
||||
if isRead(c.Method()) && !authorize(p, c.Method(), entityOf(c.Path()), c.Query("owner"), c.Query("name")) {
|
||||
return zip.ErrForbidden("forbidden")
|
||||
if guards(c.Method(), c.Path()) {
|
||||
owner, name := target(c)
|
||||
if !authorize(p, c.Method(), entityOf(c.Path()), owner, name) {
|
||||
return zip.ErrForbidden("forbidden")
|
||||
}
|
||||
}
|
||||
c.SetContext(context.WithValue(c.Context(), ctxKey{}, p))
|
||||
return c.Continue()
|
||||
@@ -208,22 +282,34 @@ func Authorize(ctx context.Context, op zip.Op, in any) error {
|
||||
// `owner` (named `name`) on the given entity? The order IS the policy:
|
||||
//
|
||||
// 1. SuperAdmin may do anything — the only cross-tenant scope.
|
||||
//
|
||||
// 2. A platform-owned resource (admin/built-in — the reserved owners the token
|
||||
// verifier trusts to sign) is writable only by a SuperAdmin. This single
|
||||
// rule is the signing-cert poisoning gate, the admin-scoped app/provider
|
||||
// registration gate, AND the built-in-org gap, all at once: a built-in-org
|
||||
// principal is not SuperAdmin (that is admin only), so it cannot write a
|
||||
// built-in-owned signing cert either.
|
||||
//
|
||||
// 3. Tenant isolation: a normal principal may act only within its OWN org. An
|
||||
// empty or foreign owner is refused — the target org is bound to the
|
||||
// principal, never trusted from the request.
|
||||
//
|
||||
// 4. Inside its own org, an org admin manages everything; a regular user may
|
||||
// only READ its own user record (self-service). The users entity serves
|
||||
// reads as GET and writes as POST, so gating the self clause to GET keeps a
|
||||
// regular user from writing its own record — a raw entity write would
|
||||
// otherwise let it carry isAdmin and self-promote. Privileged self-mutation
|
||||
// is the Phase-5 provision-don't-promote concern; here it is closed by
|
||||
// denial.
|
||||
// only READ its own user record, or manage its own MFA (self-service). The
|
||||
// users entity serves reads as GET and writes as POST, so gating the self
|
||||
// clause to GET keeps a regular user from writing its own record — a raw
|
||||
// entity write would otherwise let it carry isAdmin and self-promote.
|
||||
// Privileged self-mutation is the Phase-5 provision-don't-promote concern;
|
||||
// here it is closed by denial.
|
||||
//
|
||||
// The MFA entities are the ONE self-service WRITE, and they are safe only
|
||||
// because those handlers are column-scoped: every one of them persists
|
||||
// through mfa.Save, which overlays the multi-factor columns onto the STORED
|
||||
// row, so the request's user value never reaches the store and cannot carry
|
||||
// isAdmin. Widen those handlers to a whole-row write and this clause becomes
|
||||
// the self-promotion path the users clause is narrow to avoid. The grant does
|
||||
// NOT widen the users entity; enrolling a factor and editing a profile stay
|
||||
// different rights.
|
||||
func authorize(p *Principal, method, entity, owner, name string) bool {
|
||||
if p.Super {
|
||||
return true
|
||||
@@ -237,7 +323,23 @@ func authorize(p *Principal, method, entity, owner, name string) bool {
|
||||
if p.Admin {
|
||||
return true
|
||||
}
|
||||
return method == "GET" && entity == "users" && name != "" && name == p.User
|
||||
if name == "" || name != p.User {
|
||||
return false // every remaining grant is self-service
|
||||
}
|
||||
return (method == "GET" && entity == "users") || isMfa(entity)
|
||||
}
|
||||
|
||||
// isMfa reports whether entity is one of the multi-factor enrollment surfaces —
|
||||
// the entity segment of the five frozen v1 paths (mfa/setup/*, delete-mfa,
|
||||
// set-preferred-mfa). They are named here rather than derived so that adding a
|
||||
// route under one of these entities is a deliberate act: everything they admit,
|
||||
// a regular user may do to itself.
|
||||
func isMfa(entity string) bool {
|
||||
switch entity {
|
||||
case "mfa", "delete-mfa", "set-preferred-mfa":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// owned is implemented by a typed input whose authorization target is NOT its
|
||||
|
||||
@@ -22,9 +22,18 @@ type Response struct {
|
||||
Data3 any `json:"data3,omitempty"`
|
||||
}
|
||||
|
||||
// Ok writes 200 { status:"ok", data }.
|
||||
func Ok(c *zip.Ctx, data any) error {
|
||||
return c.JSON(200, Response{Status: "ok", Data: data})
|
||||
// Ok writes 200 { status:"ok", data }, plus data2 when a second value is given —
|
||||
// the shape of v1's ResponseOk(data ...interface{}) (controllers/util.go:43). The
|
||||
// MFA gate is the one caller that needs both: it answers `data:"NextMfa"` with the
|
||||
// allowed factors in data2, and the portal string-compares data, so the pair must
|
||||
// ride one envelope. Variadic rather than a second Ok-like function: one helper,
|
||||
// one way.
|
||||
func Ok(c *zip.Ctx, data any, more ...any) error {
|
||||
r := Response{Status: "ok", Data: data}
|
||||
if len(more) > 0 {
|
||||
r.Data2 = more[0]
|
||||
}
|
||||
return c.JSON(200, r)
|
||||
}
|
||||
|
||||
// Err writes 200 { status:"error", msg } — the SDK contract (branch on status,
|
||||
@@ -33,6 +42,21 @@ func Err(c *zip.Ctx, msg string) error {
|
||||
return c.JSON(200, Response{Status: "error", Msg: msg})
|
||||
}
|
||||
|
||||
// Form returns a request parameter the way v1 reads every MFA parameter —
|
||||
// c.Ctx.Request.Form.Get (controllers/mfa.go:36-38), Go's merge of the URL query
|
||||
// with the posted form. The underlying FormValue searches QueryArgs → PostArgs →
|
||||
// MultipartForm, which is that same precedence, so ONE call serves every live
|
||||
// client of the frozen wire: the hanzo.id portal posts multipart FormData
|
||||
// (web/src/backend/MfaBackend.ts), the console BFF sends the query with an empty
|
||||
// body (console app/console/mfa/[action]/route.ts:76-87), and an SDK may send
|
||||
// urlencoded.
|
||||
//
|
||||
// This is the ONLY way an MFA parameter is read, by the handler that executes it
|
||||
// AND by the authz Guard that authorizes its (owner, name) — the same function
|
||||
// over the same buffered request, so the value authorized cannot diverge from the
|
||||
// value executed (internal/authz, invariant 2).
|
||||
func Form(c *zip.Ctx, name string) string { return c.Fiber().FormValue(name) }
|
||||
|
||||
// Bearer returns the token from an `Authorization: Bearer <token>` header, or "".
|
||||
func Bearer(c *zip.Ctx) string {
|
||||
const p = "Bearer "
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package mfa is the multi-factor domain: what a factor IS, whether a passcode
|
||||
// verifies, which factors a user has, whether the organization demands one, how
|
||||
// that state is written, and the enrollment surface that drives it (mount.go).
|
||||
// The login gate (internal/oidc) imports the same functions the enrollment
|
||||
// handlers call, so there is exactly one implementation of each.
|
||||
//
|
||||
// Two secrets, two different invariants, and conflating them breaks either
|
||||
// security or the product:
|
||||
//
|
||||
// - TotpSecret is a SYMMETRIC shared secret. The verifier needs it back in
|
||||
// the clear to recompute the code, so it cannot be hashed. Its invariant is
|
||||
// that it never crosses a response (users.redact strips it, and
|
||||
// schema.MfaProps declares Secret json:"-"). It crosses the API exactly once,
|
||||
// outbound, at enrollment — that IS the QR code — and never again.
|
||||
// - A recovery code is a BEARER credential, verified by equality alone, so it
|
||||
// is hashed at rest like a password (v1 stores it in the clear:
|
||||
// object/mfa.go:81 compares `code == recoveryCode`).
|
||||
//
|
||||
// Ported from v1 object/mfa.go + object/mfa_totp.go. Radius and push are
|
||||
// deliberately absent: no v2 provider transport serves them, and a factor listed
|
||||
// as available but unservable is an unusable challenge.
|
||||
package mfa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The factor types, verbatim from v1 (object/mfa.go:42-48). "app" is TOTP —
|
||||
// the name is v1's and it is on the wire, so it does not get "improved".
|
||||
const (
|
||||
App = "app"
|
||||
SMS = "sms"
|
||||
Email = "email"
|
||||
)
|
||||
|
||||
// Types lists the factors this package can project, in v1's order
|
||||
// (object/mfa.go:102). It bounds AllProps: a factor absent here is never offered
|
||||
// on a challenge.
|
||||
var Types = []string{SMS, Email, App}
|
||||
|
||||
// The TOTP parameters. v1 pins them at object/mfa_totp.go:27 (30s period), :115
|
||||
// (20-byte secret, six digits) and :62-68 (skew 1, SHA1) — the RFC 6238 defaults
|
||||
// every authenticator app assumes. They are the wire format of a QR already in a
|
||||
// user's phone, so they are fixed, not configurable.
|
||||
const (
|
||||
period = 30
|
||||
secrets = 20
|
||||
skew = 1
|
||||
digits = otp.DigitsSix
|
||||
algo = otp.AlgorithmSHA1
|
||||
)
|
||||
|
||||
// issuerFallback labels the account in an authenticator app when the
|
||||
// organization sets no display name (v1 object/mfa_totp.go:40).
|
||||
const issuerFallback = "HanzoIAM"
|
||||
|
||||
// Enrollment is what a client needs to add an account to an authenticator and
|
||||
// nothing more. It exists because schema.MfaProps — the STORED/READ projection —
|
||||
// declares Secret and RecoveryCodes as json:"-", so returning one here would
|
||||
// serialize an empty secret and an empty URL: a blank QR, no error, enrollment
|
||||
// silently dead. The two directions are different values, so they are different
|
||||
// types. This one is built, sent once, and never persisted.
|
||||
type Enrollment struct {
|
||||
MfaType string `json:"mfaType"`
|
||||
Secret string `json:"secret"`
|
||||
URL string `json:"url"`
|
||||
RecoveryCodes []string `json:"recoveryCodes"`
|
||||
MfaRememberInHours int `json:"mfaRememberInHours"`
|
||||
}
|
||||
|
||||
// Issuer is the label an authenticator app shows for the account: the
|
||||
// organization's display name, else its name, else the product (v1
|
||||
// controllers/mfa.go:68-73).
|
||||
func Issuer(org *schema.Organization) string {
|
||||
if org != nil && org.DisplayName != "" {
|
||||
return org.DisplayName
|
||||
}
|
||||
if org != nil && org.Name != "" {
|
||||
return org.Name
|
||||
}
|
||||
return issuerFallback
|
||||
}
|
||||
|
||||
// Enroll generates a fresh TOTP secret for userID ("owner/name") and the
|
||||
// otpauth:// URL that encodes it. It persists NOTHING: enrollment is stateless
|
||||
// and client-held until enable commits it (v1 object/mfa_totp.go:37-60).
|
||||
func Enroll(userID, issuer string) (secret, url string, err error) {
|
||||
if issuer == "" {
|
||||
issuer = issuerFallback
|
||||
}
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: issuer,
|
||||
AccountName: userID,
|
||||
Period: period,
|
||||
SecretSize: secrets,
|
||||
Digits: digits,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return key.Secret(), key.URL(), nil
|
||||
}
|
||||
|
||||
// Verify reports whether passcode is currently valid for secret. It is the ONE
|
||||
// TOTP verification point — enrollment's setup check and the login challenge
|
||||
// call this same function, so they cannot drift apart (the users.VerifyPassword
|
||||
// precedent). Skew 1 accepts the adjacent windows, tolerating clock drift
|
||||
// (v1 object/mfa_totp.go:97-113).
|
||||
func Verify(secret, passcode string) bool {
|
||||
if secret == "" || passcode == "" {
|
||||
return false
|
||||
}
|
||||
ok, err := totp.ValidateCustom(passcode, secret, time.Now().UTC(), totp.ValidateOpts{
|
||||
Period: period,
|
||||
Skew: skew,
|
||||
Digits: digits,
|
||||
Algorithm: algo,
|
||||
})
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
// recoveryBytes is the entropy behind one recovery code: 20 bytes → 32 base32
|
||||
// characters, the same strength as the TOTP secret it backs up.
|
||||
const recoveryBytes = 20
|
||||
|
||||
// MintRecovery returns one fresh recovery code, in the clear, for the user to
|
||||
// write down. v1 mints exactly one (controllers/mfa.go:81-82) and the console
|
||||
// reads only recoveryCodes[0], so one it is.
|
||||
//
|
||||
// v1 uses uuid.NewString(): a v4 UUID does carry 122 bits from crypto/rand, but
|
||||
// it is a value formatted to be an identifier, not a secret. This asks
|
||||
// crypto/rand for a secret directly.
|
||||
func MintRecovery() (string, error) {
|
||||
b := make([]byte, recoveryBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
// HashRecovery is the digest a recovery code is STORED as. A recovery code is a
|
||||
// bearer credential verified by equality alone, so — unlike the TOTP secret,
|
||||
// which the verifier needs back in the clear — it hashes like a password.
|
||||
func HashRecovery(plain string) (string, error) {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
return string(h), err
|
||||
}
|
||||
|
||||
// UseRecovery consumes one of the user's recovery codes, reporting whether code
|
||||
// matched. A hit is DELETED from u.RecoveryCodes in place — one-time use
|
||||
// (v1 object/mfa.go:83) — and the caller persists the row.
|
||||
//
|
||||
// Stored codes are bcrypt digests, but every code migrated from v1 is PLAINTEXT
|
||||
// (object/mfa.go:81 compares in the clear), so a stored value that is not a
|
||||
// digest is compared literally. The algorithm is a property of the stored value,
|
||||
// never a constant — the same rule the password path lives by. A legacy hit is
|
||||
// spent and removed like any other, so the plaintext dies on first use.
|
||||
func UseRecovery(u *schema.User, code string) bool {
|
||||
if u == nil || code == "" {
|
||||
return false
|
||||
}
|
||||
for i, stored := range u.RecoveryCodes {
|
||||
if !recoveryMatches(stored, code) {
|
||||
continue
|
||||
}
|
||||
u.RecoveryCodes = append(u.RecoveryCodes[:i:i], u.RecoveryCodes[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recoveryMatches compares one presented code against one stored value, choosing
|
||||
// the comparison from what the value IS: a bcrypt digest is verified with
|
||||
// bcrypt, a v1-era plaintext by equality.
|
||||
func recoveryMatches(stored, code string) bool {
|
||||
if isBcrypt(stored) {
|
||||
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(code)) == nil
|
||||
}
|
||||
return stored != "" && stored == code
|
||||
}
|
||||
|
||||
// isBcrypt reports whether s is a bcrypt digest by its PHC-style prefix
|
||||
// ($2a$/$2b$/$2y$). bcrypt.Cost is the library's own parser, so the answer comes
|
||||
// from the format itself rather than a hand-rolled guess.
|
||||
func isBcrypt(s string) bool {
|
||||
_, err := bcrypt.Cost([]byte(s))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Enabled reports whether the user has multi-factor sign-in on. The predicate is
|
||||
// PreferredMfaType != "" and nothing else (v1 object/user.go:1641-1646): the
|
||||
// per-factor Mfa*Enabled flags say which factors exist, not whether the gate
|
||||
// runs, so reading one of those here would let a user with a stale flag skip the
|
||||
// challenge.
|
||||
func Enabled(u *schema.User) bool { return u != nil && u.PreferredMfaType != "" }
|
||||
|
||||
// Prompt reports whether the organization REQUIRES a factor the user has not
|
||||
// enrolled yet — the sign-in must divert to enrollment before it can finish.
|
||||
// The user's own MfaItems override the org's entirely when present (not merge:
|
||||
// v1 object/organization.go:770-792, verbatim), so a per-user policy is a
|
||||
// replacement, not an addition.
|
||||
func Prompt(org *schema.Organization, u *schema.User) bool {
|
||||
if org == nil || u == nil {
|
||||
return false
|
||||
}
|
||||
items := org.MfaItems
|
||||
if len(u.MfaItems) > 0 {
|
||||
items = u.MfaItems
|
||||
}
|
||||
for _, item := range items {
|
||||
if item == nil || item.Rule != "Required" {
|
||||
continue
|
||||
}
|
||||
switch item.Name {
|
||||
case Email:
|
||||
if !u.MfaEmailEnabled {
|
||||
return true
|
||||
}
|
||||
case SMS:
|
||||
if !u.MfaPhoneEnabled {
|
||||
return true
|
||||
}
|
||||
case App:
|
||||
if u.TotpSecret == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Props projects one factor of the user for a client, ALWAYS masked. v1 takes a
|
||||
// `masked bool` and its false branch returns the live TOTP secret / full phone /
|
||||
// full email (object/mfa.go:108-205); every caller that reaches a response
|
||||
// passes true, and the one that passes false does so to hand the secret to a
|
||||
// verifier. Here the verifier reads u.TotpSecret directly, so the projection has
|
||||
// no unmasked mode to misuse: Secret and RecoveryCodes are never populated, and
|
||||
// they are json:"-" besides. users.redact is the backstop, not the primary.
|
||||
func Props(u *schema.User, mfaType string) *schema.MfaProps {
|
||||
p := &schema.MfaProps{MfaType: mfaType}
|
||||
if u == nil {
|
||||
return p
|
||||
}
|
||||
switch mfaType {
|
||||
case SMS:
|
||||
p.Enabled = u.MfaPhoneEnabled
|
||||
if p.Enabled {
|
||||
p.CountryCode = u.CountryCode
|
||||
}
|
||||
case Email:
|
||||
p.Enabled = u.MfaEmailEnabled
|
||||
case App:
|
||||
p.Enabled = u.TotpSecret != ""
|
||||
}
|
||||
if !p.Enabled {
|
||||
// v1 returns a bare {enabled,mfaType} for a disabled factor and does not
|
||||
// mark it preferred — preserve that shape (object/mfa.go:113-117).
|
||||
return &schema.MfaProps{MfaType: mfaType}
|
||||
}
|
||||
p.IsPreferred = u.PreferredMfaType == mfaType
|
||||
return p
|
||||
}
|
||||
|
||||
// AllProps projects every factor this package serves, masked, in v1's order
|
||||
// (object/mfa.go:99-106).
|
||||
func AllProps(u *schema.User) []*schema.MfaProps {
|
||||
all := make([]*schema.MfaProps, 0, len(Types))
|
||||
for _, t := range Types {
|
||||
all = append(all, Props(u, t))
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// Copy overwrites dst's multi-factor state with src's, and nothing else. It is
|
||||
// the ONE declaration of which columns ARE multi-factor state, so every writer
|
||||
// agrees on the set by construction: users.SaveMfa copies a caller's factors
|
||||
// onto the STORED row through this, which is what makes an MFA write
|
||||
// column-scoped — the request's user value never reaches the store, so it cannot
|
||||
// carry isAdmin along and self-promote (internal/authz:221-226 documents that
|
||||
// exact trap). Disable is the same copy from a zero user, so "which columns to
|
||||
// clear" cannot drift from "which columns to write".
|
||||
//
|
||||
// The set is v1's eleven (object/mfa.go:207-219) plus MfaRememberDeadline: v1
|
||||
// omits the deadline from disable, which leaves a future "don't ask again"
|
||||
// window alive across a disable → re-enable and skips the next challenge. It is
|
||||
// dark in v1 only because every live organization leaves MfaRememberInHours at
|
||||
// zero, which puts every deadline in the past. Carrying the deadline with the
|
||||
// state it belongs to closes it.
|
||||
func Copy(dst, src *schema.User) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
dst.PreferredMfaType = src.PreferredMfaType
|
||||
dst.RecoveryCodes = src.RecoveryCodes
|
||||
dst.TotpSecret = src.TotpSecret
|
||||
dst.MfaPhoneEnabled = src.MfaPhoneEnabled
|
||||
dst.MfaEmailEnabled = src.MfaEmailEnabled
|
||||
dst.MfaRadiusEnabled = src.MfaRadiusEnabled
|
||||
dst.MfaRadiusUsername = src.MfaRadiusUsername
|
||||
dst.MfaRadiusProvider = src.MfaRadiusProvider
|
||||
dst.MfaPushEnabled = src.MfaPushEnabled
|
||||
dst.MfaPushReceiver = src.MfaPushReceiver
|
||||
dst.MfaPushProvider = src.MfaPushProvider
|
||||
dst.MfaRememberDeadline = src.MfaRememberDeadline
|
||||
}
|
||||
|
||||
// Disable turns multi-factor sign-in off, clearing every column that could keep
|
||||
// a factor half-alive. Clearing PreferredMfaType alone would leave TotpSecret
|
||||
// behind — a secret retained past the user's request to remove it, and a factor
|
||||
// that silently returns the moment anything sets a preference again.
|
||||
func Disable(u *schema.User) { Copy(u, &schema.User{}) }
|
||||
|
||||
// Save writes u's multi-factor state — and ONLY that — onto its stored row. It
|
||||
// is the single write point for every MFA mutation: enroll, disable, prefer,
|
||||
// spend a recovery code, remember a device. It lives beside Copy because the two
|
||||
// halves of "which columns are MFA state" and "write those columns" must not be
|
||||
// able to drift; internal/users keeps the whole-row CRUD, and the two never
|
||||
// overlap.
|
||||
//
|
||||
// The scoping is what makes it safe. The caller's user value is never the thing
|
||||
// stored: the row is loaded fresh and Copy overlays exactly the multi-factor
|
||||
// columns, so an isAdmin, a balance, or a password digest arriving on an MFA
|
||||
// request reaches nothing. Without that, MFA enrollment — which a regular user is
|
||||
// allowed to do to itself — would be a raw self-write, and therefore a
|
||||
// self-promotion path (internal/authz:221-226 documents that exact trap).
|
||||
func Save(ctx context.Context, db orm.DB, u *schema.User) error {
|
||||
if u == nil {
|
||||
return errNoUser
|
||||
}
|
||||
stored, err := store.GetUserByName(ctx, db, u.Owner, u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stored == nil {
|
||||
return errNoUser
|
||||
}
|
||||
Copy(stored, u)
|
||||
return stored.UpdateCtx(ctx)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package mfa
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// errNoUser is the ONE answer to every unresolvable enrollment subject —
|
||||
// missing, malformed, or simply absent. The Guard has already bound (owner,
|
||||
// name) to the caller's own identity, so there is nothing here for a prober to
|
||||
// learn, and one message keeps it that way.
|
||||
var errNoUser = errors.New("user doesn't exist")
|
||||
|
||||
// The enrollment surface: the five v1 paths, unchanged (routers/router.go:398-402).
|
||||
// hanzo.id serves v1's portal and the console ships its own BFF, so the wire is
|
||||
// frozen — the paths, the parameter names, and the envelope are all theirs.
|
||||
//
|
||||
// PARAMETERS RIDE THE REQUEST FORM. Every parameter is read through httpx.Form,
|
||||
// which is v1's own c.Ctx.Request.Form.Get: the query merged with the body. That
|
||||
// is not a convenience — it is the only read that serves both live clients, and
|
||||
// they disagree. The hanzo.id portal posts multipart FormData
|
||||
// (web/src/backend/MfaBackend.ts); the console's BFF sends the query with a
|
||||
// deliberately EMPTY body (console app/console/mfa/[action]/route.ts:76-87),
|
||||
// because v1's own authz filter can only derive owner/name from a query — a form
|
||||
// body there yields an empty object and the self-service grant never matches,
|
||||
// which is the "Unauthorized operation" the BFF exists to route around.
|
||||
//
|
||||
// These are therefore raw handlers, not typed ops: neither client sends JSON, so
|
||||
// there is no decoded body for the op-invoke seam to authorize. The authz Guard
|
||||
// authorizes them instead, reading (owner, name) through the SAME httpx.Form call
|
||||
// the handlers bind — one function over one buffered request, so the value
|
||||
// authorized is the value executed (internal/authz formPaths).
|
||||
|
||||
// The five frozen paths. internal/authz names these same constants in its
|
||||
// form-route set, so the route that is mounted and the route that is authorized
|
||||
// are the same string.
|
||||
const (
|
||||
PathInitiate = "/v1/iam/mfa/setup/initiate"
|
||||
PathVerify = "/v1/iam/mfa/setup/verify"
|
||||
PathEnable = "/v1/iam/mfa/setup/enable"
|
||||
PathDelete = "/v1/iam/delete-mfa"
|
||||
PathPrefer = "/v1/iam/set-preferred-mfa"
|
||||
)
|
||||
|
||||
// Mount registers the enrollment surface on app.
|
||||
func Mount(app *zip.App, db orm.DB) {
|
||||
app.Post(PathInitiate, initiate(db))
|
||||
app.Post(PathVerify, verify())
|
||||
app.Post(PathEnable, enable(db))
|
||||
app.Post(PathDelete, remove(db))
|
||||
app.Post(PathPrefer, prefer(db))
|
||||
}
|
||||
|
||||
// initiate generates a TOTP secret + its otpauth:// URL + one recovery code, and
|
||||
// persists NOTHING (v1 controllers/mfa.go:35-87). Enrollment is stateless and
|
||||
// client-held until enable commits it.
|
||||
//
|
||||
// This is the ONE response in the whole system that carries a TOTP secret, and it
|
||||
// has to: the secret IS the QR code. It returns an Enrollment, not a
|
||||
// schema.MfaProps — MfaProps declares Secret and RecoveryCodes json:"-" (the
|
||||
// stored/read projection must never serialize them), so answering with one would
|
||||
// send an empty secret and an empty URL: a blank QR, no error, and enrollment
|
||||
// silently dead. Two directions, two types.
|
||||
func initiate(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
u, err := subject(c, db)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if httpx.Form(c, "mfaType") != App {
|
||||
return httpx.Err(c, "invalid multi-factor authentication type")
|
||||
}
|
||||
ctx := c.Context()
|
||||
org, err := store.GetOrganizationByName(ctx, db, u.Owner)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
secret, url, err := Enroll(u.Owner+"/"+u.Name, Issuer(org))
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
plain, err := MintRecovery()
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
hours := 0
|
||||
if org != nil {
|
||||
hours = org.MfaRememberInHours
|
||||
}
|
||||
return httpx.Ok(c, &Enrollment{
|
||||
MfaType: App,
|
||||
Secret: secret,
|
||||
URL: url,
|
||||
RecoveryCodes: []string{plain},
|
||||
MfaRememberInHours: hours,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// verify checks a passcode against the caller's own pending secret and does NOT
|
||||
// enable anything (v1 controllers/mfa.go:97-171). The secret is the one initiate
|
||||
// just handed this client; nothing is read from or written to any row, so there
|
||||
// is no state for the check to touch.
|
||||
func verify() zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if httpx.Form(c, "mfaType") != App {
|
||||
return httpx.Err(c, "invalid multi-factor authentication type")
|
||||
}
|
||||
secret, passcode := httpx.Form(c, "secret"), httpx.Form(c, "passcode")
|
||||
if secret == "" {
|
||||
return httpx.Err(c, "totp secret is missing")
|
||||
}
|
||||
if passcode == "" {
|
||||
return httpx.Err(c, "missing auth type or passcode")
|
||||
}
|
||||
if !Verify(secret, passcode) {
|
||||
return httpx.Err(c, "totp passcode error")
|
||||
}
|
||||
return httpx.Ok(c, "OK")
|
||||
}
|
||||
}
|
||||
|
||||
// enable commits the client-held enrollment: the secret, the recovery code
|
||||
// (hashed), and the preference (v1 controllers/mfa.go:182-276 +
|
||||
// object/mfa_totp.go:80-95).
|
||||
//
|
||||
// The secret comes FROM THE REQUEST, as in v1 — enrollment is stateless, so the
|
||||
// caller chooses the secret it will later be challenged against. That is only
|
||||
// safe because the write is bound to the authorized subject and scoped to the MFA
|
||||
// columns: users.SaveMfa overlays them onto the STORED row, so nothing else on
|
||||
// this request can reach the store.
|
||||
func enable(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
u, err := subject(c, db)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if httpx.Form(c, "mfaType") != App {
|
||||
return httpx.Err(c, "invalid multi-factor authentication type")
|
||||
}
|
||||
secret := httpx.Form(c, "secret")
|
||||
if secret == "" {
|
||||
return httpx.Err(c, "totp secret is missing")
|
||||
}
|
||||
// v1 refuses an enable with no recovery code (controllers/mfa.go:257-260):
|
||||
// enrolling a factor without the way back locks the user out the first
|
||||
// time the phone is lost.
|
||||
if httpx.Form(c, "recoveryCodes") == "" {
|
||||
return httpx.Err(c, "recovery codes is missing")
|
||||
}
|
||||
// The recovery code the client holds is bcrypt-hashed on the way in. v1
|
||||
// stores it in the clear (object/mfa.go:81 compares plaintext), so this
|
||||
// is a deliberate divergence; UseRecovery still verifies a migrated
|
||||
// plaintext row, chosen from what the stored value IS.
|
||||
hash, err := HashRecovery(httpx.Form(c, "recoveryCodes"))
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
u.TotpSecret = secret
|
||||
u.RecoveryCodes = append(u.RecoveryCodes, hash)
|
||||
// Only when empty: enabling a factor must not silently re-point a user's
|
||||
// preferred one (v1 object/mfa_totp.go:85-87).
|
||||
if u.PreferredMfaType == "" {
|
||||
u.PreferredMfaType = App
|
||||
}
|
||||
if err := Save(c.Context(), db, u); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, "OK")
|
||||
}
|
||||
}
|
||||
|
||||
// remove turns every factor off and answers with the resulting (masked) factor
|
||||
// list (v1 controllers/mfa.go:286-308).
|
||||
func remove(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
u, err := subject(c, db)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
Disable(u)
|
||||
if err := Save(c.Context(), db, u); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, AllProps(u))
|
||||
}
|
||||
}
|
||||
|
||||
// prefer points the user at one of its factors — the one the gate challenges
|
||||
// first (v1 controllers/mfa.go:319-341).
|
||||
func prefer(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
u, err := subject(c, db)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
mfaType := httpx.Form(c, "mfaType")
|
||||
// Preferring a factor that is not enrolled would make Enabled true with
|
||||
// nothing to verify against: every sign-in challenges, no answer passes.
|
||||
if !Props(u, mfaType).Enabled {
|
||||
return httpx.Err(c, "invalid multi-factor authentication type")
|
||||
}
|
||||
u.PreferredMfaType = mfaType
|
||||
if err := Save(c.Context(), db, u); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, AllProps(u))
|
||||
}
|
||||
}
|
||||
|
||||
// subject loads the user an enrollment request addresses, from the SAME
|
||||
// httpx.Form read the authz Guard authorized (owner, name) with. Reading them
|
||||
// anywhere else here — a second parse, a different precedence — is how the
|
||||
// authorized value and the executed value come apart.
|
||||
func subject(c *zip.Ctx, db orm.DB) (*schema.User, error) {
|
||||
owner, name := httpx.Form(c, "owner"), httpx.Form(c, "name")
|
||||
if owner == "" || name == "" {
|
||||
return nil, errNoUser
|
||||
}
|
||||
u, err := store.GetUserByName(c.Context(), db, owner, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u == nil {
|
||||
return nil, errNoUser
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// The challenge lifecycle: the ONE primitive for a sign-in that has proven one
|
||||
// thing and must prove another before a token exists. The MFA gate mints one
|
||||
// when a password verifies but the second factor is outstanding; the WebAuthn
|
||||
// begin endpoints mint one when the options are issued but the assertion is
|
||||
// outstanding. Both finish by taking it.
|
||||
//
|
||||
// v1 keeps this in a beego cookie session; v2 has no key/value session store, so
|
||||
// the state is a server-side row (schema.Challenge) and the client holds only
|
||||
// its opaque id. It lives beside the authorization code because it is the same
|
||||
// shape of value — a short-lived, single-use, opaque bearer of the right to
|
||||
// continue — and this package already owns that lifecycle. It is a SIBLING of
|
||||
// Token, never a Token with borrowed fields: /token resolves a grant by Code, so
|
||||
// a challenge filed there would sit on the redemption path wearing a fictional
|
||||
// Application.
|
||||
|
||||
// challengeTTL bounds a half-finished ceremony. Five minutes is the
|
||||
// authorization code's own bound (codeTTL) and the ceiling the WebAuthn timeout
|
||||
// implies — long enough to read a code off a phone, short enough that an
|
||||
// abandoned challenge is not a standing key to an account whose password is
|
||||
// already known.
|
||||
const challengeTTL = 5 * time.Minute
|
||||
|
||||
// The challenge kinds. Each names the proof still outstanding, and a taker
|
||||
// demands its own kind: a WebAuthn registration challenge must never satisfy the
|
||||
// MFA gate, which would turn "I started enrolling a passkey" into "I passed the
|
||||
// second factor".
|
||||
const (
|
||||
KindMfa = "mfa"
|
||||
KindRegistration = "registration"
|
||||
KindAuthentication = "authentication"
|
||||
)
|
||||
|
||||
// ErrChallenge is the ONE opaque failure for every way a challenge can be
|
||||
// refused — unknown, expired, spent, or the wrong kind. They collapse to one
|
||||
// answer so a prober cannot tell a spent challenge from a forged one.
|
||||
var ErrChallenge = errors.New("the multi-factor session has expired")
|
||||
|
||||
// challengeOwner files every challenge under the reserved admin org. A challenge
|
||||
// is the authorization server's own state, not a tenant record: it is never
|
||||
// listed, never served by an entity route, and its subject is the only tenancy
|
||||
// that matters (and rides inside it, verified). Naming the subject's org here
|
||||
// would put a tenant slug in the key of a row nobody may read anyway.
|
||||
const challengeOwner = "admin"
|
||||
|
||||
// MintChallenge persists a fresh challenge for subject ("owner/name") and
|
||||
// returns its opaque id. payload is the kind's own state — go-webauthn
|
||||
// SessionData JSON for a ceremony, the just-used verification type for the MFA
|
||||
// gate. now is injected for testability.
|
||||
func MintChallenge(ctx context.Context, db orm.DB, kind, subject, payload string, now time.Time) (string, error) {
|
||||
id, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c := orm.New[schema.Challenge](db)
|
||||
c.Owner = challengeOwner
|
||||
c.Name = id
|
||||
c.CreatedTime = now.UTC().Format(time.RFC3339)
|
||||
c.Kind = kind
|
||||
c.Subject = subject
|
||||
c.Payload = payload
|
||||
c.ExpireIn = now.Add(challengeTTL).Unix()
|
||||
c.SetId(challengeOwner + "/" + id)
|
||||
if err := c.CreateCtx(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// TakeChallenge resolves and SPENDS a challenge of the given kind, returning it.
|
||||
// Taking is the only read: a challenge that is found is immediately marked used,
|
||||
// so a replay of the same id loses whether it races or follows. The caller gets
|
||||
// the subject from the returned row and nowhere else — never from a request
|
||||
// parameter (invariant 3), so a body naming another user cannot redirect the
|
||||
// ceremony.
|
||||
//
|
||||
// Every refusal is ErrChallenge.
|
||||
func TakeChallenge(ctx context.Context, db orm.DB, id, kind string, now time.Time) (*schema.Challenge, error) {
|
||||
if id == "" {
|
||||
return nil, ErrChallenge
|
||||
}
|
||||
c, err := orm.Get[schema.Challenge](db, challengeOwner+"/"+id)
|
||||
if err != nil || c == nil {
|
||||
return nil, ErrChallenge
|
||||
}
|
||||
if c.Used || c.Kind != kind || now.Unix() > c.ExpireIn {
|
||||
return nil, ErrChallenge
|
||||
}
|
||||
c.Used = true
|
||||
if err := c.UpdateCtx(ctx); err != nil {
|
||||
return nil, ErrChallenge
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// challengeCookie carries the challenge id to the client exactly the way v1
|
||||
// carries its beego session: a host-only, HttpOnly cookie the browser returns on
|
||||
// the finishing request. Every live client already sends credentials with these
|
||||
// calls (web/src/auth/LoginPage.tsx:421,449 and the MFA form), so the frozen wire
|
||||
// needs no new field. Script cannot read it; it is bound to the ceremony's own
|
||||
// short life.
|
||||
const challengeCookie = "hanzo_challenge"
|
||||
|
||||
// SetChallenge writes the challenge id for the finishing request to return.
|
||||
// HttpOnly keeps script out of it; SameSite=Lax lets the portal's own POST carry
|
||||
// it while refusing a cross-site one; the MaxAge matches the row's TTL so the
|
||||
// browser forgets it exactly when the server does.
|
||||
func SetChallenge(c *zip.Ctx, id string) {
|
||||
c.Fiber().Cookie(&fiber.Cookie{
|
||||
Name: challengeCookie,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
MaxAge: int(challengeTTL / time.Second),
|
||||
HTTPOnly: true,
|
||||
Secure: true,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
}
|
||||
|
||||
// ClearChallenge expires the cookie once its challenge is spent, so a finished
|
||||
// ceremony leaves nothing behind to replay.
|
||||
func ClearChallenge(c *zip.Ctx) {
|
||||
c.Fiber().Cookie(&fiber.Cookie{
|
||||
Name: challengeCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HTTPOnly: true,
|
||||
Secure: true,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
}
|
||||
|
||||
// ReadChallenge returns the challenge id a finishing request presents: the body
|
||||
// field when one is given (an SDK holding no cookie jar), else the cookie the
|
||||
// browser returned. ONE function, ONE precedence, called once per request — the
|
||||
// id is the bearer of the ceremony either way, and the row it names is
|
||||
// single-use, short-lived, and carries its own subject, so neither source can
|
||||
// widen what it proves.
|
||||
func ReadChallenge(c *zip.Ctx, fromBody string) string {
|
||||
if fromBody != "" {
|
||||
return fromBody
|
||||
}
|
||||
return c.Fiber().Cookies(challengeCookie)
|
||||
}
|
||||
+302
-48
@@ -5,11 +5,13 @@ package oidc
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/mfa"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
"github.com/hanzoai/iam2/internal/users"
|
||||
@@ -22,13 +24,21 @@ import (
|
||||
// then exchanges it at /v1/iam/oauth/token. Login by EMAIL or USERNAME.
|
||||
//
|
||||
// This is the interactive-flow counterpart to the token endpoint: login mints
|
||||
// the code, /token redeems it. Password verification is bcrypt (constant-time),
|
||||
// never plaintext, and the hash never crosses a response.
|
||||
// the code, /token redeems it. The password is verified against the row's own
|
||||
// digest and never crosses a response.
|
||||
//
|
||||
// The route serves TWO requests, because v1 does (controllers/auth.go:905 and
|
||||
// :1290): the credential post, and — when a challenge is outstanding — the
|
||||
// second-factor post that finishes it. They are one endpoint because the client
|
||||
// posts to one endpoint; they are separate branches because they prove different
|
||||
// things. A code is minted only past the gate, on either path.
|
||||
|
||||
// PathLogin is the canonical credential-login endpoint.
|
||||
const PathLogin = "/v1/iam/login"
|
||||
|
||||
// loginForm is the request body the SDK/portal posts.
|
||||
// loginForm is the request body the SDK/portal posts. The first block is the
|
||||
// credential post; MfaType/Passcode/RecoveryCode/EnableMfaRemember/Challenge are
|
||||
// the second-factor post that answers a challenge this endpoint issued.
|
||||
type loginForm struct {
|
||||
Application string `json:"application"`
|
||||
Organization string `json:"organization"`
|
||||
@@ -45,8 +55,31 @@ type loginForm struct {
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
Resource string `json:"resource"`
|
||||
|
||||
// The second factor. Challenge names the outstanding ceremony; a browser
|
||||
// returns it in the cookie the gate set and leaves this empty.
|
||||
MfaType string `json:"mfaType"`
|
||||
Passcode string `json:"passcode"`
|
||||
RecoveryCode string `json:"recoveryCode"`
|
||||
EnableMfaRemember bool `json:"enableMfaRemember"`
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
|
||||
// The gate's two answers, verbatim from v1 (object/mfa.go:50-54). They are the
|
||||
// literal STRING the client compares against in the envelope's `data` — the
|
||||
// portal (web/src/auth/LoginPage.tsx:248) and the console's iam-login.ts both
|
||||
// branch on it — so they are wire format, not internal names. Any other shape
|
||||
// and the client reads the answer as an authorization code and the factor is
|
||||
// skipped.
|
||||
const (
|
||||
// RequiredMfa — the organization requires a factor this user has not
|
||||
// enrolled; the client must divert to enrollment.
|
||||
RequiredMfa = "RequiredMfa"
|
||||
// NextMfa — the user has factors; data2 carries the allowed ones and the
|
||||
// client must post one back. NO code is minted with this answer.
|
||||
NextMfa = "NextMfa"
|
||||
)
|
||||
|
||||
// MountLogin registers POST /v1/iam/login.
|
||||
func MountLogin(app *zip.App, db orm.DB) {
|
||||
app.Post(PathLogin, loginHandler(db))
|
||||
@@ -58,10 +91,20 @@ func loginHandler(db orm.DB) zip.Handler {
|
||||
if err := c.Bind(&f); err != nil {
|
||||
return httpx.Err(c, "invalid request body")
|
||||
}
|
||||
ctx := c.Context()
|
||||
|
||||
// A post carrying no credential but naming an outstanding challenge is
|
||||
// the second half of a sign-in this endpoint already gated. The user
|
||||
// comes from the challenge, never from the body (invariant 3).
|
||||
if f.Username == "" && f.Password == "" {
|
||||
if id := ReadChallenge(c, f.Challenge); id != "" {
|
||||
return finishMfa(c, db, id, f)
|
||||
}
|
||||
}
|
||||
|
||||
if f.Organization == "" || f.Username == "" || f.Password == "" {
|
||||
return httpx.Err(c, "organization, username and password are required")
|
||||
}
|
||||
ctx := c.Context()
|
||||
|
||||
user, err := resolveLoginUser(ctx, db, f.Organization, f.Username)
|
||||
if err != nil {
|
||||
@@ -73,60 +116,271 @@ func loginHandler(db orm.DB) zip.Handler {
|
||||
return httpx.Err(c, "the username or password is incorrect")
|
||||
}
|
||||
|
||||
userID := user.Owner + "/" + user.Name
|
||||
|
||||
// type=login: a bare portal sign-in. Session issuance lands with the
|
||||
// session layer; for now report success + the user id (the shape the
|
||||
// portal expects for a non-OAuth sign-in).
|
||||
if f.Type != "code" {
|
||||
return httpx.Ok(c, userID)
|
||||
}
|
||||
|
||||
// type=code: mint a PKCE-bound authorization code for the OAuth flow.
|
||||
app, err := resolveLoginApp(ctx, db, f)
|
||||
// The password proved ONE factor. Everything past this point is the
|
||||
// second: the gate answers the request itself when a factor is
|
||||
// outstanding, and only a fall-through reaches a token.
|
||||
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
}
|
||||
// Tenant isolation: the authenticated user's organization must be
|
||||
// permitted for this application — its own org, a shared app, or an app
|
||||
// that lets users choose their org. Without this a user in one tenant
|
||||
// could obtain a token whose `organization` claim names another tenant.
|
||||
if f.Organization != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
|
||||
return httpx.Err(c, "the user is not permitted to sign in to this application")
|
||||
}
|
||||
// Bind the code to an EXACTLY-registered redirect URI (RFC 6749 §3.1.2.3);
|
||||
// the token endpoint re-checks it. A supplied-but-unregistered URI is
|
||||
// refused — never minted against.
|
||||
if f.RedirectUri != "" && !app.IsRedirectUriValid(f.RedirectUri) {
|
||||
return httpx.Err(c, "invalid redirect_uri")
|
||||
}
|
||||
method := normalizeChallengeMethod(f.CodeChallenge, f.CodeChallengeMethod)
|
||||
if f.CodeChallenge != "" && method != "S256" {
|
||||
return httpx.Err(c, "only S256 PKCE is supported")
|
||||
}
|
||||
// A public client (no secret) must use PKCE — no downgrade.
|
||||
if app.ClientSecret == "" && f.CodeChallenge == "" {
|
||||
return httpx.Err(c, "PKCE is required for public clients")
|
||||
}
|
||||
code, err := MintCode(app, userID, f.Scope, f.CodeChallenge, method, f.Resource, nowFunc())
|
||||
// verificationType is the factor JUST used. A password proves none of
|
||||
// the offerable factors, so it excludes nothing ("" — v1
|
||||
// controllers/auth.go:905 passes the same).
|
||||
gated, err := gate(c, db, user, org, "")
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// Bind the redirect_uri and nonce onto the code so the token exchange can
|
||||
// re-verify the redirect and echo the nonce into the id_token.
|
||||
code.RedirectUri = f.RedirectUri
|
||||
code.Nonce = f.Nonce
|
||||
if err := store.PersistToken(ctx, db, code); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
if gated {
|
||||
return nil
|
||||
}
|
||||
// The SDK reads data as the authorization code to exchange at /token.
|
||||
return httpx.Ok(c, code.Code)
|
||||
|
||||
return grant(c, db, user, f)
|
||||
}
|
||||
}
|
||||
|
||||
// gate is the second-factor decision — the ONE place a sign-in is held. It
|
||||
// answers the request itself and reports true when it did; a false means this
|
||||
// principal has proven everything it owes and the caller may mint.
|
||||
//
|
||||
// Every path that signs a user in calls this BEFORE minting: the credential post
|
||||
// above, and — when they land — the social/OAuth branch where the account
|
||||
// already exists (v1 controllers/auth.go:1054) and the Web3 branch (v1
|
||||
// controllers/web3_auth.go:229). v1 shipped the social one late, in 843e74f4,
|
||||
// because an account-takeover fix exposed that "sign in with Google" walked past
|
||||
// the factor entirely. One function, every call site — a gate that exists in one
|
||||
// branch is not a gate.
|
||||
//
|
||||
// verificationType names the factor the caller already proved, so the challenge
|
||||
// never offers it back (a code texted to a phone must not be answerable by
|
||||
// texting that phone again). "" excludes nothing.
|
||||
func gate(c *zip.Ctx, db orm.DB, user *schema.User, org *schema.Organization, verificationType string) (bool, error) {
|
||||
ctx := c.Context()
|
||||
|
||||
// The organization REQUIRES a factor this user has not enrolled: the answer
|
||||
// is enrollment, not a challenge (v1 controllers/auth.go:515-520).
|
||||
if mfa.Prompt(org, user) {
|
||||
return true, httpx.Ok(c, RequiredMfa)
|
||||
}
|
||||
if !mfa.Enabled(user) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// "Remember this device" — a deadline in the FUTURE skips the factor
|
||||
// (v1 controllers/auth.go:523-527). Written by finishMfa with the same
|
||||
// nowRFC3339 the parse below expects; a format the parser cannot read is
|
||||
// treated as no deadline, so a bad value re-challenges rather than
|
||||
// silently granting a permanent skip.
|
||||
if remembered(user, nowFunc()) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
allow := allowList(user, org, verificationType)
|
||||
if len(allow) == 0 {
|
||||
// Every factor is either the one just used or not actually enrolled:
|
||||
// there is nothing left to ask for (v1 falls through the same way).
|
||||
return false, nil
|
||||
}
|
||||
|
||||
id, err := MintChallenge(ctx, db, KindMfa, user.Owner+"/"+user.Name, verificationType, nowFunc())
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
SetChallenge(c, id)
|
||||
// data is the STRING "NextMfa"; data2 carries the factors. No code is
|
||||
// minted here — that is the whole point of the gate.
|
||||
return true, httpx.Ok(c, NextMfa, allow)
|
||||
}
|
||||
|
||||
// allowList is the factors a challenge may be answered with: enrolled, and not
|
||||
// the one the caller just used (v1 controllers/auth.go:528-544). Each carries
|
||||
// the org's remember window so the client can offer "don't ask again".
|
||||
func allowList(user *schema.User, org *schema.Organization, verificationType string) []*schema.MfaProps {
|
||||
hours := 0
|
||||
if org != nil {
|
||||
hours = org.MfaRememberInHours
|
||||
}
|
||||
allow := []*schema.MfaProps{}
|
||||
for _, p := range mfa.AllProps(user) {
|
||||
if !p.Enabled || p.MfaType == verificationType {
|
||||
continue
|
||||
}
|
||||
p.MfaRememberInHours = hours
|
||||
allow = append(allow, p)
|
||||
}
|
||||
return allow
|
||||
}
|
||||
|
||||
// remembered reports whether the user's "don't ask again" window is still open.
|
||||
// An unparsable or empty deadline is not a skip: this fails CLOSED, to the
|
||||
// challenge.
|
||||
func remembered(user *schema.User, now time.Time) bool {
|
||||
if user.MfaRememberDeadline == "" {
|
||||
return false
|
||||
}
|
||||
deadline, err := time.Parse(time.RFC3339, user.MfaRememberDeadline)
|
||||
return err == nil && deadline.After(now)
|
||||
}
|
||||
|
||||
// finishMfa answers an outstanding challenge. The user is loaded from the
|
||||
// CHALLENGE's subject — never from the request — so a body naming another
|
||||
// account cannot redirect the ceremony (invariant 3). Taking the challenge
|
||||
// spends it, so a passcode replayed against the same id loses.
|
||||
func finishMfa(c *zip.Ctx, db orm.DB, id string, f loginForm) error {
|
||||
ctx := c.Context()
|
||||
ch, err := TakeChallenge(ctx, db, id, KindMfa, nowFunc())
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
ClearChallenge(c)
|
||||
|
||||
owner, name, _ := strings.Cut(ch.Subject, "/")
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if user == nil {
|
||||
return httpx.Err(c, ErrChallenge.Error())
|
||||
}
|
||||
|
||||
switch {
|
||||
case f.Passcode != "":
|
||||
// The challenge's payload is the factor already used to get here.
|
||||
// Answering with that same factor proves nothing new (v1
|
||||
// controllers/auth.go:1325-1328).
|
||||
if f.MfaType == "" || f.MfaType == ch.Payload {
|
||||
return httpx.Err(c, "invalid multi-factor authentication type")
|
||||
}
|
||||
if f.MfaType != mfa.App {
|
||||
// Only TOTP has a verifier here. Refuse anything else rather than
|
||||
// wave it through: a factor with no verification is not a factor.
|
||||
return httpx.Err(c, "invalid multi-factor authentication type")
|
||||
}
|
||||
if !mfa.Verify(user.TotpSecret, f.Passcode) {
|
||||
return httpx.Err(c, "the multi-factor authentication code is incorrect")
|
||||
}
|
||||
case f.RecoveryCode != "":
|
||||
// A recovery code is one-time: the hit is removed and the row written
|
||||
// whether or not the rest of the sign-in succeeds, so a code cannot be
|
||||
// spent twice (v1 object/mfa.go:73-96).
|
||||
if !mfa.UseRecovery(user, f.RecoveryCode) {
|
||||
return httpx.Err(c, "the recovery code is incorrect")
|
||||
}
|
||||
if err := mfa.Save(ctx, db, user); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
default:
|
||||
return httpx.Err(c, "missing passcode or recovery code")
|
||||
}
|
||||
|
||||
if f.EnableMfaRemember {
|
||||
if err := remember(ctx, db, user); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
}
|
||||
return grant(c, db, user, f)
|
||||
}
|
||||
|
||||
// remember opens the "don't ask again" window: now + the ORG's
|
||||
// MfaRememberInHours (v1 controllers/auth.go:1350-1360). A zero window — every
|
||||
// live organization today — yields a deadline already in the past, so the gate
|
||||
// keeps challenging. That is the shipped behavior and it is preserved: turning a
|
||||
// zero into "forever" would silently disable the factor for every tenant.
|
||||
func remember(ctx context.Context, db orm.DB, user *schema.User) error {
|
||||
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hours := 0
|
||||
if org != nil {
|
||||
hours = org.MfaRememberInHours
|
||||
}
|
||||
// Written with the SAME format `remembered` parses — a mismatch here is a
|
||||
// permanent skip or a permanent challenge, silently.
|
||||
user.MfaRememberDeadline = nowFunc().UTC().Add(time.Duration(hours) * time.Hour).Format(time.RFC3339)
|
||||
return mfa.Save(ctx, db, user)
|
||||
}
|
||||
|
||||
// grant completes a sign-in that has passed the gate: the bare-session answer,
|
||||
// or a PKCE-bound authorization code for the OAuth flow.
|
||||
func grant(c *zip.Ctx, db orm.DB, user *schema.User, f loginForm) error {
|
||||
ctx := c.Context()
|
||||
userID := user.Owner + "/" + user.Name
|
||||
|
||||
// type=login: a bare portal sign-in. Session issuance lands with the
|
||||
// session layer; for now report success + the user id (the shape the
|
||||
// portal expects for a non-OAuth sign-in).
|
||||
if f.Type != "code" {
|
||||
return httpx.Ok(c, userID)
|
||||
}
|
||||
|
||||
// type=code: mint a PKCE-bound authorization code for the OAuth flow.
|
||||
app, err := resolveLoginApp(ctx, db, f)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
}
|
||||
// Tenant isolation: the authenticated user's organization must be
|
||||
// permitted for this application — its own org, a shared app, or an app
|
||||
// that lets users choose their org. Without this a user in one tenant
|
||||
// could obtain a token whose `organization` claim names another tenant.
|
||||
// The org is the USER's own, from the loaded row, so a second-factor post
|
||||
// (which carries no organization field) is checked exactly like the first.
|
||||
if user.Owner != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
|
||||
return httpx.Err(c, "the user is not permitted to sign in to this application")
|
||||
}
|
||||
// Bind the code to an EXACTLY-registered redirect URI (RFC 6749 §3.1.2.3);
|
||||
// the token endpoint re-checks it. A supplied-but-unregistered URI is
|
||||
// refused — never minted against.
|
||||
if f.RedirectUri != "" && !app.IsRedirectUriValid(f.RedirectUri) {
|
||||
return httpx.Err(c, "invalid redirect_uri")
|
||||
}
|
||||
method := normalizeChallengeMethod(f.CodeChallenge, f.CodeChallengeMethod)
|
||||
if f.CodeChallenge != "" && method != "S256" {
|
||||
return httpx.Err(c, "only S256 PKCE is supported")
|
||||
}
|
||||
// A public client (no secret) must use PKCE — no downgrade.
|
||||
if app.ClientSecret == "" && f.CodeChallenge == "" {
|
||||
return httpx.Err(c, "PKCE is required for public clients")
|
||||
}
|
||||
code, err := MintCode(app, userID, f.Scope, f.CodeChallenge, method, f.Resource, nowFunc())
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// Bind the redirect_uri and nonce onto the code so the token exchange can
|
||||
// re-verify the redirect and echo the nonce into the id_token.
|
||||
code.RedirectUri = f.RedirectUri
|
||||
code.Nonce = f.Nonce
|
||||
if err := store.PersistToken(ctx, db, code); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// The SDK reads data as the authorization code to exchange at /token.
|
||||
return httpx.Ok(c, code.Code)
|
||||
}
|
||||
|
||||
// GrantWebauthn completes a passkey sign-in through the SAME grant the password
|
||||
// path uses. v1 puts the OAuth params of a webauthn finish in the QUERY
|
||||
// (controllers/webauthn.go:175-176 + web/src/auth/LoginPage.tsx:444), so they are
|
||||
// read there and folded into the one form the grant understands — one minting
|
||||
// path, not a second copy of the PKCE and redirect rules.
|
||||
//
|
||||
// Note the query key is `challengeMethod`, not `codeChallengeMethod`: that is
|
||||
// what the portal sends on this route.
|
||||
func GrantWebauthn(c *zip.Ctx, db orm.DB, u *schema.User) error {
|
||||
return grant(c, db, u, loginForm{
|
||||
Type: c.Query("responseType"),
|
||||
ClientId: c.Query("clientId"),
|
||||
RedirectUri: c.Query("redirectUri"),
|
||||
State: c.Query("state"),
|
||||
Scope: c.Query("scope"),
|
||||
Nonce: c.Query("nonce"),
|
||||
CodeChallenge: c.Query("codeChallenge"),
|
||||
CodeChallengeMethod: c.Query("challengeMethod"),
|
||||
Resource: c.Query("resource"),
|
||||
})
|
||||
}
|
||||
|
||||
// resolveLoginUser looks a user up by email (contains "@") or username, scoped
|
||||
// to the org.
|
||||
func resolveLoginUser(ctx context.Context, db orm.DB, org, identifier string) (*schema.User, error) {
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/mfa"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// The MFA gate at login, driven through the REAL mounted router. The contract is
|
||||
// not a status code: every one of these answers is a 200, because the envelope
|
||||
// carries the outcome. What matters is WHICH answer, and — the point of the whole
|
||||
// gate — whether a token row exists afterwards. A test that checked only the
|
||||
// status would pass while every 2FA user signed in with a password alone.
|
||||
|
||||
// newApp mounts the OIDC surface on an EXISTING store, so a test can seed the
|
||||
// same db the router serves (newServer opens its own).
|
||||
func newApp(t *testing.T, db orm.DB) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{AppName: "iam2-test", DisableStartupMessage: true})
|
||||
Mount(app, db)
|
||||
return app
|
||||
}
|
||||
|
||||
// enrolled seeds a user with a password AND a live TOTP factor, returning the
|
||||
// TOTP secret.
|
||||
func enrolled(t *testing.T, db orm.DB, name, password string) string {
|
||||
t.Helper()
|
||||
seedUser(t, db, name, name+"@hanzo.ai", password)
|
||||
secret, _, err := mfa.Enroll("hanzo/"+name, "Hanzo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", "hanzo").Filter("Name=", name).First()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u.TotpSecret = secret
|
||||
u.PreferredMfaType = mfa.App
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
// tokens counts persisted token rows — the store-side proof that no credential
|
||||
// was minted. The gate's whole job is that this stays zero until the second
|
||||
// factor lands.
|
||||
func tokens(t *testing.T, db orm.DB) int {
|
||||
t.Helper()
|
||||
n, err := orm.TypedQuery[schema.Token](db).Count(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// passcode computes the code an authenticator would show right now.
|
||||
func passcode(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
code, err := totp.GenerateCode(secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// challengeOf extracts the challenge id the gate set as a cookie.
|
||||
func challengeOf(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == challengeCookie && ck.Value != "" {
|
||||
return ck.Value
|
||||
}
|
||||
}
|
||||
t.Fatal("the gate set no challenge cookie")
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestEnrolledUserIsChallengedAndGetsNoToken is THE regression. Before the gate,
|
||||
// login verified the password and minted a code directly: an enrolled user signed
|
||||
// in with one factor and the second was never asked for. Not a missing feature —
|
||||
// a silent downgrade of every 2FA account.
|
||||
func TestEnrolledUserIsChallengedAndGetsNoToken(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
enrolled(t, db, "alice", "correct horse battery staple")
|
||||
|
||||
resp, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "correct horse battery staple",
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
m := decode(t, body)
|
||||
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("gate answered an error: %v", m["msg"])
|
||||
}
|
||||
// `data` is the literal string the portal compares against. Any other shape
|
||||
// and the client reads it as an authorization code.
|
||||
if m["data"] != NextMfa {
|
||||
t.Fatalf("data = %q, want %q — the client treats anything else as a code, so MFA is bypassed", m["data"], NextMfa)
|
||||
}
|
||||
// data2 carries the factors to choose from.
|
||||
list, ok := m["data2"].([]any)
|
||||
if !ok || len(list) != 1 {
|
||||
t.Fatalf("data2 = %#v, want exactly the one enrolled factor", m["data2"])
|
||||
}
|
||||
got := list[0].(map[string]any)
|
||||
if got["mfaType"] != mfa.App || got["enabled"] != true {
|
||||
t.Fatalf("offered factor = %#v, want the enabled app factor", got)
|
||||
}
|
||||
// The masked projection must not carry the shared secret out.
|
||||
if s := string(body); strings.Contains(s, "secret") || strings.Contains(s, "recoveryCodes") {
|
||||
t.Fatalf("the challenge leaked secret material: %s", s)
|
||||
}
|
||||
|
||||
// THE assertion: nothing was minted.
|
||||
if n := tokens(t, db); n != 0 {
|
||||
t.Fatalf("%d token row(s) persisted at the challenge — the password alone bought a credential", n)
|
||||
}
|
||||
challengeOf(t, resp)
|
||||
}
|
||||
|
||||
// TestChallengeAnsweredWithPasscodeMintsCode — the happy path: the second factor
|
||||
// lands and the code appears.
|
||||
func TestChallengeAnsweredWithPasscodeMintsCode(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
"challenge": id, "mfaType": mfa.App, "passcode": passcode(t, secret),
|
||||
}))
|
||||
m := decode(t, body)
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("the correct passcode was refused: %v", m["msg"])
|
||||
}
|
||||
code, _ := m["data"].(string)
|
||||
if code == "" || code == NextMfa || code == RequiredMfa {
|
||||
t.Fatalf("data = %q, want an authorization code", m["data"])
|
||||
}
|
||||
tok, err := store2GetTokenByCode(db, code)
|
||||
if err != nil || tok == nil {
|
||||
t.Fatalf("the minted code resolves to no token row: %v", err)
|
||||
}
|
||||
if tok.User != "hanzo/alice" {
|
||||
t.Fatalf("code bound to %q, want hanzo/alice", tok.User)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrongPasscodeMintsNothing — a failed second factor must leave the sign-in
|
||||
// exactly where it was: nowhere.
|
||||
func TestWrongPasscodeMintsNothing(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
enrolled(t, db, "alice", "pw")
|
||||
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
"challenge": id, "mfaType": mfa.App, "passcode": "000000",
|
||||
}))
|
||||
if m := decode(t, body); m["status"] != "error" {
|
||||
t.Fatalf("a wrong passcode was accepted: %#v", m)
|
||||
}
|
||||
if n := tokens(t, db); n != 0 {
|
||||
t.Fatalf("%d token row(s) persisted for a wrong passcode", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChallengeIsSingleUse — a challenge is spent by the attempt that takes it,
|
||||
// so a captured id cannot be replayed. The wrong passcode below spends it; the
|
||||
// RIGHT passcode afterwards must still fail, on the challenge and not the code.
|
||||
func TestChallengeIsSingleUse(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
first := map[string]any{"type": "code", "clientId": "hanzo-app", "challenge": id, "mfaType": mfa.App, "passcode": passcode(t, secret)}
|
||||
if m := decode(t, mustBody(t, app, first)); m["status"] != "ok" {
|
||||
t.Fatalf("first use failed: %v", m["msg"])
|
||||
}
|
||||
// Same id, same valid passcode, second time.
|
||||
m := decode(t, mustBody(t, app, first))
|
||||
if m["status"] != "error" {
|
||||
t.Fatalf("a spent challenge was accepted again: %#v", m)
|
||||
}
|
||||
if m["msg"] != ErrChallenge.Error() {
|
||||
t.Fatalf("msg = %q, want the challenge refusal %q", m["msg"], ErrChallenge.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChallengeBindsItsOwnSubject — invariant 3. A challenge minted for alice
|
||||
// must resolve alice even when the body names mallory. The user comes from the
|
||||
// verified server-side record, never from a request parameter.
|
||||
func TestChallengeBindsItsOwnSubject(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
seedUser(t, db, "mallory", "mallory@hanzo.ai", "pw")
|
||||
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
"challenge": id, "mfaType": mfa.App, "passcode": passcode(t, secret),
|
||||
// The body tries to redirect the ceremony at another account.
|
||||
"username": "", "organization": "hanzo", "name": "mallory",
|
||||
}))
|
||||
m := decode(t, body)
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("the ceremony failed: %v", m["msg"])
|
||||
}
|
||||
tok, err := store2GetTokenByCode(db, m["data"].(string))
|
||||
if err != nil || tok == nil {
|
||||
t.Fatal("no token row for the minted code")
|
||||
}
|
||||
if tok.User != "hanzo/alice" {
|
||||
t.Fatalf("code bound to %q — the body redirected the challenge's subject", tok.User)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryCodeIsAcceptedOnceAndStoredHashed proves three things at once: a
|
||||
// recovery code answers the challenge, it is CONSUMED (a second use fails), and
|
||||
// what sits in the row is a bcrypt digest — never the code itself.
|
||||
func TestRecoveryCodeIsAcceptedOnceAndStoredHashed(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
enrolled(t, db, "alice", "pw")
|
||||
|
||||
plain, err := mfa.MintRecovery()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := mfa.HashRecovery(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := userRow(t, db, "alice")
|
||||
u.RecoveryCodes = []string{hash}
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(hash, plain) {
|
||||
t.Fatal("the stored value contains the plaintext recovery code")
|
||||
}
|
||||
|
||||
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app", "challenge": id, "recoveryCode": plain,
|
||||
}))
|
||||
m := decode(t, body)
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("the recovery code was refused: %v", m["msg"])
|
||||
}
|
||||
if code, _ := m["data"].(string); code == "" || code == NextMfa {
|
||||
t.Fatalf("data = %q, want an authorization code", m["data"])
|
||||
}
|
||||
// Spent: the row no longer carries it.
|
||||
if got := userRow(t, db, "alice").RecoveryCodes; len(got) != 0 {
|
||||
t.Fatalf("recovery codes after use = %v, want none — a one-time code survived", got)
|
||||
}
|
||||
// And a second sign-in cannot reuse it.
|
||||
resp2, _ := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
_, body2 := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp2), "recoveryCode": plain,
|
||||
}))
|
||||
if m2 := decode(t, body2); m2["status"] != "error" {
|
||||
t.Fatalf("a spent recovery code signed in a second time: %#v", m2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyPlaintextRecoveryCodeStillVerifies — every recovery code migrated
|
||||
// from v1 is PLAINTEXT (object/mfa.go:81 compares in the clear). The algorithm is
|
||||
// a property of the stored value, so a legacy row must still verify, and the
|
||||
// plaintext must die on first use.
|
||||
func TestLegacyPlaintextRecoveryCodeStillVerifies(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
enrolled(t, db, "alice", "pw")
|
||||
|
||||
const legacy = "0d5a7f0e-3a1e-4a1a-9f6c-2b1d3e4f5a6b" // a v1 uuid.NewString() code
|
||||
u := userRow(t, db, "alice")
|
||||
u.RecoveryCodes = []string{legacy}
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp), "recoveryCode": legacy,
|
||||
}))
|
||||
if m := decode(t, body); m["status"] != "ok" {
|
||||
t.Fatalf("a migrated v1 plaintext recovery code was refused: %v — every live 2FA user's way back is gone", m["msg"])
|
||||
}
|
||||
if got := userRow(t, db, "alice").RecoveryCodes; len(got) != 0 {
|
||||
t.Fatalf("the legacy plaintext survived its use: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasscodeRefusedWhenItRepeatsTheUsedFactor — v1 controllers/auth.go:1325.
|
||||
// The factor already used to get here cannot answer for the one still owed.
|
||||
func TestPasscodeRefusedWhenItRepeatsTheUsedFactor(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
u := userRow(t, db, "alice")
|
||||
|
||||
// A challenge whose payload says "the app factor was already used".
|
||||
id, err := MintChallenge(context.Background(), db, KindMfa, "hanzo/"+u.Name, mfa.App, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
"challenge": id, "mfaType": mfa.App, "passcode": passcode(t, secret),
|
||||
}))
|
||||
if m := decode(t, body); m["status"] != "error" {
|
||||
t.Fatalf("the just-used factor answered its own challenge: %#v", m)
|
||||
}
|
||||
if n := tokens(t, db); n != 0 {
|
||||
t.Fatalf("%d token row(s) persisted", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRememberDeadlineRoundTrips — the "don't ask again" window short-circuits
|
||||
// the whole gate, so the value the writer writes must be the value the reader
|
||||
// reads. A format mismatch is silent: a permanent skip, or a permanent challenge.
|
||||
func TestRememberDeadlineRoundTrips(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
|
||||
// An org with a real remember window (live orgs leave it at zero).
|
||||
o := orm.New[schema.Organization](db)
|
||||
o.Owner, o.Name, o.MfaRememberInHours = "admin", "hanzo", 24
|
||||
o.SetId("admin/hanzo")
|
||||
if err := o.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp),
|
||||
"mfaType": mfa.App, "passcode": passcode(t, secret), "enableMfaRemember": true,
|
||||
}))
|
||||
if m := decode(t, body); m["status"] != "ok" {
|
||||
t.Fatalf("the passcode was refused: %v", m["msg"])
|
||||
}
|
||||
|
||||
// The exact stored string must parse for the exact reader the gate uses.
|
||||
stored := userRow(t, db, "alice").MfaRememberDeadline
|
||||
if stored == "" {
|
||||
t.Fatal("enableMfaRemember wrote no deadline")
|
||||
}
|
||||
if !remembered(userRow(t, db, "alice"), time.Now()) {
|
||||
t.Fatalf("the gate cannot read back the deadline it wrote (%q) — the window is silently dead", stored)
|
||||
}
|
||||
|
||||
// A future deadline SKIPS the challenge: the next password login mints.
|
||||
_, body2 := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
m2 := decode(t, body2)
|
||||
if m2["data"] == NextMfa {
|
||||
t.Fatal("a live remember window still challenged")
|
||||
}
|
||||
if code, _ := m2["data"].(string); code == "" {
|
||||
t.Fatalf("remembered login did not mint: %#v", m2)
|
||||
}
|
||||
|
||||
// A PAST deadline challenges again.
|
||||
u := userRow(t, db, "alice")
|
||||
u.MfaRememberDeadline = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, body3 := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
if m3 := decode(t, body3); m3["data"] != NextMfa {
|
||||
t.Fatalf("an expired remember window skipped the gate: %#v", m3)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroRememberWindowStillChallenges pins the LIVE configuration: every
|
||||
// organization today leaves MfaRememberInHours at zero, which puts the deadline
|
||||
// in the past the instant it is written. "Fixing" a zero into an always-on skip
|
||||
// would turn 2FA off for every tenant at once.
|
||||
func TestZeroRememberWindowStillChallenges(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
|
||||
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp),
|
||||
"mfaType": mfa.App, "passcode": passcode(t, secret), "enableMfaRemember": true,
|
||||
}))
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
if m := decode(t, body); m["data"] != NextMfa {
|
||||
t.Fatalf("a zero remember window skipped the gate: %#v — 2FA is off for every live org", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrgRequiredFactorPromptsEnrollment — v1 object/organization.go:770. The org
|
||||
// demands a factor the user has not enrolled, so the answer is "go enroll", not a
|
||||
// challenge it could never answer.
|
||||
func TestOrgRequiredFactorPromptsEnrollment(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw") // no factor
|
||||
|
||||
o := orm.New[schema.Organization](db)
|
||||
o.Owner, o.Name = "admin", "hanzo"
|
||||
o.MfaItems = []*schema.MfaItem{{Name: mfa.App, Rule: "Required"}}
|
||||
o.SetId("admin/hanzo")
|
||||
if err := o.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
m := decode(t, body)
|
||||
if m["data"] != RequiredMfa {
|
||||
t.Fatalf("data = %q, want %q", m["data"], RequiredMfa)
|
||||
}
|
||||
if n := tokens(t, db); n != 0 {
|
||||
t.Fatalf("%d token row(s) persisted while a required factor was missing", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnenrolledUserSignsInUnchanged — the gate must be invisible to everyone
|
||||
// else. A user with no factor still logs in with a password, exactly as before.
|
||||
func TestUnenrolledUserSignsInUnchanged(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
seedUser(t, db, "bob", "bob@hanzo.ai", "pw")
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "bob", "password": "pw", "type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
m := decode(t, body)
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("an unenrolled user was refused: %v", m["msg"])
|
||||
}
|
||||
if code, _ := m["data"].(string); code == "" || code == NextMfa || code == RequiredMfa {
|
||||
t.Fatalf("data = %q, want an authorization code", m["data"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func mustBody(t *testing.T, app *zip.App, body any) []byte {
|
||||
t.Helper()
|
||||
_, b := do(t, app, jsonReq("POST", PathLogin, body))
|
||||
return b
|
||||
}
|
||||
|
||||
func userRow(t *testing.T, db orm.DB, name string) *schema.User {
|
||||
t.Helper()
|
||||
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", "hanzo").Filter("Name=", name).First()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func store2GetTokenByCode(db orm.DB, code string) (*schema.Token, error) {
|
||||
t, err := orm.TypedQuery[schema.Token](db).Filter("Code=", code).First()
|
||||
if err == orm.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/hanzoai/iam2/internal/certs"
|
||||
"github.com/hanzoai/iam2/internal/invitations"
|
||||
"github.com/hanzoai/iam2/internal/keys"
|
||||
"github.com/hanzoai/iam2/internal/mfa"
|
||||
"github.com/hanzoai/iam2/internal/oidc"
|
||||
"github.com/hanzoai/iam2/internal/organizations"
|
||||
"github.com/hanzoai/iam2/internal/permission"
|
||||
@@ -58,6 +59,7 @@ func Mount(app *zip.App, db orm.DB) {
|
||||
// from. One entry point wires the whole identity core.
|
||||
oidc.Mount(app, db)
|
||||
|
||||
mfa.Mount(app, db)
|
||||
users.Mount(app, db)
|
||||
organizations.Mount(app, db)
|
||||
applications.Mount(app, db)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package schema
|
||||
|
||||
import "github.com/hanzoai/orm"
|
||||
|
||||
// Challenge is a half-finished authentication ceremony: the server-side memory
|
||||
// of a sign-in that has proven one thing and must prove another before a token
|
||||
// exists. It is minted by the MFA gate at login (the password verified, the
|
||||
// second factor outstanding) and by the WebAuthn begin endpoints (the options
|
||||
// issued, the assertion outstanding), and it is consumed exactly once by the
|
||||
// matching finish.
|
||||
//
|
||||
// v1 keeps this in a beego cookie session — MfaSessionUserId (object/mfa.go:51),
|
||||
// "registration"/"authentication" (controllers/webauthn.go:68,162). v2 has no
|
||||
// key/value session store, so the state is an owner-scoped row and the client
|
||||
// holds only its opaque id.
|
||||
//
|
||||
// It is a SIBLING of Token, never a Token with borrowed fields. A Token is a
|
||||
// grant: /v1/iam/oauth/token resolves one by Code and mints an access token from
|
||||
// it. Filing a challenge there would put a row on that lookup whose Application
|
||||
// and Scope are fictions, and a fiction on the redemption path is an access
|
||||
// token waiting to be minted from a half-authenticated ceremony. The two are
|
||||
// different values with different lifecycles, so they are different entities.
|
||||
//
|
||||
// Identity is the (Owner, Name) pair and the orm string key is "owner/name";
|
||||
// Name is the opaque id the client returns. Subject is the "owner/name" of the
|
||||
// principal the ceremony is for — the ONE place a finish learns whom it is
|
||||
// acting as (invariant 3: never a request parameter). Payload is the kind's own
|
||||
// state: the go-webauthn SessionData JSON for a ceremony, the just-used
|
||||
// verification type for an MFA challenge (v1's "verificationCodeType" session
|
||||
// key, controllers/auth.go:539). Used makes it one-shot; ExpireIn (unix) bounds
|
||||
// it. Id carries orm:"index" because the client presents the id alone.
|
||||
type Challenge struct {
|
||||
orm.Model[Challenge]
|
||||
|
||||
Owner string `json:"owner" orm:"index"`
|
||||
Name string `json:"name" orm:"index"`
|
||||
CreatedTime string `json:"createdTime"`
|
||||
|
||||
Kind string `json:"kind"`
|
||||
Subject string `json:"subject"`
|
||||
Payload string `json:"payload"`
|
||||
Used bool `json:"used"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package schema declares the thirteen IAM v2 identity entities on
|
||||
// hanzoai/orm.
|
||||
// hanzoai/orm, plus Challenge — the one record that is not an identity but the
|
||||
// server-side memory of a half-finished sign-in (v1 keeps it in a cookie
|
||||
// session, which v2 has no store for).
|
||||
//
|
||||
// Each entity embeds orm.Model[T]; every kind is registered exactly once in
|
||||
// this file's init(). orm stores every entity as one row in a single
|
||||
@@ -31,6 +33,11 @@ func Kinds() []string {
|
||||
"roles", "permissions", "certs", "keys",
|
||||
"webauthn_credentials", "sessions", "tokens", "audit_logs",
|
||||
"invitations",
|
||||
// challenges has no v1 table to compare against: v1 keeps a
|
||||
// half-finished ceremony in a beego cookie session (object/mfa.go:51,
|
||||
// controllers/webauthn.go:68), so there is nothing to migrate — the rows
|
||||
// are minted fresh and expire within minutes.
|
||||
"challenges",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +55,5 @@ func init() {
|
||||
orm.Register[Token]("tokens")
|
||||
orm.Register[AuditLog]("audit_logs")
|
||||
orm.Register[Invitation]("invitations")
|
||||
orm.Register[Challenge]("challenges")
|
||||
}
|
||||
|
||||
@@ -51,6 +51,21 @@ func GetUserByName(_ context.Context, db orm.DB, owner, name string) (*schema.Us
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetOrganizationByName resolves a tenant by name. Organizations are filed under
|
||||
// the reserved admin org (v1 object/organization.go composes "admin/<name>"), so
|
||||
// the caller supplies only the tenant slug — a user's Owner IS this name.
|
||||
// Returns (nil, nil) when absent.
|
||||
func GetOrganizationByName(_ context.Context, db orm.DB, name string) (*schema.Organization, error) {
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
o, err := orm.TypedQuery[schema.Organization](db).Filter("Name=", name).First()
|
||||
if err == orm.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
|
||||
// GetUserByEmail resolves a user by (owner, email) — the email-login identifier.
|
||||
func GetUserByEmail(_ context.Context, db orm.DB, owner, email string) (*schema.User, error) {
|
||||
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Email=", email).First()
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
wa "github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/authz"
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/oidc"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The WebAuthn ceremony — the four v1 paths, unchanged (routers/router.go:393-396).
|
||||
// The CRUD above is the passkey REGISTRY; this is how a passkey comes to exist and
|
||||
// how it signs someone in.
|
||||
//
|
||||
// A passkey lives in exactly ONE home: the WebauthnCredential entity. v1 keeps
|
||||
// the blob inline on the user row (user.WebauthnCredentials) and v2 retains that
|
||||
// column for migration, but the ceremony reads and writes only the entity — two
|
||||
// homes would mean a passkey registered here does not authenticate there.
|
||||
//
|
||||
// The begin endpoints answer RAW protocol JSON, not the {status,msg,data}
|
||||
// envelope: the browser feeds the body straight to navigator.credentials, so a
|
||||
// wrapper is a broken ceremony (v1 controllers/webauthn.go:69-70 does the same
|
||||
// with c.Data["json"] + ServeJSON).
|
||||
//
|
||||
// The four routes split on WHO they are for, and the split is a trust boundary:
|
||||
// - signup/* REGISTERS a credential and needs a bearer. Its subject is that
|
||||
// bearer and nothing else, so it can only ever add a credential to the
|
||||
// caller's own account (internal/authz bearerBound).
|
||||
// - signin/* AUTHENTICATES with a credential, so no bearer can exist yet. Both
|
||||
// halves are public and gate themselves on possession of the private key.
|
||||
|
||||
// The four frozen paths.
|
||||
const (
|
||||
PathSignupBegin = "/v1/iam/webauthn/signup/begin"
|
||||
PathSignupFinish = "/v1/iam/webauthn/signup/finish"
|
||||
PathSigninBegin = "/v1/iam/webauthn/signin/begin"
|
||||
PathSigninFinish = "/v1/iam/webauthn/signin/finish"
|
||||
)
|
||||
|
||||
// display names the relying party in the authenticator's prompt (v1 reads the
|
||||
// beego appname).
|
||||
const display = "Hanzo"
|
||||
|
||||
// MountCeremony registers the registration and authentication ceremonies.
|
||||
func MountCeremony(app *zip.App, db orm.DB) {
|
||||
app.Get(PathSignupBegin, signupBegin(db))
|
||||
app.Post(PathSignupFinish, signupFinish(db))
|
||||
app.Get(PathSigninBegin, signinBegin(db))
|
||||
app.Post(PathSigninFinish, signinFinish(db))
|
||||
}
|
||||
|
||||
// rp builds the relying party for the request's own host. The RPID is the host
|
||||
// WITHOUT its port and the origin is the full scheme+host: an authenticator
|
||||
// binds a credential to the RPID and the browser refuses any assertion whose
|
||||
// origin does not match, so both must describe where the user actually is —
|
||||
// which is why they come from the effective host and not from configuration
|
||||
// (v1 object/user_webauthn.go:29-50).
|
||||
func rp(c *zip.Ctx) (*wa.WebAuthn, error) {
|
||||
host := httpx.EffectiveHost(c)
|
||||
if host == "" {
|
||||
return nil, errors.New("cannot determine the request host")
|
||||
}
|
||||
scheme := "https"
|
||||
if c.Fiber().Protocol() == "http" {
|
||||
scheme = "http"
|
||||
}
|
||||
id, _, _ := strings.Cut(host, ":")
|
||||
return wa.New(&wa.Config{
|
||||
RPDisplayName: display,
|
||||
RPID: id,
|
||||
RPOrigins: []string{scheme + "://" + host},
|
||||
})
|
||||
}
|
||||
|
||||
// principal is the webauthn.User adapter over a stored user. Its credentials come
|
||||
// from the WebauthnCredential ENTITY rows — the one home — never from the user
|
||||
// row's migration column.
|
||||
type principal struct {
|
||||
user *schema.User
|
||||
creds []wa.Credential
|
||||
}
|
||||
|
||||
// WebAuthnID is the handle the authenticator stores and hands back on a
|
||||
// discoverable login: "owner/name" (v1 object/user_webauthn.go:54-56).
|
||||
func (p *principal) WebAuthnID() []byte { return []byte(p.user.Owner + "/" + p.user.Name) }
|
||||
func (p *principal) WebAuthnName() string { return p.user.Name }
|
||||
func (p *principal) WebAuthnDisplayName() string { return p.user.DisplayName }
|
||||
func (p *principal) WebAuthnIcon() string { return p.user.Avatar }
|
||||
func (p *principal) WebAuthnCredentials() []wa.Credential { return p.creds }
|
||||
|
||||
// load builds the adapter for a user, reading its passkeys from the entity.
|
||||
func load(ctx context.Context, db orm.DB, u *schema.User) (*principal, error) {
|
||||
rows, err := orm.TypedQuery[schema.WebauthnCredential](db).
|
||||
Filter("Owner=", u.Owner).Filter("User=", u.Owner+"/"+u.Name).GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &principal{user: u}
|
||||
for _, r := range rows {
|
||||
p.creds = append(p.creds, credential(r))
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// credential maps a stored row back to the go-webauthn value, re-nesting the
|
||||
// Flags and Authenticator the entity flattens into columns.
|
||||
func credential(r *schema.WebauthnCredential) wa.Credential {
|
||||
c := wa.Credential{
|
||||
ID: r.CredentialId,
|
||||
PublicKey: r.PublicKey,
|
||||
AttestationType: r.AttestationType,
|
||||
Flags: wa.CredentialFlags{
|
||||
UserPresent: r.UserPresent,
|
||||
UserVerified: r.UserVerified,
|
||||
BackupEligible: r.BackupEligible,
|
||||
BackupState: r.BackupState,
|
||||
},
|
||||
Authenticator: wa.Authenticator{
|
||||
AAGUID: r.Aaguid,
|
||||
SignCount: r.SignCount,
|
||||
CloneWarning: r.CloneWarning,
|
||||
Attachment: protocol.AuthenticatorAttachment(r.Attachment),
|
||||
},
|
||||
}
|
||||
for _, t := range r.Transport {
|
||||
c.Transport = append(c.Transport, protocol.AuthenticatorTransport(t))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// row maps a go-webauthn credential onto the entity, flattening Flags and
|
||||
// Authenticator. Name is the standard-base64 credential id — the same value v1
|
||||
// keys a credential by, and the value a discoverable login resolves through.
|
||||
func row(db orm.DB, owner, user string, c *wa.Credential) *schema.WebauthnCredential {
|
||||
r := orm.New[schema.WebauthnCredential](db)
|
||||
r.Owner = owner
|
||||
r.Name = base64.StdEncoding.EncodeToString(c.ID)
|
||||
r.CreatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
r.User = user
|
||||
r.CredentialId = c.ID
|
||||
r.PublicKey = c.PublicKey
|
||||
r.AttestationType = c.AttestationType
|
||||
r.UserPresent = c.Flags.UserPresent
|
||||
r.UserVerified = c.Flags.UserVerified
|
||||
r.BackupEligible = c.Flags.BackupEligible
|
||||
r.BackupState = c.Flags.BackupState
|
||||
r.Aaguid = c.Authenticator.AAGUID
|
||||
r.SignCount = c.Authenticator.SignCount
|
||||
r.CloneWarning = c.Authenticator.CloneWarning
|
||||
r.Attachment = string(c.Authenticator.Attachment)
|
||||
for _, t := range c.Transport {
|
||||
r.Transport = append(r.Transport, string(t))
|
||||
}
|
||||
r.SetId(webauthnCredentialId(r.Owner, r.Name))
|
||||
return r
|
||||
}
|
||||
|
||||
// signupBegin issues CredentialCreationOptions for the SIGNED-IN user and files
|
||||
// the challenge. The subject is the verified bearer — never a request parameter
|
||||
// — so a passkey can only ever be added to the caller's own account.
|
||||
func signupBegin(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
p, err := self(c, db)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
obj, err := rp(c)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
options, session, err := obj.BeginRegistration(p, func(o *protocol.PublicKeyCredentialCreationOptions) {
|
||||
// Exclude what this user already has: an authenticator that would
|
||||
// otherwise silently replace its own credential for this account
|
||||
// (v1 controllers/webauthn.go:50-59).
|
||||
o.CredentialExcludeList = exclude(p)
|
||||
o.AuthenticatorSelection.ResidentKey = protocol.ResidentKeyRequirementPreferred
|
||||
o.Attestation = protocol.PreferNoAttestation
|
||||
o.Extensions = protocol.AuthenticationExtensions{"credProps": true}
|
||||
})
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return begin(c, db, oidc.KindRegistration, string(p.WebAuthnID()), session, options)
|
||||
}
|
||||
}
|
||||
|
||||
// signupFinish verifies the attestation and stores the passkey. The user comes
|
||||
// from the CHALLENGE, so the credential lands on the account that started the
|
||||
// ceremony even if the body claims otherwise.
|
||||
func signupFinish(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
obj, session, ch, err := take(c, db, oidc.KindRegistration)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// Bind the ceremony to the caller: the bearer that finishes must be the
|
||||
// one that began, so a challenge cannot be handed to another account.
|
||||
p, err := self(c, db)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if string(p.WebAuthnID()) != ch.Subject {
|
||||
return httpx.Err(c, oidc.ErrChallenge.Error())
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(c.Body()))
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
cred, err := obj.CreateCredential(p, *session, parsed)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if err := row(db, p.user.Owner, ch.Subject, cred).CreateCtx(c.Context()); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, "OK")
|
||||
}
|
||||
}
|
||||
|
||||
// signinBegin issues CredentialAssertionOptions. With no name it starts a
|
||||
// DISCOVERABLE login — the authenticator picks the account — which is the
|
||||
// passkey flow that reveals nothing about who exists.
|
||||
func signinBegin(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
obj, err := rp(c)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
owner, name := c.Query("owner"), c.Query("name")
|
||||
if name == "" {
|
||||
options, session, err := obj.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return begin(c, db, oidc.KindAuthentication, "", session, options)
|
||||
}
|
||||
u, err := store.GetUserByName(c.Context(), db, owner, name)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if u == nil {
|
||||
return httpx.Err(c, "the user doesn't exist")
|
||||
}
|
||||
p, err := load(c.Context(), db, u)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if len(p.creds) == 0 {
|
||||
return httpx.Err(c, "found no credentials for this user")
|
||||
}
|
||||
options, session, err := obj.BeginLogin(p)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return begin(c, db, oidc.KindAuthentication, string(p.WebAuthnID()), session, options)
|
||||
}
|
||||
}
|
||||
|
||||
// signinFinish verifies the assertion and signs the user in, minting a code from
|
||||
// the OAuth params in the QUERY (v1 controllers/webauthn.go:174-237).
|
||||
//
|
||||
// It does NOT re-gate on MFA, deliberately: a passkey IS a strong factor — it
|
||||
// proves possession of a private key that never left the authenticator — so
|
||||
// challenging it with a TOTP code would demand a second factor of a credential
|
||||
// that is already two.
|
||||
func signinFinish(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
obj, session, ch, err := take(c, db, oidc.KindAuthentication)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(c.Body()))
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
|
||||
var p *principal
|
||||
var cred *wa.Credential
|
||||
if ch.Subject != "" {
|
||||
if p, err = subject(c.Context(), db, ch.Subject); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
cred, err = obj.ValidateLogin(p, *session, parsed)
|
||||
} else {
|
||||
// Discoverable: the authenticator names the credential, and the
|
||||
// credential names its user. base64(rawID) IS the entity's Name.
|
||||
cred, err = obj.ValidateDiscoverableLogin(func(rawID, _ []byte) (wa.User, error) {
|
||||
found, err := byCredential(c.Context(), db, base64.StdEncoding.EncodeToString(rawID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p = found
|
||||
return found, nil
|
||||
}, *session, parsed)
|
||||
}
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
|
||||
// Persist the advanced counter. Without this the stored SignCount never
|
||||
// moves, every future assertion looks like a replay of the same value,
|
||||
// and clone detection reports nothing forever.
|
||||
if err := advance(c.Context(), db, p.user.Owner, cred); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return oidc.GrantWebauthn(c, db, p.user)
|
||||
}
|
||||
}
|
||||
|
||||
// begin files the ceremony's session data as a challenge, hands its id to the
|
||||
// client, and answers RAW protocol JSON — what navigator.credentials expects.
|
||||
func begin(c *zip.Ctx, db orm.DB, kind, subject string, session *wa.SessionData, options any) error {
|
||||
payload, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
id, err := oidc.MintChallenge(c.Context(), db, kind, subject, string(payload), time.Now())
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
oidc.SetChallenge(c, id)
|
||||
return c.JSON(200, options)
|
||||
}
|
||||
|
||||
// take spends the ceremony's challenge and rebuilds its session data. Taking is
|
||||
// one-shot, so an assertion replayed against the same challenge loses.
|
||||
func take(c *zip.Ctx, db orm.DB, kind string) (*wa.WebAuthn, *wa.SessionData, *schema.Challenge, error) {
|
||||
obj, err := rp(c)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
ch, err := oidc.TakeChallenge(c.Context(), db, oidc.ReadChallenge(c, ""), kind, time.Now())
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
oidc.ClearChallenge(c)
|
||||
var session wa.SessionData
|
||||
if err := json.Unmarshal([]byte(ch.Payload), &session); err != nil {
|
||||
return nil, nil, nil, oidc.ErrChallenge
|
||||
}
|
||||
return obj, &session, ch, nil
|
||||
}
|
||||
|
||||
// self resolves the ceremony's subject from the VERIFIED BEARER and nowhere else
|
||||
// (invariant 3). Registration takes no target parameter at all, so an org admin
|
||||
// cannot register a credential onto a member's account — which would be a silent,
|
||||
// permanent takeover.
|
||||
func self(c *zip.Ctx, db orm.DB) (*principal, error) {
|
||||
p, ok := authz.From(c.Context())
|
||||
if !ok || p.User == "" {
|
||||
return nil, errors.New("please login first")
|
||||
}
|
||||
return subject(c.Context(), db, p.Org+"/"+p.User)
|
||||
}
|
||||
|
||||
// subject loads the adapter for an "owner/name" id.
|
||||
func subject(ctx context.Context, db orm.DB, id string) (*principal, error) {
|
||||
owner, name, _ := strings.Cut(id, "/")
|
||||
u, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u == nil {
|
||||
return nil, errors.New("the user doesn't exist")
|
||||
}
|
||||
return load(ctx, db, u)
|
||||
}
|
||||
|
||||
// byCredential resolves the user a passkey belongs to, from the credential's own
|
||||
// base64 id — the entity's Name (v1 GetUserByWebauthID).
|
||||
func byCredential(ctx context.Context, db orm.DB, name string) (*principal, error) {
|
||||
r, err := orm.TypedQuery[schema.WebauthnCredential](db).Filter("Name=", name).First()
|
||||
if err != nil || r == nil {
|
||||
return nil, errors.New("found no credentials for this user")
|
||||
}
|
||||
return subject(ctx, db, r.User)
|
||||
}
|
||||
|
||||
// exclude lists the credentials this user already registered, so an authenticator
|
||||
// does not quietly overwrite one of them (v1 object/user_webauthn.go:75-87).
|
||||
func exclude(p *principal) []protocol.CredentialDescriptor {
|
||||
list := []protocol.CredentialDescriptor{}
|
||||
for _, c := range p.creds {
|
||||
list = append(list, protocol.CredentialDescriptor{
|
||||
Type: protocol.PublicKeyCredentialType,
|
||||
CredentialID: c.ID,
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// advance writes back the counter and clone warning a successful assertion
|
||||
// produced — the only mutable state a passkey has.
|
||||
func advance(ctx context.Context, db orm.DB, owner string, c *wa.Credential) error {
|
||||
name := base64.StdEncoding.EncodeToString(c.ID)
|
||||
r, err := orm.Get[schema.WebauthnCredential](db, webauthnCredentialId(owner, name))
|
||||
if err != nil || r == nil {
|
||||
return nil // discoverable login on a foreign-owner row: nothing to advance
|
||||
}
|
||||
r.SignCount = c.Authenticator.SignCount
|
||||
r.CloneWarning = c.Authenticator.CloneWarning
|
||||
r.BackupState = c.Flags.BackupState
|
||||
return r.UpdateCtx(ctx)
|
||||
}
|
||||
@@ -53,8 +53,11 @@ type webauthnCredentialMutationResult struct {
|
||||
WebauthnCredential *schema.WebauthnCredential `json:"webauthnCredential,omitempty"`
|
||||
}
|
||||
|
||||
// Mount registers the passkey surface on app, closing over the entity store.
|
||||
// Mount registers the passkey surface on app, closing over the entity store: the
|
||||
// entity CRUD below, plus the ceremony (ceremony.go) that creates a passkey and
|
||||
// signs a user in with one.
|
||||
func Mount(app *zip.App, db orm.DB) {
|
||||
MountCeremony(app, db)
|
||||
zip.Get[listWebauthnCredentialsIn, listWebauthnCredentialsOut](app, "/v1/iam/webauthn-credentials", listWebauthnCredentials(db),
|
||||
zip.WithOperationID("listWebauthnCredentials"),
|
||||
zip.WithSummary("List webauthn credentials in an owner scope"),
|
||||
|
||||
Reference in New Issue
Block a user