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.POST("/casdoor_callback", CasdoorCallback)
r.GET("/passkeys/config", GetPasskeyConfigStatus)
}
func InitManageUserRouter(r *gin.RouterGroup) {
@ -38,7 +40,6 @@ func InitUserRouter(r *gin.RouterGroup) {
r.GET("/begin_passkey_register", BeginPasskeyRegistration)
r.POST("/finish_passkey_register", FinishPasskeyRegistration)
r.GET("/passkeys/config", GetPasskeyConfigStatus)
r.GET("/passkeys", GetPasskeyList)
r.POST("/passkeys/:id", UpdatePasskey)
r.DELETE("/passkeys/:id", DeletePasskey)

1
app/components.d.ts vendored
View file

@ -8,6 +8,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
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']
AAlert: typeof import('ant-design-vue/es')['Alert']
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 {
name: string
user_id: string
raw_id: string
}
const passkey = {
@ -27,8 +28,8 @@ const passkey = {
remove(passkeyId: number) {
return http.delete(`/passkeys/${passkeyId}`)
},
get_passkey_config_status(): Promise<{ status: boolean }> {
return http.get('/passkey/config')
get_config_status(): Promise<{ status: boolean }> {
return http.get('/passkeys/config')
},
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,13 +2,12 @@
import { message } from 'ant-design-vue'
import type { Ref } from 'vue'
import dayjs from 'dayjs'
import PasskeyRegistration from './components/Passkey.vue'
import type { BannedIP } from '@/api/settings'
import setting from '@/api/settings'
import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
import type { Settings } from '@/views/preference/typedef'
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

View file

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

View file

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