Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 1.2, sms auth and enhanced rule engine #114

Merged
merged 4 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .yarn/versions/a381126e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
releases:
"@aoi-js/frontend": minor
"@aoi-js/rule": minor
"@aoi-js/server": minor
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
11 changes: 8 additions & 3 deletions apps/frontend/src/pages/about.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
<AoiLogo width="128" />
<q class="u-text-2xl u-font-italic">The <b>AOI</b> Project</q>
<span class="text-black u-text-xl">
AOI V1.1
<ruby class="text-primary">はるひ <rt>Haruhi</rt> </ruby>
AOI V1.2
<ruby class="text-primary">こみち <rt>Komichi</rt> </ruby>
</span>
<span class="text-secondary">Released under the AGPL-3.0 License</span>
<span class="text-secondary">2024-04-20</span>
<span class="text-secondary">2024-12-23</span>
</a>
</VCardText>
<VDivider />
Expand All @@ -27,6 +27,11 @@
</tr>
</thead>
<tbody>
<tr>
<td>2024-04-20</td>
<td>1.0.x</td>
<td>せかい</td>
</tr>
<tr>
<td>2024-02-20</td>
<td>1.0.x</td>
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 }
}
Loading
Loading