]> source.dussan.org Git - gitea.git/commitdiff
Add Passkey login support (#31504)
authorAnbraten <6918444+anbraten@users.noreply.github.com>
Sat, 29 Jun 2024 22:50:03 +0000 (00:50 +0200)
committerGitHub <noreply@github.com>
Sat, 29 Jun 2024 22:50:03 +0000 (22:50 +0000)
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>
models/auth/webauthn.go
modules/auth/webauthn/webauthn.go
options/locale/locale_en-US.ini
routers/web/auth/webauthn.go
routers/web/user/setting/security/webauthn.go
routers/web/web.go
templates/user/auth/signin_inner.tmpl
web_src/js/features/user-auth-webauthn.js

index a65d2e1e343db52f125cfcd22dc1f0d6a93cabdd..553130ee2e9ee32d71912dbebe0c99f492aa4e55 100644 (file)
@@ -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 {
index 189d197333e002f5fcae2af9c7272c05dbfc704d..790006ee567cf206b7a4e82ef12eaea3ddaf6488 100644 (file)
@@ -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 {
index 815cba6eecaf155d570711a17956359ee6cd8928..d10f61f2ffc9ebe36f74107d63d98a8279f11b8d 100644 (file)
@@ -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
index 1079f44a085b34696988cc3641412a1ba3eee979..3160c5e23f03c1a8d2ac979207079137356aaf4d 100644 (file)
@@ -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.
index e382c8b9af413c765c56448abd3ed52397372aa6..1b8d0171f56338e8c0a0b9887376ca8be81a5ea1 100644 (file)
@@ -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
index 9f9a1bb0988e65c928229de117d415578642d798..d08e8da772857e999f35ea18416b38a7b6fd21d5 100644 (file)
@@ -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)
                })
index 9872096fbc6affeb0cc426c1939c7fd2aa474bfc..51e0e3b98256340f9fc9a83a35e8613666546318 100644 (file)
@@ -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}}">
                </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"}}
index ea26614ba7696436d372a9696bd2c2e6c38d0ee8..a317fee7e27639b724cd4283880b243446d442c0 100644 (file)
@@ -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;
+    }
+    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,
@@ -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;
   }