mirror of
https://github.com/go-gitea/gitea.git
synced 2024-11-27 12:39:29 +08:00
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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"}}
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user