mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-09 08:02:27 +00:00
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:
+1
-1
@@ -136,7 +136,7 @@ export async function apiDeleteOrg(requestContext: APIRequestContext, name: stri
|
||||
}
|
||||
|
||||
/** Password shared by all test users — used for both API user creation and browser login. */
|
||||
const testUserPassword = 'e2e-password!aA1';
|
||||
export const testUserPassword = 'e2e-password!aA1';
|
||||
|
||||
export function apiUserHeaders(username: string) {
|
||||
return apiAuthHeader(username, testUserPassword);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {test, expect, type Page} from '@playwright/test';
|
||||
import {apiCreateUser, loginUser, randomString, testUserPassword} from './utils.ts';
|
||||
|
||||
const signedIn = /^(?!.*\/user\/(login|webauthn))/; // the target of a finished login varies
|
||||
|
||||
async function registerKey(page: Page, nickname: string) {
|
||||
await page.goto('/user/settings/security');
|
||||
await page.getByLabel('Nickname').fill(nickname);
|
||||
await page.getByRole('button', {name: 'Add Security Key'}).click();
|
||||
}
|
||||
|
||||
async function signInWithPassword(page: Page, username: string) {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/user/login');
|
||||
await page.getByLabel('Username or Email Address').fill(username);
|
||||
await page.getByLabel('Password').fill(testUserPassword);
|
||||
await page.getByRole('button', {name: 'Sign In'}).click();
|
||||
}
|
||||
|
||||
// regression: credProtect level 3 hid the credential from the second-factor login
|
||||
test('security key survives credProtect', async ({page, request, browserName}) => {
|
||||
test.skip(browserName !== 'chromium', 'only the CDP authenticator emulates credProtect'); // eslint-disable-line playwright/no-skipped-test
|
||||
|
||||
const username = `e2e-credprotect-${randomString(8)}`;
|
||||
await apiCreateUser(request, username);
|
||||
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('WebAuthn.enable');
|
||||
await cdp.send('WebAuthn.addVirtualAuthenticator', {options: {
|
||||
protocol: 'ctap2',
|
||||
ctap2Version: 'ctap2_1',
|
||||
transport: 'usb',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
hasCredBlob: true, // CDP only emulates credProtect together with credBlob
|
||||
isUserVerified: true,
|
||||
}});
|
||||
|
||||
await loginUser(page, username);
|
||||
await registerKey(page, 'e2e-key');
|
||||
await expect(page.getByText('e2e-key')).toBeVisible();
|
||||
|
||||
await registerKey(page, 'e2e-key-again');
|
||||
await expect(page.locator('#webauthn-error-msg')).toContainText('already registered');
|
||||
|
||||
await signInWithPassword(page, username);
|
||||
await expect(page).toHaveURL(signedIn);
|
||||
});
|
||||
|
||||
// this authenticator has no credProtect, so it cannot replace the test above
|
||||
test('security key signs in as second factor and as passkey', async ({page, request}) => {
|
||||
const username = `e2e-passkey-${randomString(8)}`;
|
||||
await apiCreateUser(request, username);
|
||||
await page.context().credentials.install();
|
||||
|
||||
await loginUser(page, username);
|
||||
await registerKey(page, 'e2e-key');
|
||||
await expect(page.getByText('e2e-key')).toBeVisible();
|
||||
|
||||
await signInWithPassword(page, username);
|
||||
await expect(page).toHaveURL(signedIn);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/user/login');
|
||||
await page.getByText('Sign in with a passkey').click();
|
||||
await expect(page).toHaveURL(signedIn);
|
||||
});
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user