Skip to content

Commit 91745ae

Browse files
anbratensilverwind
andauthored
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 <[email protected]>
1 parent 5821d22 commit 91745ae

File tree

8 files changed

+184
-11
lines changed

8 files changed

+184
-11
lines changed

models/auth/webauthn.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ func DeleteCredential(ctx context.Context, id, userID int64) (bool, error) {
181181
return had > 0, err
182182
}
183183

184-
// WebAuthnCredentials implementns the webauthn.User interface
184+
// WebAuthnCredentials implements the webauthn.User interface
185185
func WebAuthnCredentials(ctx context.Context, userID int64) ([]webauthn.Credential, error) {
186186
dbCreds, err := GetWebAuthnCredentialsByUID(ctx, userID)
187187
if err != nil {

modules/auth/webauthn/webauthn.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func Init() {
3131
RPID: setting.Domain,
3232
RPOrigins: []string{appURL},
3333
AuthenticatorSelection: protocol.AuthenticatorSelection{
34-
UserVerification: "discouraged",
34+
UserVerification: protocol.VerificationDiscouraged,
3535
},
3636
AttestationPreference: protocol.PreferDirectAttestation,
3737
},
@@ -66,7 +66,7 @@ func (u *User) WebAuthnIcon() string {
6666
return (*user_model.User)(u).AvatarLink(db.DefaultContext)
6767
}
6868

69-
// WebAuthnCredentials implementns the webauthn.User interface
69+
// WebAuthnCredentials implements the webauthn.User interface
7070
func (u *User) WebAuthnCredentials() []webauthn.Credential {
7171
dbCreds, err := auth.GetWebAuthnCredentialsByUID(db.DefaultContext, u.ID)
7272
if err != nil {

options/locale/locale_en-US.ini

+1
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ sspi_auth_failed = SSPI authentication failed
458458
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.
459459
password_pwned_err = Could not complete request to HaveIBeenPwned
460460
last_admin = You cannot remove the last admin. There must be at least one admin.
461+
signin_passkey = Sign in with a passkey
461462
462463
[mail]
463464
view_it_on = View it on %s

routers/web/auth/webauthn.go

+99
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package auth
55

66
import (
7+
"encoding/binary"
78
"errors"
89
"net/http"
910

@@ -47,6 +48,104 @@ func WebAuthn(ctx *context.Context) {
4748
ctx.HTML(http.StatusOK, tplWebAuthn)
4849
}
4950

51+
// WebAuthnPasskeyAssertion submits a WebAuthn challenge for the passkey login to the browser
52+
func WebAuthnPasskeyAssertion(ctx *context.Context) {
53+
assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin()
54+
if err != nil {
55+
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
56+
return
57+
}
58+
59+
if err := ctx.Session.Set("webauthnPasskeyAssertion", sessionData); err != nil {
60+
ctx.ServerError("Session.Set", err)
61+
return
62+
}
63+
64+
ctx.JSON(http.StatusOK, assertion)
65+
}
66+
67+
// WebAuthnPasskeyLogin handles the WebAuthn login process using a Passkey
68+
func WebAuthnPasskeyLogin(ctx *context.Context) {
69+
sessionData, okData := ctx.Session.Get("webauthnPasskeyAssertion").(*webauthn.SessionData)
70+
if !okData || sessionData == nil {
71+
ctx.ServerError("ctx.Session.Get", errors.New("not in WebAuthn session"))
72+
return
73+
}
74+
defer func() {
75+
_ = ctx.Session.Delete("webauthnPasskeyAssertion")
76+
}()
77+
78+
// Validate the parsed response.
79+
var user *user_model.User
80+
cred, err := wa.WebAuthn.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
81+
userID, n := binary.Varint(userHandle)
82+
if n <= 0 {
83+
return nil, errors.New("invalid rawID")
84+
}
85+
86+
var err error
87+
user, err = user_model.GetUserByID(ctx, userID)
88+
if err != nil {
89+
return nil, err
90+
}
91+
92+
return (*wa.User)(user), nil
93+
}, *sessionData, ctx.Req)
94+
if err != nil {
95+
// Failed authentication attempt.
96+
log.Info("Failed authentication attempt for passkey from %s: %v", ctx.RemoteAddr(), err)
97+
ctx.Status(http.StatusForbidden)
98+
return
99+
}
100+
101+
if !cred.Flags.UserPresent {
102+
ctx.Status(http.StatusBadRequest)
103+
return
104+
}
105+
106+
if user == nil {
107+
ctx.Status(http.StatusBadRequest)
108+
return
109+
}
110+
111+
// Ensure that the credential wasn't cloned by checking if CloneWarning is set.
112+
// (This is set if the sign counter is less than the one we have stored.)
113+
if cred.Authenticator.CloneWarning {
114+
log.Info("Failed authentication attempt for %s from %s: cloned credential", user.Name, ctx.RemoteAddr())
115+
ctx.Status(http.StatusForbidden)
116+
return
117+
}
118+
119+
// Success! Get the credential and update the sign count with the new value we received.
120+
dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID)
121+
if err != nil {
122+
ctx.ServerError("GetWebAuthnCredentialByCredID", err)
123+
return
124+
}
125+
126+
dbCred.SignCount = cred.Authenticator.SignCount
127+
if err := dbCred.UpdateSignCount(ctx); err != nil {
128+
ctx.ServerError("UpdateSignCount", err)
129+
return
130+
}
131+
132+
// Now handle account linking if that's requested
133+
if ctx.Session.Get("linkAccount") != nil {
134+
if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil {
135+
ctx.ServerError("LinkAccountFromStore", err)
136+
return
137+
}
138+
}
139+
140+
remember := false // TODO: implement remember me
141+
redirect := handleSignInFull(ctx, user, remember, false)
142+
if redirect == "" {
143+
redirect = setting.AppSubURL + "/"
144+
}
145+
146+
ctx.JSONRedirect(redirect)
147+
}
148+
50149
// WebAuthnLoginAssertion submits a WebAuthn challenge to the browser
51150
func WebAuthnLoginAssertion(ctx *context.Context) {
52151
// Ensure user is in a WebAuthn session.

routers/web/user/setting/security/webauthn.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ func WebAuthnRegister(ctx *context.Context) {
4545
return
4646
}
4747

48-
credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer))
48+
credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer), webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
49+
ResidentKey: protocol.ResidentKeyRequirementRequired,
50+
}))
4951
if err != nil {
5052
ctx.ServerError("Unable to BeginRegistration", err)
5153
return

routers/web/web.go

+2
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,8 @@ func registerRoutes(m *web.Router) {
535535
})
536536
m.Group("/webauthn", func() {
537537
m.Get("", auth.WebAuthn)
538+
m.Get("/passkey/assertion", auth.WebAuthnPasskeyAssertion)
539+
m.Post("/passkey/login", auth.WebAuthnPasskeyLogin)
538540
m.Get("/assertion", auth.WebAuthnLoginAssertion)
539541
m.Post("/assertion", auth.WebAuthnLoginAssertionPost)
540542
})

