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>
697 lines
20 KiB
Go
697 lines
20 KiB
Go
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})
|
|
}
|