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>
This commit is contained in:
parent
5821d22891
commit
91745ae46f
@ -181,7 +181,7 @@ func DeleteCredential(ctx context.Context, id, userID int64) (bool, error) {
|
|||||||
return had > 0, err
|
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) {
|
func WebAuthnCredentials(ctx context.Context, userID int64) ([]webauthn.Credential, error) {
|
||||||
dbCreds, err := GetWebAuthnCredentialsByUID(ctx, userID)
|
dbCreds, err := GetWebAuthnCredentialsByUID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -31,7 +31,7 @@ func Init() {
|
|||||||
RPID: setting.Domain,
|
RPID: setting.Domain,
|
||||||
RPOrigins: []string{appURL},
|
RPOrigins: []string{appURL},
|
||||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||||
UserVerification: "discouraged",
|
UserVerification: protocol.VerificationDiscouraged,
|
||||||
},
|
},
|
||||||
AttestationPreference: protocol.PreferDirectAttestation,
|
AttestationPreference: protocol.PreferDirectAttestation,
|
||||||
},
|
},
|
||||||
@ -66,7 +66,7 @@ func (u *User) WebAuthnIcon() string {
|
|||||||
return (*user_model.User)(u).AvatarLink(db.DefaultContext)
|
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 {
|
func (u *User) WebAuthnCredentials() []webauthn.Credential {
|
||||||
dbCreds, err := auth.GetWebAuthnCredentialsByUID(db.DefaultContext, u.ID)
|
dbCreds, err := auth.GetWebAuthnCredentialsByUID(db.DefaultContext, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -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 = 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
|
password_pwned_err = Could not complete request to HaveIBeenPwned
|
||||||
last_admin = You cannot remove the last admin. There must be at least one admin.
|
last_admin = You cannot remove the last admin. There must be at least one admin.
|
||||||
|
signin_passkey = Sign in with a passkey
|
||||||
|
|
||||||
[mail]
|
[mail]
|
||||||
view_it_on = View it on %s
|
view_it_on = View it on %s
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@ -47,6 +48,104 @@ func WebAuthn(ctx *context.Context) {
|
|||||||
ctx.HTML(http.StatusOK, tplWebAuthn)
|
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
|
// WebAuthnLoginAssertion submits a WebAuthn challenge to the browser
|
||||||
func WebAuthnLoginAssertion(ctx *context.Context) {
|
func WebAuthnLoginAssertion(ctx *context.Context) {
|
||||||
// Ensure user is in a WebAuthn session.
|
// Ensure user is in a WebAuthn session.
|
||||||
|
@ -45,7 +45,9 @@ func WebAuthnRegister(ctx *context.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
ctx.ServerError("Unable to BeginRegistration", err)
|
ctx.ServerError("Unable to BeginRegistration", err)
|
||||||
return
|
return
|
||||||
|
@ -535,6 +535,8 @@ func registerRoutes(m *web.Router) {
|
|||||||
})
|
})
|
||||||
m.Group("/webauthn", func() {
|
m.Group("/webauthn", func() {
|
||||||
m.Get("", auth.WebAuthn)
|
m.Get("", auth.WebAuthn)
|
||||||
|
m.Get("/passkey/assertion", auth.WebAuthnPasskeyAssertion)
|
||||||
|
m.Post("/passkey/login", auth.WebAuthnPasskeyLogin)
|
||||||
m.Get("/assertion", auth.WebAuthnLoginAssertion)
|
m.Get("/assertion", auth.WebAuthnLoginAssertion)
|
||||||
m.Post("/assertion", auth.WebAuthnLoginAssertionPost)
|
m.Post("/assertion", auth.WebAuthnLoginAssertionPost)
|
||||||
})
|
})
|
||||||
|
@ -9,6 +9,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="ui attached segment">
|
<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">
|
<form class="ui form tw-max-w-2xl tw-m-auto" action="{{.SignInLink}}" method="post">
|
||||||
{{.CsrfTokenHtml}}
|
{{.CsrfTokenHtml}}
|
||||||
<div class="required field {{if and (.Err_UserName) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
|
<div class="required field {{if and (.Err_UserName) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
|
||||||
@ -49,6 +51,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<a class="signin-passkey">{{ctx.Locale.Tr "auth.signin_passkey"}}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if .OAuth2Providers}}
|
{{if .OAuth2Providers}}
|
||||||
<div class="divider divider-text">
|
<div class="divider divider-text">
|
||||||
{{ctx.Locale.Tr "sign_in_or"}}
|
{{ctx.Locale.Tr "sign_in_or"}}
|
||||||
|
@ -5,25 +5,88 @@ import {GET, POST} from '../modules/fetch.js';
|
|||||||
const {appSubUrl} = window.config;
|
const {appSubUrl} = window.config;
|
||||||
|
|
||||||
export async function initUserAuthWebAuthn() {
|
export async function initUserAuthWebAuthn() {
|
||||||
const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
|
|
||||||
if (!elPrompt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!detectWebAuthnSupport()) {
|
if (!detectWebAuthnSupport()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await GET(`${appSubUrl}/user/webauthn/assertion`);
|
const elSignInPasskeyBtn = document.querySelector('.signin-passkey');
|
||||||
if (res.status !== 200) {
|
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');
|
webAuthnError('unknown');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = await res.json();
|
const options = await res.json();
|
||||||
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
|
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);
|
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 {
|
try {
|
||||||
const credential = await navigator.credentials.get({
|
const credential = await navigator.credentials.get({
|
||||||
publicKey: options.publicKey,
|
publicKey: options.publicKey,
|
||||||
@ -71,7 +134,7 @@ async function verifyAssertion(assertedCredential) {
|
|||||||
if (res.status === 500) {
|
if (res.status === 500) {
|
||||||
webAuthnError('unknown');
|
webAuthnError('unknown');
|
||||||
return;
|
return;
|
||||||
} else if (res.status !== 200) {
|
} else if (!res.ok) {
|
||||||
webAuthnError('unable-to-process');
|
webAuthnError('unable-to-process');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -167,7 +230,7 @@ async function webAuthnRegisterRequest() {
|
|||||||
if (res.status === 409) {
|
if (res.status === 409) {
|
||||||
webAuthnError('duplicated');
|
webAuthnError('duplicated');
|
||||||
return;
|
return;
|
||||||
} else if (res.status !== 200) {
|
} else if (!res.ok) {
|
||||||
webAuthnError('unknown');
|
webAuthnError('unknown');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user