diff --git a/.yarn/versions/500b0de7.yml b/.yarn/versions/500b0de7.yml new file mode 100644 index 0000000..85ee0e5 --- /dev/null +++ b/.yarn/versions/500b0de7.yml @@ -0,0 +1,3 @@ +releases: + "@aoi-js/frontend": patch + "@aoi-js/server": patch diff --git a/apps/frontend/src/pages/login/iaaa.vue b/apps/frontend/src/pages/login/iaaa.vue new file mode 100644 index 0000000..60914a4 --- /dev/null +++ b/apps/frontend/src/pages/login/iaaa.vue @@ -0,0 +1,86 @@ + + + + + +en: + iaaa-wait: Waiting for IAAA login... +zh-Hans: + iaaa-wait: 等待 IAAA 登录... + diff --git a/apps/frontend/src/pages/login/index.vue b/apps/frontend/src/pages/login/index.vue index dde0793..9e537d7 100644 --- a/apps/frontend/src/pages/login/index.vue +++ b/apps/frontend/src/pages/login/index.vue @@ -31,12 +31,14 @@ const { t } = useI18n() const icons: Record = { password: 'mdi-lock', - mail: 'mdi-email' + 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 = { password: 'primary', - mail: 'blue' + mail: 'blue', + iaaa: '#9b0000' } const login = useAsyncState( @@ -52,7 +54,9 @@ const login = useAsyncState( en: provider-password: Password Login provider-mail: Email Login + provider-iaaa: IAAA Login zh-Hans: provider-password: 密码登录 provider-mail: 邮箱登录 + provider-iaaa: 北京大学统一身份认证登录 diff --git a/apps/frontend/src/plugins/vuetify.ts b/apps/frontend/src/plugins/vuetify.ts index 4cf07bf..9dc668c 100644 --- a/apps/frontend/src/plugins/vuetify.ts +++ b/apps/frontend/src/plugins/vuetify.ts @@ -10,5 +10,10 @@ export default createVuetify({ locale: 'zh-Hans', fallback: 'en', messages: { 'zh-Hans': zhHans, en } + }, + icons: { + sets: { + svg: {} as never + } } }) diff --git a/apps/frontend/src/utils/async.ts b/apps/frontend/src/utils/async.ts index 03fcd5c..31293c6 100644 --- a/apps/frontend/src/utils/async.ts +++ b/apps/frontend/src/utils/async.ts @@ -115,3 +115,7 @@ export class BatchingQueue { .catch((err) => entries.forEach(({ reject }) => reject(err))) } } + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/apps/server/package.json b/apps/server/package.json index 0a989ae..974aa9b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -23,6 +23,7 @@ "@fastify/jwt": "^7.2.0", "@fastify/sensible": "^5.2.0", "@fastify/type-provider-typebox": "^3.3.0", + "@lcpu/iaaa": "^1.0.0", "@sinclair/typebox": "^0.29.4", "@types/nodemailer": "^6.4.14", "bcrypt": "^5.1.1", diff --git a/apps/server/src/auth/base.ts b/apps/server/src/auth/base.ts index 45189e4..9f81294 100644 --- a/apps/server/src/auth/base.ts +++ b/apps/server/src/auth/base.ts @@ -1,3 +1,4 @@ +import { FastifyReply, FastifyRequest } from 'fastify' import { BSON } from 'mongodb' export abstract class BaseAuthProvider { @@ -7,12 +8,36 @@ export abstract class BaseAuthProvider { init?(): Promise - preBind?(userId: BSON.UUID, payload: unknown): Promise - abstract bind(userId: BSON.UUID, payload: unknown): Promise + preBind?( + userId: BSON.UUID, + payload: unknown, + req: FastifyRequest, + rep: FastifyReply + ): Promise + abstract bind( + userId: BSON.UUID, + payload: unknown, + req: FastifyRequest, + rep: FastifyReply + ): Promise - preVerify?(userId: BSON.UUID, payload: unknown): Promise - abstract verify(userId: BSON.UUID, payload: unknown): Promise + preVerify?( + userId: BSON.UUID, + payload: unknown, + req: FastifyRequest, + rep: FastifyReply + ): Promise + abstract verify( + userId: BSON.UUID, + payload: unknown, + req: FastifyRequest, + rep: FastifyReply + ): Promise - preLogin?(payload: unknown): Promise - abstract login(payload: unknown): Promise<[userId: BSON.UUID, tags?: string[]]> + preLogin?(payload: unknown, req: FastifyRequest, rep: FastifyReply): Promise + abstract login( + payload: unknown, + req: FastifyRequest, + rep: FastifyReply + ): Promise<[userId: BSON.UUID, tags?: string[]]> } diff --git a/apps/server/src/auth/iaaa.ts b/apps/server/src/auth/iaaa.ts new file mode 100644 index 0000000..a96bf9d --- /dev/null +++ b/apps/server/src/auth/iaaa.ts @@ -0,0 +1,107 @@ +import { Type } from '@sinclair/typebox' +import { loadEnv, users } from '../index.js' +import { BaseAuthProvider } from './base.js' +import { TypeCompiler } from '@sinclair/typebox/compiler' +import { UUID } from 'mongodb' +import { httpErrors } from '@fastify/sensible' +import { validate } from '@lcpu/iaaa' +import { FastifyRequest } from 'fastify' + +const STokenPayload = Type.Object({ + token: Type.String() +}) + +const TokenPayload = TypeCompiler.Compile(STokenPayload) + +export class IaaaAuthProvider extends BaseAuthProvider { + private iaaaId = '' + private iaaaKey = '' + private allowSignupFromLogin = false + + constructor() { + super() + } + + override readonly name = 'iaaa' + + override async init(): Promise { + this.iaaaId = loadEnv('IAAA_ID', String) + this.iaaaKey = loadEnv('IAAA_KEY', String) + this.allowSignupFromLogin = loadEnv( + 'IAAA_ALLOW_SIGNUP_FROM_LOGIN', + (x) => !!JSON.parse(x), + false + ) + + await users.createIndex( + { 'authSources.iaaaId': 1 }, + { unique: true, partialFilterExpression: { 'authSources.iaaaId': { $exists: true } } } + ) + } + + override async bind(userId: UUID, payload: unknown, req: FastifyRequest): Promise { + if (!TokenPayload.Check(payload)) throw httpErrors.badRequest() + const resp = await validate(req.ip, this.iaaaId, this.iaaaKey, payload.token) + if (!resp.success) throw httpErrors.forbidden(resp.errMsg) + await users.updateOne( + { _id: userId }, + { + $set: { + 'profile.realname': resp.userInfo.name, + 'profile.school': '北京大学', + 'profile.studentGrade': `${resp.userInfo.dept}${resp.userInfo.detailType}(${resp.userInfo.campus})`, + 'authSources.iaaaId': resp.userInfo.identityId + }, + $addToSet: { 'profile.verified': ['realname', 'school', 'studentGrade'] } + } + ) + return {} + } + + override async verify(userId: UUID, payload: unknown, req: FastifyRequest): Promise { + if (!TokenPayload.Check(payload)) throw httpErrors.badRequest() + const resp = await validate(req.ip, this.iaaaId, this.iaaaKey, payload.token) + if (!resp.success) throw httpErrors.forbidden(resp.errMsg) + const user = await users.findOne({ _id: userId }, { projection: { 'authSources.iaaaId': 1 } }) + if (!user) throw httpErrors.notFound('User not found') + if (!user.authSources.iaaaId) throw httpErrors.forbidden('User has no IAAA ID') + if (user.authSources.iaaaId !== resp.userInfo.identityId) + throw httpErrors.forbidden('Invalid IAAA ID') + return true + } + + override async login( + payload: unknown, + req: FastifyRequest + ): Promise<[userId: UUID, tags?: string[]]> { + if (!TokenPayload.Check(payload)) throw httpErrors.badRequest() + const resp = await validate(req.ip, this.iaaaId, this.iaaaKey, payload.token) + if (!resp.success) throw httpErrors.forbidden(resp.errMsg) + let userId: UUID + const user = await users.findOne( + { 'authSources.iaaaId': resp.userInfo.identityId }, + { projection: { _id: 1 } } + ) + if (user) { + userId = user._id + } else { + if (!this.allowSignupFromLogin) throw httpErrors.notFound('User not found') + const { insertedId } = await users.insertOne({ + _id: new UUID(), + profile: { + name: resp.userInfo.identityId, + email: `${resp.userInfo.identityId}@pku.edu.cn`, + realname: resp.userInfo.name, + school: '北京大学', + studentGrade: `${resp.userInfo.dept}${resp.userInfo.detailType}(${resp.userInfo.campus})`, + verified: ['realname', 'school', 'studentGrade'] + }, + authSources: { + iaaaId: resp.userInfo.identityId + } + }) + userId = insertedId + } + return [userId] + } +} diff --git a/apps/server/src/auth/index.ts b/apps/server/src/auth/index.ts index eab97d1..cfa250e 100644 --- a/apps/server/src/auth/index.ts +++ b/apps/server/src/auth/index.ts @@ -1,14 +1,15 @@ import { loadEnv } from '../index.js' import { BaseAuthProvider } from './base.js' +import { IaaaAuthProvider } from './iaaa.js' import { MailAuthProvider } from './mail.js' import { PasswordAuthProvider } from './password.js' const enabledAuthProviders = loadEnv('AUTH_PROVIDERS', String, 'password').split(',') export const authProviderList: Array = [ - // new PasswordAuthProvider(), - new MailAuthProvider() + new MailAuthProvider(), + new IaaaAuthProvider() ].filter((p) => enabledAuthProviders.includes(p.name)) await Promise.all(authProviderList.map((p) => p.init?.())) diff --git a/apps/server/src/db/user.ts b/apps/server/src/db/user.ts index 508f21c..2ae83a9 100644 --- a/apps/server/src/db/user.ts +++ b/apps/server/src/db/user.ts @@ -12,6 +12,7 @@ export interface IUserAuthSources { password?: string passwordResetDue?: boolean mail?: string + iaaaId?: string } export interface IUser { diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index 22205d6..52620e1 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -50,7 +50,7 @@ export const authRoutes = defineRoutes(async (s) => { const { provider, payload } = req.body const providerInstance = authProviders[provider] if (!providerInstance || !providerInstance.preLogin) return rep.badRequest() - return providerInstance.preLogin(payload) + return providerInstance.preLogin(payload, req, rep) } ) @@ -74,7 +74,7 @@ export const authRoutes = defineRoutes(async (s) => { const { provider, payload } = req.body const providerInstance = authProviders[provider] if (!providerInstance) return rep.badRequest() - const [userId, tags] = await providerInstance.login(payload) + const [userId, tags] = await providerInstance.login(payload, req, rep) const token = await rep.jwtSign({ userId: userId.toString(), tags }, { expiresIn: '7d' }) return { token } } diff --git a/apps/server/src/routes/user/scoped.ts b/apps/server/src/routes/user/scoped.ts index 067d90c..3e28bf0 100644 --- a/apps/server/src/routes/user/scoped.ts +++ b/apps/server/src/routes/user/scoped.ts @@ -140,7 +140,7 @@ export const userScopedRoutes = defineRoutes(async (s) => { const { provider, payload } = req.body const providerInstance = authProviders[provider] if (!providerInstance || !providerInstance.preBind) return rep.badRequest() - return providerInstance.preBind(ctx._userId, payload) + return providerInstance.preBind(ctx._userId, payload, req, rep) } ) @@ -171,7 +171,7 @@ export const userScopedRoutes = defineRoutes(async (s) => { const { provider, payload } = req.body const providerInstance = authProviders[provider] if (!providerInstance) return rep.badRequest() - return providerInstance.bind(ctx._userId, payload) + return providerInstance.bind(ctx._userId, payload, req, rep) } ) }) diff --git a/yarn.lock b/yarn.lock index 849d328..39fe2cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -289,6 +289,7 @@ __metadata: "@fastify/swagger": ^8.12.1 "@fastify/swagger-ui": ^2.0.1 "@fastify/type-provider-typebox": ^3.3.0 + "@lcpu/iaaa": ^1.0.0 "@sinclair/typebox": ^0.29.4 "@types/bcrypt": ^5.0.0 "@types/nodemailer": ^6.4.14 @@ -2005,6 +2006,15 @@ __metadata: languageName: node linkType: hard +"@lcpu/iaaa@npm:^1.0.0": + version: 1.0.0 + resolution: "@lcpu/iaaa@npm:1.0.0" + dependencies: + node-fetch: ^3.3.0 + checksum: ffc6e58920ea0cc3ef35d1b106380644b26dc792f760c44b119166f59f8eaa4b73b33d131b61c7b4e48b745954db8c9570a4329e70e7cbed4f55bb8c83983856 + languageName: node + linkType: hard + "@lukeed/ms@npm:^2.0.0, @lukeed/ms@npm:^2.0.1": version: 2.0.2 resolution: "@lukeed/ms@npm:2.0.2" @@ -7490,6 +7500,17 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^3.3.0": + version: 3.3.2 + resolution: "node-fetch@npm:3.3.2" + dependencies: + data-uri-to-buffer: ^4.0.0 + fetch-blob: ^3.1.4 + formdata-polyfill: ^4.0.10 + checksum: 06a04095a2ddf05b0830a0d5302699704d59bda3102894ea64c7b9d4c865ecdff2d90fd042df7f5bc40337266961cb6183dcc808ea4f3000d024f422b462da92 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 10.0.1 resolution: "node-gyp@npm:10.0.1"