Fix WebAuthn login: username pre-fill and policy check
- webauthn.js: read #username value before calling mciasWebAuthnLogin so non-discoverable keys work when a username is typed (previously always passed empty string, forcing discoverable/resident-key flow only) - handleWebAuthnLoginFinish: evaluate auth:login policy after credential verification, mirroring the gate in handleLogin; returns 403 on deny so policy rules apply equally to both password and passkey authentication paths Security: policy is checked post-verification so 403 vs 401 distinguishes a policy restriction from a bad credential without leaking account existence. No service context is sent (WebAuthn login carries no service_name/tags), so per-service deny rules don't fire on passkey login; account-level deny rules do. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
|
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
|
||||||
)
|
)
|
||||||
@@ -618,13 +619,37 @@ func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Reques
|
|||||||
// Login succeeded: clear lockout counter.
|
// Login succeeded: clear lockout counter.
|
||||||
_ = s.db.ClearLoginFailures(acct.ID)
|
_ = s.db.ClearLoginFailures(acct.ID)
|
||||||
|
|
||||||
// Issue JWT.
|
// Load roles for policy check and expiry decision.
|
||||||
roles, err := s.db.GetRoles(acct.ID)
|
roles, err := s.db.GetRoles(acct.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Policy check: evaluate auth:login rules.
|
||||||
|
// WebAuthn login has no service context (no service_name or tags in the
|
||||||
|
// request body), so per-service deny rules won't fire. Account-level deny
|
||||||
|
// rules (e.g. deny a specific role from all auth:login actions) apply.
|
||||||
|
// This mirrors the policy gate in handleLogin so both auth paths are consistent.
|
||||||
|
//
|
||||||
|
// Security: policy is checked after credential verification so that a
|
||||||
|
// policy-denied login returns 403 (not 401), distinguishing a policy
|
||||||
|
// restriction from a bad credential without leaking account existence.
|
||||||
|
if s.polEng != nil {
|
||||||
|
input := policy.PolicyInput{
|
||||||
|
Subject: acct.UUID,
|
||||||
|
AccountType: string(acct.AccountType),
|
||||||
|
Roles: roles,
|
||||||
|
Action: policy.ActionLogin,
|
||||||
|
Resource: policy.Resource{},
|
||||||
|
}
|
||||||
|
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
|
||||||
|
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"policy_denied"}`)
|
||||||
|
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expiry := s.cfg.DefaultExpiry()
|
expiry := s.cfg.DefaultExpiry()
|
||||||
for _, role := range roles {
|
for _, role := range roles {
|
||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
|
|||||||
@@ -206,10 +206,12 @@
|
|||||||
if (loginBtn) {
|
if (loginBtn) {
|
||||||
loginBtn.addEventListener('click', function () {
|
loginBtn.addEventListener('click', function () {
|
||||||
hideError('webauthn-login-error');
|
hideError('webauthn-login-error');
|
||||||
|
var usernameInput = document.getElementById('username');
|
||||||
|
var username = usernameInput ? usernameInput.value.trim() : '';
|
||||||
loginBtn.disabled = true;
|
loginBtn.disabled = true;
|
||||||
loginBtn.textContent = 'Waiting for authenticator...';
|
loginBtn.textContent = 'Waiting for authenticator...';
|
||||||
|
|
||||||
window.mciasWebAuthnLogin('', function () {
|
window.mciasWebAuthnLogin(username, function () {
|
||||||
window.location.href = '/dashboard';
|
window.location.href = '/dashboard';
|
||||||
}, function (err) {
|
}, function (err) {
|
||||||
loginBtn.disabled = false;
|
loginBtn.disabled = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user