Add FIDO2/WebAuthn passkey authentication

Phase 14: Full WebAuthn support for passwordless passkey login and
hardware security key 2FA.

- go-webauthn/webauthn v0.16.1 dependency
- WebAuthnConfig with RPID/RPOrigin/DisplayName validation
- Migration 000009: webauthn_credentials table
- DB CRUD with ownership checks and admin operations
- internal/webauthn adapter: encrypt/decrypt at rest with AES-256-GCM
- REST: register begin/finish, login begin/finish, list, delete
- Web UI: profile enrollment, login passkey button, admin management
- gRPC: ListWebAuthnCredentials, RemoveWebAuthnCredential RPCs
- mciasdb: webauthn list/delete/reset subcommands
- OpenAPI: 6 new endpoints, WebAuthnCredentialInfo schema
- Policy: self-service enrollment rule, admin remove via wildcard
- Tests: DB CRUD, adapter round-trip, interface compliance
- Docs: ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14

Security: Credential IDs and public keys encrypted at rest with
AES-256-GCM via vault master key. Challenge ceremonies use 128-bit
nonces with 120s TTL in sync.Map. Sign counter validated on each
assertion to detect cloned authenticators. Password re-auth required
for registration (SEC-01 pattern). No credential material in API
responses or logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 16:12:59 -07:00
parent 19fa0c9a8e
commit 25417b24f4
42 changed files with 4214 additions and 84 deletions

View File

