Add FIDO2/WebAuthn passkey authentication
Phase 14: Full WebAuthn support for passwordless passkey login and hardware security key 2FA. - go-webauthn/webauthn v0.16.1 dependency - WebAuthnConfig with RPID/RPOrigin/DisplayName validation - Migration 000009: webauthn_credentials table - DB CRUD with ownership checks and admin operations - internal/webauthn adapter: encrypt/decrypt at rest with AES-256-GCM - REST: register begin/finish, login begin/finish, list, delete - Web UI: profile enrollment, login passkey button, admin management - gRPC: ListWebAuthnCredentials, RemoveWebAuthnCredential RPCs - mciasdb: webauthn list/delete/reset subcommands - OpenAPI: 6 new endpoints, WebAuthnCredentialInfo schema - Policy: self-service enrollment rule, admin remove via wildcard - Tests: DB CRUD, adapter round-trip, interface compliance - Docs: ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14 Security: Credential IDs and public keys encrypted at rest with AES-256-GCM via vault master key. Challenge ceremonies use 128-bit nonces with 120s TTL in sync.Map. Sign counter validated on each assertion to detect cloned authenticators. Password re-auth required for registration (SEC-01 pattern). No credential material in API responses or logs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
696
internal/ui/handlers_webauthn.go
Normal file
696
internal/ui/handlers_webauthn.go
Normal file
@@ -0,0 +1,696 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
|
||||
)
|
||||
|
||||
const (
|
||||
webauthnCeremonyTTL = 120 * time.Second
|
||||
webauthnCleanupPeriod = 5 * time.Minute
|
||||
webauthnNonceBytes = 16
|
||||
)
|
||||
|
||||
// webauthnCeremony holds a pending WebAuthn ceremony.
|
||||
type webauthnCeremony struct {
|
||||
expiresAt time.Time
|
||||
session *libwebauthn.SessionData
|
||||
accountID int64
|
||||
}
|
||||
|
||||
// pendingWebAuthnCeremonies stores in-flight WebAuthn ceremonies for the UI.
|
||||
var pendingUIWebAuthnCeremonies sync.Map //nolint:gochecknoglobals
|
||||
|
||||
func init() {
|
||||
go cleanupUIWebAuthnCeremonies()
|
||||
}
|
||||
|
||||
func cleanupUIWebAuthnCeremonies() {
|
||||
ticker := time.NewTicker(webauthnCleanupPeriod)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
pendingUIWebAuthnCeremonies.Range(func(key, value any) bool {
|
||||
c, ok := value.(*webauthnCeremony)
|
||||
if !ok || now.After(c.expiresAt) {
|
||||
pendingUIWebAuthnCeremonies.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string, error) {
|
||||
raw, err := crypto.RandomBytes(webauthnNonceBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
|
||||
}
|
||||
nonce := fmt.Sprintf("%x", raw)
|
||||
pendingUIWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
|
||||
session: session,
|
||||
accountID: accountID,
|
||||
expiresAt: time.Now().Add(webauthnCeremonyTTL),
|
||||
})
|
||||
return nonce, nil
|
||||
}
|
||||
|
||||
func consumeUICeremony(nonce string) (*webauthnCeremony, bool) {
|
||||
v, ok := pendingUIWebAuthnCeremonies.LoadAndDelete(nonce)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
c, ok2 := v.(*webauthnCeremony)
|
||||
if !ok2 || time.Now().After(c.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// ---- Profile: registration ----
|
||||
|
||||
// handleWebAuthnBegin starts a WebAuthn credential registration ceremony.
|
||||
func (u *UIServer) handleWebAuthnBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !u.cfg.WebAuthnEnabled() {
|
||||
u.renderError(w, r, http.StatusNotFound, "WebAuthn not configured")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "password is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check lockout.
|
||||
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
u.logger.Error("lockout check (WebAuthn enroll)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
writeJSONError(w, http.StatusTooManyRequests, "account temporarily locked")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: verify current password.
|
||||
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
||||
if verifyErr != nil || !ok {
|
||||
_ = u.db.RecordLoginFailure(acct.ID)
|
||||
writeJSONError(w, http.StatusUnauthorized, "password is incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||
return
|
||||
}
|
||||
|
||||
dbCreds, err := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
u.logger.Error("create webauthn instance", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
creation, session, err := wa.BeginRegistration(user,
|
||||
libwebauthn.WithExclusions(libwebauthn.Credentials(libCreds).CredentialDescriptors()),
|
||||
libwebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
||||
)
|
||||
if err != nil {
|
||||
u.logger.Error("begin webauthn registration", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
nonce, err := storeUICeremony(session, acct.ID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
optionsJSON, _ := json.Marshal(creation)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"options": json.RawMessage(optionsJSON),
|
||||
"nonce": nonce,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebAuthnFinish completes WebAuthn credential registration.
|
||||
func (u *UIServer) handleWebAuthnFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !u.cfg.WebAuthnEnabled() {
|
||||
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(r.Body); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &wrapper); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
ceremony, ok := consumeUICeremony(wrapper.Nonce)
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusBadRequest, "ceremony expired or invalid")
|
||||
return
|
||||
}
|
||||
if ceremony.accountID != acct.ID {
|
||||
writeJSONError(w, http.StatusForbidden, "ceremony mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||
return
|
||||
}
|
||||
|
||||
dbCreds, err := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
fakeReq, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
|
||||
fakeReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
cred, err := wa.FinishRegistration(user, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
u.logger.Error("finish webauthn registration", "error", err)
|
||||
writeJSONError(w, http.StatusBadRequest, "registration failed")
|
||||
return
|
||||
}
|
||||
|
||||
discoverable := cred.Flags.UserVerified && cred.Flags.BackupEligible
|
||||
name := wrapper.Name
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
|
||||
modelCred, err := mciaswebauthn.EncryptCredential(masterKey, cred, name, discoverable)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
modelCred.AccountID = acct.ID
|
||||
|
||||
credID, err := u.db.CreateWebAuthnCredential(modelCred)
|
||||
if err != nil {
|
||||
u.logger.Error("store webauthn credential", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID,
|
||||
audit.JSON("credential_id", fmt.Sprintf("%d", credID), "name", name))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": credID,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebAuthnDelete removes a WebAuthn credential from the profile page.
|
||||
func (u *UIServer) handleWebAuthnDelete(w http.ResponseWriter, r *http.Request) {
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
credIDStr := r.PathValue("id")
|
||||
credID, err := strconv.ParseInt(credIDStr, 10, 64)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid credential ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.DeleteWebAuthnCredential(credID, acct.ID); err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "credential not found")
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventWebAuthnRemoved, &acct.ID, &acct.ID,
|
||||
audit.JSON("credential_id", credIDStr))
|
||||
|
||||
// Return updated credentials list fragment.
|
||||
creds, _ := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "webauthn_credentials", ProfileData{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
ActorName: u.actorName(r),
|
||||
IsAdmin: isAdmin(r),
|
||||
},
|
||||
WebAuthnCreds: creds,
|
||||
DeletePrefix: "/profile/webauthn",
|
||||
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Login: WebAuthn ----
|
||||
|
||||
// handleWebAuthnLoginBegin starts a WebAuthn login ceremony from the UI.
|
||||
func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !u.cfg.WebAuthnEnabled() {
|
||||
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
assertion *protocol.CredentialAssertion
|
||||
session *libwebauthn.SessionData
|
||||
accountID int64
|
||||
)
|
||||
|
||||
if req.Username != "" {
|
||||
acct, lookupErr := u.db.GetAccountByUsername(req.Username)
|
||||
if lookupErr != nil || acct.Status != model.AccountStatusActive {
|
||||
// Security: return discoverable login as dummy for unknown users.
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
} else {
|
||||
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
u.logger.Error("lockout check (WebAuthn UI login)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
} else {
|
||||
masterKey, mkErr := u.vault.MasterKey()
|
||||
if mkErr != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||
return
|
||||
}
|
||||
dbCreds, dbErr := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if dbErr != nil || len(dbCreds) == 0 {
|
||||
writeJSONError(w, http.StatusBadRequest, "no passkeys registered")
|
||||
return
|
||||
}
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if decErr != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
assertion, session, err = wa.BeginLogin(user)
|
||||
accountID = acct.ID
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
u.logger.Error("begin webauthn login", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
nonce, err := storeUICeremony(session, accountID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
optionsJSON, _ := json.Marshal(assertion)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"options": json.RawMessage(optionsJSON),
|
||||
"nonce": nonce,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebAuthnLoginFinish completes a WebAuthn login from the UI.
|
||||
func (u *UIServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !u.cfg.WebAuthnEnabled() {
|
||||
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(r.Body); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &wrapper); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
ceremony, ok := consumeUICeremony(wrapper.Nonce)
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||
return
|
||||
}
|
||||
|
||||
fakeReq, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
|
||||
fakeReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var (
|
||||
acct *model.Account
|
||||
cred *libwebauthn.Credential
|
||||
dbCreds []*model.WebAuthnCredential
|
||||
)
|
||||
|
||||
if ceremony.accountID != 0 {
|
||||
acct, err = u.db.GetAccountByID(ceremony.accountID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
dbCreds, err = u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if decErr != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
cred, err = wa.FinishLogin(user, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
u.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"assertion_failed"}`)
|
||||
_ = u.db.RecordLoginFailure(acct.ID)
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
handler := func(rawID, userHandle []byte) (libwebauthn.User, error) {
|
||||
acctUUID := string(userHandle)
|
||||
foundAcct, lookupErr := u.db.GetAccountByUUID(acctUUID)
|
||||
if lookupErr != nil {
|
||||
return nil, fmt.Errorf("account not found")
|
||||
}
|
||||
if foundAcct.Status != model.AccountStatusActive {
|
||||
return nil, fmt.Errorf("account inactive")
|
||||
}
|
||||
acct = foundAcct
|
||||
|
||||
foundDBCreds, credErr := u.db.GetWebAuthnCredentials(foundAcct.ID)
|
||||
if credErr != nil {
|
||||
return nil, fmt.Errorf("load credentials: %w", credErr)
|
||||
}
|
||||
dbCreds = foundDBCreds
|
||||
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, foundDBCreds)
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("decrypt credentials: %w", decErr)
|
||||
}
|
||||
return mciaswebauthn.NewAccountUser(userHandle, foundAcct.Username, libCreds), nil
|
||||
}
|
||||
|
||||
cred, err = wa.FinishDiscoverableLogin(handler, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
u.writeAudit(r, model.EventWebAuthnLoginFail, nil, nil, `{"reason":"discoverable_assertion_failed"}`)
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if acct == nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
if acct.Status != model.AccountStatusActive {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
u.logger.Error("lockout check (WebAuthn UI login finish)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate sign counter.
|
||||
var matchedDBCred *model.WebAuthnCredential
|
||||
for _, dc := range dbCreds {
|
||||
decrypted, decErr := mciaswebauthn.DecryptCredential(masterKey, dc)
|
||||
if decErr != nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(decrypted.ID, cred.ID) {
|
||||
matchedDBCred = dc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchedDBCred != nil {
|
||||
if cred.Authenticator.SignCount > 0 || matchedDBCred.SignCount > 0 {
|
||||
if cred.Authenticator.SignCount <= matchedDBCred.SignCount {
|
||||
u.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil,
|
||||
audit.JSON("reason", "counter_rollback"))
|
||||
_ = u.db.RecordLoginFailure(acct.ID)
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = u.db.UpdateWebAuthnSignCount(matchedDBCred.ID, cred.Authenticator.SignCount)
|
||||
_ = u.db.UpdateWebAuthnLastUsed(matchedDBCred.ID)
|
||||
}
|
||||
|
||||
_ = u.db.ClearLoginFailures(acct.ID)
|
||||
|
||||
// Issue JWT and set session cookie.
|
||||
expiry := u.cfg.DefaultExpiry()
|
||||
roles, err := u.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
for _, rol := range roles {
|
||||
if rol == "admin" {
|
||||
expiry = u.cfg.AdminExpiry()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
privKey, err := u.vault.PrivKey()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||
return
|
||||
}
|
||||
tokenStr, tokenClaims, err := token.IssueToken(privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.TrackToken(tokenClaims.JTI, acct.ID, tokenClaims.IssuedAt, tokenClaims.ExpiresAt); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: tokenStr,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: tokenClaims.ExpiresAt,
|
||||
})
|
||||
|
||||
if _, err := u.setCSRFCookies(w); err != nil {
|
||||
u.logger.Error("set CSRF cookie", "error", err)
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
|
||||
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
|
||||
audit.JSON("jti", tokenClaims.JTI, "via", "webauthn_ui"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"redirect": "/dashboard"})
|
||||
}
|
||||
|
||||
// ---- Admin: WebAuthn credential management ----
|
||||
|
||||
// handleAdminWebAuthnDelete removes a WebAuthn credential from the admin account detail page.
|
||||
func (u *UIServer) handleAdminWebAuthnDelete(w http.ResponseWriter, r *http.Request) {
|
||||
accountUUID := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(accountUUID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
credIDStr := r.PathValue("credentialId")
|
||||
credID, err := strconv.ParseInt(credIDStr, 10, 64)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid credential ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.DeleteWebAuthnCredentialAdmin(credID); err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "credential not found")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventWebAuthnRemoved, actorID, &acct.ID,
|
||||
audit.JSON("credential_id", credIDStr, "admin", "true"))
|
||||
|
||||
// Return updated credentials list.
|
||||
creds, _ := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "webauthn_credentials", struct { //nolint:govet // fieldalignment: anonymous struct
|
||||
PageData
|
||||
WebAuthnCreds []*model.WebAuthnCredential
|
||||
DeletePrefix string
|
||||
WebAuthnEnabled bool
|
||||
}{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
ActorName: u.actorName(r),
|
||||
IsAdmin: isAdmin(r),
|
||||
},
|
||||
WebAuthnCreds: creds,
|
||||
DeletePrefix: "/accounts/" + accountUUID + "/webauthn",
|
||||
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSONError writes a JSON error response.
|
||||
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
Reference in New Issue
Block a user