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