@@ -0,0 +1,696 @@
package ui
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-webauthn/webauthn/protocol"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
)
const (
webauthnCeremonyTTL = 120 * time.Second
webauthnCleanupPeriod = 5 * time.Minute
webauthnNonceBytes = 16
)
// webauthnCeremony holds a pending WebAuthn ceremony.
type webauthnCeremony struct {
expiresAt time.Time
session *libwebauthn.SessionData
accountID int64
}
// pendingWebAuthnCeremonies stores in-flight WebAuthn ceremonies for the UI.
var pendingUIWebAuthnCeremonies sync.Map //nolint:gochecknoglobals
func init() {
go cleanupUIWebAuthnCeremonies()
}
func cleanupUIWebAuthnCeremonies() {
ticker := time.NewTicker(webauthnCleanupPeriod)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
pendingUIWebAuthnCeremonies.Range(func(key, value any) bool {
c, ok := value.(*webauthnCeremony)
if !ok || now.After(c.expiresAt) {
pendingUIWebAuthnCeremonies.Delete(key)
}
return true
})
}
}
func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string, error) {
raw, err := crypto.RandomBytes(webauthnNonceBytes)
if err != nil {
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
}
nonce := fmt.Sprintf("%x", raw)
pendingUIWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
session: session,
accountID: accountID,
expiresAt: time.Now().Add(webauthnCeremonyTTL),
})
return nonce, nil
}
func consumeUICeremony(nonce string) (*webauthnCeremony, bool) {
v, ok := pendingUIWebAuthnCeremonies.LoadAndDelete(nonce)
if !ok {
return nil, false
}
c, ok2 := v.(*webauthnCeremony)
if !ok2 || time.Now().After(c.expiresAt) {
return nil, false
}
return c, true
}
// ---- Profile: registration ----
// handleWebAuthnBegin starts a WebAuthn credential registration ceremony.
func (u *UIServer) handleWebAuthnBegin(w http.ResponseWriter, r *http.Request) {
if !u.cfg.WebAuthnEnabled() {
u.renderError(w, r, http.StatusNotFound, "WebAuthn not configured")
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
u.renderError(w, r, http.StatusUnauthorized, "account not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
var req struct {
Password string `json:"password"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid request")
return
}
if req.Password == "" {
writeJSONError(w, http.StatusBadRequest, "password is required")
return
}
// Security: check lockout.
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (WebAuthn enroll)", "error", lockErr)
}
if locked {
writeJSONError(w, http.StatusTooManyRequests, "account temporarily locked")
return
}
// Security: verify current password.
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = u.db.RecordLoginFailure(acct.ID)
writeJSONError(w, http.StatusUnauthorized, "password is incorrect")
return
}
masterKey, err := u.vault.MasterKey()
if err != nil {
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
return
}
dbCreds, err := u.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
if err != nil {
u.logger.Error("create webauthn instance", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
creation, session, err := wa.BeginRegistration(user,
libwebauthn.WithExclusions(libwebauthn.Credentials(libCreds).CredentialDescriptors()),
libwebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
)
if err != nil {
u.logger.Error("begin webauthn registration", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
nonce, err := storeUICeremony(session, acct.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
optionsJSON, _ := json.Marshal(creation)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"options": json.RawMessage(optionsJSON),
"nonce": nonce,
})
}
// handleWebAuthnFinish completes WebAuthn credential registration.
func (u *UIServer) handleWebAuthnFinish(w http.ResponseWriter, r *http.Request) {
if !u.cfg.WebAuthnEnabled() {
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
return
}
claims := claimsFromContext(r.Context())
if claims == nil {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
return
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "account not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
var buf bytes.Buffer
if _, err := buf.ReadFrom(r.Body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
var wrapper struct {
Nonce string `json:"nonce"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := json.Unmarshal(buf.Bytes(), &wrapper); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
return
}
ceremony, ok := consumeUICeremony(wrapper.Nonce)
if !ok {
writeJSONError(w, http.StatusBadRequest, "ceremony expired or invalid")
return
}
if ceremony.accountID != acct.ID {
writeJSONError(w, http.StatusForbidden, "ceremony mismatch")
return
}
masterKey, err := u.vault.MasterKey()
if err != nil {
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
return
}
dbCreds, err := u.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
fakeReq, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
fakeReq.Header.Set("Content-Type", "application/json")
cred, err := wa.FinishRegistration(user, *ceremony.session, fakeReq)
if err != nil {
u.logger.Error("finish webauthn registration", "error", err)
writeJSONError(w, http.StatusBadRequest, "registration failed")
return
}
discoverable := cred.Flags.UserVerified && cred.Flags.BackupEligible
name := wrapper.Name
if name == "" {
name = "Passkey"
}
modelCred, err := mciaswebauthn.EncryptCredential(masterKey, cred, name, discoverable)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
modelCred.AccountID = acct.ID
credID, err := u.db.CreateWebAuthnCredential(modelCred)
if err != nil {
u.logger.Error("store webauthn credential", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
u.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID,
audit.JSON("credential_id", fmt.Sprintf("%d", credID), "name", name))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": credID,
"name": name,
})
}
// handleWebAuthnDelete removes a WebAuthn credential from the profile page.
func (u *UIServer) handleWebAuthnDelete(w http.ResponseWriter, r *http.Request) {
claims := claimsFromContext(r.Context())
if claims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
u.renderError(w, r, http.StatusUnauthorized, "account not found")
return
}
credIDStr := r.PathValue("id")
credID, err := strconv.ParseInt(credIDStr, 10, 64)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid credential ID")
return
}
if err := u.db.DeleteWebAuthnCredential(credID, acct.ID); err != nil {
u.renderError(w, r, http.StatusNotFound, "credential not found")
return
}
u.writeAudit(r, model.EventWebAuthnRemoved, &acct.ID, &acct.ID,
audit.JSON("credential_id", credIDStr))
// Return updated credentials list fragment.
creds, _ := u.db.GetWebAuthnCredentials(acct.ID)
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "webauthn_credentials", ProfileData{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
},
WebAuthnCreds: creds,
DeletePrefix: "/profile/webauthn",
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
})
}
// ---- Login: WebAuthn ----
// handleWebAuthnLoginBegin starts a WebAuthn login ceremony from the UI.
func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
if !u.cfg.WebAuthnEnabled() {
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
var (
assertion *protocol.CredentialAssertion
session *libwebauthn.SessionData
accountID int64
)
if req.Username != "" {
acct, lookupErr := u.db.GetAccountByUsername(req.Username)
if lookupErr != nil || acct.Status != model.AccountStatusActive {
// Security: return discoverable login as dummy for unknown users.
assertion, session, err = wa.BeginDiscoverableLogin()
} else {
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (WebAuthn UI login)", "error", lockErr)
}
if locked {
assertion, session, err = wa.BeginDiscoverableLogin()
} else {
masterKey, mkErr := u.vault.MasterKey()
if mkErr != nil {
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
return
}
dbCreds, dbErr := u.db.GetWebAuthnCredentials(acct.ID)
if dbErr != nil || len(dbCreds) == 0 {
writeJSONError(w, http.StatusBadRequest, "no passkeys registered")
return
}
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if decErr != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
assertion, session, err = wa.BeginLogin(user)
accountID = acct.ID
}
}
} else {
assertion, session, err = wa.BeginDiscoverableLogin()
}
if err != nil {
u.logger.Error("begin webauthn login", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
nonce, err := storeUICeremony(session, accountID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
optionsJSON, _ := json.Marshal(assertion)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"options": json.RawMessage(optionsJSON),
"nonce": nonce,
})
}
// handleWebAuthnLoginFinish completes a WebAuthn login from the UI.
func (u *UIServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
if !u.cfg.WebAuthnEnabled() {
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
var buf bytes.Buffer
if _, err := buf.ReadFrom(r.Body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
var wrapper struct {
Nonce string `json:"nonce"`
Credential json.RawMessage `json:"credential"`
}
if err := json.Unmarshal(buf.Bytes(), &wrapper); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
return
}
ceremony, ok := consumeUICeremony(wrapper.Nonce)
if !ok {
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
masterKey, err := u.vault.MasterKey()
if err != nil {
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
return
}
fakeReq, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
fakeReq.Header.Set("Content-Type", "application/json")
var (
acct *model.Account
cred *libwebauthn.Credential
dbCreds []*model.WebAuthnCredential
)
if ceremony.accountID != 0 {
acct, err = u.db.GetAccountByID(ceremony.accountID)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
dbCreds, err = u.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if decErr != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
cred, err = wa.FinishLogin(user, *ceremony.session, fakeReq)
if err != nil {
u.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"assertion_failed"}`)
_ = u.db.RecordLoginFailure(acct.ID)
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
} else {
handler := func(rawID, userHandle []byte) (libwebauthn.User, error) {
acctUUID := string(userHandle)
foundAcct, lookupErr := u.db.GetAccountByUUID(acctUUID)
if lookupErr != nil {
return nil, fmt.Errorf("account not found")
}
if foundAcct.Status != model.AccountStatusActive {
return nil, fmt.Errorf("account inactive")
}
acct = foundAcct
foundDBCreds, credErr := u.db.GetWebAuthnCredentials(foundAcct.ID)
if credErr != nil {
return nil, fmt.Errorf("load credentials: %w", credErr)
}
dbCreds = foundDBCreds
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, foundDBCreds)
if decErr != nil {
return nil, fmt.Errorf("decrypt credentials: %w", decErr)
}
return mciaswebauthn.NewAccountUser(userHandle, foundAcct.Username, libCreds), nil
}
cred, err = wa.FinishDiscoverableLogin(handler, *ceremony.session, fakeReq)
if err != nil {
u.writeAudit(r, model.EventWebAuthnLoginFail, nil, nil, `{"reason":"discoverable_assertion_failed"}`)
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
}
if acct == nil {
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
if acct.Status != model.AccountStatusActive {
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (WebAuthn UI login finish)", "error", lockErr)
}
if locked {
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
// Validate sign counter.
var matchedDBCred *model.WebAuthnCredential
for _, dc := range dbCreds {
decrypted, decErr := mciaswebauthn.DecryptCredential(masterKey, dc)
if decErr != nil {
continue
}
if bytes.Equal(decrypted.ID, cred.ID) {
matchedDBCred = dc
break
}
}
if matchedDBCred != nil {
if cred.Authenticator.SignCount > 0 || matchedDBCred.SignCount > 0 {
if cred.Authenticator.SignCount <= matchedDBCred.SignCount {
u.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil,
audit.JSON("reason", "counter_rollback"))
_ = u.db.RecordLoginFailure(acct.ID)
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
return
}
}
_ = u.db.UpdateWebAuthnSignCount(matchedDBCred.ID, cred.Authenticator.SignCount)
_ = u.db.UpdateWebAuthnLastUsed(matchedDBCred.ID)
}
_ = u.db.ClearLoginFailures(acct.ID)
// Issue JWT and set session cookie.
expiry := u.cfg.DefaultExpiry()
roles, err := u.db.GetRoles(acct.ID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
for _, rol := range roles {
if rol == "admin" {
expiry = u.cfg.AdminExpiry()
break
}
}
privKey, err := u.vault.PrivKey()
if err != nil {
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
return
}
tokenStr, tokenClaims, err := token.IssueToken(privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.TrackToken(tokenClaims.JTI, acct.ID, tokenClaims.IssuedAt, tokenClaims.ExpiresAt); err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: tokenStr,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Expires: tokenClaims.ExpiresAt,
})
if _, err := u.setCSRFCookies(w); err != nil {
u.logger.Error("set CSRF cookie", "error", err)
}
u.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
audit.JSON("jti", tokenClaims.JTI, "via", "webauthn_ui"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"redirect": "/dashboard"})
}
// ---- Admin: WebAuthn credential management ----
// handleAdminWebAuthnDelete removes a WebAuthn credential from the admin account detail page.
func (u *UIServer) handleAdminWebAuthnDelete(w http.ResponseWriter, r *http.Request) {
accountUUID := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(accountUUID)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
credIDStr := r.PathValue("credentialId")
credID, err := strconv.ParseInt(credIDStr, 10, 64)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid credential ID")
return
}
if err := u.db.DeleteWebAuthnCredentialAdmin(credID); err != nil {
u.renderError(w, r, http.StatusNotFound, "credential not found")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventWebAuthnRemoved, actorID, &acct.ID,
audit.JSON("credential_id", credIDStr, "admin", "true"))
// Return updated credentials list.
creds, _ := u.db.GetWebAuthnCredentials(acct.ID)
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "webauthn_credentials", struct { //nolint:govet // fieldalignment: anonymous struct
PageData
WebAuthnCreds []*model.WebAuthnCredential
DeletePrefix string
WebAuthnEnabled bool
}{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
IsAdmin: isAdmin(r),
},
WebAuthnCreds: creds,
DeletePrefix: "/accounts/" + accountUUID + "/webauthn",
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
})
}
// writeJSONError writes a JSON error response.
func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}