Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Passkey login support #31504

Merged
merged 10 commits into from
Jun 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
anbraten marked this conversation as resolved.
Show resolved Hide resolved
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"))
delvh marked this conversation as resolved.
Show resolved Hide resolved
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) {
delvh marked this conversation as resolved.
Show resolved Hide resolved
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;
}
anbraten marked this conversation as resolved.
Show resolved Hide resolved
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