Skip to content

Commit

Permalink
Add Passkey login support (#31504)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
anbraten and silverwind committed Jun 29, 2024
1 parent 5821d22 commit 91745ae
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 11 deletions.
2 changes: 1 addition & 1 deletion models/auth/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions modules/auth/webauthn/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func Init() {
RPID: setting.Domain,
RPOrigins: []string{appURL},
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: "discouraged",
UserVerification: protocol.VerificationDiscouraged,
},
AttestationPreference: protocol.PreferDirectAttestation,
},
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions routers/web/auth/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package auth

import (
"encoding/binary"
"errors"
"net/http"

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion routers/web/user/setting/security/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
6 changes: 6 additions & 0 deletions templates/user/auth/signin_inner.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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}}">
Expand Down Expand Up @@ -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"}}
Expand Down
77 changes: 70 additions & 7 deletions web_src/js/features/user-auth-webauthn.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
if (!detectWebAuthnSupport()) {
return;
}

if (!detectWebAuthnSupport()) {
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 ?? []) {
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.status !== 200) {
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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down

0 comments on commit 91745ae

Please sign in to comment.