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>
742 lines
24 KiB
Go
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
|
|
}
|