Files
mcias/internal/server/handlers_webauthn.go
Kyle Isom 25417b24f4 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>
2026-03-16 16:12:59 -07:00

742 lines
24 KiB
Go

// Package server: WebAuthn/passkey REST API handlers.
//
// Security design:
// - Registration requires re-authentication (current password) to prevent a
// stolen session token from enrolling attacker-controlled credentials.
// - Challenge sessions are stored in a sync.Map with a 120-second TTL and are
// single-use (deleted on consumption) to prevent replay attacks.
// - All credential material (IDs, public keys) is encrypted at rest with
// AES-256-GCM via the vault master key.
// - Sign counter validation detects cloned authenticators.
// - Login endpoints return generic errors to prevent credential enumeration.
package server
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/middleware"
"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
webauthnCeremonyNonce = 16 // 128 bits of entropy
)
// webauthnCeremony holds a pending registration or login ceremony.
type webauthnCeremony struct {
expiresAt time.Time
session *libwebauthn.SessionData
accountID int64 // 0 for discoverable login
}
// pendingWebAuthnCeremonies is the package-level ceremony store.
// Stored on the Server struct would require adding fields; using a
// package-level map is consistent with the TOTP/token pattern from the UI.
var pendingWebAuthnCeremonies sync.Map //nolint:gochecknoglobals
func init() {
go cleanupWebAuthnCeremonies()
}
func cleanupWebAuthnCeremonies() {
ticker := time.NewTicker(webauthnCleanupPeriod)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
pendingWebAuthnCeremonies.Range(func(key, value any) bool {
c, ok := value.(*webauthnCeremony)
if !ok || now.After(c.expiresAt) {
pendingWebAuthnCeremonies.Delete(key)
}
return true
})
}
}
func storeWebAuthnCeremony(session *libwebauthn.SessionData, accountID int64) (string, error) {
raw, err := crypto.RandomBytes(webauthnCeremonyNonce)
if err != nil {
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
}
nonce := fmt.Sprintf("%x", raw)
pendingWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
session: session,
accountID: accountID,
expiresAt: time.Now().Add(webauthnCeremonyTTL),
})
return nonce, nil
}
func consumeWebAuthnCeremony(nonce string) (*webauthnCeremony, bool) {
v, ok := pendingWebAuthnCeremonies.LoadAndDelete(nonce)
if !ok {
return nil, false
}
c, ok2 := v.(*webauthnCeremony)
if !ok2 || time.Now().After(c.expiresAt) {
return nil, false
}
return c, true
}
// ---- Registration ----
type webauthnRegisterBeginRequest struct {
Password string `json:"password"`
Name string `json:"name"`
}
type webauthnRegisterBeginResponse struct {
Nonce string `json:"nonce"`
Options json.RawMessage `json:"options"`
}
// handleWebAuthnRegisterBegin starts a WebAuthn credential registration ceremony.
//
// Security (SEC-01): the current password is required to prevent a stolen
// session from enrolling attacker-controlled credentials.
func (s *Server) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) {
if !s.cfg.WebAuthnEnabled() {
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
return
}
claims := middleware.ClaimsFromContext(r.Context())
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
return
}
var req webauthnRegisterBeginRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Password == "" {
middleware.WriteError(w, http.StatusBadRequest, "password is required", "bad_request")
return
}
// Security: check lockout before password verification.
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check (WebAuthn register)", "error", lockErr)
}
if locked {
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
return
}
// Security: verify current password with constant-time Argon2id.
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = s.db.RecordLoginFailure(acct.ID)
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
middleware.WriteError(w, http.StatusUnauthorized, "password is incorrect", "unauthorized")
return
}
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
// Load existing credentials to exclude them from registration.
dbCreds, err := s.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
s.logger.Error("load webauthn credentials", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if err != nil {
s.logger.Error("decrypt webauthn credentials", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
if err != nil {
s.logger.Error("create webauthn instance", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "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 {
s.logger.Error("begin webauthn registration", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
nonce, err := storeWebAuthnCeremony(session, acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
optionsJSON, err := json.Marshal(creation)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, webauthnRegisterBeginResponse{
Options: optionsJSON,
Nonce: nonce,
})
}
// handleWebAuthnRegisterFinish completes WebAuthn credential registration.
func (s *Server) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Request) {
if !s.cfg.WebAuthnEnabled() {
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
return
}
claims := middleware.ClaimsFromContext(r.Context())
acct, err := s.db.GetAccountByUUID(claims.Subject)
if err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
return
}
// Read the raw body so we can extract the nonce and also pass
// the credential response to the library via a reconstructed request.
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
bodyBytes, err := readAllBody(r)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid request body", "bad_request")
return
}
// Extract nonce and name from the wrapper.
var wrapper struct {
Nonce string `json:"nonce"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid JSON", "bad_request")
return
}
ceremony, ok := consumeWebAuthnCeremony(wrapper.Nonce)
if !ok {
middleware.WriteError(w, http.StatusBadRequest, "ceremony expired or invalid", "bad_request")
return
}
if ceremony.accountID != acct.ID {
middleware.WriteError(w, http.StatusForbidden, "ceremony mismatch", "forbidden")
return
}
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
dbCreds, err := s.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
// Build a fake http.Request from the credential JSON for the library.
fakeReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
fakeReq.Header.Set("Content-Type", "application/json")
cred, err := wa.FinishRegistration(user, *ceremony.session, fakeReq)
if err != nil {
s.logger.Error("finish webauthn registration", "error", err)
middleware.WriteError(w, http.StatusBadRequest, "registration failed", "bad_request")
return
}
// Determine if the credential is discoverable based on the flags.
discoverable := cred.Flags.UserVerified && cred.Flags.BackupEligible
name := wrapper.Name
if name == "" {
name = "Passkey"
}
// Encrypt and store the credential.
modelCred, err := mciaswebauthn.EncryptCredential(masterKey, cred, name, discoverable)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
modelCred.AccountID = acct.ID
credID, err := s.db.CreateWebAuthnCredential(modelCred)
if err != nil {
s.logger.Error("store webauthn credential", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID,
audit.JSON("credential_id", fmt.Sprintf("%d", credID), "name", name))
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": credID,
"name": name,
})
}
// ---- Login ----
type webauthnLoginBeginRequest struct {
Username string `json:"username,omitempty"`
}
type webauthnLoginBeginResponse struct {
Nonce string `json:"nonce"`
Options json.RawMessage `json:"options"`
}
// handleWebAuthnLoginBegin starts a WebAuthn login ceremony.
// If username is provided, loads that account's credentials (non-discoverable flow).
// If empty, starts a discoverable login.
func (s *Server) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
if !s.cfg.WebAuthnEnabled() {
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
return
}
var req webauthnLoginBeginRequest
if !decodeJSON(w, r, &req) {
return
}
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
if err != nil {
s.logger.Error("create webauthn instance", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
var (
assertion *protocol.CredentialAssertion
session *libwebauthn.SessionData
accountID int64
)
if req.Username != "" {
// Non-discoverable flow: load account credentials.
acct, lookupErr := s.db.GetAccountByUsername(req.Username)
if lookupErr != nil || acct.Status != model.AccountStatusActive {
// Security: return a valid-looking response even for unknown users
// to prevent username enumeration. Use discoverable login as a dummy.
assertion, session, err = wa.BeginDiscoverableLogin()
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
} else {
// Check lockout.
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check (WebAuthn login)", "error", lockErr)
}
if locked {
// Return discoverable login as dummy to avoid enumeration.
assertion, session, err = wa.BeginDiscoverableLogin()
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
} else {
masterKey, mkErr := s.vault.MasterKey()
if mkErr != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
dbCreds, dbErr := s.db.GetWebAuthnCredentials(acct.ID)
if dbErr != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if len(dbCreds) == 0 {
middleware.WriteError(w, http.StatusBadRequest, "no WebAuthn credentials registered", "no_credentials")
return
}
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if decErr != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
assertion, session, err = wa.BeginLogin(user)
if err != nil {
s.logger.Error("begin webauthn login", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
accountID = acct.ID
}
}
} else {
// Discoverable login (passkey).
assertion, session, err = wa.BeginDiscoverableLogin()
if err != nil {
s.logger.Error("begin discoverable webauthn login", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
}
nonce, err := storeWebAuthnCeremony(session, accountID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
optionsJSON, err := json.Marshal(assertion)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, webauthnLoginBeginResponse{
Options: optionsJSON,
Nonce: nonce,
})
}
// handleWebAuthnLoginFinish completes a WebAuthn login ceremony and issues a JWT.
func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
if !s.cfg.WebAuthnEnabled() {
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
bodyBytes, err := readAllBody(r)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid request body", "bad_request")
return
}
var wrapper struct {
Nonce string `json:"nonce"`
Credential json.RawMessage `json:"credential"`
}
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid JSON", "bad_request")
return
}
ceremony, ok := consumeWebAuthnCeremony(wrapper.Nonce)
if !ok {
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
masterKey, err := s.vault.MasterKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
fakeReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
fakeReq.Header.Set("Content-Type", "application/json")
var (
acct *model.Account
cred *libwebauthn.Credential
dbCreds []*model.WebAuthnCredential
)
if ceremony.accountID != 0 {
// Non-discoverable: we know the account.
acct, err = s.db.GetAccountByID(ceremony.accountID)
if err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
dbCreds, err = s.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
if decErr != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
cred, err = wa.FinishLogin(user, *ceremony.session, fakeReq)
if err != nil {
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"assertion_failed"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
} else {
// Discoverable login: the library resolves the user from the credential.
handler := func(rawID, userHandle []byte) (libwebauthn.User, error) {
// userHandle is the WebAuthnID we set (account UUID as bytes).
acctUUID := string(userHandle)
foundAcct, lookupErr := s.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 := s.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 {
s.writeAudit(r, model.EventWebAuthnLoginFail, nil, nil, `{"reason":"discoverable_assertion_failed"}`)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
}
if acct == nil {
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
// Security: check account status and lockout.
if acct.Status != model.AccountStatusActive {
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
locked, lockErr := s.db.IsLockedOut(acct.ID)
if lockErr != nil {
s.logger.Error("lockout check (WebAuthn login finish)", "error", lockErr)
}
if locked {
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
// Security: validate sign counter to detect cloned authenticators.
// Find the matching DB credential to update.
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 {
// Security: reject sign counter rollback (cloned authenticator detection).
// If both are 0, the authenticator doesn't support counters — allow it.
if cred.Authenticator.SignCount > 0 || matchedDBCred.SignCount > 0 {
if cred.Authenticator.SignCount <= matchedDBCred.SignCount {
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil,
audit.JSON("reason", "counter_rollback",
"expected_gt", fmt.Sprintf("%d", matchedDBCred.SignCount),
"got", fmt.Sprintf("%d", cred.Authenticator.SignCount)))
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
}
// Update sign count and last used.
_ = s.db.UpdateWebAuthnSignCount(matchedDBCred.ID, cred.Authenticator.SignCount)
_ = s.db.UpdateWebAuthnLastUsed(matchedDBCred.ID)
}
// Login succeeded: clear lockout counter.
_ = s.db.ClearLoginFailures(acct.ID)
// Issue JWT.
roles, err := s.db.GetRoles(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
expiry := s.cfg.DefaultExpiry()
for _, role := range roles {
if role == "admin" {
expiry = s.cfg.AdminExpiry()
break
}
}
privKey, err := s.vault.PrivKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
tokenStr, tokenClaims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.TrackToken(tokenClaims.JTI, acct.ID, tokenClaims.IssuedAt, tokenClaims.ExpiresAt); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, audit.JSON("jti", tokenClaims.JTI, "via", "webauthn"))
writeJSON(w, http.StatusOK, loginResponse{
Token: tokenStr,
ExpiresAt: tokenClaims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
})
}
// ---- Credential management ----
type webauthnCredentialView struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
Name string `json:"name"`
AAGUID string `json:"aaguid"`
Transports string `json:"transports,omitempty"`
ID int64 `json:"id"`
SignCount uint32 `json:"sign_count"`
Discoverable bool `json:"discoverable"`
}
// handleListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
func (s *Server) handleListWebAuthnCredentials(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
return
}
creds, err := s.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
views := make([]webauthnCredentialView, 0, len(creds))
for _, c := range creds {
v := webauthnCredentialView{
ID: c.ID,
Name: c.Name,
AAGUID: c.AAGUID,
SignCount: c.SignCount,
Discoverable: c.Discoverable,
Transports: c.Transports,
CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
if c.LastUsedAt != nil {
v.LastUsedAt = c.LastUsedAt.Format("2006-01-02T15:04:05Z")
}
views = append(views, v)
}
writeJSON(w, http.StatusOK, views)
}
// handleDeleteWebAuthnCredential removes a specific WebAuthn credential.
func (s *Server) handleDeleteWebAuthnCredential(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
return
}
credIDStr := r.PathValue("credentialId")
credID, err := strconv.ParseInt(credIDStr, 10, 64)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid credential ID", "bad_request")
return
}
if err := s.db.DeleteWebAuthnCredentialAdmin(credID); err != nil {
middleware.WriteError(w, http.StatusNotFound, "credential not found", "not_found")
return
}
s.writeAudit(r, model.EventWebAuthnRemoved, nil, &acct.ID,
audit.JSON("credential_id", credIDStr))
w.WriteHeader(http.StatusNoContent)
}
// readAllBody reads the entire request body and returns it as a byte slice.
func readAllBody(r *http.Request) ([]byte, error) {
var buf bytes.Buffer
_, err := buf.ReadFrom(r.Body)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}