From 91745ae46fe4387d53230624e0a1b126c5975603 Mon Sep 17 00:00:00 2001 From: Anbraten <6918444+anbraten@users.noreply.github.com> Date: Sun, 30 Jun 2024 00:50:03 +0200 Subject: [PATCH] Add Passkey login support (#31504) closes #22015 After adding a passkey, you can now simply login with it directly by clicking `Sign in with a passkey`. ![Screenshot from 2024-06-26 12-18-17](https://github.com/go-gitea/gitea/assets/6918444/079013c0-ed70-481c-8497-4427344bcdfc) Note for testing. You need to run gitea using `https` to get the full passkeys experience. --------- Co-authored-by: silverwind --- models/auth/webauthn.go | 2 +- modules/auth/webauthn/webauthn.go | 4 +- options/locale/locale_en-US.ini | 1 + routers/web/auth/webauthn.go | 99 +++++++++++++++++++ routers/web/user/setting/security/webauthn.go | 4 +- routers/web/web.go | 2 + templates/user/auth/signin_inner.tmpl | 6 ++ web_src/js/features/user-auth-webauthn.js | 83 ++++++++++++++-- 8 files changed, 187 insertions(+), 14 deletions(-) diff --git a/models/auth/webauthn.go b/models/auth/webauthn.go index a65d2e1e34..553130ee2e 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 189d197333..790006ee56 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 815cba6eec..d10f61f2ff 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 1079f44a08..3160c5e23f 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 e382c8b9af..1b8d0171f5 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 9f9a1bb098..d08e8da772 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 9872096fbc..51e0e3b982 100644 --- a/templates/user/auth/signin_inner.tmpl +++ b/templates/user/auth/signin_inner.tmpl @@ -9,6 +9,8 @@ {{end}}
+ {{template "user/auth/webauthn_error" .}} +
{{.CsrfTokenHtml}}
@@ -49,6 +51,10 @@
{{end}} + + {{if .OAuth2Providers}}
{{ctx.Locale.Tr "sign_in_or"}} diff --git a/web_src/js/features/user-auth-webauthn.js b/web_src/js/features/user-auth-webauthn.js index ea26614ba7..a317fee7e2 100644 --- a/web_src/js/features/user-auth-webauthn.js +++ b/web_src/js/features/user-auth-webauthn.js @@ -5,25 +5,88 @@ import {GET, POST} from '../modules/fetch.js'; const {appSubUrl} = window.config; export async function initUserAuthWebAuthn() { - const elPrompt = document.querySelector('.user.signin.webauthn-prompt'); - if (!elPrompt) { - return; - } - if (!detectWebAuthnSupport()) { return; } - const res = await GET(`${appSubUrl}/user/webauthn/assertion`); - if (res.status !== 200) { + const elSignInPasskeyBtn = document.querySelector('.signin-passkey'); + if (elSignInPasskeyBtn) { + elSignInPasskeyBtn.addEventListener('click', loginPasskey); + } + + const elPrompt = document.querySelector('.user.signin.webauthn-prompt'); + if (elPrompt) { + login2FA(); + } +} + +async function loginPasskey() { + const res = await GET(`${appSubUrl}/user/webauthn/passkey/assertion`); + if (!res.ok) { webAuthnError('unknown'); return; } + const options = await res.json(); options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge); - for (const cred of options.publicKey.allowCredentials) { + for (const cred of options.publicKey.allowCredentials ?? []) { cred.id = decodeURLEncodedBase64(cred.id); } + + try { + const credential = await navigator.credentials.get({ + publicKey: options.publicKey, + }); + + // Move data into Arrays in case it is super long + const authData = new Uint8Array(credential.response.authenticatorData); + const clientDataJSON = new Uint8Array(credential.response.clientDataJSON); + const rawId = new Uint8Array(credential.rawId); + const sig = new Uint8Array(credential.response.signature); + const userHandle = new Uint8Array(credential.response.userHandle); + + const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, { + data: { + id: credential.id, + rawId: encodeURLEncodedBase64(rawId), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + authenticatorData: encodeURLEncodedBase64(authData), + clientDataJSON: encodeURLEncodedBase64(clientDataJSON), + signature: encodeURLEncodedBase64(sig), + userHandle: encodeURLEncodedBase64(userHandle), + }, + }, + }); + if (res.status === 500) { + webAuthnError('unknown'); + return; + } else if (!res.ok) { + webAuthnError('unable-to-process'); + return; + } + const reply = await res.json(); + + window.location.href = reply?.redirect ?? `${appSubUrl}/`; + } catch (err) { + webAuthnError('general', err.message); + } +} + +async function login2FA() { + const res = await GET(`${appSubUrl}/user/webauthn/assertion`); + if (!res.ok) { + webAuthnError('unknown'); + return; + } + + const options = await res.json(); + options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge); + for (const cred of options.publicKey.allowCredentials ?? []) { + cred.id = decodeURLEncodedBase64(cred.id); + } + try { const credential = await navigator.credentials.get({ publicKey: options.publicKey, @@ -71,7 +134,7 @@ async function verifyAssertion(assertedCredential) { if (res.status === 500) { webAuthnError('unknown'); return; - } else if (res.status !== 200) { + } else if (!res.ok) { webAuthnError('unable-to-process'); return; } @@ -167,7 +230,7 @@ async function webAuthnRegisterRequest() { if (res.status === 409) { webAuthnError('duplicated'); return; - } else if (res.status !== 200) { + } else if (!res.ok) { webAuthnError('unknown'); return; }