diff --git a/apps/frontend/src/components/user/UserAuth.vue b/apps/frontend/src/components/user/UserAuth.vue
index 41151ca..bbb731f 100644
--- a/apps/frontend/src/components/user/UserAuth.vue
+++ b/apps/frontend/src/components/user/UserAuth.vue
@@ -8,7 +8,7 @@
-
+
@@ -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'
@@ -40,16 +41,13 @@ const { hasMfaToken, doVerify } = useMfa()
const components: Record = {
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: []
+})
@@ -58,6 +56,7 @@ 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:
@@ -65,6 +64,7 @@ zh-Hans:
provider-password: 密码登录
provider-mail: 邮箱登录
provider-iaaa: 北京大学统一身份认证
+ provider-sms: 短信登录
mfa-required: 需要多因子身份认证
do-verify: 开始认证
diff --git a/apps/frontend/src/components/user/UserAuthSms.vue b/apps/frontend/src/components/user/UserAuthSms.vue
new file mode 100644
index 0000000..a020518
--- /dev/null
+++ b/apps/frontend/src/components/user/UserAuthSms.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+ {{ t('action.update') }}
+
+
+
+
+
+
+
+en:
+ code: Code
+ hint:
+ violate-phone-rule: Invalid phone number
+zh-Hans:
+ code: 验证码
+ hint:
+ violate-phone-rule: 无效的手机号
+
diff --git a/apps/frontend/src/locales/zh-Hans.yml b/apps/frontend/src/locales/zh-Hans.yml
index 2a58fdf..251b981 100644
--- a/apps/frontend/src/locales/zh-Hans.yml
+++ b/apps/frontend/src/locales/zh-Hans.yml
@@ -186,6 +186,7 @@ msg:
recommended-browsers: 推荐的浏览器
registered: 已报名
not-registered: 未报名
+ code-sent: 验证码已发送
tabs:
description: 描述
diff --git a/apps/frontend/src/pages/auth/verify/index.vue b/apps/frontend/src/pages/auth/verify/index.vue
index c8859f9..0162b47 100644
--- a/apps/frontend/src/pages/auth/verify/index.vue
+++ b/apps/frontend/src/pages/auth/verify/index.vue
@@ -1,10 +1,10 @@
-
+
-
+
= {
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 = {
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
@@ -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: 短信验证
diff --git a/apps/frontend/src/pages/auth/verify/sms.vue b/apps/frontend/src/pages/auth/verify/sms.vue
new file mode 100644
index 0000000..4485c81
--- /dev/null
+++ b/apps/frontend/src/pages/auth/verify/sms.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('pages.verify') }}
+
+
+
+
+
+
+
+
+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: 验证成功
+
diff --git a/apps/frontend/src/utils/user/sms.ts b/apps/frontend/src/utils/user/sms.ts
new file mode 100644
index 0000000..9e309ba
--- /dev/null
+++ b/apps/frontend/src/utils/user/sms.ts
@@ -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) {
+ 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 }
+}
diff --git a/apps/frontend/src/utils/vaptcha.ts b/apps/frontend/src/utils/vaptcha.ts
new file mode 100644
index 0000000..09fcf0c
--- /dev/null
+++ b/apps/frontend/src/utils/vaptcha.ts
@@ -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((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
+ }
+}
diff --git a/apps/server/src/auth/base.ts b/apps/server/src/auth/base.ts
index bd07668..33d0e13 100644
--- a/apps/server/src/auth/base.ts
+++ b/apps/server/src/auth/base.ts
@@ -41,7 +41,7 @@ export abstract class BaseAuthProvider {
): Promise
preLogin?(payload: unknown, req: FastifyRequest, rep: FastifyReply): Promise
- abstract login(
+ login?(
payload: unknown,
req: FastifyRequest,
rep: FastifyReply
diff --git a/apps/server/src/auth/index.ts b/apps/server/src/auth/index.ts
index dc8cb70..803d90f 100644
--- a/apps/server/src/auth/index.ts
+++ b/apps/server/src/auth/index.ts
@@ -6,6 +6,7 @@ import { BaseAuthProvider } from './base.js'
import { IaaaAuthProvider } from './iaaa.js'
import { MailAuthProvider } from './mail.js'
import { PasswordAuthProvider } from './password.js'
+import { SMSAuthProvider } from './sms.js'
declare module 'fastify' {
interface FastifyInstance {
@@ -19,7 +20,8 @@ export const authProviderPlugin = fastifyPlugin(async (s) => {
const authProviderList: Array = [
new PasswordAuthProvider(s.db.users),
new MailAuthProvider(s.db.users, s.cache),
- new IaaaAuthProvider(s.db.users)
+ new IaaaAuthProvider(s.db.users),
+ new SMSAuthProvider(s.db.users, s.cache)
].filter((p) => enabledAuthProviders.includes(p.name))
await Promise.all(authProviderList.map((p) => p.init?.()))
diff --git a/apps/server/src/auth/sms.ts b/apps/server/src/auth/sms.ts
new file mode 100644
index 0000000..81154fa
--- /dev/null
+++ b/apps/server/src/auth/sms.ts
@@ -0,0 +1,132 @@
+import { httpErrors } from '@fastify/sensible'
+import { TypeCompiler } from '@sinclair/typebox/compiler'
+import { Collection, UUID } from 'mongodb'
+
+import { BaseCache } from '../cache/index.js'
+import { IUser } from '../db/index.js'
+import { T } from '../schemas/index.js'
+import { loadEnv, logger } from '../utils/index.js'
+
+import { BaseAuthProvider } from './base.js'
+
+const SCodeSendPayload = T.Object({
+ phone: T.String({ pattern: '^1[0-9]{10}$' }),
+ token: T.String({ maxLength: 4096 })
+})
+
+const CodeSendPayload = TypeCompiler.Compile(SCodeSendPayload)
+
+const SCodeVerifyPayload = T.Object({
+ phone: T.String({ pattern: '^1[0-9]{10}$' }),
+ code: T.String({ pattern: '^[0-9]{6}$' })
+})
+
+const CodeVerifyPayload = TypeCompiler.Compile(SCodeVerifyPayload)
+
+export class SMSAuthProvider extends BaseAuthProvider {
+ private _vaptchaSmsId!: string
+ private _vaptchaSmsKey!: string
+ private _vaptchaSmsTemplateId!: string
+
+ constructor(
+ private users: Collection,
+ private cache: BaseCache
+ ) {
+ super()
+ }
+
+ override readonly name = 'sms'
+
+ override async init(): Promise {
+ this._vaptchaSmsId = loadEnv('VAPTCHA_SMS_ID', String)
+ this._vaptchaSmsKey = loadEnv('VAPTCHA_SMS_KEY', String)
+ this._vaptchaSmsTemplateId = loadEnv('VAPTCHA_SMS_TEMPLATE_ID', String)
+ await this.users.createIndex(
+ { 'authSources.sms': 1 },
+ { unique: true, partialFilterExpression: { 'authSources.sms': { $exists: true } } }
+ )
+ }
+
+ private async _sendCode(phone: string, token: string): Promise {
+ const resp = await fetch(`http://sms.vaptcha.com/send`, {
+ headers: {
+ 'Content-type': "application/json;charset='utf-8'",
+ Accept: 'application/json'
+ },
+ method: 'POST',
+ body: JSON.stringify({
+ smsid: this._vaptchaSmsId,
+ smskey: this._vaptchaSmsKey,
+ templateid: this._vaptchaSmsTemplateId,
+ countrycode: '86',
+ token,
+ data: ['_vcode'],
+ phone: phone
+ })
+ })
+ const text = await resp.text()
+ logger.info(`SMS sent to ${phone}: ${text}`)
+ }
+
+ private async _verifyCode(phone: string, code: string): Promise {
+ const resp = await fetch(`http://sms.vaptcha.com/verify`, {
+ headers: {
+ 'Content-type': "application/json;charset='utf-8'",
+ Accept: 'application/json'
+ },
+ method: 'POST',
+ body: JSON.stringify({
+ smsid: this._vaptchaSmsId,
+ smskey: this._vaptchaSmsKey,
+ phone,
+ vcode: code
+ })
+ })
+ const text = await resp.text()
+ logger.info(`SMS verify for ${phone}: ${text}`)
+ if (text !== '600') throw httpErrors.forbidden('Invalid code')
+ }
+
+ override async preBind(userId: UUID, payload: unknown): Promise {
+ if (!CodeSendPayload.Check(payload)) throw httpErrors.badRequest('Invalid payload')
+ const { phone, token } = payload
+ await this._sendCode(phone, token)
+ return {}
+ }
+
+ override async bind(userId: UUID, payload: unknown): Promise {
+ if (!CodeVerifyPayload.Check(payload)) throw httpErrors.badRequest('Invalid payload')
+ const { code, phone } = payload
+ await this._verifyCode(phone, code)
+ await this.users.updateOne(
+ { _id: userId },
+ {
+ $set: { 'profile.telephone': phone, 'authSources.sms': phone },
+ $addToSet: { 'profile.verified': { $each: ['telephone'] } }
+ }
+ )
+ return {}
+ }
+
+ override async preVerify(userId: UUID, payload: unknown): Promise {
+ if (!CodeSendPayload.Check(payload)) throw httpErrors.badRequest('Invalid payload')
+ const { phone, token } = payload
+ const user = await this.users.findOne({ _id: userId }, { projection: { 'authSources.sms': 1 } })
+ if (!user) throw httpErrors.notFound('User not found')
+ if (!user.authSources.sms) throw new Error('user has no binded phone')
+ if (user.authSources.sms !== phone) throw httpErrors.forbidden('Invalid phone')
+ await this._sendCode(phone, token)
+ return {}
+ }
+
+ override async verify(userId: UUID, payload: unknown): Promise {
+ if (!CodeVerifyPayload.Check(payload)) throw httpErrors.badRequest('Invalid payload')
+ const { code, phone } = payload
+ const user = await this.users.findOne({ _id: userId }, { projection: { 'authSources.sms': 1 } })
+ if (!user) throw httpErrors.notFound('User not found')
+ if (!user.authSources.sms) throw new Error('user has no binded phone')
+ if (user.authSources.sms !== phone) throw httpErrors.forbidden('Invalid phone')
+ await this._verifyCode(phone, code)
+ return true
+ }
+}
diff --git a/apps/server/src/db/user.ts b/apps/server/src/db/user.ts
index d8d56eb..41d6263 100644
--- a/apps/server/src/db/user.ts
+++ b/apps/server/src/db/user.ts
@@ -14,6 +14,7 @@ export interface IUserAuthSources {
password?: string
passwordResetDue?: boolean
mail?: string
+ sms?: string
iaaaId?: string
iaaaInfo?: IAAAUserInfo
}
diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts
index 19ce4a1..ad6bde9 100644
--- a/apps/server/src/routes/auth/index.ts
+++ b/apps/server/src/routes/auth/index.ts
@@ -11,7 +11,8 @@ const signupEnabled = loadEnv('SIGNUP_ENABLED', parseBoolean, true)
export const authRoutes = defineRoutes(async (s) => {
const { users, orgMemberships, infos } = s.db
const authProviders = s.authProviders
- const providerNames = Object.keys(authProviders)
+ const loginProviders = Object.keys(authProviders).filter((name) => authProviders[name].login)
+ const verifyProviders = Object.keys(authProviders).filter((name) => authProviders[name].verify)
s.addHook('onRoute', (route) => {
;(route.schema ??= {}).security ??= []
@@ -31,7 +32,7 @@ export const authRoutes = defineRoutes(async (s) => {
}
},
async () => {
- return { providers: providerNames, signup: signupEnabled }
+ return { providers: loginProviders, signup: signupEnabled }
}
)
@@ -73,12 +74,30 @@ export const authRoutes = defineRoutes(async (s) => {
async (req, rep) => {
const { provider, payload } = req.body
if (!Object.hasOwn(authProviders, provider)) return rep.badRequest()
- const [userId, tags] = await authProviders[provider].login(payload, req, rep)
+ const impl = authProviders[provider]
+ if (!impl.login) return rep.badRequest()
+ const [userId, tags] = await impl.login(payload, req, rep)
const token = await rep.jwtSign({ userId: userId.toString(), tags }, { expiresIn: '7d' })
return { token }
}
)
+ s.get(
+ '/verify',
+ {
+ schema: {
+ response: {
+ 200: T.Object({
+ providers: T.Array(T.String())
+ })
+ }
+ }
+ },
+ async () => {
+ return { providers: verifyProviders }
+ }
+ )
+
s.post(
'/preVerify',
{