templates/user/auth/signin_inner.tmpl

+6
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
{{end}}
1010
</h4>
1111
<div class="ui attached segment">
12+
{{template "user/auth/webauthn_error" .}}
13+
1214
<form class="ui form tw-max-w-2xl tw-m-auto" action="{{.SignInLink}}" method="post">
1315
{{.CsrfTokenHtml}}
1416
<div class="required field {{if and (.Err_UserName) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
@@ -49,6 +51,10 @@
4951
</div>
5052
{{end}}
5153

54+
<div class="field">
55+
<a class="signin-passkey">{{ctx.Locale.Tr "auth.signin_passkey"}}</a>
56+
</div>
57+
5258
{{if .OAuth2Providers}}
5359
<div class="divider divider-text">
5460
{{ctx.Locale.Tr "sign_in_or"}}

web_src/js/features/user-auth-webauthn.js

+70-7
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,88 @@ import {GET, POST} from '../modules/fetch.js';
55
const {appSubUrl} = window.config;
66

77
export async function initUserAuthWebAuthn() {
8-
const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
9-
if (!elPrompt) {
8+
if (!detectWebAuthnSupport()) {
109
return;
1110
}
1211

13-
if (!detectWebAuthnSupport()) {
12+
const elSignInPasskeyBtn = document.querySelector('.signin-passkey');
13+
if (elSignInPasskeyBtn) {
14+
elSignInPasskeyBtn.addEventListener('click', loginPasskey);
15+
}
16+
17+
const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
18+
if (elPrompt) {
19+
login2FA();
20+
}
21+
}
22+
23+
async function loginPasskey() {
24+
const res = await GET(`${appSubUrl}/user/webauthn/passkey/assertion`);
25+
if (!res.ok) {
26+
webAuthnError('unknown');
1427
return;
1528
}
1629

30+
const options = await res.json();
31+
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
32+
for (const cred of options.publicKey.allowCredentials ?? []) {
33+
cred.id = decodeURLEncodedBase64(cred.id);
34+
}
35+
36+
try {
37+
const credential = await navigator.credentials.get({
38+
publicKey: options.publicKey,
39+
});
40+
41+
// Move data into Arrays in case it is super long
42+
const authData = new Uint8Array(credential.response.authenticatorData);
43+
const clientDataJSON = new Uint8Array(credential.response.clientDataJSON);
44+
const rawId = new Uint8Array(credential.rawId);
45+
const sig = new Uint8Array(credential.response.signature);
46+
const userHandle = new Uint8Array(credential.response.userHandle);
47+
48+
const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, {
49+
data: {
50+
id: credential.id,
51+
rawId: encodeURLEncodedBase64(rawId),
52+
type: credential.type,
53+
clientExtensionResults: credential.getClientExtensionResults(),
54+
response: {
55+
authenticatorData: encodeURLEncodedBase64(authData),
56+
clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
57+
signature: encodeURLEncodedBase64(sig),
58+
userHandle: encodeURLEncodedBase64(userHandle),
59+
},
60+
},
61+
});
62+
if (res.status === 500) {
63+
webAuthnError('unknown');
64+
return;
65+
} else if (!res.ok) {
66+
webAuthnError('unable-to-process');
67+
return;
68+
}
69+
const reply = await res.json();
70+
71+
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
72+
} catch (err) {
73+
webAuthnError('general', err.message);
74+
}
75+
}
76+
77+
async function login2FA() {
1778
const res = await GET(`${appSubUrl}/user/webauthn/assertion`);
18-
if (res.status !== 200) {
79+
if (!res.ok) {
1980
webAuthnError('unknown');
2081
return;
2182
}
83+
2284
const options = await res.json();
2385
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
24-
for (const cred of options.publicKey.allowCredentials) {
86+
for (const cred of options.publicKey.allowCredentials ?? []) {
2587
cred.id = decodeURLEncodedBase64(cred.id);
2688
}
89+
2790
try {
2891
const credential = await navigator.credentials.get({
2992
publicKey: options.publicKey,
@@ -71,7 +134,7 @@ async function verifyAssertion(assertedCredential) {
71134
if (res.status === 500) {
72135
webAuthnError('unknown');
73136
return;
74-
} else if (res.status !== 200) {
137+
} else if (!res.ok) {
75138
webAuthnError('unable-to-process');
76139
return;
77140
}
@@ -167,7 +230,7 @@ async function webAuthnRegisterRequest() {
167230
if (res.status === 409) {
168231
webAuthnError('duplicated');
169232
return;
170-
} else if (res.status !== 200) {
233+
} else if (!res.ok) {
171234
webAuthnError('unknown');
172235
return;
173236
}

0 commit comments

Comments
 (0)