add passkey & totp

This commit is contained in:
biss committed 2026-08-27 21:45:39 +08:00
1 parent 70f6b52577
commit 1d06bbd482
13 files changed
+9114 -4

No files matched your search

+11 -1
View File
@@ -28,12 +28,21 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed(() => Boolean(token.value))
const isSuperAdmin = computed(() => user.value?.roles.includes('SuperAdmin') ?? false)
async function login(userName: string, password: string) {
async function login(userName: string, password: string): Promise<{ twoFactorTicket?: string }> {
const { data } = await http.post('/auth/login', {
userName,
password,
isNativeApp: isNativeApp(),
})
if (data.requiresTotp) return { twoFactorTicket: String(data.twoFactorTicket) }
token.value = data.token
user.value = data.user
saveAuthSession(data)
return {}
}
async function completeTotpLogin(twoFactorTicket: string, code: string) {
const { data } = await http.post('/auth/login/totp', { twoFactorTicket, code })
token.value = data.token
user.value = data.user
saveAuthSession(data)
@@ -92,6 +101,7 @@ export const useAuthStore = defineStore('auth', () => {
isLoggedIn,
isSuperAdmin,
login,
completeTotpLogin,
exchangeSso,
bindSso,
refresh,
+39
View File
@@ -11,6 +11,7 @@ const router = useRouter()
const auth = useAuthStore()
const loading = ref(true)
const actionLoading = ref(false)
const totp = reactive({ enabled: false, setupUri: '', recoveryCodes: [] as string[] })
const sso = reactive({
enabled: false,
providerDisplayName: '学校统一身份认证',
@@ -35,6 +36,8 @@ async function loadAccount() {
sso.providerDisplayName = String(data.providerDisplayName || sso.providerDisplayName)
sso.isBound = Boolean(data.isBound)
sso.callbackUrl = String(data.callbackUrl || '')
const totpResult = await http.get('/auth/security/totp')
totp.enabled = Boolean(totpResult.data.enabled)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
@@ -42,6 +45,34 @@ async function loadAccount() {
}
}
async function setupTotp() {
try {
const result = await ElMessageBox.prompt('请输入当前密码以开始绑定认证器应用。', '启用动态验证码', { inputType: 'password', inputValidator: value => Boolean(value) || '密码不能为空' })
actionLoading.value = true
const { data } = await http.post('/auth/security/totp/setup', { currentPassword: result.value })
totp.setupUri = String(data.otpauthUri)
const confirm = await ElMessageBox.prompt(`请在认证器应用中手动添加以下密钥,随后输入 6 位验证码:\n${data.secret}`, '确认动态验证码', { inputValidator: value => Boolean(value) || '验证码不能为空' })
const enabled = await http.post('/auth/security/totp/enable', { code: confirm.value })
totp.enabled = true
totp.recoveryCodes = enabled.data.recoveryCodes
ElMessage.success('动态验证码已启用,请立即保存恢复码。')
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { actionLoading.value = false }
}
async function disableTotp() {
try {
const result = await ElMessageBox.prompt('请输入当前密码以关闭动态验证码。', '关闭动态验证码', { inputType: 'password', type: 'warning', inputValidator: value => Boolean(value) || '密码不能为空' })
actionLoading.value = true
await http.post('/auth/security/totp/disable', { currentPassword: result.value })
totp.enabled = false
ElMessage.success('动态验证码已关闭。')
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { actionLoading.value = false }
}
async function startBinding() {
actionLoading.value = true
try {
@@ -130,6 +161,14 @@ onMounted(async () => {
</dl>
</article>
<article class="account-card sso-card">
<div class="card-title"><div><span>动态验证码</span><h2>认证器应用</h2></div><el-tag :type="totp.enabled ? 'success' : 'info'">{{ totp.enabled ? '已启用' : '未启用' }}</el-tag></div>
<p class="sso-description">使用 Microsoft Authenticator、Google Authenticator 等应用生成登录验证码。启用后,密码登录需完成二次验证。</p>
<el-button v-if="!totp.enabled" type="primary" :loading="actionLoading" @click="setupTotp">启用动态验证码</el-button>
<el-button v-else type="danger" plain :loading="actionLoading" @click="disableTotp">关闭动态验证码</el-button>
<el-alert v-if="totp.recoveryCodes.length" title="请立即保存恢复码,每个恢复码只能使用一次。" type="warning" :closable="false" show-icon><template #default><code>{{ totp.recoveryCodes.join(' ') }}</code></template></el-alert>
</article>
<article v-loading="loading" class="account-card sso-card">
<div class="card-title">
<div>
+29 -2
View File
@@ -11,17 +11,35 @@ const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const twoFactorTicket = ref('')
const ssoLoading = ref(false)
const sso = reactive({ enabled: false, displayName: '学校统一身份认证' })
const form = reactive({
userName: String(route.query.userName ?? ''),
password: '',
totpCode: '',
})
async function submit() {
loading.value = true
try {
await auth.login(form.userName, form.password)
const result = await auth.login(form.userName, form.password)
if (result.twoFactorTicket) {
twoFactorTicket.value = result.twoFactorTicket
return
}
await router.replace(String(route.query.redirect ?? '/dashboard'))
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function submitTotp() {
loading.value = true
try {
await auth.completeTotpLogin(twoFactorTicket.value, form.totpCode)
await router.replace(String(route.query.redirect ?? '/dashboard'))
} catch (error) {
ElMessage.error(apiErrorMessage(error))
@@ -93,7 +111,7 @@ onMounted(async () => {
</section>
<section class="login-panel">
<form class="login-form" @submit.prevent="submit">
<form v-if="!twoFactorTicket" class="login-form" @submit.prevent="submit">
<div class="form-intro">
<span>欢迎回来</span>
<h2>登录教务工作台</h2>
@@ -136,6 +154,15 @@ onMounted(async () => {
<router-link class="public-timetable-link" to="/timetable">无需登录,查询班级课表 →</router-link>
<router-link class="account-activation-link" to="/activate">学生首次登录?自助激活账号 →</router-link>
</form>
<form v-else class="login-form" @submit.prevent="submitTotp">
<div class="form-intro">
<span>二次验证</span><h2>输入验证码</h2>
<p>请输入认证器应用中的 6 位验证码,也可以使用恢复码。</p>
</div>
<label><span>验证码</span><el-input v-model="form.totpCode" size="large" inputmode="numeric" autocomplete="one-time-code" /></label>
<el-button class="login-submit" type="primary" size="large" native-type="submit" :loading="loading">验证并登录</el-button>
<el-button text @click="twoFactorTicket = ''">返回密码登录</el-button>
</form>
</section>
</main>
</template>