add passkey & totp
This commit is contained in:
1 parent
70f6b52577
commit
1d06bbd482
13 files changed
+9114
-4
No files matched your search
+11
-1
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user