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 {
RPID: setting.Domain,
RPOrigins: []string{appURL},
AuthenticatorSelection: protocol.AuthenticatorSelection{
- UserVerification: "discouraged",
+ UserVerification: protocol.VerificationDiscouraged,
},
AttestationPreference: protocol.PreferDirectAttestation,
},
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 {
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
package auth
import (
+ "encoding/binary"
"errors"
"net/http"
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.
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
})
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)
})
{{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}}">
</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"}}
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,
if (res.status === 500) {
webAuthnError('unknown');
return;
- } else if (res.status !== 200) {
+ } else if (!res.ok) {
webAuthnError('unable-to-process');
return;
}
if (res.status === 409) {
webAuthnError('duplicated');
return;
- } else if (res.status !== 200) {
+ } else if (!res.ok) {
webAuthnError('unknown');
return;
}