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 @@
+
+
+
+ {{ t('iaaa-wait') }}
+
+
+
+
+
+
+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"