1 Commits

Author SHA1 Message Date
Yaosanqi137 398149f35b Merge pull request #5 from Yaosanqi137/feature/p2-api-auth
Feature/p2 api auth
2026-04-04 22:56:12 +08:00
48 changed files with 6258 additions and 1 deletions
+66
View File
@@ -0,0 +1,66 @@
name: API Docker Image
on:
pull_request:
branches: [main, develop]
paths:
- "apps/api/**"
- ".github/workflows/api-docker-image.yml"
push:
branches: [main]
paths:
- "apps/api/**"
- ".github/workflows/api-docker-image.yml"
workflow_dispatch:
concurrency:
group: api-docker-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-publish:
name: Build API Docker Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check Dockerfile
id: dockerfile
run: |
if [ -f apps/api/Dockerfile ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Docker Buildx
if: steps.dockerfile.outputs.exists == 'true'
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
if: steps.dockerfile.outputs.exists == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build (PR/manual) or Build and Push (main)
if: steps.dockerfile.outputs.exists == 'true'
uses: docker/build-push-action@v6
with:
context: ./apps/api
file: ./apps/api/Dockerfile
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: |
ghcr.io/${{ github.repository }}/api:${{ github.sha }}
ghcr.io/${{ github.repository }}/api:latest
- name: Skip notice
if: steps.dockerfile.outputs.exists != 'true'
run: echo "apps/api/Dockerfile not found, skip docker build."
+59
View File
@@ -0,0 +1,59 @@
name: Deploy Admin
on:
push:
branches: [main]
paths:
- "apps/admin/**"
- "packages/shared-types/**"
- "packages/ui/**"
- ".github/workflows/deploy-admin.yml"
workflow_dispatch:
concurrency:
group: deploy-admin-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build Admin
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build workspace
run: pnpm run build
deploy:
name: Deploy Admin (Template)
runs-on: ubuntu-latest
needs: build
steps:
- name: Trigger deployment webhook
env:
ADMIN_DEPLOY_WEBHOOK_URL: ${{ secrets.ADMIN_DEPLOY_WEBHOOK_URL }}
run: |
if [ -z "$ADMIN_DEPLOY_WEBHOOK_URL" ]; then
echo "ADMIN_DEPLOY_WEBHOOK_URL is not configured. Skipping deploy."
exit 0
fi
curl -X POST "$ADMIN_DEPLOY_WEBHOOK_URL"
echo "Admin deployment webhook triggered."
+59
View File
@@ -0,0 +1,59 @@
name: Deploy Web
on:
push:
branches: [main]
paths:
- "apps/web/**"
- "packages/shared-types/**"
- "packages/ui/**"
- ".github/workflows/deploy-web.yml"
workflow_dispatch:
concurrency:
group: deploy-web-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build Web
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build workspace
run: pnpm run build
deploy:
name: Deploy Web (Template)
runs-on: ubuntu-latest
needs: build
steps:
- name: Trigger deployment webhook
env:
WEB_DEPLOY_WEBHOOK_URL: ${{ secrets.WEB_DEPLOY_WEBHOOK_URL }}
run: |
if [ -z "$WEB_DEPLOY_WEBHOOK_URL" ]; then
echo "WEB_DEPLOY_WEBHOOK_URL is not configured. Skipping deploy."
exit 0
fi
curl -X POST "$WEB_DEPLOY_WEBHOOK_URL"
echo "Web deployment webhook triggered."
+46
View File
@@ -0,0 +1,46 @@
name: PR Quality
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: [main, develop]
concurrency:
group: pr-quality-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Lint, Typecheck, Test, Build
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Typecheck
run: pnpm run typecheck
- name: Test
run: pnpm run test
- name: Build
run: pnpm run build
+5 -1
View File
@@ -1,2 +1,6 @@
develop.md
.idea/
node_modules/
.turbo/
.idea/
.eslintcache
/.husky/_
+1
View File
@@ -0,0 +1 @@
pnpm lint:staged
+2
View File
@@ -0,0 +1,2 @@
pnpm typecheck
pnpm test
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
"*.{js,mjs,cjs,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,yml,yaml}": ["prettier --write"]
};
+7
View File
@@ -0,0 +1,7 @@
node_modules
.turbo
.idea
dist
build
coverage
*.png
+6
View File
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "none",
"printWidth": 100
}
+69
View File
@@ -0,0 +1,69 @@
# 贡献指南(Contributing
本文档定义 TodoList 仓库的协作规范,所有贡献者提交代码前请先阅读。
## 1. 分支模型
- 长期分支:
- `main`:生产稳定分支
- `develop`:开发集成分支
- 功能分支:
- 命名:`feature/<phase>-<name>`
- 示例:`feature/p1-code-quality-hooks`
- 其他分支:
- `release/<version>`
- `hotfix/<issue-id>-<short-desc>`
## 2. 提交流程
1. 从目标基线分支切出功能分支。
2. 每完成一个小功能,提交一个最小 commit。
3. 完成后推送分支并创建 PR。
4. 通过 Code Review 后再合并到目标分支。
## 3. Commit 规范
- 使用 Conventional Commits
- `feat(scope): ...`
- `fix(scope): ...`
- `chore(scope): ...`
- `docs(scope): ...`
- `test(scope): ...`
- `ci(scope): ...`
- 要求:
- commit 粒度最小化,不要把多个不相关改动塞进一个提交。
- commit 必须可回滚、可解释。
- 默认使用 GPG 签名提交:`git commit -S`
## 4. PR 规范
- PR 标题简明描述变更目标。
- PR 描述至少包含:
- 变更概述
- 具体改动
- 测试结果
- 风险评估
- 回滚方案
- 一个 PR 只解决一类问题,避免“超大 PR”。
## 5. 代码质量检查
提交前建议至少执行:
```bash
pnpm install
pnpm run lint
pnpm run typecheck
pnpm run test
```
说明:
- `pre-commit` 会自动执行 `lint-staged`
- `pre-push` 会自动执行 `typecheck + test`
## 6. 变更边界要求
- 不要提交无关文件(例如本地 IDE 缓存、临时导出文件)。
- 不要随意修改与当前任务无关的历史代码。
- 如发现仓库出现非本人预期改动,先暂停并和维护者确认。
View File
+19
View File
@@ -0,0 +1,19 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/todolist?schema=public"
AUTH_ACCESS_SECRET="dev-access-secret"
AUTH_ACCESS_EXPIRES_IN_SECONDS="900"
AUTH_REFRESH_EXPIRES_IN_SECONDS="2592000"
AUTH_EMAIL_CODE_TTL_SECONDS="300"
AUTH_TOTP_ISSUER="TodoList"
OAUTH_GITHUB_CLIENT_ID="github-client-id"
OAUTH_GITHUB_CLIENT_SECRET="github-client-secret"
OAUTH_GITHUB_CALLBACK_URL="http://localhost:3000/auth/oauth/github/callback"
OAUTH_QQ_CLIENT_ID="qq-client-id"
OAUTH_QQ_CLIENT_SECRET="qq-client-secret"
OAUTH_QQ_CALLBACK_URL="http://localhost:3000/auth/oauth/qq/callback"
OAUTH_QQ_AUTH_URL="https://graph.qq.com/oauth2.0/authorize"
OAUTH_QQ_TOKEN_URL="https://graph.qq.com/oauth2.0/token"
OAUTH_WECHAT_CLIENT_ID="wechat-client-id"
OAUTH_WECHAT_CLIENT_SECRET="wechat-client-secret"
OAUTH_WECHAT_CALLBACK_URL="http://localhost:3000/auth/oauth/wechat/callback"
OAUTH_WECHAT_AUTH_URL="https://open.weixin.qq.com/connect/qrconnect"
OAUTH_WECHAT_TOKEN_URL="https://api.weixin.qq.com/sns/oauth2/access_token"
+8
View File
@@ -0,0 +1,8 @@
node_modules
# 环境变量文件不纳入版本控制
.env
/generated/prisma
dist
prisma.config.js
prisma.config.js.map
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@todolist/api",
"version": "0.1.0",
"description": "TodoList API service",
"scripts": {
"prisma:generate": "prisma generate",
"prisma:format": "prisma format",
"prisma:validate": "prisma validate",
"start": "node dist/main.js",
"start:dev": "ts-node-dev --respawn --transpile-only src/main.ts",
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "echo api tests pending"
},
"license": "GPL-3.0-or-later",
"devDependencies": {
"@types/node": "^25.5.2",
"@types/passport-github2": "^1.2.9",
"@types/passport-oauth2": "^1.8.0",
"dotenv": "^16.6.1",
"prisma": "^7.6.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.9.3"
},
"private": true,
"dependencies": {
"@nestjs/common": "^11.1.18",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.18",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.1.18",
"@otplib/preset-default": "^12.0.1",
"@prisma/client": "^7.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"otplib": "^13.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
}
}
+13
View File
@@ -0,0 +1,13 @@
// Prisma CLI 配置(TodoList
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations"
},
datasource: {
url: process.env["DATABASE_URL"]
}
});
+403
View File
@@ -0,0 +1,403 @@
// Prisma 数据模型定义(TodoList
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum UserStatus {
ACTIVE
DISABLED
BANNED
}
enum AuthProvider {
EMAIL
GITHUB
QQ
WECHAT
}
enum TaskPriority {
LOW
MEDIUM
HIGH
URGENT
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
ARCHIVED
}
enum AttachmentType {
IMAGE
VIDEO
FILE
LINK
}
enum AiChannel {
USER_KEY
ASTRBOT
PUBLIC_POOL
}
enum NotificationChannel {
EMAIL
WEB_PUSH
}
enum NotificationStatus {
PENDING
SENT
FAILED
CANCELED
}
model User {
id String @id @default(cuid())
email String @unique
nickname String?
avatarUrl String?
status UserStatus @default(ACTIVE)
defaultStorageQuotaMb Int @default(100)
usedStorageBytes BigInt @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
identities AuthIdentity[]
refreshTokens RefreshToken[]
security UserSecurity?
tasks Task[]
tags Tag[]
attachments Attachment[]
taskActivityLogs TaskActivityLog[]
syncOperations SyncOperation[]
syncCursors SyncCursor[]
taskTombstones TaskTombstone[]
aiProviderBindings AiProviderBinding[]
aiUsageLogs AiUsageLog[]
notificationRules NotificationRule[]
notificationJobs NotificationJob[]
createdAdminTokens AdminToken[]
auditLogs AuditLog[]
@@map("users")
}
model AuthIdentity {
id String @id @default(cuid())
userId String
provider AuthProvider
providerUserId String
email String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerUserId])
@@index([userId])
@@map("auth_identities")
}
model UserSecurity {
id String @id @default(cuid())
userId String @unique
twoFactorEnabled Boolean @default(false)
twoFactorSecret String?
recoveryCodes String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_security")
}
model RefreshToken {
id String @id @default(cuid())
userId String
tokenHash String @unique
deviceId String?
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, expiresAt])
@@map("refresh_tokens")
}
model Task {
id String @id @default(cuid())
userId String
title String
contentJson Json?
contentText String?
priority TaskPriority @default(MEDIUM)
status TaskStatus @default(TODO)
ddl DateTime?
completedAt DateTime?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
taskTags TaskTag[]
attachments Attachment[]
activityLogs TaskActivityLog[]
notificationJobs NotificationJob[]
notificationRules NotificationRule[]
@@index([userId, status])
@@index([userId, ddl])
@@map("tasks")
}
model Tag {
id String @id @default(cuid())
userId String
name String
color String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
taskTags TaskTag[]
@@unique([userId, name])
@@index([userId])
@@map("tags")
}
model TaskTag {
taskId String
tagId String
createdAt DateTime @default(now())
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([taskId, tagId])
@@index([tagId])
@@map("task_tags")
}
model Attachment {
id String @id @default(cuid())
userId String
taskId String?
type AttachmentType
url String
mimeType String?
fileName String?
fileSize Int
width Int?
height Int?
durationMs Int?
checksum String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([taskId])
@@map("attachments")
}
model TaskActivityLog {
id String @id @default(cuid())
userId String
taskId String
action String
payload Json?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@index([taskId, createdAt])
@@index([userId, createdAt])
@@map("task_activity_logs")
}
model SyncOperation {
id String @id @default(cuid())
opId String @unique
userId String
deviceId String
entityType String
entityId String
action String
payload Json?
clientTs DateTime
serverTs DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, deviceId, serverTs])
@@index([userId, entityType, entityId])
@@map("sync_operations")
}
model SyncCursor {
id String @id @default(cuid())
userId String
deviceId String
lastPulledAt DateTime?
lastOperationServerTs DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, deviceId])
@@map("sync_cursors")
}
model TaskTombstone {
id String @id @default(cuid())
taskId String @unique
userId String
deletedAt DateTime @default(now())
deleteOpId String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, deletedAt])
@@map("task_tombstones")
}
model AiProviderBinding {
id String @id @default(cuid())
userId String
channel AiChannel
providerName String
model String?
encryptedApiKey String?
endpoint String?
isDefault Boolean @default(false)
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, isEnabled])
@@map("ai_provider_bindings")
}
model AiPublicPoolConfig {
id String @id @default(cuid())
enabled Boolean @default(false)
providerName String?
model String?
encryptedApiKey String?
endpoint String?
rpmLimit Int @default(60)
dailyTokenLimit Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("ai_public_pool_config")
}
model AiUsageLog {
id String @id @default(cuid())
userId String?
channel AiChannel
providerName String?
model String?
promptTokens Int @default(0)
completionTokens Int @default(0)
totalTokens Int @default(0)
latencyMs Int?
success Boolean @default(true)
errorCode String?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([channel, createdAt])
@@map("ai_usage_logs")
}
model NotificationRule {
id String @id @default(cuid())
userId String
taskId String?
channel NotificationChannel @default(EMAIL)
advanceMinutes Int @default(60)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
jobs NotificationJob[]
@@index([userId, enabled])
@@index([taskId])
@@map("notification_rules")
}
model NotificationJob {
id String @id @default(cuid())
userId String
taskId String?
ruleId String?
channel NotificationChannel
scheduledAt DateTime
sentAt DateTime?
status NotificationStatus @default(PENDING)
retryCount Int @default(0)
errorMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
rule NotificationRule? @relation(fields: [ruleId], references: [id], onDelete: SetNull)
@@index([status, scheduledAt])
@@index([userId, createdAt])
@@map("notification_jobs")
}
model SystemSetting {
id String @id @default(cuid())
key String @unique
value Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("system_settings")
}
model AdminToken {
id String @id @default(cuid())
tokenHash String @unique
name String
expiresAt DateTime
lastUsedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
createdByUserId String?
createdByUser User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull)
@@index([expiresAt])
@@map("admin_tokens")
}
model AuditLog {
id String @id @default(cuid())
actorUserId String?
action String
targetType String
targetId String?
meta Json?
ip String?
userAgent String?
createdAt DateTime @default(now())
actorUser User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull)
@@index([action, createdAt])
@@index([actorUserId, createdAt])
@@map("audit_logs")
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AuthModule } from "./auth/auth.module";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ".env"
}),
AuthModule
]
})
export class AppModule {}
+120
View File
@@ -0,0 +1,120 @@
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { AuthService } from "./auth.service";
import { EmailLoginDto } from "./dto/email-login.dto";
import { RefreshTokenDto } from "./dto/refresh-token.dto";
import { SendEmailCodeDto } from "./dto/send-email-code.dto";
import { TwoFactorEnrollDto } from "./dto/two-factor-enroll.dto";
import { TwoFactorVerifyDto } from "./dto/two-factor-verify.dto";
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("email/send-code")
async sendEmailCode(
@Body() body: SendEmailCodeDto
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
return this.authService.sendEmailCode(body.email);
}
@Post("email/login")
async loginWithEmailCode(@Body() body: EmailLoginDto): Promise<{
accessToken: string;
tokenType: "Bearer";
expiresInSeconds: number;
refreshToken: string;
refreshExpiresInSeconds: number;
user: { id: string; email: string };
}> {
return this.authService.loginWithEmailCode(body.email, body.code);
}
@Post("token/refresh")
async refreshTokens(@Body() body: RefreshTokenDto): Promise<{
accessToken: string;
tokenType: "Bearer";
expiresInSeconds: number;
refreshToken: string;
refreshExpiresInSeconds: number;
user: { id: string; email: string };
}> {
return this.authService.refreshTokens(body.refreshToken);
}
@Post("token/revoke")
async revokeRefreshToken(@Body() body: RefreshTokenDto): Promise<{ success: boolean }> {
return this.authService.revokeRefreshToken(body.refreshToken);
}
@Post("2fa/enroll")
async enrollTwoFactor(@Body() body: TwoFactorEnrollDto): Promise<{
userId: string;
secret: string;
otpauthUrl: string;
enabled: boolean;
}> {
return this.authService.enrollTwoFactor(body.email);
}
@Post("2fa/verify")
async verifyTwoFactor(
@Body() body: TwoFactorVerifyDto
): Promise<{ success: boolean; enabled: boolean }> {
return this.authService.verifyTwoFactor(body.email, body.token);
}
@Get("oauth/github")
@UseGuards(AuthGuard("github"))
githubLogin(): void {}
@Get("oauth/github/callback")
@UseGuards(AuthGuard("github"))
githubCallback(@Req() req: { user: unknown }): {
success: boolean;
provider: "github";
profile: unknown;
} {
return {
success: true,
provider: "github",
profile: req.user
};
}
@Get("oauth/qq")
@UseGuards(AuthGuard("qq"))
qqLogin(): void {}
@Get("oauth/qq/callback")
@UseGuards(AuthGuard("qq"))
qqCallback(@Req() req: { user: unknown }): {
success: boolean;
provider: "qq";
profile: unknown;
} {
return {
success: true,
provider: "qq",
profile: req.user
};
}
@Get("oauth/wechat")
@UseGuards(AuthGuard("wechat"))
wechatLogin(): void {}
@Get("oauth/wechat/callback")
@UseGuards(AuthGuard("wechat"))
wechatCallback(@Req() req: { user: unknown }): {
success: boolean;
provider: "wechat";
profile: unknown;
} {
return {
success: true,
provider: "wechat",
profile: req.user
};
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { GithubStrategy } from "./strategies/github.strategy";
import { QqStrategy } from "./strategies/qq.strategy";
import { WechatStrategy } from "./strategies/wechat.strategy";
@Module({
imports: [
ConfigModule,
PassportModule.register({ session: false }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const expiresInSeconds = Number(configService.get("AUTH_ACCESS_EXPIRES_IN_SECONDS") ?? 900);
return {
secret: configService.get<string>("AUTH_ACCESS_SECRET") ?? "dev-access-secret",
signOptions: {
expiresIn: expiresInSeconds
}
};
}
})
],
controllers: [AuthController],
providers: [AuthService, GithubStrategy, QqStrategy, WechatStrategy]
})
export class AuthModule {}
+211
View File
@@ -0,0 +1,211 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { randomUUID } from "node:crypto";
import { authenticator } from "@otplib/preset-default";
type EmailCodeEntry = {
code: string;
expiresAt: number;
};
type AuthUser = {
id: string;
email: string;
};
type RefreshTokenEntry = {
userId: string;
expiresAt: number;
revokedAt?: number;
};
type TwoFactorEntry = {
secret: string;
enabled: boolean;
};
type AuthTokenResult = {
accessToken: string;
tokenType: "Bearer";
expiresInSeconds: number;
refreshToken: string;
refreshExpiresInSeconds: number;
user: AuthUser;
};
@Injectable()
export class AuthService {
private readonly emailCodeStore = new Map<string, EmailCodeEntry>();
private readonly userStoreByEmail = new Map<string, AuthUser>();
private readonly userStoreById = new Map<string, AuthUser>();
private readonly refreshTokenStore = new Map<string, RefreshTokenEntry>();
private readonly twoFactorStore = new Map<string, TwoFactorEntry>();
constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService
) {}
async sendEmailCode(
email: string
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
const ttlSeconds = Number(this.configService.get("AUTH_EMAIL_CODE_TTL_SECONDS") ?? 300);
const code = this.generateCode();
const expiresAt = Date.now() + ttlSeconds * 1000;
this.emailCodeStore.set(email.toLowerCase(), { code, expiresAt });
return {
success: true,
expiresInSeconds: ttlSeconds,
debugCode: code
};
}
async loginWithEmailCode(email: string, code: string): Promise<AuthTokenResult> {
const lowerEmail = email.toLowerCase();
const codeEntry = this.emailCodeStore.get(lowerEmail);
if (!codeEntry) {
throw new UnauthorizedException("验证码不存在或已失效");
}
if (codeEntry.expiresAt < Date.now()) {
this.emailCodeStore.delete(lowerEmail);
throw new UnauthorizedException("验证码已过期");
}
if (codeEntry.code !== code) {
throw new UnauthorizedException("验证码错误");
}
this.emailCodeStore.delete(lowerEmail);
const user = this.getOrCreateUser(lowerEmail);
return this.issueTokens(user);
}
async refreshTokens(refreshToken: string): Promise<AuthTokenResult> {
const entry = this.refreshTokenStore.get(refreshToken);
if (!entry) {
throw new UnauthorizedException("刷新令牌不存在");
}
if (entry.revokedAt) {
throw new UnauthorizedException("刷新令牌已注销");
}
if (entry.expiresAt < Date.now()) {
this.refreshTokenStore.delete(refreshToken);
throw new UnauthorizedException("刷新令牌已过期");
}
const user = this.userStoreById.get(entry.userId);
if (!user) {
throw new UnauthorizedException("用户不存在");
}
entry.revokedAt = Date.now();
return this.issueTokens(user);
}
async revokeRefreshToken(refreshToken: string): Promise<{ success: boolean }> {
const entry = this.refreshTokenStore.get(refreshToken);
if (!entry) {
return { success: true };
}
entry.revokedAt = Date.now();
return { success: true };
}
async enrollTwoFactor(
email: string
): Promise<{ userId: string; secret: string; otpauthUrl: string; enabled: boolean }> {
const user = this.getOrCreateUser(email.toLowerCase());
const secret = authenticator.generateSecret();
const issuer = this.configService.get<string>("AUTH_TOTP_ISSUER") ?? "TodoList";
const otpauthUrl = authenticator.keyuri(user.email, issuer, secret);
this.twoFactorStore.set(user.id, {
secret,
enabled: false
});
return {
userId: user.id,
secret,
otpauthUrl,
enabled: false
};
}
async verifyTwoFactor(
email: string,
token: string
): Promise<{ success: boolean; enabled: boolean }> {
const user = this.getOrCreateUser(email.toLowerCase());
const entry = this.twoFactorStore.get(user.id);
if (!entry) {
throw new UnauthorizedException("尚未启用两步验证");
}
const valid = authenticator.check(token, entry.secret);
if (!valid) {
throw new UnauthorizedException("两步验证码错误");
}
entry.enabled = true;
return {
success: true,
enabled: true
};
}
private getOrCreateUser(email: string): AuthUser {
const existingUser = this.userStoreByEmail.get(email);
if (existingUser) {
return existingUser;
}
const newUser = {
id: randomUUID(),
email
};
this.userStoreByEmail.set(email, newUser);
this.userStoreById.set(newUser.id, newUser);
return newUser;
}
private generateCode(): string {
return String(Math.floor(100000 + Math.random() * 900000));
}
private async issueTokens(user: AuthUser): Promise<AuthTokenResult> {
const accessExpiresInSeconds = Number(
this.configService.get("AUTH_ACCESS_EXPIRES_IN_SECONDS") ?? 900
);
const refreshExpiresInSeconds = Number(
this.configService.get("AUTH_REFRESH_EXPIRES_IN_SECONDS") ?? 2592000
);
const accessToken = await this.jwtService.signAsync({
sub: user.id,
email: user.email
});
const refreshToken = `${randomUUID()}${randomUUID()}`;
this.refreshTokenStore.set(refreshToken, {
userId: user.id,
expiresAt: Date.now() + refreshExpiresInSeconds * 1000
});
return {
accessToken,
tokenType: "Bearer",
expiresInSeconds: accessExpiresInSeconds,
refreshToken,
refreshExpiresInSeconds,
user
};
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsEmail, IsString, Length, Matches } from "class-validator";
export class EmailLoginDto {
@IsEmail()
email!: string;
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/)
code!: string;
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class RefreshTokenDto {
@IsString()
@MinLength(20)
refreshToken!: string;
}
@@ -0,0 +1,6 @@
import { IsEmail } from "class-validator";
export class SendEmailCodeDto {
@IsEmail()
email!: string;
}
@@ -0,0 +1,6 @@
import { IsEmail } from "class-validator";
export class TwoFactorEnrollDto {
@IsEmail()
email!: string;
}
@@ -0,0 +1,11 @@
import { IsEmail, IsString, Length, Matches } from "class-validator";
export class TwoFactorVerifyDto {
@IsEmail()
email!: string;
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/)
token!: string;
}
@@ -0,0 +1,32 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { Profile, Strategy } from "passport-github2";
@Injectable()
export class GithubStrategy extends PassportStrategy(Strategy, "github") {
constructor(configService: ConfigService) {
super({
clientID: configService.get<string>("OAUTH_GITHUB_CLIENT_ID") ?? "github-client-id",
clientSecret:
configService.get<string>("OAUTH_GITHUB_CLIENT_SECRET") ?? "github-client-secret",
callbackURL:
configService.get<string>("OAUTH_GITHUB_CALLBACK_URL") ??
"http://localhost:3000/auth/oauth/github/callback",
scope: ["user:email"]
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile
): Promise<{ provider: "github"; accessToken: string; refreshToken: string; profile: Profile }> {
return {
provider: "github",
accessToken,
refreshToken,
profile
};
}
}
@@ -0,0 +1,33 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-oauth2";
@Injectable()
export class QqStrategy extends PassportStrategy(Strategy, "qq") {
constructor(configService: ConfigService) {
super({
authorizationURL:
configService.get<string>("OAUTH_QQ_AUTH_URL") ?? "https://graph.qq.com/oauth2.0/authorize",
tokenURL:
configService.get<string>("OAUTH_QQ_TOKEN_URL") ?? "https://graph.qq.com/oauth2.0/token",
clientID: configService.get<string>("OAUTH_QQ_CLIENT_ID") ?? "qq-client-id",
clientSecret: configService.get<string>("OAUTH_QQ_CLIENT_SECRET") ?? "qq-client-secret",
callbackURL:
configService.get<string>("OAUTH_QQ_CALLBACK_URL") ??
"http://localhost:3000/auth/oauth/qq/callback",
scope: ["get_user_info"]
});
}
async validate(
accessToken: string,
refreshToken: string
): Promise<{ provider: "qq"; accessToken: string; refreshToken: string }> {
return {
provider: "qq",
accessToken,
refreshToken
};
}
}
@@ -0,0 +1,36 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-oauth2";
@Injectable()
export class WechatStrategy extends PassportStrategy(Strategy, "wechat") {
constructor(configService: ConfigService) {
super({
authorizationURL:
configService.get<string>("OAUTH_WECHAT_AUTH_URL") ??
"https://open.weixin.qq.com/connect/qrconnect",
tokenURL:
configService.get<string>("OAUTH_WECHAT_TOKEN_URL") ??
"https://api.weixin.qq.com/sns/oauth2/access_token",
clientID: configService.get<string>("OAUTH_WECHAT_CLIENT_ID") ?? "wechat-client-id",
clientSecret:
configService.get<string>("OAUTH_WECHAT_CLIENT_SECRET") ?? "wechat-client-secret",
callbackURL:
configService.get<string>("OAUTH_WECHAT_CALLBACK_URL") ??
"http://localhost:3000/auth/oauth/wechat/callback",
scope: ["snsapi_login"]
});
}
async validate(
accessToken: string,
refreshToken: string
): Promise<{ provider: "wechat"; accessToken: string; refreshToken: string }> {
return {
provider: "wechat",
accessToken,
refreshToken
};
}
}
+19
View File
@@ -0,0 +1,19 @@
import "reflect-metadata";
import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true
})
);
await app.listen(3000);
}
void bootstrap();
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../packages/tsconfig/nest-app.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
View File
+62
View File
@@ -0,0 +1,62 @@
# ADR-XXXX<决策标题>
- 状态:Proposed | Accepted | Deprecated | Superseded
- 日期:YYYY-MM-DD
- 决策人:<团队/人员>
- 关联需求:<Issue/PR/文档链接>
## 背景
描述当前问题、约束条件,以及为什么现在必须做出这项决策。
## 决策驱动因素
- <驱动因素 1>
- <驱动因素 2>
- <驱动因素 3>
## 可选方案
1. <方案 A>
2. <方案 B>
3. <方案 C>
## 最终决策
选择方案:**<方案 X>**
说明选择该方案的理由,以及未选择其他方案的原因。
## 影响评估
### 正向影响
- <收益 1>
- <收益 2>
### 负向影响 / 取舍
- <代价 1>
- <代价 2>
## 实施计划
1. <步骤 1>
2. <步骤 2>
3. <步骤 3>
## 回滚方案
说明当风险发生时,如何撤销或回退这项决策。
## 验证清单
- [ ] 单元测试
- [ ] 集成测试
- [ ] 性能检查
- [ ] 安全检查
## 参考资料
- <参考资料 1>
- <参考资料 2>
+29
View File
@@ -0,0 +1,29 @@
import js from "@eslint/js";
import globals from "globals";
export default [
{
ignores: ["**/node_modules/**", "**/.turbo/**", "**/dist/**", "**/build/**"]
},
js.configs.recommended,
{
files: ["**/*.{js,mjs}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.node
}
}
},
{
files: ["**/*.cjs"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "commonjs",
globals: {
...globals.node
}
}
}
];
+30
View File
@@ -0,0 +1,30 @@
{
"name": "todolist",
"version": "0.1.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "turbo run test",
"dev": "turbo run dev --parallel",
"build": "turbo run build",
"lint": "turbo run lint && eslint .",
"typecheck": "turbo run typecheck",
"format": "prettier --write .",
"lint:staged": "lint-staged",
"prepare": "husky"
},
"keywords": [],
"author": "",
"license": "GPL-3.0-or-later",
"private": true,
"packageManager": "pnpm@9.15.2",
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.2.0",
"globals": "^17.4.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.1",
"turbo": "^2.9.3"
}
}
+16
View File
@@ -0,0 +1,16 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
env: {
browser: true,
es2022: true,
node: true
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module"
},
rules: {
"no-console": "warn",
"no-debugger": "error"
}
};
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@todolist/eslint-config",
"version": "0.1.0",
"description": "Shared ESLint config presets for TodoList",
"main": "base.cjs",
"license": "GPL-3.0-or-later",
"private": true,
"files": [
"base.cjs"
]
}
View File
View File
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"],
"types": ["node"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@todolist/tsconfig",
"version": "0.1.0",
"description": "Shared TypeScript config presets for TodoList",
"license": "GPL-3.0-or-later",
"private": true,
"files": [
"base.json",
"react-app.json",
"nest-app.json"
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"]
}
}
View File
+4667
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"dev": {
"cache": false,
"persistent": true
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "build/**"]
},
"lint": {
"dependsOn": ["^lint"],
"outputs": []
},
"test": {
"dependsOn": ["^test"],
"outputs": ["coverage/**"]
},
"typecheck": {
"dependsOn": ["^typecheck"],
"outputs": []
},
"format": {
"outputs": []
}
}
}