Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions controller/passkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,17 @@ func PasskeyStatus(c *gin.Context) {
return
}

data := gin.H{
"enabled": false,
"system_enabled": system_setting.GetPasskeySettings().Enabled,
}

credential, err := model.GetPasskeyByUserID(user.Id)
if errors.Is(err, model.ErrPasskeyNotFound) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"enabled": false,
},
"data": data,
})
return
}
Expand All @@ -202,10 +205,8 @@ func PasskeyStatus(c *gin.Context) {
return
}

data := gin.H{
"enabled": true,
"last_used_at": credential.LastUsedAt,
}
data["enabled"] = true
data["last_used_at"] = credential.LastUsedAt

c.JSON(http.StatusOK, gin.H{
"success": true,
Expand Down
102 changes: 102 additions & 0 deletions controller/passkey_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
Copyright (C) 2023-2026 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
package controller

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type passkeyStatusResponse struct {
Success bool `json:"success"`
Data struct {
Enabled bool `json:"enabled"`
SystemEnabled bool `json:"system_enabled"`
} `json:"data"`
}

func TestPasskeyStatusSeparatesCredentialAndSystemState(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.PasskeyCredential{}))

user := &model.User{
Username: "passkey-status-user",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
}
require.NoError(t, db.Create(user).Error)

passkeySettings := system_setting.GetPasskeySettings()
originalEnabled := passkeySettings.Enabled
t.Cleanup(func() {
passkeySettings.Enabled = originalEnabled
})

requestStatus := func() passkeyStatusResponse {
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(sessions.Sessions("session", cookie.NewStore([]byte("passkey-status-secret"))))
router.GET("/", func(c *gin.Context) {
session := sessions.Default(c)
session.Set("id", user.Id)
PasskeyStatus(c)
})

request := httptest.NewRequest(http.MethodGet, "/", nil)
router.ServeHTTP(recorder, request)

require.Equal(t, http.StatusOK, recorder.Code)
var response passkeyStatusResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
return response
}

passkeySettings.Enabled = false
response := requestStatus()
assert.False(t, response.Data.Enabled)
assert.False(t, response.Data.SystemEnabled)

passkeySettings.Enabled = true
response = requestStatus()
assert.False(t, response.Data.Enabled)
assert.True(t, response.Data.SystemEnabled)

require.NoError(t, db.Create(&model.PasskeyCredential{
UserID: user.Id,
CredentialID: "credential-id",
PublicKey: "public-key",
}).Error)
passkeySettings.Enabled = false
response = requestStatus()
assert.True(t, response.Data.Enabled)
assert.False(t, response.Data.SystemEnabled)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import i18next from 'i18next'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'

import {
Expand Down Expand Up @@ -171,8 +171,9 @@ export function usePasskeyManagement(
}
}, [fetchStatus])

const enabled = useMemo(() => Boolean(status?.enabled), [status])
const lastUsed = useMemo(() => status?.last_used_at ?? null, [status])
const enabled = Boolean(status?.enabled)
const systemEnabled = status?.system_enabled ?? null
const lastUsed = status?.last_used_at ?? null

