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 <me@silverwind.io>
pull/30205/merge
Anbraten 2024-06-30 00:50:03 +02:00 committed by GitHub
parent 5821d22891
commit 91745ae46f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 187 additions and 14 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -458,6 +458,7 @@ sspi_auth_failed = SSPI authentication failed
password_pwned = The password you chose is on a <a target="_blank" rel="noopener noreferrer" href="https://haveibeenpwned.com/Passwords">list of stolen passwords</a> 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

View File

@ -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.

View File

@ -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

View File

@ -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)
})

View File

@ -9,6 +9,8 @@
{{end}}
</h4>
<div class="ui attached segment">
{{template "user/auth/webauthn_error" .}}
<form class="ui form tw-max-w-2xl tw-m-auto" action="{{.SignInLink}}" method="post">
{{.CsrfTokenHtml}}
<div class="required field {{if and (.Err_UserName) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
@ -49,6 +51,10 @@
</div>
{{end}}
<div class="field">
<a class="signin-passkey">{{ctx.Locale.Tr "auth.signin_passkey"}}</a>
</div>
{{if .OAuth2Providers}}
<div class="divider divider-text">
{{ctx.Locale.Tr "sign_in_or"}}

View File

@ -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;
}