diff --git a/models/auth/webauthn.go b/models/auth/webauthn.go index a65d2e1e343..553130ee2e9 100644 --- a/models/auth/webauthn.go +++ b/models/auth/webauthn.go @@ -181,7 +181,7 @@ func DeleteCredential(ctx context.Context, id, userID int64) (bool, error) { return had > 0, err } -// WebAuthnCredentials implementns the webauthn.User interface +// WebAuthnCredentials implements the webauthn.User interface func WebAuthnCredentials(ctx context.Context, userID int64) ([]webauthn.Credential, error) { dbCreds, err := GetWebAuthnCredentialsByUID(ctx, userID) if err != nil { diff --git a/modules/auth/webauthn/webauthn.go b/modules/auth/webauthn/webauthn.go index 189d197333e..790006ee567 100644 --- a/modules/auth/webauthn/webauthn.go +++ b/modules/auth/webauthn/webauthn.go @@ -31,7 +31,7 @@ func Init() { RPID: setting.Domain, RPOrigins: []string{appURL}, AuthenticatorSelection: protocol.AuthenticatorSelection{ - UserVerification: "discouraged", + UserVerification: protocol.VerificationDiscouraged, }, AttestationPreference: protocol.PreferDirectAttestation, }, @@ -66,7 +66,7 @@ func (u *User) WebAuthnIcon() string { return (*user_model.User)(u).AvatarLink(db.DefaultContext) } -// WebAuthnCredentials implementns the webauthn.User interface +// WebAuthnCredentials implements the webauthn.User interface func (u *User) WebAuthnCredentials() []webauthn.Credential { dbCreds, err := auth.GetWebAuthnCredentialsByUID(db.DefaultContext, u.ID) if err != nil { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 815cba6eeca..d10f61f2ffc 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -458,6 +458,7 @@ sspi_auth_failed = SSPI authentication failed password_pwned = The password you chose is on a list of stolen passwords previously exposed in public data breaches. Please try again with a different password and consider changing this password elsewhere too. password_pwned_err = Could not complete request to HaveIBeenPwned last_admin = You cannot remove the last admin. There must be at least one admin. +signin_passkey = Sign in with a passkey [mail] view_it_on = View it on %s diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go index 1079f44a085..3160c5e23f0 100644 --- a/routers/web/auth/webauthn.go +++ b/routers/web/auth/webauthn.go @@ -4,6 +4,7 @@ package auth import ( + "encoding/binary" "errors" "net/http" @@ -47,6 +48,104 @@ func WebAuthn(ctx *context.Context) { ctx.HTML(http.StatusOK, tplWebAuthn) } +// WebAuthnPasskeyAssertion submits a WebAuthn challenge for the passkey login to the browser +func WebAuthnPasskeyAssertion(ctx *context.Context) { + assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin() + if err != nil { + ctx.ServerError("webauthn.BeginDiscoverableLogin", err) + return + } + + if err := ctx.Session.Set("webauthnPasskeyAssertion", sessionData); err != nil { + ctx.ServerError("Session.Set", err) + return + } + + ctx.JSON(http.StatusOK, assertion) +} + +// WebAuthnPasskeyLogin handles the WebAuthn login process using a Passkey +func WebAuthnPasskeyLogin(ctx *context.Context) { + sessionData, okData := ctx.Session.Get("webauthnPasskeyAssertion").(*webauthn.SessionData) + if !okData || sessionData == nil { + ctx.ServerError("ctx.Session.Get", errors.New("not in WebAuthn session")) + return + } + defer func() { + _ = ctx.Session.Delete("webauthnPasskeyAssertion") + }() + + // Validate the parsed response. + var user *user_model.User + cred, err := wa.WebAuthn.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) { + userID, n := binary.Varint(userHandle) + if n <= 0 { + return nil, errors.New("invalid rawID") + } + + var err error + user, err = user_model.GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + + return (*wa.User)(user), nil + }, *sessionData, ctx.Req) + if err != nil { + // Failed authentication attempt. + log.Info("Failed authentication attempt for passkey from %s: %v", ctx.RemoteAddr(), err) + ctx.Status(http.StatusForbidden) + return + } + + if !cred.Flags.UserPresent { + ctx.Status(http.StatusBadRequest) + return + } + + if user == nil { + ctx.Status(http.StatusBadRequest) + return + } + + // Ensure that the credential wasn't cloned by checking if CloneWarning is set. + // (This is set if the sign counter is less than the one we have stored.) + if cred.Authenticator.CloneWarning { + log.Info("Failed authentication attempt for %s from %s: cloned credential", user.Name, ctx.RemoteAddr()) + ctx.Status(http.StatusForbidden) + return + } + + // Success! Get the credential and update the sign count with the new value we received. + dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID) + if err != nil { + ctx.ServerError("GetWebAuthnCredentialByCredID", err) + return + } + + dbCred.SignCount = cred.Authenticator.SignCount + if err := dbCred.UpdateSignCount(ctx); err != nil { + ctx.ServerError("UpdateSignCount", err) + return + } + + // Now handle account linking if that's requested + if ctx.Session.Get("linkAccount") != nil { + if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil { + ctx.ServerError("LinkAccountFromStore", err) + return + } + } + + remember := false // TODO: implement remember me + redirect := handleSignInFull(ctx, user, remember, false) + if redirect == "" { + redirect = setting.AppSubURL + "/" + } + + ctx.JSONRedirect(redirect) +} + // WebAuthnLoginAssertion submits a WebAuthn challenge to the browser func WebAuthnLoginAssertion(ctx *context.Context) { // Ensure user is in a WebAuthn session. diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index e382c8b9af4..1b8d0171f56 100644 --- a/routers/web/user/setting/security/webauthn.go +++ b/routers/web/user/setting/security/webauthn.go @@ -45,7 +45,9 @@ func WebAuthnRegister(ctx *context.Context) { return } - credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer)) + credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer), webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + })) if err != nil { ctx.ServerError("Unable to BeginRegistration", err) return diff --git a/routers/web/web.go b/routers/web/web.go index 9f9a1bb0988..d08e8da7728 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -535,6 +535,8 @@ func registerRoutes(m *web.Router) { }) m.Group("/webauthn", func() { m.Get("", auth.WebAuthn) + m.Get("/passkey/assertion", auth.WebAuthnPasskeyAssertion) + m.Post("/passkey/login", auth.WebAuthnPasskeyLogin) m.Get("/assertion", auth.WebAuthnLoginAssertion) m.Post("/assertion", auth.WebAuthnLoginAssertionPost) }) diff --git a/templates/user/auth/signin_inner.tmpl b/templates/user/auth/signin_inner.tmpl index 9872096fbc6..51e0e3b9825 100644 --- a/templates/user/auth/signin_inner.tmpl +++ b/templates/user/auth/signin_inner.tmpl @@ -9,6 +9,8 @@ {{end}}