Skip to content

Commit fa82f2c

Browse files
committed
refactor(auth)!: replace legacy auth system with better auth
- Added support for the '@better-auth/passkey' package to enhance authentication options. - Refactored the AuthGuard and RolesGuard to utilize session-based user roles instead of user models. - Updated the authentication middleware to handle passkey options and improve session management. - Introduced breaking changes in the authentication system, requiring users to follow the new upgrade guide. These changes improve security and flexibility in user authentication, aligning with modern standards. Signed-off-by: Innei <tukon479@gmail.com>
1 parent 583ea80 commit fa82f2c

File tree

72 files changed

+2251
-877
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

72 files changed

+2251
-877
lines changed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,16 @@
2323
- `pnpm test`:运行测试
2424
- `pnpm lint`:运行 ESLint
2525

26+
## 升级指南
27+
28+
从 v9 升级到 v10 涉及鉴权体系重构,属于 **breaking change**
29+
30+
详细升级指南请查看 **[Upgrading to v10](./docs/migrations/v10.md)**
31+
2632
# 许可
2733

2834
此项目在 `apps/` 目录下的所有文件均使用 GNU Affero General Public License v3.0 (AGPLv3) with Additional Terms (ADDITIONAL_TERMS) 许可。
2935

3036
其他部分使用 MIT License 许可。
3137

32-
详情请查看 [LICENSE](./LICENSE)[ADDITIONAL_TERMS](./ADDITIONAL_TERMS.md)
38+
详情请查看 [LICENSE](./LICENSE)[ADDITIONAL_TERMS](./ADDITIONAL_TERMS.md)

apps/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"@babel/plugin-transform-modules-commonjs": "7.28.6",
5959
"@babel/plugin-transform-typescript": "7.28.6",
6060
"@babel/types": "^7.28.5",
61+
"@better-auth/passkey": "^1.4.18",
6162
"@fastify/cookie": "11.0.2",
6263
"@fastify/multipart": "9.4.0",
6364
"@fastify/static": "9.0.0",

apps/core/src/app.module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { MarkdownModule } from './modules/markdown/markdown.module'
4343
import { MetaPresetModule } from './modules/meta-preset/meta-preset.module'
4444
import { NoteModule } from './modules/note/note.module'
4545
import { OptionModule } from './modules/option/option.module'
46+
import { OwnerModule } from './modules/owner/owner.module'
4647
import { PageModule } from './modules/page/page.module'
4748
import { PageProxyModule } from './modules/pageproxy/pageproxy.module'
4849
import { PostModule } from './modules/post/post.module'
@@ -60,7 +61,6 @@ import { SnippetModule } from './modules/snippet/snippet.module'
6061
import { SubscribeModule } from './modules/subscribe/subscribe.module'
6162
import { TopicModule } from './modules/topic/topic.module'
6263
import { UpdateModule } from './modules/update/update.module'
63-
import { UserModule } from './modules/user/user.module'
6464
import { WebhookModule } from './modules/webhook/webhook.module'
6565
import { DatabaseModule } from './processors/database/database.module'
6666
import { GatewayModule } from './processors/gateway/gateway.module'
@@ -115,7 +115,7 @@ import { TaskQueueModule } from './processors/task-queue/task-queue.module'
115115
SubscribeModule,
116116
TopicModule,
117117
UpdateModule,
118-
UserModule,
118+
OwnerModule,
119119
WebhookModule,
120120

121121
PageProxyModule,

apps/core/src/common/contexts/request.context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { AsyncLocalStorage } from 'node:async_hooks'
44
import type { ServerResponse } from 'node:http'
55
import { UnauthorizedException } from '@nestjs/common'
6-
import type { UserModel } from '~/modules/user/user.model'
6+
import type { SessionUser } from '~/modules/auth/auth.types'
77
import type { BizIncomingMessage } from '~/transformers/get-req.transformer'
88

99
type Nullable<T> = T | null
@@ -39,7 +39,7 @@ export class RequestContext {
3939
return null
4040
}
4141

