Skip to content

Commit

Permalink
feat: sms api
Browse files Browse the repository at this point in the history
  • Loading branch information
thezzisu committed Dec 23, 2024
1 parent 676cb5c commit 0d9e43e
Show file tree
Hide file tree
Showing 12 changed files with 460 additions and 20 deletions.
18 changes: 9 additions & 9 deletions apps/frontend/src/components/user/UserAuth.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<VBtn variant="outlined" @click="doVerify" :text="t('do-verify')" />
</VAlert>
<VRow v-else>
<VCol v-for="method of login.state.value.providers" :key="method">
<VCol v-for="method of login.state.value.providers" :key="method" cols="6">
<VCard variant="outlined" :title="t(`provider-${method}`)">
<component :is="components[method]" :userId="userId" />
</VCard>
Expand All @@ -25,6 +25,7 @@ import { useI18n } from 'vue-i18n'
import UserAuthIaaa from './UserAuthIaaa.vue'
import UserAuthMail from './UserAuthMail.vue'
import UserAuthPassword from './UserAuthPassword.vue'
import UserAuthSms from './UserAuthSms.vue'
import { useMfa } from '@/stores/app'
import { enableMfa } from '@/utils/flags'
Expand All @@ -40,16 +41,13 @@ const { hasMfaToken, doVerify } = useMfa()
const components: Record<string, Component> = {
password: UserAuthPassword,
mail: UserAuthMail,
iaaa: UserAuthIaaa
iaaa: UserAuthIaaa,
sms: UserAuthSms
}
const login = useAsyncState(
() => http.get('auth/login').json<{ providers: string[]; signup: boolean }>(),
{
providers: [],
signup: false
}
)
const login = useAsyncState(() => http.get('auth/verify').json<{ providers: string[] }>(), {
providers: []
})
</script>

<i18n>
Expand All @@ -58,13 +56,15 @@ en:
provider-password: Password Login
provider-mail: Email Login
provider-iaaa: IAAA Login
provider-sms: SMS Login
mfa-required: MFA Required
do-verify: Verify
zh-Hans:
user-auth: 用户认证
provider-password: 密码登录
provider-mail: 邮箱登录
provider-iaaa: 北京大学统一身份认证
provider-sms: 短信登录
mfa-required: 需要多因子身份认证
do-verify: 开始认证
</i18n>
56 changes: 56 additions & 0 deletions apps/frontend/src/components/user/UserAuthSms.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<template>
<VCardText>
<VTextField
v-model="newPhone"
prepend-inner-icon="mdi-phone"
:label="t('term.telephone')"
:rules="phoneRules"
/>
<div id="vaptcha"></div>
<VOtpInput v-if="token" v-model.trim="code" />
</VCardText>
<VCardActions>
<VBtn
variant="elevated"
@click="updateTask.execute()"
:loading="updateTask.isLoading.value"
:disabled="!token || sendTask.isLoading.value"
>
{{ t('action.update') }}
</VBtn>
</VCardActions>
</template>

<script setup lang="ts">
import { toRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useChangePhone } from '@/utils/user/sms'
const props = defineProps<{
userId: string
}>()
const { t } = useI18n()
const { newPhone, code, token, sendTask, updateTask } = useChangePhone(toRef(props, 'userId'))
const phoneRules = [
(value: string) => {
const re = /^1\d{10}$/
if (re.test(value)) return true
return t('hint.violate-phone-rule')
}
]
</script>

<i18n>
en:
code: Code
hint:
violate-phone-rule: Invalid phone number
zh-Hans:
code: 验证码
hint:
violate-phone-rule: 无效的手机号
</i18n>
1 change: 1 addition & 0 deletions apps/frontend/src/locales/zh-Hans.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ msg:
recommended-browsers: 推荐的浏览器
registered: 已报名
not-registered: 未报名
code-sent: 验证码已发送

