Skip to content

Commit

Permalink
feat: MFA user bind (#29)
Browse files Browse the repository at this point in the history
* feat: add mfa bind

* feat: implemented mfa user bind

* fix: env name

* fix: iaaa verification
  • Loading branch information
thezzisu authored Jan 21, 2024
1 parent 1582d2b commit 055b051
Show file tree
Hide file tree
Showing 34 changed files with 589 additions and 70 deletions.
3 changes: 3 additions & 0 deletions .yarn/versions/5d434078.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
releases:
"@aoi-js/frontend": patch
"@aoi-js/server": patch
2 changes: 1 addition & 1 deletion apps/frontend/src/components/app/AppBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<AppBarUserMenu />
</VToolbarItems>
<VToolbarItems v-else>
<VBtn color="blue-darken-1" to="/login" exact>
<VBtn color="blue-darken-1" to="/auth/login" exact>
{{ t('pages.signin') }}
</VBtn>
</VToolbarItems>
Expand Down
12 changes: 11 additions & 1 deletion apps/frontend/src/components/user/UserAuth.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
</VCardTitle>
<VSkeletonLoader type="image" v-if="login.isLoading.value" />
<VCardText v-else>
<VRow>
<VAlert v-if="enableMfa && !hasMfaToken" type="info" color="" :title="t('mfa-required')">
<VBtn variant="outlined" @click="doVerify" :text="t('do-verify')" />
</VAlert>
<VRow v-else>
<VCol v-for="method of login.state.value.providers" :key="method">
<VCard variant="outlined" :title="t(`provider-${method}`)">
<component :is="components[method]" :userId="userId" />
Expand All @@ -22,12 +25,15 @@ import UserAuthMail from './UserAuthMail.vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import UserAuthIaaa from './UserAuthIaaa.vue'
import { useMfa } from '@/stores/app'
import { enableMfa } from '@/utils/flags'
defineProps<{
userId: string
}>()
const { t } = useI18n()
const { hasMfaToken, doVerify } = useMfa()
const components: Record<string, Component> = {
password: UserAuthPassword,
Expand All @@ -50,9 +56,13 @@ en:
provider-password: Password Login
provider-mail: Email Login
provider-iaaa: IAAA Login
mfa-required: MFA Required
do-verify: Verify
zh-Hans:
user-auth: 用户认证
provider-password: 密码登录
provider-mail: 邮箱登录
provider-iaaa: 北京大学统一身份认证
mfa-required: 需要多因子身份认证
do-verify: 开始认证
</i18n>
8 changes: 7 additions & 1 deletion apps/frontend/src/components/user/UserAuthPassword.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
<template>
<VCardText>
<VTextField v-model="oldPassword" :label="t('old-password')" type="password" />
<VTextField
v-if="!enableMfa"
v-model="oldPassword"
:label="t('old-password')"
type="password"
/>
<VTextField v-model="newPassword" :label="t('new-password')" type="password" />
</VCardText>
<VCardActions>
Expand All @@ -11,6 +16,7 @@
</template>

<script setup lang="ts">
import { enableMfa } from '@/utils/flags'
import { useChangePassword } from '@/utils/user/password'
import { toRef } from 'vue'
import { useI18n } from 'vue-i18n'
Expand Down
45 changes: 45 additions & 0 deletions apps/frontend/src/components/utils/NotFound.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<template>
<VContainer class="fill-height">
<VRow justify="center" align="center">
<VCol lg="4" xl="3">
<VCard variant="text">
<VCardTitle class="text-center">
<div>
<VAvatar size="180" rounded="0">
<AppLogo />
</VAvatar>
</div>
</VCardTitle>
<VAlert type="warning" title="404" :text="t('pages.not-found')" />
<VCardActions class="justify-center">
<VBtn
variant="tonal"
color="blue"
rounded="0"
prepend-icon="mdi-arrow-left"
:text="t('action.back')"
@click="router.back()"
/>
<VBtn
variant="tonal"
color="green"
rounded="0"
prepend-icon="mdi-home"
:text="t('pages.home')"
to="/"
/>
</VCardActions>
</VCard>
</VCol>
</VRow>
</VContainer>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import AppLogo from '../app/AppLogo.vue'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
</script>
3 changes: 3 additions & 0 deletions apps/frontend/src/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pages:
groups: Group
signin: Sign in
signup: Sign up
verify: Verify
admin: Admin
plans: Plans
user-info: User Info
Expand All @@ -16,6 +17,7 @@ pages:
modify-passwd: Modify Password
announcements: Announcements
solutions: Solutions
not-found: Not Found

action:
reload: Reload
Expand All @@ -38,6 +40,7 @@ action:
publish: Publish
cancel-publish: Cancel Publish
export: Export
back: Go Back

term:
content: Content
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/src/locales/zh-Hans.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pages:
groups: 小组
signin: 登录
signup: 注册
verify: 身份验证
admin: 管理
plans: 计划
user-info: 用户信息
Expand All @@ -16,6 +17,7 @@ pages:
modify-passwd: 修改密码
announcements: 公告
solutions: 提交记录
not-found: 页面不存在

action:
reload: 重载
Expand All @@ -38,6 +40,7 @@ action:
publish: 发布
cancel-publish: 取消发布
export: 导出
back: 返回

term:
content: 内容
Expand Down
50 changes: 50 additions & 0 deletions apps/frontend/src/pages/auth/login.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<template>
<VContainer class="fill-height">
<VRow justify="center" align="center">
<VCol lg="4" xl="3">
<VCard variant="text">
<VCardTitle class="text-center">
<div>
<VAvatar size="180" rounded="0">
<AppLogo />
</VAvatar>
</div>
<div>
{{ t('pages.signin') }}
</div>
<VBtn
v-if="!isRoot"
variant="tonal"
prepend-icon="mdi-arrow-left"
:text="t('action.back')"
:to="{ path: '/auth/login', query: route.query }"
/>
</VCardTitle>
<VDivider />
<VAlert v-if="hint" type="info" class="ma-4 mb-0 u-whitespace-pre" :text="hint" />
<RouterView />
</VCard>
</VCol>
</VRow>
</VContainer>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAppState } from '@/stores/app'
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
import { withI18nTitle } from '@/utils/title'
import AppLogo from '@/components/app/AppLogo.vue'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const appState = useAppState()
withI18nTitle('pages.signin')
if (appState.loggedIn) router.replace('/')
const hint = import.meta.env.VITE_LOGIN_HINT
const isRoot = computed(() => /^\/auth\/login\/?$/.test(route.path))
</script>
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<VRow dense>
<VCol v-for="method of login.state.value.providers" :key="method" cols="12">
<VBtn
:to="`/login/${method}`"
:to="{ path: `/auth/login/${method}`, query: route.query }"
block
variant="flat"
:prepend-icon="icons[method] ?? 'mdi-fingerprint'"
Expand All @@ -26,9 +26,12 @@
import { http } from '@/utils/http'
import { useAsyncState } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
const { t } = useI18n()
const route = useRoute()
const icons: Record<string, string> = {
password: 'mdi-lock',
mail: 'mdi-email',
Expand Down
File renamed without changes.
File renamed without changes.
52 changes: 52 additions & 0 deletions apps/frontend/src/pages/auth/verify.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<template>
<VContainer class="fill-height">
<VRow justify="center" align="center">
<VCol lg="4" xl="3">
<VCard variant="text">
<VCardTitle class="text-center">
<div>
<VAvatar size="180" rounded="0">
<AppLogo />
</VAvatar>
</div>
<div>
{{ t('pages.verify') }}
</div>
<VBtn
v-if="!isRoot"
variant="tonal"
prepend-icon="mdi-arrow-left"
:text="t('action.back')"
:to="{ path: '/auth/verify', query: route.query }"
/>
</VCardTitle>
<VDivider />
<VAlert v-if="hint" type="info" class="ma-4 mb-0 u-whitespace-pre" :text="hint" />
<RouterView />
</VCard>
</VCol>
</VRow>
</VContainer>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAppState, useMfa } from '@/stores/app'
import { useRoute, useRouter } from 'vue-router'
import AppLogo from '@/components/app/AppLogo.vue'
import { withI18nTitle } from '@/utils/title'
import { computed } from 'vue'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const appState = useAppState()
const { hasMfaToken } = useMfa()
withI18nTitle('pages.verify')
if (!appState.loggedIn) router.replace('/login')
if (hasMfaToken.value) router.replace('/')
const hint = import.meta.env.VITE_VERIFY_HINT
const isRoot = computed(() => /^\/auth\/verify\/?$/.test(route.path))
</script>
16 changes: 16 additions & 0 deletions apps/frontend/src/pages/auth/verify/iaaa.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<VAlert type="error" :text="t('iaaa-not-supported')" />
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

<i18n>
en:
iaaa-not-supported: IAAA is not supported for verification.
zh-Hans:
iaaa-not-supported: IAAA 不支持验证。
</i18n>
65 changes: 65 additions & 0 deletions apps/frontend/src/pages/auth/verify/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<template>
<VCardText v-if="login.isLoading.value">
<VSkeletonLoader type="image" />
</VCardText>
<VCardText v-else>
<VRow dense>
<VCol v-for="method of login.state.value.providers" :key="method" cols="12">
<VBtn
:to="{ path: `/auth/verify/${method}`, query: route.query }"
block
variant="flat"
:prepend-icon="icons[method] ?? 'mdi-fingerprint'"
:color="colors[method]"
>
{{ t('provider-' + method) }}
</VBtn>
</VCol>
</VRow>
</VCardText>
<VCardText v-if="login.state.value.signup" class="text-center">
<VBtn variant="tonal" to="/signup"> {{ t('pages.signup') }} </VBtn>
</VCardText>
</template>

<script setup lang="ts">
import { http } from '@/utils/http'
import { useAsyncState } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
const { t } = useI18n()
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'
}
const colors: Record<string, string> = {
password: 'primary',
mail: 'blue',
iaaa: '#9b0000'
}
const login = useAsyncState(
() => http.get('auth/login').json<{ providers: string[]; signup: boolean }>(),
{
providers: [],
signup: false
}
)
</script>

<i18n>
en:
provider-password: Password Verify
provider-mail: Email Verify
provider-iaaa: IAAA Verify
zh-Hans:
provider-password: 密码验证
provider-mail: 邮箱验证
provider-iaaa: 北京大学统一身份认证验证
</i18n>
Loading

0 comments on commit 055b051

Please sign in to comment.