enhance: by default, passkey is used for 2fa if passkey is used to login

This commit is contained in:
Jacky 2024-09-16 13:57:31 +08:00
parent 0a6a7693a1
commit f42a6c2d08
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
13 changed files with 263 additions and 192 deletions

View file

@ -13,6 +13,8 @@ func InitAuthRouter(r *gin.RouterGroup) {
r.GET("/casdoor_uri", GetCasdoorUri) r.GET("/casdoor_uri", GetCasdoorUri)
r.POST("/casdoor_callback", CasdoorCallback) r.POST("/casdoor_callback", CasdoorCallback)
r.GET("/passkeys/config", GetPasskeyConfigStatus)
} }
func InitManageUserRouter(r *gin.RouterGroup) { func InitManageUserRouter(r *gin.RouterGroup) {
@ -38,7 +40,6 @@ func InitUserRouter(r *gin.RouterGroup) {
r.GET("/begin_passkey_register", BeginPasskeyRegistration) r.GET("/begin_passkey_register", BeginPasskeyRegistration)
r.POST("/finish_passkey_register", FinishPasskeyRegistration) r.POST("/finish_passkey_register", FinishPasskeyRegistration)
r.GET("/passkeys/config", GetPasskeyConfigStatus)
r.GET("/passkeys", GetPasskeyList) r.GET("/passkeys", GetPasskeyList)
r.POST("/passkeys/:id", UpdatePasskey) r.POST("/passkeys/:id", UpdatePasskey)
r.DELETE("/passkeys/:id", DeletePasskey) r.DELETE("/passkeys/:id", DeletePasskey)

1
app/components.d.ts vendored
View file

@ -8,6 +8,7 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
2FA2FAAuthorization: typeof import('./src/components/2FA/2FAAuthorization.vue')['default'] 2FA2FAAuthorization: typeof import('./src/components/2FA/2FAAuthorization.vue')['default']
2FAAuthorization: typeof import('./src/components/2FA/Authorization.vue')['default']
2FAOTPAuthorization: typeof import('./src/components/2FA/OTPAuthorization.vue')['default'] 2FAOTPAuthorization: typeof import('./src/components/2FA/OTPAuthorization.vue')['default']
AAlert: typeof import('ant-design-vue/es')['Alert'] AAlert: typeof import('ant-design-vue/es')['Alert']
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete'] AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']

View file

@ -5,6 +5,7 @@ import type { ModelBase } from '@/api/curd'
export interface Passkey extends ModelBase { export interface Passkey extends ModelBase {
name: string name: string
user_id: string user_id: string
raw_id: string
} }
const passkey = { const passkey = {
@ -27,8 +28,8 @@ const passkey = {
remove(passkeyId: number) { remove(passkeyId: number) {
return http.delete(`/passkeys/${passkeyId}`) return http.delete(`/passkeys/${passkeyId}`)
}, },
get_passkey_config_status(): Promise<{ status: boolean }> { get_config_status(): Promise<{ status: boolean }> {
return http.get('/passkey/config') return http.get('/passkeys/config')
}, },
} }

View file

@ -3,11 +3,17 @@ import { KeyOutlined } from '@ant-design/icons-vue'
import { startAuthentication } from '@simplewebauthn/browser' import { startAuthentication } from '@simplewebauthn/browser'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import OTPInput from '@/components/OTPInput/OTPInput.vue' import OTPInput from '@/components/OTPInput/OTPInput.vue'
import { $gettext } from '@/gettext' import type { TwoFAStatusResponse } from '@/api/2fa'
import twoFA from '@/api/2fa' import twoFA from '@/api/2fa'
import { useUserStore } from '@/pinia'
defineProps<{
twoFAStatus: TwoFAStatusResponse
}>()
const emit = defineEmits(['submitOTP', 'submitSecureSessionID']) const emit = defineEmits(['submitOTP', 'submitSecureSessionID'])
const user = useUserStore()
const refOTP = ref() const refOTP = ref()
const useRecoveryCode = ref(false) const useRecoveryCode = ref(false)
const passcode = ref('') const passcode = ref('')
@ -55,10 +61,16 @@ async function passkeyAuthenticate() {
} }
passkeyLoading.value = false passkeyLoading.value = false
} }
onMounted(() => {
if (user.passkeyLoginAvailable)
passkeyAuthenticate()
})
</script> </script>
<template> <template>
<div> <div>
<div v-if="twoFAStatus.otp_status">
<div v-if="!useRecoveryCode"> <div v-if="!useRecoveryCode">
<p>{{ $gettext('Please enter the OTP code:') }}</p> <p>{{ $gettext('Please enter the OTP code:') }}</p>
<OTPInput <OTPInput
@ -94,9 +106,13 @@ async function passkeyAuthenticate() {
@click="clickUseOTP" @click="clickUseOTP"
>{{ $gettext('Use OTP') }}</a> >{{ $gettext('Use OTP') }}</a>
</div> </div>
</div>
<div class="flex flex-col justify-center"> <div
<ADivider> v-if="twoFAStatus.passkey_status"
class="flex flex-col justify-center"
>
<ADivider v-if="twoFAStatus.otp_status">
<div class="text-sm font-normal opacity-75"> <div class="text-sm font-normal opacity-75">
{{ $gettext('Or') }} {{ $gettext('Or') }}
</div> </div>

View file

@ -1,12 +1,12 @@
import { createVNode, render } from 'vue' import { createVNode, render } from 'vue'
import { Modal, message } from 'ant-design-vue' import { Modal, message } from 'ant-design-vue'
import { useCookies } from '@vueuse/integrations/useCookies' import { useCookies } from '@vueuse/integrations/useCookies'
import OTPAuthorization from '@/components/2FA/2FAAuthorization.vue' import Authorization from '@/components/2FA/Authorization.vue'
import twoFA from '@/api/2fa' import twoFA from '@/api/2fa'
import { useUserStore } from '@/pinia' import { useUserStore } from '@/pinia'
const use2FAModal = () => { const use2FAModal = () => {
const refOTPAuthorization = ref<typeof OTPAuthorization>() const refOTPAuthorization = ref<typeof Authorization>()
const randomId = Math.random().toString(36).substring(2, 8) const randomId = Math.random().toString(36).substring(2, 8)
const { secureSessionId } = storeToRefs(useUserStore()) const { secureSessionId } = storeToRefs(useUserStore())
@ -22,11 +22,11 @@ const use2FAModal = () => {
} }
const open = async (): Promise<string> => { const open = async (): Promise<string> => {
const { enabled } = await twoFA.status() const twoFAStatus = await twoFA.status()
const { status: secureSessionStatus } = await twoFA.secure_session_status() const { status: secureSessionStatus } = await twoFA.secure_session_status()
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!enabled) { if (!twoFAStatus.enabled) {
resolve('') resolve('')
return return
@ -80,9 +80,10 @@ const use2FAModal = () => {
}, },
}, { }, {
default: () => h( default: () => h(
OTPAuthorization, Authorization,
{ {
ref: refOTPAuthorization, ref: refOTPAuthorization,
twoFAStatus,
class: 'mt-3', class: 'mt-3',
onSubmitOTP: verifyOTP, onSubmitOTP: verifyOTP,
onSubmitSecureSessionID: setSessionId, onSubmitSecureSessionID: setSessionId,

View file

@ -29,14 +29,22 @@ const current = computed({
const languageAvailable = gettext.available const languageAvailable = gettext.available
const updateTitle = () => {
const name = route.meta.name as never as () => string
document.title = `${name()} | Nginx UI`
}
watch(current, v => { watch(current, v => {
loadTranslations(route) loadTranslations(route)
settings.set_language(v) settings.set_language(v)
gettext.current = v gettext.current = v
const name = route.meta.name as never as () => string updateTitle()
})
document.title = `${name()} | Nginx UI` onMounted(() => {
updateTitle()
}) })
function init() { function init() {

View file

@ -5,18 +5,29 @@ export const useUserStore = defineStore('user', {
token: '', token: '',
unreadCount: 0, unreadCount: 0,
secureSessionId: '', secureSessionId: '',
passkeyRawId: '',
}), }),
getters: { getters: {
is_login(state): boolean { isLogin(state): boolean {
return !!state.token return !!state.token
}, },
passkeyLoginAvailable(state): boolean {
return !!state.passkeyRawId
},
}, },
actions: { actions: {
passkeyLogin(rawId: string, token: string) {
this.passkeyRawId = rawId
this.login(token)
},
login(token: string) { login(token: string) {
this.token = token this.token = token
}, },
logout() { logout() {
this.token = '' this.token = ''
this.passkeyRawId = ''
this.secureSessionId = ''
this.unreadCount = 0
}, },
}, },
persist: true, persist: true,

View file

@ -325,9 +325,8 @@ router.beforeEach((to, _, next) => {
NProgress.start() NProgress.start()
const user = useUserStore() const user = useUserStore()
const { is_login } = user
if (to.meta.noAuth || is_login) if (to.meta.noAuth || user.isLogin)
next() next()
else else
next({ path: '/login', query: { next: to.fullPath } }) next({ path: '/login', query: { next: to.fullPath } })

View file

@ -8,8 +8,9 @@ import auth from '@/api/auth'
import install from '@/api/install' import install from '@/api/install'
import SetLanguage from '@/components/SetLanguage/SetLanguage.vue' import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue' import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
import OTPAuthorization from '@/components/2FA/2FAAuthorization.vue' import Authorization from '@/components/2FA/Authorization.vue'
import gettext, { $gettext } from '@/gettext' import gettext, { $gettext } from '@/gettext'
import passkey from '@/api/passkey'
const thisYear = new Date().getFullYear() const thisYear = new Date().getFullYear()
@ -26,6 +27,7 @@ const enabled2FA = ref(false)
const refOTP = ref() const refOTP = ref()
const passcode = ref('') const passcode = ref('')
const recoveryCode = ref('') const recoveryCode = ref('')
const passkeyConfigStatus = ref(false)
const modelRef = reactive({ const modelRef = reactive({
username: '', username: '',
@ -49,7 +51,7 @@ const rulesRef = reactive({
const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef) const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
const userStore = useUserStore() const userStore = useUserStore()
const { login } = userStore const { login, passkeyLogin } = userStore
const { secureSessionId } = storeToRefs(userStore) const { secureSessionId } = storeToRefs(userStore)
const onSubmit = () => { const onSubmit = () => {
@ -97,7 +99,7 @@ const onSubmit = () => {
const user = useUserStore() const user = useUserStore()
if (user.is_login) { if (user.isLogin) {
const next = (route.query?.next || '').toString() || '/dashboard' const next = (route.query?.next || '').toString() || '/dashboard'
router.push(next) router.push(next)
@ -147,8 +149,13 @@ function handleOTPSubmit(code: string, recovery: string) {
onSubmit() onSubmit()
}) })
} }
passkey.get_config_status().then(r => {
passkeyConfigStatus.value = r.status
})
const passkeyLoginLoading = ref(false) const passkeyLoginLoading = ref(false)
async function passkeyLogin() { async function handlePasskeyLogin() {
passkeyLoginLoading.value = true passkeyLoginLoading.value = true
try { try {
const begin = await auth.begin_passkey_login() const begin = await auth.begin_passkey_login()
@ -162,7 +169,7 @@ async function passkeyLogin() {
if (r.token) { if (r.token) {
const next = (route.query?.next || '').toString() || '/' const next = (route.query?.next || '').toString() || '/'
login(r.token) passkeyLogin(asseResp.rawId, r.token)
await router.push(next) await router.push(next)
} }
@ -217,9 +224,14 @@ async function passkeyLogin() {
</AButton> </AButton>
</template> </template>
<div v-else> <div v-else>
<OTPAuthorization <Authorization
ref="refOTP" ref="refOTP"
@on-submit="handleOTPSubmit" :two-f-a-status="{
enabled: true,
otp_status: true,
passkey_status: true,
}"
@submit-o-t-p="handleOTPSubmit"
/> />
</div> </div>
@ -235,7 +247,10 @@ async function passkeyLogin() {
{{ $gettext('Login') }} {{ $gettext('Login') }}
</AButton> </AButton>
<div class="flex flex-col justify-center"> <div
v-if="passkeyConfigStatus"
class="flex flex-col justify-center"
>
<ADivider> <ADivider>
<div class="text-sm font-normal opacity-75"> <div class="text-sm font-normal opacity-75">
{{ $gettext('Or') }} {{ $gettext('Or') }}
@ -244,7 +259,7 @@ async function passkeyLogin() {
<AButton <AButton
:loading="passkeyLoginLoading" :loading="passkeyLoginLoading"
@click="passkeyLogin" @click="handlePasskeyLogin"
> >
<KeyOutlined /> <KeyOutlined />
{{ $gettext('Sign in with a passkey') }} {{ $gettext('Sign in with a passkey') }}

View file

@ -2,13 +2,12 @@
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import PasskeyRegistration from './components/Passkey.vue'
import type { BannedIP } from '@/api/settings' import type { BannedIP } from '@/api/settings'
import setting from '@/api/settings' import setting from '@/api/settings'
import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer' import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
import type { Settings } from '@/views/preference/typedef' import type { Settings } from '@/views/preference/typedef'
import TOTP from '@/views/preference/components/TOTP.vue' import TOTP from '@/views/preference/components/TOTP.vue'
import PasskeyRegistration from '@/components/Passkey/PasskeyRegistration.vue'
import { $gettext } from '@/gettext'
const data: Settings = inject('data') as Settings const data: Settings = inject('data') as Settings

View file

@ -8,10 +8,11 @@ import { formatDateTime } from '@/lib/helper'
import type { Passkey } from '@/api/passkey' import type { Passkey } from '@/api/passkey'
import passkey from '@/api/passkey' import passkey from '@/api/passkey'
import ReactiveFromNow from '@/components/ReactiveFromNow/ReactiveFromNow.vue' import ReactiveFromNow from '@/components/ReactiveFromNow/ReactiveFromNow.vue'
import { $gettext } from '@/gettext' import { useUserStore } from '@/pinia'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const user = useUserStore()
const passkeyName = ref('') const passkeyName = ref('')
const addPasskeyModelOpen = ref(false) const addPasskeyModelOpen = ref(false)
@ -29,6 +30,8 @@ async function registerPasskey() {
message.success($gettext('Register passkey successfully')) message.success($gettext('Register passkey successfully'))
addPasskeyModelOpen.value = false addPasskeyModelOpen.value = false
user.passkeyRawId = attestationResponse.rawId
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
catch (e: any) { catch (e: any) {
@ -66,10 +69,14 @@ function update(id: number, record: Passkey) {
}) })
} }
function remove(id: number) { function remove(item: Passkey) {
passkey.remove(id).then(() => { passkey.remove(item.id).then(() => {
getList() getList()
message.success($gettext('Remove successfully')) message.success($gettext('Remove successfully'))
// if current passkey is removed, clear it from user store
if (user.passkeyLoginAvailable && user.passkeyRawId === item.raw_id)
user.passkeyRawId = ''
}).catch((e: { message?: string }) => { }).catch((e: { message?: string }) => {
message.error(e?.message ?? $gettext('Server error')) message.error(e?.message ?? $gettext('Server error'))
}) })
@ -83,19 +90,31 @@ function addPasskey() {
<template> <template>
<div> <div>
<div class="flex justify-between items-center"> <div>
<h3 class="mb-0"> <h3>
{{ $gettext('Passkey') }} {{ $gettext('Passkey') }}
</h3> </h3>
<AButton @click="addPasskey"> <p>
{{ $gettext('Add a passkey') }} {{ $gettext('Passkeys are webauthn credentials that validate your identity using touch, '
</AButton> + 'facial recognition, a device password, or a PIN. '
+ 'They can be used as a password replacement or as a 2FA method.') }}
</p>
</div> </div>
<AList <AList
class="mt-4" class="mt-4"
bordered bordered
:data-source="data" :data-source="data"
> >
<template #header>
<div class="flex items-center justify-between">
<div class="font-bold">
{{ $gettext('Your passkeys') }}
</div>
<AButton @click="addPasskey">
{{ $gettext('Add a passkey') }}
</AButton>
</div>
</template>
<template #renderItem="{ item, index }"> <template #renderItem="{ item, index }">
<AListItem> <AListItem>
<AListItemMeta> <AListItemMeta>
@ -127,7 +146,7 @@ function addPasskey() {
<APopconfirm <APopconfirm
:title="$gettext('Are you sure to delete this passkey immediately?')" :title="$gettext('Are you sure to delete this passkey immediately?')"
@confirm="() => remove(item.id)" @confirm="() => remove(item)"
> >
<AButton <AButton
type="link" type="link"

View file

@ -4,7 +4,7 @@ import { CheckCircleOutlined } from '@ant-design/icons-vue'
import { UseClipboard } from '@vueuse/components' import { UseClipboard } from '@vueuse/components'
import otp from '@/api/otp' import otp from '@/api/otp'
import OTPInput from '@/components/OTPInput/OTPInput.vue' import OTPInput from '@/components/OTPInput/OTPInput.vue'
import { $gettext } from '@/gettext'
import twoFA from '@/api/2fa' import twoFA from '@/api/2fa'
const status = ref(false) const status = ref(false)