Skip to content

Commit

Permalink
feat: pku iaaa (#12)
Browse files Browse the repository at this point in the history
* feat(server): support iaaa provider

* feat: finished iaaa

* chore: bump versions
  • Loading branch information
thezzisu authored Jan 16, 2024
1 parent e419c4b commit 9b76b2c
Show file tree
Hide file tree
Showing 13 changed files with 272 additions and 14 deletions.
3 changes: 3 additions & 0 deletions .yarn/versions/500b0de7.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
releases:
"@aoi-js/frontend": patch
"@aoi-js/server": patch
86 changes: 86 additions & 0 deletions apps/frontend/src/pages/login/iaaa.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<template>
<VCardText class="text-center">
<VProgressCircular indeterminate />
<p>{{ t('iaaa-wait') }}</p>
</VCardText>
</template>

<script setup lang="ts">
import { useAsyncTask, sleep } from '@/utils/async'
import { http, login } from '@/utils/http'
import { HTTPError } from 'ky'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
const { t } = useI18n()
const router = useRouter()
const toast = useToast()
const html = `
<form action=https://iaaa.pku.edu.cn/iaaa/oauth.jsp method=post name=iaaa style="display: none">
<input type=hidden name=appID value="${import.meta.env.VITE_IAAA_APPID}" />
<input type=hidden name=appName value="${import.meta.env.VITE_IAAA_APPNAME}" />
<input type=hidden name=redirectUrl value="${import.meta.env.VITE_IAAA_REDIR}" />
</form>
`
async function getToken() {
const win = window.open('/auth/iaaa', 'iaaa', 'width=800,height=600')
const client = win?.window
if (!client) throw new Error('Failed to open IAAA window')
client.document.write(html)
client.document.forms[0].submit()
for (;;) {
try {
const token = new URLSearchParams(client.document.location.search).get('token')
if (token) {
win.close()
return token
}
} catch (err) {
//
}
await sleep(200)
}
}
const task = useAsyncTask(async () => {
try {
const resp = await http.post('auth/login', {
json: {
provider: 'iaaa',
payload: {
token: await getToken()
}
}
})
const { token } = await resp.json<{ token: string }>()
toast.success(t('hint.signin-success'))
login(token)
router.replace('/')
} catch (err) {
router.replace('/login')
let msg = `${err}`
if (err instanceof HTTPError) {
msg = await err.response
.json()
.then(({ message }) => message)
.catch((err) => `${err}`)
}
throw new Error(msg)
}
})
onMounted(() => {
task.execute()
})
</script>

<i18n>
en:
iaaa-wait: Waiting for IAAA login...
zh-Hans:
iaaa-wait: 等待 IAAA 登录...
</i18n>
8 changes: 6 additions & 2 deletions apps/frontend/src/pages/login/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ const { t } = useI18n()
const icons: Record<string, string> = {
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<string, string> = {
password: 'primary',
mail: 'blue'
mail: 'blue',
iaaa: '#9b0000'
}
const login = useAsyncState(
Expand All @@ -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: 北京大学统一身份认证登录
</i18n>
5 changes: 5 additions & 0 deletions apps/frontend/src/plugins/vuetify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,10 @@ export default createVuetify({
locale: 'zh-Hans',
fallback: 'en',
messages: { 'zh-Hans': zhHans, en }
},
icons: {
sets: {
svg: {} as never
}
}
})
4 changes: 4 additions & 0 deletions apps/frontend/src/utils/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,7 @@ export class BatchingQueue<T, R> {
.catch((err) => entries.forEach(({ reject }) => reject(err)))
}
}

export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 31 additions & 6 deletions apps/server/src/auth/base.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FastifyReply, FastifyRequest } from 'fastify'
import { BSON } from 'mongodb'

export abstract class BaseAuthProvider {
Expand All @@ -7,12 +8,36 @@ export abstract class BaseAuthProvider {

init?(): Promise<void>

preBind?(userId: BSON.UUID, payload: unknown): Promise<unknown>
abstract bind(userId: BSON.UUID, payload: unknown): Promise<unknown>
preBind?(
userId: BSON.UUID,
payload: unknown,
req: FastifyRequest,
rep: FastifyReply
): Promise<unknown>
abstract bind(
userId: BSON.UUID,
payload: unknown,
req: FastifyRequest,
rep: FastifyReply
): Promise<unknown>

preVerify?(userId: BSON.UUID, payload: unknown): Promise<unknown>
abstract verify(userId: BSON.UUID, payload: unknown): Promise<boolean>
preVerify?(
userId: BSON.UUID,
payload: unknown,
req: FastifyRequest,
rep: FastifyReply
): Promise<unknown>
abstract verify(
userId: BSON.UUID,
payload: unknown,
req: FastifyRequest,
rep: FastifyReply
): Promise<boolean>

preLogin?(payload: unknown): Promise<unknown>
abstract login(payload: unknown): Promise<[userId: BSON.UUID, tags?: string[]]>
preLogin?(payload: unknown, req: FastifyRequest, rep: FastifyReply): Promise<unknown>
abstract login(
payload: unknown,
req: FastifyRequest,
rep: FastifyReply
): Promise<[userId: BSON.UUID, tags?: string[]]>
}
107 changes: 107 additions & 0 deletions apps/server/src/auth/iaaa.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<unknown> {
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<boolean> {
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]
}
}
5 changes: 3 additions & 2 deletions apps/server/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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<BaseAuthProvider> = [
//
new PasswordAuthProvider(),
new MailAuthProvider()
new MailAuthProvider(),
new IaaaAuthProvider()
].filter((p) => enabledAuthProviders.includes(p.name))

await Promise.all(authProviderList.map((p) => p.init?.()))
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/db/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface IUserAuthSources {
password?: string
passwordResetDue?: boolean
mail?: string
iaaaId?: string
}

export interface IUser {
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
)

Expand All @@ -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 }
}
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/routes/user/scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
)

Expand Down Expand Up @@ -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)
}
)
})
Loading

0 comments on commit 9b76b2c

Please sign in to comment.