42-
static currentUser(throwError?: boolean): Nullable<UserModel> {
42+
static currentUser(throwError?: boolean): Nullable<SessionUser> {
4343
const requestContext = RequestContext.currentRequestContext()
4444

4545
if (requestContext) {

apps/core/src/common/guards/auth.guard.ts

Lines changed: 37 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,72 @@
11
import type { CanActivate, ExecutionContext } from '@nestjs/common'
2-
import { Injectable, UnauthorizedException } from '@nestjs/common'
2+
import { Injectable, Logger } from '@nestjs/common'
3+
import { ErrorCodeEnum } from '~/constants/error-code.constant'
34
import { AuthService } from '~/modules/auth/auth.service'
4-
import { ConfigsService } from '~/modules/configs/configs.service'
5-
import type { UserModel } from '~/modules/user/user.model'
6-
import { UserService } from '~/modules/user/user.service'
5+
import type { SessionUser } from '~/modules/auth/auth.types'
76
import type { FastifyBizRequest } from '~/transformers/get-req.transformer'
87
import { getNestExecutionContextRequest } from '~/transformers/get-req.transformer'
9-
import { isJWT } from '~/utils/validator.util'
8+
import { BizException } from '../exceptions/biz.exception'
109

1110
/**
12-
* JWT auth guard
11+
* Better Auth (cookie + API key) guard
1312
*/
1413

1514
@Injectable()
1615
export class AuthGuard implements CanActivate {
17-
constructor(
18-
protected readonly authService: AuthService,
19-
protected readonly configs: ConfigsService,
20-
21-
protected readonly userService: UserService,
22-
) {}
16+
protected readonly logger = new Logger(AuthGuard.name)
17+
constructor(protected readonly authService: AuthService) {}
2318
async canActivate(context: ExecutionContext): Promise<any> {
2419
const request = this.getRequest(context)
2520

26-
const query = request.query as any
27-
const headers = request.headers
28-
2921
const session = await this.authService.getSessionUser(request.raw)
3022

31-
const Authorization: string =
32-
headers.authorization || headers.Authorization || query.token
33-
3423
if (session) {
35-
const isOwner = !!session.user?.isOwner
24+
const isOwner = session.user?.role === 'owner'
3625

3726
if (isOwner) {
3827
this.attachUserAndToken(
3928
request,
40-
await this.userService.getMaster(),
41-
Authorization,
29+
session.user as SessionUser,
30+
session.session?.token || '',
4231
)
4332
return true
4433
}
4534
}
4635

47-
if (!Authorization) {
48-
throw new UnauthorizedException('未登录')
36+
const apiKey = this.authService.getApiKeyFromRequest({
37+
headers: request.headers,
38+
query: request.query as any,
39+
})
40+
41+
if (!apiKey) {
42+
throw new BizException(ErrorCodeEnum.AuthNotLoggedIn)
4943
}
5044

51-
if (this.authService.isCustomToken(Authorization)) {
52-
const [isValid, userModel] =
53-
await this.authService.verifyCustomToken(Authorization)
54-
if (!isValid) {
55-
throw new UnauthorizedException('令牌无效')
56-
}
45+
if (apiKey.deprecated) {
46+
// this.logger.warn(
47+
// '[Auth] Authorization bearer token is deprecated. Use x-api-key instead.',
48+
// )
49+
}
5750

58-
this.attachUserAndToken(request, userModel, Authorization)
59-
return true
51+
if (!this.authService.isCustomToken(apiKey.key)) {
52+
throw new BizException(ErrorCodeEnum.AuthTokenInvalid)
6053
}
6154

62-
const jwt = Authorization.replace(/[Bb]earer /, '')
55+
const result = await this.authService.verifyApiKey(apiKey.key)
56+
if (!result?.userId) {
57+
throw new BizException(ErrorCodeEnum.AuthTokenInvalid)
58+
}
6359

64-
if (!isJWT(jwt)) {
65-
throw new UnauthorizedException('令牌无效')
60+
const isOwner = await this.authService.isOwnerReaderId(result.userId)
61+
if (!isOwner) {
62+
throw new BizException(ErrorCodeEnum.AuthTokenInvalid)
6663
}
67-
const valid = await this.authService.jwtServicePublic.verify(jwt)
6864

69-
if (!valid) throw new UnauthorizedException('身份过期')
70-
this.attachUserAndToken(
71-
request,
72-
await this.userService.getMaster(),
73-
Authorization,
74-
)
65+
const readerUser = await this.authService.getReaderById(result.userId)
66+
if (!readerUser) {
67+
throw new BizException(ErrorCodeEnum.AuthTokenInvalid)
68+
}
69+
this.attachUserAndToken(request, readerUser, apiKey.key)
7570
return true
7671
}
7772

@@ -81,7 +76,7 @@ export class AuthGuard implements CanActivate {
8176

8277
attachUserAndToken(
8378
request: FastifyBizRequest,
84-
user: UserModel,
79+
user: SessionUser,
8580
token: string,
8681
) {
8782
request.user = user

apps/core/src/common/guards/roles.guard.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { CanActivate, ExecutionContext } from '@nestjs/common'
22
import { Injectable } from '@nestjs/common'
33
import { AuthService } from '~/modules/auth/auth.service'
44
import { ConfigsService } from '~/modules/configs/configs.service'
5-
import { UserService } from '~/modules/user/user.service'
65
import { getNestExecutionContextRequest } from '~/transformers/get-req.transformer'
76
import { AuthGuard } from './auth.guard'
87

@@ -15,10 +14,8 @@ export class RolesGuard extends AuthGuard implements CanActivate {
1514
constructor(
1615
protected readonly authService: AuthService,
1716
protected readonly configs: ConfigsService,
18-
19-
protected readonly userService: UserService,
2017
) {
21-
super(authService, configs, userService)
18+
super(authService)
2219
}
2320
async canActivate(context: ExecutionContext): Promise<boolean> {
2421
const request = this.getRequest(context)
@@ -29,10 +26,9 @@ export class RolesGuard extends AuthGuard implements CanActivate {
2926
} catch {}
3027

3128
const session = await this.authService.getSessionUser(request.raw)
29+
const readerId = session?.user?.id || request.user?.id
3230

33-
if (session) {
34-
const readerId = session.user?.id
35-
31+
if (readerId) {
3632
request.readerId = readerId
3733

3834
Object.assign(request.raw, {

apps/core/src/constants/db.constant.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const DRAFT_COLLECTION_NAME = 'drafts'
2222
export const FILE_REFERENCE_COLLECTION_NAME = 'file_references'
2323

2424
export const USER_COLLECTION_NAME = 'users'
25+
export const OWNER_PROFILE_COLLECTION_NAME = 'owner_profiles'
2526
export enum CollectionRefTypes {
2627
Post = POST_COLLECTION_NAME,
2728
Note = NOTE_COLLECTION_NAME,

apps/core/src/constants/error-code.constant.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export enum ErrorCodeEnum {
7777
AuthSessionNotFound = 15005,
7878
AuthUserIdNotFound = 15006,
7979
AuthFailed = 15007,
80+
AuthNotLoggedIn = 15008,
81+
AuthTokenInvalid = 15009,
8082

8183
// biz - operation failed (400)
8284
CategoryHasPosts = 16000,
@@ -230,6 +232,8 @@ export const ErrorCode = Object.freeze<Record<ErrorCodeEnum, [string, number]>>(
230232
[ErrorCodeEnum.AuthSessionNotFound]: ['会话不存在', 400],
231233
[ErrorCodeEnum.AuthUserIdNotFound]: ['用户 ID 不存在', 400],
232234
[ErrorCodeEnum.AuthFailed]: ['认证失败', 400],
235+
[ErrorCodeEnum.AuthNotLoggedIn]: ['未登录', 401],
236+
[ErrorCodeEnum.AuthTokenInvalid]: ['令牌无效', 401],
233237

234238
// operation failed (400)
235239
[ErrorCodeEnum.CategoryHasPosts]: ['该分类中有其他文章,无法被删除', 400],

apps/core/src/migration/history.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ import v9_4_1 from './version/v9.4.1'
2020
import v9_5_0 from './version/v9.5.0'
2121
import v9_6_0 from './version/v9.6.0'
2222
import v9_6_3 from './version/v9.6.3'
23+
import v9_7_0 from './version/v9.7.0'
24+
import v9_7_1 from './version/v9.7.1'
25+
import v9_7_2 from './version/v9.7.2'
26+
import v9_7_3 from './version/v9.7.3'
27+
import v9_7_4 from './version/v9.7.4'
28+
import v9_7_5 from './version/v9.7.5'
29+
import v9_7_6 from './version/v9.7.6'
2330

2431
export default [
2532
v200Alpha1,
@@ -44,4 +51,11 @@ export default [
4451
v9_5_0,
4552
v9_6_0,
4653
v9_6_3,
54+
v9_7_0,
55+
v9_7_1,
56+
v9_7_2,
57+
v9_7_3,
58+
v9_7_4,
59+
v9_7_5,
60+
v9_7_6,
4761
]
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { USER_COLLECTION_NAME } from '~/constants/db.constant'
2+
import { AUTH_JS_USER_COLLECTION } from '~/modules/auth/auth.constant'
3+
import type { Db } from 'mongodb'
4+
import { defineMigration } from '../helper'
5+
6+
const base64UrlToBase64 = (value: string) => {
7+
const normalized = value.replaceAll('-', '+').replaceAll('_', '/')
8+
const padding = normalized.length % 4
9+
const pad = padding === 0 ? '' : '='.repeat(4 - padding)
10+
return `${normalized}${pad}`
11+
}
12+
13+
export default defineMigration(
14+
'v9.7.0-better-auth-migration',
15+
async (db: Db) => {
16+
const readers = db.collection(AUTH_JS_USER_COLLECTION)
17+
let owner = await readers.findOne({ isOwner: true })
18+
19+
if (!owner) {
20+
owner = await readers.findOne({})
21+
if (owner && !owner.isOwner) {
22+
await readers.updateOne({ _id: owner._id }, { $set: { isOwner: true } })
23+
}
24+
}
25+
26+
if (!owner) {
27+
const legacyOwner = await db.collection(USER_COLLECTION_NAME).findOne({})
28+
if (!legacyOwner) {
29+
return
30+
}
31+
32+
const now = new Date()
33+
const newOwner = {
34+
name: legacyOwner.name ?? legacyOwner.username ?? 'owner',
35+
email: legacyOwner.mail ?? 'owner@local',
36+
emailVerified: true,
37+
image: legacyOwner.avatar ?? null,
38+
createdAt: now,
39+
updatedAt: now,
40+
isOwner: true,
41+
handle: legacyOwner.username ?? '',
42+
}
43+
const result = await readers.insertOne(newOwner)
44+
owner = {
45+
_id: result.insertedId,
46+
...newOwner,
47+
}
48+
}
49+
50+
if (!owner?._id) {
51+
return
52+
}
53+
54+
const ownerId = owner._id.toString()
55+
const apiKeyCollection = db.collection('apikey')
56+
const ownerUser = await db
57+
.collection(USER_COLLECTION_NAME)
58+
.findOne({}, { projection: { apiToken: 1 } })
59+
const apiTokens = ownerUser?.apiToken
60+
61+
if (Array.isArray(apiTokens)) {
62+
for (const token of apiTokens) {
63+
if (!token?.token) continue
64+
const exists = await apiKeyCollection.findOne({ key: token.token })
65+
if (exists) continue
66+
67+
const createdAt = token.created ? new Date(token.created) : new Date()
68+
const expiresAt = token.expired ? new Date(token.expired) : null
69+
await apiKeyCollection.insertOne({
70+
name: token.name ?? 'txo',
71+
start: token.token.slice(0, 6),
72+
prefix: token.token.startsWith('txo') ? 'txo' : undefined,
73+
key: token.token,
74+
userId: ownerId,
75+
enabled: true,
76+
rateLimitEnabled: true,
77+
requestCount: 0,
78+
createdAt,
79+
updatedAt: createdAt,
80+
expiresAt,
81+
})
82+
}
83+
}
84+
85+
const passkeyCollection = db.collection('passkey')
86+
const authnCollection = db.collection('authn')
87+
const authnItems = await authnCollection.find().toArray()
88+
89+
for (const item of authnItems) {
90+
if (!item?.credentialID || !item?.credentialPublicKey) continue
91+
const existing = await passkeyCollection.findOne({
92+
credentialID: String(item.credentialID),
93+
})
94+
if (existing) continue
95+
96+
const createdAt = item.created ? new Date(item.created) : new Date()
97+
const publicKey = base64UrlToBase64(String(item.credentialPublicKey))
98+
99+
await passkeyCollection.insertOne({
100+
name: item.name,
101+
publicKey,
102+
userId: ownerId,
103+
credentialID: String(item.credentialID),
104+
counter: item.counter ?? 0,
105+
deviceType: item.credentialDeviceType ?? 'singleDevice',
106+
backedUp: item.credentialBackedUp ?? false,
107+
transports: item.transports ?? undefined,
108+
createdAt,
109+
aaguid: item.aaguid ?? undefined,
110+
})
111+
}
112+
},
113+
)

0 commit comments

Comments
 (0)