fix(auth): set WebAuthn user verification per request (#38805)

Registration omitted `userVerification`, so Chromium raised the
credential to credProtect level 3 and the authenticator then hid it from
the second-factor login, which asked for `discouraged`. Registration and
each login now set their own value, with `preferred` on the second
factor so credentials already registered at level 3 keep working without
re-enrollment.

Also add relevant e2e test coverage for webauthn, one test chromium only
because Firefox lacks the APIs needed.

Fixes https://github.com/go-gitea/gitea/issues/33531
Fixes https://github.com/go-gitea/gitea/issues/36019
Fixes https://github.com/go-gitea/gitea/issues/38139
This commit is contained in:
silverwind
2026-08-07 02:11:34 +02:00
committed by GitHub
parent c210ef6dbb
commit 9dac77fdc2
16 changed files with 316 additions and 107 deletions
+63
View File
@@ -22,6 +22,7 @@ import (
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/tests"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -570,3 +571,65 @@ func TestOAuth2AutoLinkWithTwoFactor(t *testing.T) {
session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK)
}
// a security key must be challenged on every path that issues a session, not just the password login
func TestWebAuthnSecondFactorRequired(t *testing.T) {
defer tests.PrepareTestEnv(t)()
newWebAuthnUser := func(t *testing.T, name string) *user_model.User {
u := &user_model.User{Name: name, Email: name + "@example.com"}
require.NoError(t, user_model.CreateUser(t.Context(), u, &user_model.Meta{}))
_, err := auth_model.CreateCredential(t.Context(), u.ID, "test-key", &webauthn.Credential{ID: []byte(name)})
require.NoError(t, err)
return u
}
assertOAuth2Challenged := func(t *testing.T, sourceName string) {
session := emptyTestSession(t)
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/oauth2/"+sourceName), http.StatusTemporaryRedirect)
u, err := url.Parse(resp.Header().Get("Location"))
require.NoError(t, err)
state := u.Query().Get("state")
require.NotEmpty(t, state)
callbackURL := fmt.Sprintf("/user/oauth2/%s/callback?code=test-code&state=%s", sourceName, url.QueryEscape(state))
resp = session.MakeRequest(t, NewRequest(t, "GET", callbackURL), http.StatusSeeOther)
assert.Contains(t, resp.Header().Get("Location"), "/user/webauthn")
session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusSeeOther) // the redirect alone does not prove no session was issued
}
t.Run("OAuth2AutoLink", func(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.AccountLinking, setting.OAuth2AccountLinkingAuto)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameEmail)()
const sourceName, sub = "oauth-autolink-webauthn", "autolink-sub"
u := newWebAuthnUser(t, "autolink-webauthn")
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: sub, Email: u.Email, Name: u.Name})
addOAuth2Source(t, sourceName, newOIDCSource(srv, false, false))
assertOAuth2Challenged(t, sourceName)
})
t.Run("OAuth2LinkedIdentity", func(t *testing.T) {
const sourceName, sub = "oauth-signin-webauthn", "signin-sub"
u := newWebAuthnUser(t, "signin-webauthn")
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: sub, Email: u.Email, Name: u.Name})
addOAuth2Source(t, sourceName, newOIDCSource(srv, false, false))
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), sourceName)
require.NoError(t, err)
require.NoError(t, user_model.LinkExternalToUser(t.Context(), u, &user_model.ExternalLoginUser{
ExternalID: sub, UserID: u.ID, LoginSourceID: authSource.ID, Provider: "openidConnect",
}))
assertOAuth2Challenged(t, sourceName)
})
t.Run("PasswordReset", func(t *testing.T) {
u := newWebAuthnUser(t, "reset-webauthn")
code := user_model.GenerateUserTimeLimitCode(&user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}, u)
session := emptyTestSession(t)
req := NewRequestWithValues(t, "POST", "/user/recover_account", map[string]string{"code": code, "password": "new-Password!1"})
resp := session.MakeRequest(t, req, http.StatusSeeOther)
assert.Contains(t, resp.Header().Get("Location"), "/user/webauthn")
session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusSeeOther)
})
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
"gitea.dev/tests"
"github.com/go-webauthn/webauthn/protocol"
"github.com/stretchr/testify/assert"
)
// one credential serves both logins, so their user verification is coupled
func TestWebAuthnUserVerification(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/request_register", map[string]string{"name": "test-key"})
creation := DecodeJSON(t, session.MakeRequest(t, req, http.StatusOK), &protocol.CredentialCreation{})
assert.Equal(t, protocol.VerificationRequired, creation.Response.AuthenticatorSelection.UserVerification)
session = loginUserWithPassword(t, "user32", "notpassword") // user32 has a webauthn credential
req = NewRequest(t, "GET", "/user/webauthn/assertion")
secondFactor := DecodeJSON(t, session.MakeRequest(t, req, http.StatusOK), &protocol.CredentialAssertion{})
assert.Equal(t, protocol.VerificationPreferred, secondFactor.Response.UserVerification)
session = emptyTestSession(t)
req = NewRequest(t, "GET", "/user/webauthn/passkey/assertion") // also seeds the session for the request below
passkey := DecodeJSON(t, session.MakeRequest(t, req, http.StatusOK), &protocol.CredentialAssertion{})
assert.Equal(t, protocol.VerificationRequired, passkey.Response.UserVerification)
// a malformed response used to dereference a nil user
req = NewRequestWithJSON(t, "POST", "/user/webauthn/passkey/login", map[string]string{"bogus": "1"})
session.MakeRequest(t, req, http.StatusForbidden)
}