From d03e7fd65eb47f5df267ff657c7ed0e9971b06c8 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Sat, 8 Mar 2025 03:31:25 +0800
Subject: [PATCH] Support disable passkey auth (#33348) (#33819)

* Backport #33348
* Backport #33820

---------

Co-authored-by: yp05327 <576951401@qq.com>
---
 custom/conf/app.example.ini               |  5 ++++-
 modules/setting/service.go                |  2 ++
 routers/web/auth/auth.go                  |  1 +
 routers/web/auth/linkaccount.go           |  3 +++
 routers/web/auth/webauthn.go              | 10 ++++++++++
 templates/user/auth/signin_inner.tmpl     |  7 ++++---
 tests/integration/signin_test.go          | 20 +++++++++++++++++++-
 web_src/js/features/user-auth-webauthn.ts | 11 ++++++++++-
 8 files changed, 53 insertions(+), 6 deletions(-)

diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 6896b073e1..b417baae8b 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -784,10 +784,13 @@ LEVEL = Info
 ;; Please note that setting this to false will not disable OAuth Basic or Basic authentication using a token
 ;ENABLE_BASIC_AUTHENTICATION = true
 ;;
-;; Show the password sign-in form (for password-based login), otherwise, only show OAuth2 login methods.
+;; Show the password sign-in form (for password-based login), otherwise, only show OAuth2 or passkey login methods if they are enabled.
 ;; If you set it to false, maybe it also needs to set ENABLE_BASIC_AUTHENTICATION to false to completely disable password-based authentication.
 ;ENABLE_PASSWORD_SIGNIN_FORM = true
 ;;
+;; Allow users to sign-in with a passkey
+;ENABLE_PASSKEY_AUTHENTICATION = true
+;;
 ;; More detail: https://github.com/gogits/gogs/issues/165
 ;ENABLE_REVERSE_PROXY_AUTHENTICATION = false
 ; Enable this to allow reverse proxy authentication for API requests, the reverse proxy is responsible for ensuring that no CSRF is possible.
diff --git a/modules/setting/service.go b/modules/setting/service.go
index 526ad64eb4..8c1843eeb7 100644
--- a/modules/setting/service.go
+++ b/modules/setting/service.go
@@ -46,6 +46,7 @@ var Service = struct {
 	RequireSignInView                       bool
 	EnableNotifyMail                        bool
 	EnableBasicAuth                         bool
+	EnablePasskeyAuth                       bool
 	EnableReverseProxyAuth                  bool
 	EnableReverseProxyAuthAPI               bool
 	EnableReverseProxyAutoRegister          bool
@@ -161,6 +162,7 @@ func loadServiceFrom(rootCfg ConfigProvider) {
 	Service.RequireSignInView = sec.Key("REQUIRE_SIGNIN_VIEW").MustBool()
 	Service.EnableBasicAuth = sec.Key("ENABLE_BASIC_AUTHENTICATION").MustBool(true)
 	Service.EnablePasswordSignInForm = sec.Key("ENABLE_PASSWORD_SIGNIN_FORM").MustBool(true)
+	Service.EnablePasskeyAuth = sec.Key("ENABLE_PASSKEY_AUTHENTICATION").MustBool(true)
 	Service.EnableReverseProxyAuth = sec.Key("ENABLE_REVERSE_PROXY_AUTHENTICATION").MustBool()
 	Service.EnableReverseProxyAuthAPI = sec.Key("ENABLE_REVERSE_PROXY_AUTHENTICATION_API").MustBool()
 	Service.EnableReverseProxyAutoRegister = sec.Key("ENABLE_REVERSE_PROXY_AUTO_REGISTRATION").MustBool()
diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go
index 8ddb9730c3..ee70889a53 100644
--- a/routers/web/auth/auth.go
+++ b/routers/web/auth/auth.go
@@ -169,6 +169,7 @@ func prepareSignInPageData(ctx *context.Context) {
 	ctx.Data["PageIsLogin"] = true
 	ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx)
 	ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm
+	ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth
 
 	if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
 		context.SetCaptchaData(ctx)
diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go
index ba61a1ad1a..a476536f36 100644
--- a/routers/web/auth/linkaccount.go
+++ b/routers/web/auth/linkaccount.go
@@ -46,6 +46,7 @@ func LinkAccount(ctx *context.Context) {
 	ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
 	ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm
 	ctx.Data["ShowRegistrationButton"] = false
+	ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth
 
 	// use this to set the right link into the signIn and signUp templates in the link_account template
 	ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
@@ -145,6 +146,7 @@ func LinkAccountPostSignIn(ctx *context.Context) {
 	ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
 	ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm
 	ctx.Data["ShowRegistrationButton"] = false
+	ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth
 
 	// use this to set the right link into the signIn and signUp templates in the link_account template
 	ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
@@ -235,6 +237,7 @@ func LinkAccountPostRegister(ctx *context.Context) {
 	ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
 	ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm
 	ctx.Data["ShowRegistrationButton"] = false
+	ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth
 
 	// use this to set the right link into the signIn and signUp templates in the link_account template
 	ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go
index ba25d45070..d03bcaaf03 100644
--- a/routers/web/auth/webauthn.go
+++ b/routers/web/auth/webauthn.go
@@ -50,6 +50,11 @@ func WebAuthn(ctx *context.Context) {
 
 // WebAuthnPasskeyAssertion submits a WebAuthn challenge for the passkey login to the browser
 func WebAuthnPasskeyAssertion(ctx *context.Context) {
+	if !setting.Service.EnablePasskeyAuth {
+		ctx.Error(http.StatusForbidden)
+		return
+	}
+
 	assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin()
 	if err != nil {
 		ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
@@ -66,6 +71,11 @@ func WebAuthnPasskeyAssertion(ctx *context.Context) {
 
 // WebAuthnPasskeyLogin handles the WebAuthn login process using a Passkey
 func WebAuthnPasskeyLogin(ctx *context.Context) {
+	if !setting.Service.EnablePasskeyAuth {
+		ctx.Error(http.StatusForbidden)
+		return
+	}
+
 	sessionData, okData := ctx.Session.Get("webauthnPasskeyAssertion").(*webauthn.SessionData)
 	if !okData || sessionData == nil {
 		ctx.ServerError("ctx.Session.Get", errors.New("not in WebAuthn session"))
diff --git a/templates/user/auth/signin_inner.tmpl b/templates/user/auth/signin_inner.tmpl
index dd608e5aa1..1e90b3e924 100644
--- a/templates/user/auth/signin_inner.tmpl
+++ b/templates/user/auth/signin_inner.tmpl
@@ -60,10 +60,11 @@
 </div>
 
 <div class="ui container fluid">
-	{{template "user/auth/webauthn_error" .}}
-
 	<div class="ui attached segment header top tw-max-w-2xl tw-m-auto tw-flex tw-flex-col tw-items-center">
-		<a class="signin-passkey">{{ctx.Locale.Tr "auth.signin_passkey"}}</a>
+		{{if .EnablePasskeyAuth}}
+			{{template "user/auth/webauthn_error" .}}
+			<a class="signin-passkey">{{ctx.Locale.Tr "auth.signin_passkey"}}</a>
+		{{end}}
 
 		{{if .ShowRegistrationButton}}
 			<div class="field">
diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go
index 7282a11b35..25b66bd28b 100644
--- a/tests/integration/signin_test.go
+++ b/tests/integration/signin_test.go
@@ -96,7 +96,7 @@ func TestSigninWithRememberMe(t *testing.T) {
 	session.MakeRequest(t, req, http.StatusOK)
 }
 
-func TestEnablePasswordSignInForm(t *testing.T) {
+func TestEnablePasswordSignInFormAndEnablePasskeyAuth(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 
 	mockLinkAccount := func(ctx *context.Context) {
@@ -139,4 +139,22 @@ func TestEnablePasswordSignInForm(t *testing.T) {
 		resp = MakeRequest(t, req, http.StatusOK)
 		NewHTMLParser(t, resp.Body).AssertElement(t, "form[action='/user/link_account_signin']", true)
 	})
+
+	t.Run("EnablePasskeyAuth=false", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		defer test.MockVariableValue(&setting.Service.EnablePasskeyAuth, false)()
+
+		req := NewRequest(t, "GET", "/user/login")
+		resp := MakeRequest(t, req, http.StatusOK)
+		NewHTMLParser(t, resp.Body).AssertElement(t, ".signin-passkey", false)
+	})
+
+	t.Run("EnablePasskeyAuth=true", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		defer test.MockVariableValue(&setting.Service.EnablePasskeyAuth, true)()
+
+		req := NewRequest(t, "GET", "/user/login")
+		resp := MakeRequest(t, req, http.StatusOK)
+		NewHTMLParser(t, resp.Body).AssertElement(t, ".signin-passkey", true)
+	})
 }
diff --git a/web_src/js/features/user-auth-webauthn.ts b/web_src/js/features/user-auth-webauthn.ts
index 70516c280d..743b39a11e 100644
--- a/web_src/js/features/user-auth-webauthn.ts
+++ b/web_src/js/features/user-auth-webauthn.ts
@@ -1,5 +1,5 @@
 import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
-import {showElem} from '../utils/dom.ts';
+import {hideElem, showElem} from '../utils/dom.ts';
 import {GET, POST} from '../modules/fetch.ts';
 
 const {appSubUrl} = window.config;
@@ -11,6 +11,15 @@ export async function initUserAuthWebAuthn() {
     return;
   }
 
+  if (window.location.protocol === 'http:') {
+    // webauthn is only supported on secure contexts
+    const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
+    if (!isLocalhost) {
+      hideElem(elSignInPasskeyBtn);
+      return;
+    }
+  }
+
   if (!detectWebAuthnSupport()) {
     return;
   }