mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 22:13:26 +09: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:
@@ -28,13 +28,10 @@ func Init() {
|
|||||||
|
|
||||||
WebAuthn = &webauthn.WebAuthn{
|
WebAuthn = &webauthn.WebAuthn{
|
||||||
Config: &webauthn.Config{
|
Config: &webauthn.Config{
|
||||||
RPDisplayName: setting.AppName,
|
RPDisplayName: setting.AppName,
|
||||||
RPID: setting.Domain,
|
RPID: setting.Domain,
|
||||||
RPOrigins: []string{appURL},
|
RPOrigins: []string{appURL},
|
||||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
|
||||||
UserVerification: protocol.VerificationDiscouraged,
|
|
||||||
},
|
|
||||||
AttestationPreference: protocol.PreferDirectAttestation,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,12 +73,9 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Session.Get("linkAccount") != nil {
|
if err = completePendingLinks(ctx, u); err != nil {
|
||||||
err = linkAccountFromContext(ctx, u)
|
ctx.ServerError("completePendingLinks", err)
|
||||||
if err != nil {
|
return
|
||||||
ctx.ServerError("UserSignIn", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||||
@@ -145,6 +142,11 @@ func TwoFactorScratchPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = completePendingLinks(ctx, u); err != nil {
|
||||||
|
ctx.ServerError("completePendingLinks", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleSignInFull(ctx, u, remember)
|
handleSignInFull(ctx, u, remember)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
|
|||||||
+17
-27
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"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.
|
// 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.
|
// 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 {
|
if err != nil {
|
||||||
ctx.ServerError("UserSignIn", err)
|
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !hasTwoFactor {
|
||||||
// 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
|
|
||||||
handleSignIn(ctx, u, form.Remember)
|
handleSignIn(ctx, u, form.Remember)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updates := map[string]any{
|
handleTwoFactorRequired(ctx, u, form.Remember, nil)
|
||||||
// User will need to use 2FA TOTP or WebAuthn, save data
|
}
|
||||||
"twofaUid": u.ID,
|
|
||||||
"twofaRemember": form.Remember,
|
func handleTwoFactorRequired(ctx *context.Context, u *user_model.User, remember bool, extra map[string]any) {
|
||||||
}
|
updates := map[string]any{"twofaUid": u.ID, "twofaRemember": remember}
|
||||||
if hasTOTPtwofa {
|
maps.Copy(updates, extra)
|
||||||
// User will need to use WebAuthn, save data
|
|
||||||
updates["totpEnrolled"] = u.ID
|
|
||||||
}
|
|
||||||
if err := regenerateSession(ctx, updates); err != nil {
|
if err := regenerateSession(ctx, updates); err != nil {
|
||||||
ctx.ServerError("UserSignIn: Unable to update session", err)
|
ctx.ServerError("RegenerateSession", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
|
||||||
// If we have WebAuthn redirect there first
|
if err != nil {
|
||||||
if hasWebAuthnTwofa {
|
ctx.ServerError("HasWebAuthnRegistrationsByUID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasWebAuthn {
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
|
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to 2FA
|
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/session"
|
"gitea.dev/modules/session"
|
||||||
"gitea.dev/modules/setting"
|
"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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// If this user is enrolled in 2FA, we can't sign the user in just yet.
|
||||||
// Instead, redirect them to the 2FA authentication page.
|
// 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
|
// 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 err != nil {
|
||||||
if !auth.IsErrTwoFactorNotEnrolled(err) {
|
ctx.ServerError("UserLinkAccount", err)
|
||||||
ctx.ServerError("UserLinkAccount", err)
|
return
|
||||||
return
|
}
|
||||||
}
|
if !hasTwoFactor {
|
||||||
|
if err := externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser); err != nil {
|
||||||
err = externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("UserLinkAccount", err)
|
ctx.ServerError("UserLinkAccount", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -170,25 +168,10 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := regenerateSession(ctx, map[string]any{
|
handleTwoFactorRequired(ctx, u, remember, map[string]any{
|
||||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
|
||||||
"twofaUid": u.ID,
|
|
||||||
"twofaRemember": remember,
|
|
||||||
"linkAccount": true,
|
"linkAccount": true,
|
||||||
session.KeySignInMethod: session.SignInMethodOAuth2,
|
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
|
// 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)
|
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 {
|
func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
|
||||||
linkAccountData := oauth2GetLinkAccountData(ctx)
|
linkAccountData := oauth2GetLinkAccountData(ctx)
|
||||||
if linkAccountData == nil {
|
if linkAccountData == nil {
|
||||||
|
|||||||
@@ -361,12 +361,11 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
|||||||
|
|
||||||
needs2FA := false
|
needs2FA := false
|
||||||
if !authSource.TwoFactorShouldSkip() {
|
if !authSource.TwoFactorShouldSkip() {
|
||||||
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
var err error
|
||||||
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
|
if needs2FA, err = auth.HasTwoFactorOrWebAuthn(ctx, u.ID); err != nil {
|
||||||
ctx.ServerError("UserSignIn", err)
|
ctx.ServerError("UserSignIn", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
needs2FA = err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
oauth2Source := authSource.Cfg.(*oauth2.Source)
|
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{
|
handleTwoFactorRequired(ctx, u, false, map[string]any{session.KeySignInMethod: session.SignInMethodOAuth2})
|
||||||
// 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
|
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
auth_model "gitea.dev/models/auth"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/auth/openid"
|
"gitea.dev/modules/auth/openid"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
@@ -26,6 +27,36 @@ const (
|
|||||||
tplSignUpOID templates.TplName = "user/auth/signup_openid_register"
|
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
|
// SignInOpenID render sign in page
|
||||||
func SignInOpenID(ctx *context.Context) {
|
func SignInOpenID(ctx *context.Context) {
|
||||||
ctx.Data["Title"] = ctx.Tr("sign_in")
|
ctx.Data["Title"] = ctx.Tr("sign_in")
|
||||||
@@ -154,6 +185,10 @@ func signInOpenIDVerify(ctx *context.Context) {
|
|||||||
log.Trace("User exists, logging in")
|
log.Trace("User exists, logging in")
|
||||||
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
|
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
|
||||||
log.Trace("Session stored openid-remember: %t", remember)
|
log.Trace("Session stored openid-remember: %t", remember)
|
||||||
|
openIDRequireTwoFactor(ctx, u, remember, "")
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
handleSignIn(ctx, u, remember)
|
handleSignIn(ctx, u, remember)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -270,7 +305,12 @@ func ConnectOpenIDPost(ctx *context.Context) {
|
|||||||
return
|
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}
|
userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
|
||||||
if err := user_model.AddUserOpenID(ctx, userOID); err != nil {
|
if err := user_model.AddUserOpenID(ctx, userOID); err != nil {
|
||||||
if user_model.IsErrOpenIDAlreadyUsed(err) {
|
if user_model.IsErrOpenIDAlreadyUsed(err) {
|
||||||
@@ -282,9 +322,6 @@ func ConnectOpenIDPost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
|
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)
|
handleSignIn(ctx, u, remember)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,19 @@ func ResetPasswdPost(ctx *context.Context) {
|
|||||||
return
|
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)
|
handleSignIn(ctx, u, remember)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ func WebAuthnPasskeyAssertion(ctx *context.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
|
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
|
||||||
return
|
return
|
||||||
@@ -91,7 +92,7 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
|
|||||||
parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req)
|
parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Failed authentication attempt.
|
// 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)
|
ctx.Status(http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,12 +148,9 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now handle account linking if that's requested
|
if err := completePendingLinks(ctx, user); err != nil {
|
||||||
if ctx.Session.Get("linkAccount") != nil {
|
ctx.ServerError("completePendingLinks", err)
|
||||||
if err := linkAccountFromContext(ctx, user); err != nil {
|
return
|
||||||
ctx.ServerError("LinkAccountFromStore", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remember := false // TODO: implement remember me
|
remember := false // TODO: implement remember me
|
||||||
@@ -186,7 +184,8 @@ func WebAuthnLoginAssertion(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
webAuthnUser := wa.NewWebAuthnUser(ctx, user)
|
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 {
|
if err != nil {
|
||||||
ctx.ServerError("webauthn.BeginLogin", err)
|
ctx.ServerError("webauthn.BeginLogin", err)
|
||||||
return
|
return
|
||||||
@@ -261,12 +260,9 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now handle account linking if that's requested
|
if err := completePendingLinks(ctx, user); err != nil {
|
||||||
if ctx.Session.Get("linkAccount") != nil {
|
ctx.ServerError("completePendingLinks", err)
|
||||||
if err := linkAccountFromContext(ctx, user); err != nil {
|
return
|
||||||
ctx.ServerError("LinkAccountFromStore", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remember := ctx.Session.Get("twofaRemember").(bool)
|
remember := ctx.Session.Get("twofaRemember").(bool)
|
||||||
|
|||||||
@@ -53,8 +53,17 @@ func WebAuthnRegister(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer)
|
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,
|
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 {
|
if err != nil {
|
||||||
ctx.ServerError("Unable to BeginRegistration", err)
|
ctx.ServerError("Unable to BeginRegistration", err)
|
||||||
|
|||||||
@@ -64,4 +64,5 @@ func ClearSessionKeysForSignIn(sess SessionStore) {
|
|||||||
_ = sess.Delete("webauthnAssertion")
|
_ = sess.Delete("webauthnAssertion")
|
||||||
_ = sess.Delete("linkAccount")
|
_ = sess.Delete("linkAccount")
|
||||||
_ = sess.Delete("linkAccountData")
|
_ = sess.Delete("linkAccountData")
|
||||||
|
_ = sess.Delete("openidPendingURI")
|
||||||
}
|
}
|
||||||
|
|||||||
+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. */
|
/** 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) {
|
export function apiUserHeaders(username: string) {
|
||||||
return apiAuthHeader(username, testUserPassword);
|
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/services/auth/source/oauth2"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -570,3 +571,65 @@ func TestOAuth2AutoLinkWithTwoFactor(t *testing.T) {
|
|||||||
|
|
||||||
session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK)
|
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)
|
||||||
|
}
|
||||||
@@ -263,6 +263,11 @@ async function webAuthnRegisterRequest() {
|
|||||||
});
|
});
|
||||||
await webauthnRegistered(credential);
|
await webauthnRegistered(credential);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// an already registered authenticator raises this
|
||||||
|
if (err instanceof DOMException && err.name === 'InvalidStateError') {
|
||||||
|
webAuthnError('duplicated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
webAuthnError('unknown', errorMessage(err));
|
webAuthnError('unknown', errorMessage(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user