tabs:
description: 描述
Expand Down
16 changes: 10 additions & 6 deletions apps/frontend/src/pages/auth/verify/index.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<template>
<VCardText v-if="login.isLoading.value">
<VCardText v-if="verify.isLoading.value">
<VSkeletonLoader type="image" />
</VCardText>
<VCardText v-else>
<VRow dense>
<VCol v-for="method of login.state.value.providers" :key="method" cols="12">
<VCol v-for="method of verify.state.value.providers" :key="method" cols="12">
<VBtn
:to="{ path: `/auth/verify/${method}`, query: route.query }"
block
Expand Down Expand Up @@ -33,17 +33,19 @@ const route = useRoute()
const icons: Record<string, string> = {
password: 'mdi-lock',
mail: 'mdi-email',
iaaa: 'svg:M4.67,5.18l1.83-.23v4.13c0,.29,.02,.5,.06,.63s.1,.24,.2,.33c.12,.11,.34,.2,.65,.26v.13h-2.8v-.13c.25-.03,.44-.08,.55-.14,.11-.06,.2-.17,.27-.31,.05-.1,.08-.24,.1-.44,.02-.2,.03-.48,.03-.85v-1.56c0-.43-.01-.74-.03-.91-.02-.17-.07-.32-.14-.43-.07-.12-.16-.2-.26-.26-.1-.05-.25-.09-.44-.1v-.13Zm1.32-2.5c-.17,0-.3-.05-.41-.16s-.16-.24-.16-.4,.05-.29,.17-.4c.11-.11,.25-.16,.41-.16s.3,.05,.41,.16,.17,.24,.17,.4-.05,.29-.16,.4-.25,.16-.41,.16ZM22.75,22.44h-4.27v-.15c.39-.03,.65-.1,.81-.2,.27-.17,.4-.4,.4-.7,0-.18-.06-.42-.18-.72l-.11-.27-.62-1.53h-2.86l-.34,.9-.16,.4c-.2,.48-.29,.85-.29,1.13,0,.16,.04,.31,.11,.45s.17,.25,.29,.34c.17,.12,.39,.19,.65,.21v.15h-2.91v-.15c.23-.01,.43-.07,.6-.17s.34-.26,.51-.48c.14-.18,.27-.41,.41-.69s.31-.7,.52-1.26l2.38-6.13h.37l2.83,6.84c.21,.52,.38,.88,.51,1.11s.26,.39,.41,.5c.1,.08,.22,.14,.36,.18s.34,.08,.6,.11v.15Zm-4.08-3.84l-1.37-3.37-1.29,3.37h2.65ZM0,12v12H12V12H0Zm10.75,10.44H6.48v-.15c.39-.03,.65-.1,.81-.2,.27-.17,.4-.4,.4-.7,0-.18-.06-.42-.18-.72l-.11-.27-.62-1.53H3.91l-.34,.9-.16,.4c-.2,.48-.29,.85-.29,1.13,0,.16,.04,.31,.11,.45,.07,.14,.17,.25,.29,.34,.17,.12,.39,.19,.65,.21v.15H1.25v-.15c.23-.01,.43-.07,.6-.17,.17-.1,.34-.26,.51-.48,.14-.18,.27-.41,.41-.69s.31-.7,.52-1.26l2.38-6.13h.37l2.83,6.84c.21,.52,.38,.88,.51,1.11,.13,.22,.26,.39,.41,.5,.1,.08,.22,.14,.36,.18,.13,.04,.34,.08,.6,.11v.15Zm-6.74-3.84h2.65l-1.37-3.37-1.29,3.37ZM12,0V12h12V0H12Zm10.75,10.44h-4.27v-.15c.39-.03,.65-.1,.81-.2,.27-.17,.4-.4,.4-.7,0-.18-.06-.42-.18-.72l-.11-.27-.62-1.53h-2.86l-.34,.9-.16,.4c-.2,.48-.29,.85-.29,1.13,0,.16,.04,.31,.11,.45,.07,.14,.17,.25,.29,.34,.17,.12,.39,.19,.65,.21v.15h-2.91v-.15c.23-.01,.43-.07,.6-.17,.17-.1,.34-.26,.51-.48,.14-.18,.27-.41,.41-.69s.31-.7,.52-1.26l2.38-6.13h.37l2.83,6.84c.21,.52,.38,.88,.51,1.11,.13,.22,.26,.39,.41,.5,.1,.08,.22,.14,.36,.18,.13,.04,.34,.08,.6,.11v.15Zm-6.74-3.84h2.65l-1.37-3.37-1.29,3.37Z'
iaaa: 'svg:M4.67,5.18l1.83-.23v4.13c0,.29,.02,.5,.06,.63s.1,.24,.2,.33c.12,.11,.34,.2,.65,.26v.13h-2.8v-.13c.25-.03,.44-.08,.55-.14,.11-.06,.2-.17,.27-.31,.05-.1,.08-.24,.1-.44,.02-.2,.03-.48,.03-.85v-1.56c0-.43-.01-.74-.03-.91-.02-.17-.07-.32-.14-.43-.07-.12-.16-.2-.26-.26-.1-.05-.25-.09-.44-.1v-.13Zm1.32-2.5c-.17,0-.3-.05-.41-.16s-.16-.24-.16-.4,.05-.29,.17-.4c.11-.11,.25-.16,.41-.16s.3,.05,.41,.16,.17,.24,.17,.4-.05,.29-.16,.4-.25,.16-.41,.16ZM22.75,22.44h-4.27v-.15c.39-.03,.65-.1,.81-.2,.27-.17,.4-.4,.4-.7,0-.18-.06-.42-.18-.72l-.11-.27-.62-1.53h-2.86l-.34,.9-.16,.4c-.2,.48-.29,.85-.29,1.13,0,.16,.04,.31,.11,.45s.17,.25,.29,.34c.17,.12,.39,.19,.65,.21v.15h-2.91v-.15c.23-.01,.43-.07,.6-.17s.34-.26,.51-.48c.14-.18,.27-.41,.41-.69s.31-.7,.52-1.26l2.38-6.13h.37l2.83,6.84c.21,.52,.38,.88,.51,1.11s.26,.39,.41,.5c.1,.08,.22,.14,.36,.18s.34,.08,.6,.11v.15Zm-4.08-3.84l-1.37-3.37-1.29,3.37h2.65ZM0,12v12H12V12H0Zm10.75,10.44H6.48v-.15c.39-.03,.65-.1,.81-.2,.27-.17,.4-.4,.4-.7,0-.18-.06-.42-.18-.72l-.11-.27-.62-1.53H3.91l-.34,.9-.16,.4c-.2,.48-.29,.85-.29,1.13,0,.16,.04,.31,.11,.45,.07,.14,.17,.25,.29,.34,.17,.12,.39,.19,.65,.21v.15H1.25v-.15c.23-.01,.43-.07,.6-.17,.17-.1,.34-.26,.51-.48,.14-.18,.27-.41,.41-.69s.31-.7,.52-1.26l2.38-6.13h.37l2.83,6.84c.21,.52,.38,.88,.51,1.11,.13,.22,.26,.39,.41,.5,.1,.08,.22,.14,.36,.18,.13,.04,.34,.08,.6,.11v.15Zm-6.74-3.84h2.65l-1.37-3.37-1.29,3.37ZM12,0V12h12V0H12Zm10.75,10.44h-4.27v-.15c.39-.03,.65-.1,.81-.2,.27-.17,.4-.4,.4-.7,0-.18-.06-.42-.18-.72l-.11-.27-.62-1.53h-2.86l-.34,.9-.16,.4c-.2,.48-.29,.85-.29,1.13,0,.16,.04,.31,.11,.45,.07,.14,.17,.25,.29,.34,.17,.12,.39,.19,.65,.21v.15h-2.91v-.15c.23-.01,.43-.07,.6-.17,.17-.1,.34-.26,.51-.48,.14-.18,.27-.41,.41-.69s.31-.7,.52-1.26l2.38-6.13h.37l2.83,6.84c.21,.52,.38,.88,.51,1.11,.13,.22,.26,.39,.41,.5,.1,.08,.22,.14,.36,.18,.13,.04,.34,.08,.6,.11v.15Zm-6.74-3.84h2.65l-1.37-3.37-1.29,3.37Z',
sms: 'mdi-message-text'
}
const colors: Record<string, string> = {
password: 'primary',
mail: 'blue',
iaaa: '#9b0000'
iaaa: '#9b0000',
sms: 'green'
}
const login = useAsyncState(
() => http.get('auth/login').json<{ providers: string[]; signup: boolean }>(),
const verify = useAsyncState(
() => http.get('auth/verify').json<{ providers: string[]; signup: boolean }>(),
{
providers: [],
signup: false
Expand All @@ -56,8 +58,10 @@ en:
provider-password: Password Verify
provider-mail: Email Verify
provider-iaaa: IAAA Verify
provider-sms: SMS Verify
zh-Hans:
provider-password: 密码验证
provider-mail: 邮箱验证
provider-iaaa: 北京大学统一身份认证验证
provider-sms: 短信验证
</i18n>
118 changes: 118 additions & 0 deletions apps/frontend/src/pages/auth/verify/sms.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<template>
<VForm fast-fail validate-on="submit lazy" @submit.prevent="verify">
<VCardText>
<VTextField
v-model="phone"
prepend-inner-icon="mdi-phone"
:label="t('term.telephone')"
:rules="phoneRules"
/>

<div id="vaptcha"></div>

<VOtpInput v-if="token" v-model.trim="code" />
</VCardText>

<VCardActions v-if="token">
<VBtn
:disabled="code.length !== 6"
:loading="isLoading"
type="submit"
color="primary"
block
variant="flat"
>
{{ t('pages.verify') }}
</VBtn>
</VCardActions>
</VForm>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import type { SubmitEventPromise } from 'vuetify'
import { useMfa } from '@/stores/app'
import { http, prettyHTTPError } from '@/utils/http'
import { useVaptcha } from '@/utils/vaptcha'
const { t } = useI18n()
const toast = useToast()
const { postVerify } = useMfa()
const { token } = useVaptcha({ onPass: preVerify })
const phone = ref('')
const code = ref('')
const phoneRules = [
(value: string) => {
const re = /^1\d{10}$/
if (re.test(value)) return true
return t('hint.violate-phone-rule')
}
]
const isLoading = ref(false)
async function preVerify() {
try {
await http.post('auth/preVerify', {
json: {
provider: 'sms',
payload: {
phone: phone.value,
token: token.value
}
}
})
toast.success(t('hint.sms-sent'))
} catch (err) {
toast.error(t('hint.sms-send-failed', { msg: await prettyHTTPError(err) }))
}
}
async function verify(ev: SubmitEventPromise) {
isLoading.value = true
const result = await ev
if (!result.valid) return
try {
const resp = await http.post('auth/verify', {
json: {
provider: 'sms',
payload: {
phone: phone.value,
code: code.value
}
}
})
const { token } = await resp.json<{ token: string }>()
toast.success(t('hint.verify-success'))
postVerify(token)
} catch (err) {
toast.error(t('hint.verify-wrong-credentials'))
}
isLoading.value = false
}
</script>

<i18n>
en:
hint:
violate-phone-rule: Invalid phone number
violate-code-rule: Invalid code
sms-sent: SMS sent
sms-send-failed: 'SMS send failed: {msg}. Please refresh the page.'
verify-wrong-credentials: Wrong sms or code
verify-success: Verified successfully
zh-Hans:
hint:
violate-phone-rule: 无效的手机号
violate-code-rule: 验证码无效
sms-sent: 短信已发送
sms-send-failed: '短信发送失败:{msg}。请刷新页面重试。'
verify-wrong-credentials: 验证码错误
verify-success: 验证成功
</i18n>
44 changes: 44 additions & 0 deletions apps/frontend/src/utils/user/sms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ref, type MaybeRef, toRef } from 'vue'
import { useI18n } from 'vue-i18n'

import { useAsyncTask, withMessage } from '../async'
import { http } from '../http'
import { useVaptcha } from '../vaptcha'

import { useAppState } from '@/stores/app'

export function useChangePhone(userId: MaybeRef<string>) {
const newPhone = ref('')
const code = ref('')
const userIdRef = toRef(userId)
const app = useAppState()
const { token } = useVaptcha({ onPass: (token) => sendTask.execute(token) })
const { t } = useI18n()

const sendTask = useAsyncTask(async (token: string) => {
await http.post(`user/${userIdRef.value}/preBind`, {
json: {
provider: 'sms',
payload: {
phone: newPhone.value,
token
},
mfaToken: app.mfaToken
}
})
return withMessage(t('msg.code-sent'))
})
const updateTask = useAsyncTask(async () => {
await http.post(`user/${userIdRef.value}/bind`, {
json: {
provider: 'sms',
payload: {
code: code.value,
phone: newPhone.value
},
mfaToken: app.mfaToken
}
})
})
return { newPhone, code, token, sendTask, updateTask }
}
63 changes: 63 additions & 0 deletions apps/frontend/src/utils/vaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ref, onMounted } from 'vue'

declare global {
interface Window {
vaptcha: any
vaptchaObj: any
}
}

export const useVaptcha = ({ onPass }: { onPass?: (token: string) => void } = {}) => {
const token = ref('')

const loadV3Script = () => {
return new Promise<void>((resolve) => {
if (typeof window.vaptcha === 'function') {
resolve()
} else {
const script = document.createElement('script')
script.src = 'https://v.vaptcha.com/v3.js'
script.async = true
script.addEventListener('load', () => resolve())
document.getElementsByTagName('head')[0].appendChild(script)
}
})
}

onMounted(() => {
const config = {
vid: '6768c6a0dc0ff12924d9b115',
mode: 'click',
scene: 0,
container: document.getElementById('vaptcha'),
style: 'light',
color: '#00BFFF',
lang: 'auto',
area: 'auto'
}
console.log(config)
loadV3Script().then(() => {
window.vaptcha(config).then((obj: any) => {
window.vaptchaObj = obj
obj.listen('pass', () => {
token.value = obj.getToken()
onPass?.(token.value)
})
obj.listen('close', () => {
obj.reset()
})
obj.render()
})
})
})

const reset = () => {
window.vaptchaObj.reset()
}

return {
token,
reset
}
}
Loading

0 comments on commit 0d9e43e

Please sign in to comment.