return {
status,
Expand All @@ -181,6 +182,7 @@ export function usePasskeyManagement(
removing,
supported,
enabled,
systemEnabled,
lastUsed,
fetchStatus,
register,
Expand Down
1 change: 1 addition & 0 deletions web/default/src/features/auth/passkey/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface ApiResponse<T = unknown> {

export interface PasskeyStatus {
enabled: boolean
system_enabled: boolean
last_used_at?: string | null
backup_eligible?: boolean
backup_state?: boolean
Expand Down
3 changes: 2 additions & 1 deletion web/default/src/features/auth/secure-verification/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export async function checkVerificationMethods(): Promise<VerificationMethods> {
Boolean(twoFAResponse?.success) && Boolean(twoFAResponse?.data?.enabled)
const hasPasskey =
Boolean(passkeyResponse?.success) &&
Boolean(passkeyResponse?.data?.enabled)
Boolean(passkeyResponse?.data?.enabled) &&
Boolean(passkeyResponse?.data?.system_enabled)

return {
has2FA,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ export function SecureVerificationDialog({
}, [methods])

const activeMethod =
state.method ?? (availableTabs.length > 0 ? availableTabs[0] : null)
state.method && availableTabs.includes(state.method)
? state.method
: (availableTabs[0] ?? null)

const title =
state.title ??
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import i18next from 'i18next'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'

import {
Expand All @@ -26,6 +26,7 @@ import {
} from '@/lib/secure-verification'

import { checkVerificationMethods, verify } from '../api'
import { selectVerificationMethod } from '../lib/select-verification-method'
import type {
SecureVerificationState,
StartVerificationOptions,
Expand Down Expand Up @@ -87,7 +88,12 @@ export function useSecureVerification(
const { preferredMethod, title, description } = config
const availableMethods = await fetchVerificationMethods()

if (!availableMethods.has2FA && !availableMethods.hasPasskey) {
const defaultMethod = selectVerificationMethod(
availableMethods,
preferredMethod
)

if (!defaultMethod) {
toast.error(
i18next.t(
'Please enable Two-factor Authentication or Passkey before proceeding'
Expand All @@ -101,15 +107,6 @@ export function useSecureVerification(
return false
}

let defaultMethod: VerificationMethod | null = preferredMethod ?? null
if (!defaultMethod) {
if (availableMethods.hasPasskey && availableMethods.passkeySupported) {
defaultMethod = 'passkey'
} else if (availableMethods.has2FA) {
defaultMethod = '2fa'
}
}

setState((prev) => ({
...prev,
apiCall,
Expand Down Expand Up @@ -211,11 +208,7 @@ export function useSecureVerification(
[methods]
)

const recommendedMethod = useMemo<VerificationMethod | null>(() => {
if (methods.hasPasskey && methods.passkeySupported) return 'passkey'
if (methods.has2FA) return '2fa'
return null
}, [methods])
const recommendedMethod = selectVerificationMethod(methods)

return {
open,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
Copyright (C) 2023-2026 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'

import type { VerificationMethod, VerificationMethods } from '../types'
import { selectVerificationMethod } from './select-verification-method'

describe('selectVerificationMethod', () => {
const cases: Array<{
name: string
methods: VerificationMethods
preferred?: VerificationMethod
expected: VerificationMethod | null
}> = [
{
name: 'falls back to 2FA when the preferred Passkey is unavailable',
methods: { has2FA: true, hasPasskey: false, passkeySupported: true },
preferred: 'passkey',
expected: '2fa',
},
{
name: 'falls back to 2FA when a bound Passkey is unsupported by the device',
methods: { has2FA: true, hasPasskey: true, passkeySupported: false },
preferred: 'passkey',
expected: '2fa',
},
{
name: 'uses an available preferred 2FA method',
methods: { has2FA: true, hasPasskey: true, passkeySupported: true },
preferred: '2fa',
expected: '2fa',
},
{
name: 'uses an available preferred Passkey method',
methods: { has2FA: true, hasPasskey: true, passkeySupported: true },
preferred: 'passkey',
expected: 'passkey',
},
{
name: 'defaults to Passkey when no preference is provided',
methods: { has2FA: true, hasPasskey: true, passkeySupported: true },
expected: 'passkey',
},
{
name: 'defaults to 2FA when it is the only available method',
methods: { has2FA: true, hasPasskey: false, passkeySupported: true },
expected: '2fa',
},
{
name: 'falls back to Passkey when preferred 2FA is unavailable',
methods: { has2FA: false, hasPasskey: true, passkeySupported: true },
preferred: '2fa',
expected: 'passkey',
},
{
name: 'returns null when no usable method is available',
methods: { has2FA: false, hasPasskey: true, passkeySupported: false },
expected: null,
},
]

for (const testCase of cases) {
test(testCase.name, () => {
assert.equal(
selectVerificationMethod(testCase.methods, testCase.preferred),
testCase.expected
)
})
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
Copyright (C) 2023-2026 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import type { VerificationMethod, VerificationMethods } from '../types'

export function selectVerificationMethod(
methods: VerificationMethods,
preferredMethod?: VerificationMethod
): VerificationMethod | null {
const passkeyAvailable = methods.hasPasskey && methods.passkeySupported

if (preferredMethod === '2fa' && methods.has2FA) return '2fa'
if (preferredMethod === 'passkey' && passkeyAvailable) return 'passkey'
if (passkeyAvailable) return 'passkey'
if (methods.has2FA) return '2fa'
return null
}
Loading