diff --git a/modules/auth/webauthn/webauthn.go b/modules/auth/webauthn/webauthn.go index b0b49591687..1afaf1cedd1 100644 --- a/modules/auth/webauthn/webauthn.go +++ b/modules/auth/webauthn/webauthn.go @@ -28,13 +28,10 @@ func Init() { WebAuthn = &webauthn.WebAuthn{ Config: &webauthn.Config{ - RPDisplayName: setting.AppName, - RPID: setting.Domain, - RPOrigins: []string{appURL}, - AuthenticatorSelection: protocol.AuthenticatorSelection{ - UserVerification: protocol.VerificationDiscouraged, - }, - AttestationPreference: protocol.PreferDirectAttestation, + RPDisplayName: setting.AppName, + RPID: setting.Domain, + RPOrigins: []string{appURL}, + AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation }, } } diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go index 10376842a3c..2c031c1227a 100644 --- a/routers/web/auth/2fa.go +++ b/routers/web/auth/2fa.go @@ -73,12 +73,9 @@ func TwoFactorPost(ctx *context.Context) { return } - if ctx.Session.Get("linkAccount") != nil { - err = linkAccountFromContext(ctx, u) - if err != nil { - ctx.ServerError("UserSignIn", err) - return - } + if err = completePendingLinks(ctx, u); err != nil { + ctx.ServerError("completePendingLinks", err) + return } _ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true) @@ -145,6 +142,11 @@ func TwoFactorScratchPost(ctx *context.Context) { return } + if err = completePendingLinks(ctx, u); err != nil { + ctx.ServerError("completePendingLinks", err) + return + } + handleSignInFull(ctx, u, remember) if ctx.Written() { return diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 13ea2e8bb16..13e0eca8b51 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "html/template" + "maps" "net/http" "net/url" "strings" @@ -328,46 +329,35 @@ func SignInPost(ctx *context.Context) { // If this user is enrolled in 2FA TOTP, we can't sign the user in just yet. // Instead, redirect them to the 2FA authentication page. - hasTOTPtwofa, err := auth.HasTwoFactorByUID(ctx, u.ID) + hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID) if err != nil { - ctx.ServerError("UserSignIn", err) + ctx.ServerError("HasTwoFactorOrWebAuthn", err) return } - - // Check if the user has webauthn registration - hasWebAuthnTwofa, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID) - if err != nil { - ctx.ServerError("UserSignIn", err) - return - } - - if !hasTOTPtwofa && !hasWebAuthnTwofa { - // No two-factor auth configured we can sign in the user + if !hasTwoFactor { handleSignIn(ctx, u, form.Remember) return } - updates := map[string]any{ - // User will need to use 2FA TOTP or WebAuthn, save data - "twofaUid": u.ID, - "twofaRemember": form.Remember, - } - if hasTOTPtwofa { - // User will need to use WebAuthn, save data - updates["totpEnrolled"] = u.ID - } + handleTwoFactorRequired(ctx, u, form.Remember, nil) +} + +func handleTwoFactorRequired(ctx *context.Context, u *user_model.User, remember bool, extra map[string]any) { + updates := map[string]any{"twofaUid": u.ID, "twofaRemember": remember} + maps.Copy(updates, extra) if err := regenerateSession(ctx, updates); err != nil { - ctx.ServerError("UserSignIn: Unable to update session", err) + ctx.ServerError("RegenerateSession", err) return } - - // If we have WebAuthn redirect there first - if hasWebAuthnTwofa { + hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("HasWebAuthnRegistrationsByUID", err) + return + } + if hasWebAuthn { ctx.Redirect(setting.AppSubURL + "/user/webauthn") return } - - // Fallback to 2FA ctx.Redirect(setting.AppSubURL + "/user/two_factor") } diff --git a/routers/web/auth/auth_test.go b/routers/web/auth/auth_test.go index f31511c4ff9..5d2b9095c42 100644 --- a/routers/web/auth/auth_test.go +++ b/routers/web/auth/auth_test.go @@ -11,6 +11,7 @@ import ( "testing" auth_model "gitea.dev/models/auth" + "gitea.dev/models/unittest" user_model "gitea.dev/models/user" "gitea.dev/modules/session" "gitea.dev/modules/setting" @@ -182,3 +183,19 @@ func TestWebAuthOAuth2(t *testing.T) { }) }) } + +func TestOpenIDRequireTwoFactor(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-openid")} + + user32 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32}) // has a webauthn credential + ctx, resp := contexttest.MockContext(t, "/user/openid/connect", mockOpt) + openIDRequireTwoFactor(ctx, user32, false, "https://example.com/id") + assert.Equal(t, "/user/webauthn", test.RedirectURL(resp)) + unittest.AssertNotExistsBean(t, &user_model.UserOpenID{UID: user32.ID}) // not attached before the key answered + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + ctx, _ = contexttest.MockContext(t, "/user/openid/connect", mockOpt) + openIDRequireTwoFactor(ctx, user2, false, "https://example.com/id") + assert.False(t, ctx.Written()) +} diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go index 831701da516..d73b2bd1cf8 100644 --- a/routers/web/auth/linkaccount.go +++ b/routers/web/auth/linkaccount.go @@ -148,15 +148,13 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData // If this user is enrolled in 2FA, we can't sign the user in just yet. // Instead, redirect them to the 2FA authentication page. // We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here - _, err := auth.GetTwoFactorByUID(ctx, u.ID) + hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID) if err != nil { - if !auth.IsErrTwoFactorNotEnrolled(err) { - ctx.ServerError("UserLinkAccount", err) - return - } - - err = externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser) - if err != nil { + ctx.ServerError("UserLinkAccount", err) + return + } + if !hasTwoFactor { + if err := externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser); err != nil { ctx.ServerError("UserLinkAccount", err) return } @@ -170,25 +168,10 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData return } - if err := regenerateSession(ctx, map[string]any{ - // User needs to use 2FA, save data and redirect to 2FA page. - "twofaUid": u.ID, - "twofaRemember": remember, + handleTwoFactorRequired(ctx, u, remember, map[string]any{ "linkAccount": true, session.KeySignInMethod: session.SignInMethodOAuth2, - }); err != nil { - ctx.ServerError("RegenerateSession", err) - return - } - - // If WebAuthn is enrolled -> Redirect to WebAuthn instead - regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) - if err == nil && len(regs) > 0 { - ctx.Redirect(setting.AppSubURL + "/user/webauthn") - return - } - - ctx.Redirect(setting.AppSubURL + "/user/two_factor") + }) } // LinkAccountPostRegister handle the creation of a new account for an external account using signUp @@ -279,6 +262,15 @@ func LinkAccountPostRegister(ctx *context.Context) { handleSignIn(ctx, u, false) } +func completePendingLinks(ctx *context.Context, user *user_model.User) error { + if ctx.Session.Get("linkAccount") != nil { + if err := linkAccountFromContext(ctx, user); err != nil { + return err + } + } + return openIDConnectFromContext(ctx, user) +} + func linkAccountFromContext(ctx *context.Context, user *user_model.User) error { linkAccountData := oauth2GetLinkAccountData(ctx) if linkAccountData == nil { diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 27b32d09dfa..687441a7b41 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -361,12 +361,11 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m needs2FA := false if !authSource.TwoFactorShouldSkip() { - _, err := auth.GetTwoFactorByUID(ctx, u.ID) - if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { + var err error + if needs2FA, err = auth.HasTwoFactorOrWebAuthn(ctx, u.ID); err != nil { ctx.ServerError("UserSignIn", err) return } - needs2FA = err == nil } oauth2Source := authSource.Cfg.(*oauth2.Source) @@ -453,24 +452,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m } } - if err := regenerateSession(ctx, map[string]any{ - // User needs to use 2FA, save data and redirect to 2FA page. - "twofaUid": u.ID, - "twofaRemember": false, - session.KeySignInMethod: session.SignInMethodOAuth2, - }); err != nil { - ctx.ServerError("updateSession", err) - return - } - - // If WebAuthn is enrolled -> Redirect to WebAuthn instead - regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) - if err == nil && len(regs) > 0 { - ctx.Redirect(setting.AppSubURL + "/user/webauthn") - return - } - - ctx.Redirect(setting.AppSubURL + "/user/two_factor") + handleTwoFactorRequired(ctx, u, false, map[string]any{session.KeySignInMethod: session.SignInMethodOAuth2}) } // OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go index cf9d8074cf4..35fd7397ae3 100644 --- a/routers/web/auth/openid.go +++ b/routers/web/auth/openid.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" + auth_model "gitea.dev/models/auth" user_model "gitea.dev/models/user" "gitea.dev/modules/auth/openid" "gitea.dev/modules/log" @@ -26,6 +27,36 @@ const ( tplSignUpOID templates.TplName = "user/auth/signup_openid_register" ) +// the OpenID is attached only after the second factor passed, so a stolen password cannot leave one behind +func openIDRequireTwoFactor(ctx *context.Context, u *user_model.User, remember bool, pendingURI string) { + hasTwoFactor, err := auth_model.HasTwoFactorOrWebAuthn(ctx, u.ID) + if err != nil { + ctx.ServerError("HasTwoFactorOrWebAuthn", err) + return + } + if !hasTwoFactor { + return + } + handleTwoFactorRequired(ctx, u, remember, map[string]any{"openidPendingURI": pendingURI}) +} + +func openIDConnectFromContext(ctx *context.Context, u *user_model.User) error { + uri, _ := ctx.Session.Get("openidPendingURI").(string) + if uri == "" { + return nil + } + if err := ctx.Session.Delete("openidPendingURI"); err != nil { + return err + } + if err := user_model.AddUserOpenID(ctx, &user_model.UserOpenID{UID: u.ID, URI: uri}); err != nil { + if !user_model.IsErrOpenIDAlreadyUsed(err) { + return err + } + ctx.Flash.Error(ctx.Tr("form.openid_been_used", uri)) + } + return nil +} + // SignInOpenID render sign in page func SignInOpenID(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("sign_in") @@ -154,6 +185,10 @@ func signInOpenIDVerify(ctx *context.Context) { log.Trace("User exists, logging in") remember, _ := ctx.Session.Get("openid_signin_remember").(bool) log.Trace("Session stored openid-remember: %t", remember) + openIDRequireTwoFactor(ctx, u, remember, "") + if ctx.Written() { + return + } handleSignIn(ctx, u, remember) return } @@ -270,7 +305,12 @@ func ConnectOpenIDPost(ctx *context.Context) { return } - // add OpenID for the user + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + openIDRequireTwoFactor(ctx, u, remember, oid) + if ctx.Written() { + return + } + userOID := &user_model.UserOpenID{UID: u.ID, URI: oid} if err := user_model.AddUserOpenID(ctx, userOID); err != nil { if user_model.IsErrOpenIDAlreadyUsed(err) { @@ -282,9 +322,6 @@ func ConnectOpenIDPost(ctx *context.Context) { } ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) - - remember, _ := ctx.Session.Get("openid_signin_remember").(bool) - log.Trace("Session stored openid-remember: %t", remember) handleSignIn(ctx, u, remember) } diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index ca3e37dad0c..2123a2d9fd1 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -238,6 +238,19 @@ func ResetPasswdPost(ctx *context.Context) { return } + // the reset form only carries a TOTP field, so a WebAuthn-only user finishes on its own page + if twofa == nil { + hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("HasWebAuthnRegistrationsByUID", err) + return + } + if hasWebAuthn { + handleTwoFactorRequired(ctx, u, remember, nil) + return + } + } + handleSignIn(ctx, u, remember) } diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go index cf48091a37c..11c038074df 100644 --- a/routers/web/auth/webauthn.go +++ b/routers/web/auth/webauthn.go @@ -54,7 +54,8 @@ func WebAuthnPasskeyAssertion(ctx *context.Context) { return } - assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin() + // a passkey is the only factor here + assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired)) if err != nil { ctx.ServerError("webauthn.BeginDiscoverableLogin", err) return @@ -91,7 +92,7 @@ func WebAuthnPasskeyLogin(ctx *context.Context) { parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req) if err != nil { // Failed authentication attempt. - log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err) + log.Info("Failed authentication attempt from %s: %v", ctx.RemoteAddr(), err) ctx.Status(http.StatusForbidden) return } @@ -147,12 +148,9 @@ func WebAuthnPasskeyLogin(ctx *context.Context) { return } - // Now handle account linking if that's requested - if ctx.Session.Get("linkAccount") != nil { - if err := linkAccountFromContext(ctx, user); err != nil { - ctx.ServerError("LinkAccountFromStore", err) - return - } + if err := completePendingLinks(ctx, user); err != nil { + ctx.ServerError("completePendingLinks", err) + return } remember := false // TODO: implement remember me @@ -186,7 +184,8 @@ func WebAuthnLoginAssertion(ctx *context.Context) { } webAuthnUser := wa.NewWebAuthnUser(ctx, user) - assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser) + // "discouraged" would hide credProtect protected credentials + assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser, webauthn.WithUserVerification(protocol.VerificationPreferred)) if err != nil { ctx.ServerError("webauthn.BeginLogin", err) return @@ -261,12 +260,9 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) { return } - // Now handle account linking if that's requested - if ctx.Session.Get("linkAccount") != nil { - if err := linkAccountFromContext(ctx, user); err != nil { - ctx.ServerError("LinkAccountFromStore", err) - return - } + if err := completePendingLinks(ctx, user); err != nil { + ctx.ServerError("completePendingLinks", err) + return } remember := ctx.Session.Get("twofaRemember").(bool) diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index 4db7b478792..3e46d20ca47 100644 --- a/routers/web/user/setting/security/webauthn.go +++ b/routers/web/user/setting/security/webauthn.go @@ -53,8 +53,17 @@ func WebAuthnRegister(ctx *context.Context) { } webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer) - credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration(webAuthnUser, webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ + // the exclusions stop enrolling the same authenticator twice + credentials, err := auth.GetWebAuthnCredentialsByUID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetWebAuthnCredentialsByUID", err) + return + } + exclusions := webauthn.Credentials(credentials.ToCredentials()).CredentialDescriptors() + credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration(webAuthnUser, webauthn.WithExclusions(exclusions), webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ ResidentKey: protocol.ResidentKeyRequirementRequired, + // anything else makes Chromium raise it to credProtect level 3, hiding it from the second factor + UserVerification: protocol.VerificationRequired, })) if err != nil { ctx.ServerError("Unable to BeginRegistration", err) diff --git a/services/auth/session.go b/services/auth/session.go index 835f18cc702..1a863d88f8c 100644 --- a/services/auth/session.go +++ b/services/auth/session.go @@ -64,4 +64,5 @@ func ClearSessionKeysForSignIn(sess SessionStore) { _ = sess.Delete("webauthnAssertion") _ = sess.Delete("linkAccount") _ = sess.Delete("linkAccountData") + _ = sess.Delete("openidPendingURI") } diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 18fe549f2b6..8fe335bc85d 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -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); diff --git a/tests/e2e/webauthn.test.ts b/tests/e2e/webauthn.test.ts new file mode 100644 index 00000000000..760e610d893 --- /dev/null +++ b/tests/e2e/webauthn.test.ts @@ -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); +}); diff --git a/tests/integration/auth_oauth2_test.go b/tests/integration/auth_oauth2_test.go index 0740722260e..2476bc6bb4a 100644 --- a/tests/integration/auth_oauth2_test.go +++ b/tests/integration/auth_oauth2_test.go @@ -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) + }) +} diff --git a/tests/integration/webauthn_test.go b/tests/integration/webauthn_test.go new file mode 100644 index 00000000000..01eb6db317b --- /dev/null +++ b/tests/integration/webauthn_test.go @@ -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) +} diff --git a/web_src/js/features/user-auth-webauthn.ts b/web_src/js/features/user-auth-webauthn.ts index 9d0791cc302..087c18941f9 100644 --- a/web_src/js/features/user-auth-webauthn.ts +++ b/web_src/js/features/user-auth-webauthn.ts @@ -263,6 +263,11 @@ async function webAuthnRegisterRequest() { }); await webauthnRegistered(credential); } catch (err) { + // an already registered authenticator raises this + if (err instanceof DOMException && err.name === 'InvalidStateError') { + webAuthnError('duplicated'); + return; + } webAuthnError('unknown', errorMessage(err)); } }