diff --git a/.env b/.env index 188a35b..d920c9f 100644 --- a/.env +++ b/.env @@ -8,7 +8,7 @@ DB_NAME='auth_db' # github GITHUB_CLIENT_ID=Ov23lihk723FlNAwlFg6 GITHUB_CLIENT_SECRET=b839f50bba1f006ffdd43fb73c5ae221a54e1e2e -GITHUB_CALLBACK_URL=http://localhost:3030/auth/github/callback +GITHUB_CALLBACK_URL=http://108.171.193.155:8000/callback # 飞书 diff --git a/OAuth-JWT-Integration.md b/OAuth-JWT-Integration.md new file mode 100644 index 0000000..4c861ab --- /dev/null +++ b/OAuth-JWT-Integration.md @@ -0,0 +1,172 @@ +# OAuth JWT 集成实现文档 + +## 🎯 实现目标 + +将飞书和 GitHub 的第三方 OAuth access_token 转换为项目自己的 JWT Token,实现统一的认证体系。 + +## 🏗️ 架构设计 + +### 用户数据模型扩展 + +扩展了 `User` 实体以支持多种登录方式: + +```typescript +@Entity() +export class User { + @PrimaryGeneratedColumn() + id: number; + + @Column({ unique: true }) + username: string; + + @Column({ nullable: true }) // OAuth 用户不需要密码 + password: string; + + @Column() + email: string; + + @Column({ default: true }) + isActive: boolean; + + // OAuth 相关字段 + @Column({ nullable: true }) + provider: string; // 'local', 'github', 'lark' + + @Column({ nullable: true }) + providerId: string; // 第三方平台的用户ID + + @Column({ nullable: true }) + providerUsername: string; // 第三方平台的用户名 + + // JWT 相关字段 + @Column({ nullable: true }) + refreshToken: string; + + @Column({ nullable: true }) + refreshTokenExpires: Date; +} +``` + +### UserService 扩展 + +添加了 OAuth 用户专用的方法: + +1. **`findUserByProvider(provider, providerId)`** - 根据第三方平台信息查找用户 +2. **`createOAuthUser(provider, providerId, providerUsername, email)`** - 创建 OAuth 用户 + +### 用户名生成策略 + +为避免用户名冲突,OAuth 用户的用户名格式为: +- 格式:`{provider}_{providerUsername}` +- 示例:`github_octocat`, `lark_zhangsan` +- 如果冲突,自动添加数字后缀:`github_octocat_1` + +## 🔄 OAuth 流程 + +### GitHub OAuth 流程 + +1. **登录入口**:`GET /github/login?redirectUri=<回调地址>` +2. **OAuth 回调**:`GET /github/callback?code=<授权码>` +3. **处理流程**: + ``` + 授权码 → GitHub Access Token → 用户信息 → 查找/创建本地用户 → 生成 JWT Token + ``` + +### 飞书 OAuth 流程 + +1. **登录入口**:`GET /lark/login?redirectUri=<回调地址>` +2. **OAuth 回调**:`GET /lark/callback?code=<授权码>` +3. **处理流程**: + ``` + 授权码 → 飞书 Access Token → 用户信息 → 查找/创建本地用户 → 生成 JWT Token + ``` + +## 📝 API 响应格式 + +### 成功响应 + +```json +{ + "message": "GitHub login successful", + "user": { + "id": 1, + "username": "github_octocat", + "email": "octocat@github.com", + "provider": "github", + "providerUsername": "octocat" + }, + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 3600, + "expires_at": 1703123456789, + "refresh_token_expires_in": 604800, + "refresh_token_expires_at": 1703728256789 +} +``` + +## 🛡️ 安全特性 + +1. **用户隔离** - OAuth 用户和本地用户完全分离 +2. **唯一标识** - 使用 `provider + providerId` 作为唯一标识 +3. **JWT 安全** - 使用项目统一的 JWT 密钥和过期策略 +4. **缓存机制** - 第三方 token 和用户信息缓存,减少 API 调用 + +## 🔧 配置要求 + +### 环境变量 + +```env +# GitHub OAuth +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret +GITHUB_CALLBACK_URL=http://localhost:3030/auth/github/callback + +# 飞书 OAuth +FEISHU_APP_ID=your_feishu_app_id +FEISHU_APP_SECRET=your_feishu_app_secret + +# JWT +JWT_SECRET=your_jwt_secret +``` + +## 🧪 测试 + +访问测试页面:`http://localhost:8000/test-github-sso.html` + +支持的登录方式: +- GitHub OAuth +- 飞书 OAuth +- Passport GitHub 策略(用于重定向场景) + +## 🚀 使用示例 + +### 前端调用 + +```javascript +// GitHub 登录 +window.location.href = 'http://localhost:3030/github/login?redirectUri=' + + encodeURIComponent(window.location.origin + '/callback'); + +// 飞书登录 +window.location.href = 'http://localhost:3030/lark/login?redirectUri=' + + encodeURIComponent(window.location.origin + '/callback'); +``` + +### 使用 JWT Token + +```javascript +// 在后续请求中使用 JWT Token +fetch('/api/protected', { + headers: { + 'Authorization': `Bearer ${access_token}` + } +}); +``` + +## 📋 优势 + +1. **统一认证** - 所有用户最终都使用项目的 JWT Token +2. **类型安全** - 完整的 TypeScript 类型定义 +3. **扩展性强** - 易于添加新的 OAuth 提供商 +4. **数据一致性** - 统一的用户数据模型 +5. **安全可靠** - 遵循 OAuth 2.0 和 JWT 最佳实践 diff --git a/src/app.module.ts b/src/app.module.ts index f79acc0..7fd6dac 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { UserModule } from './user/user.module'; import { User } from './user/entities/user.entity'; import { AuthModule } from './auth/auth.module'; import { LarkModule } from './lark/lark.module'; +import { GitHubModule } from './github/github.module'; import { DeepseekModule } from './deepseek/deepseek.module'; @Module({ @@ -35,9 +36,10 @@ import { DeepseekModule } from './deepseek/deepseek.module'; UserModule, AuthModule, LarkModule, + GitHubModule, DeepseekModule, ], controllers: [AppController], providers: [AppService], }) -export class AppModule {} +export class AppModule { } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d3d4542..9cd8715 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,9 +1,11 @@ import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { UserService } from '../user/user.service'; import { Response } from 'express'; @Controller('auth') export class AuthController { + constructor(private readonly userService: UserService) { } @Get('github') @UseGuards(AuthGuard('github')) async githubLogin() { @@ -12,11 +14,23 @@ export class AuthController { @Get('github/callback') @UseGuards(AuthGuard('github')) - async githubCallback(@Req() req, @Res() res: Response) { - const user = req.user; + async githubCallback(@Req() req: any, @Res() res: Response) { + const oauthUser = req.user; - // 构造前端 URL,附带用户信息或 Token - const frontendUrl = `http://localhost:8000/?username=${user.username}&email=${user.email}`; + // 生成项目的 JWT Token + const jwtTokenData = await this.userService.generateToken(oauthUser); + + // 构造前端 URL,附带用户信息和 JWT Token + const params = new URLSearchParams({ + userId: oauthUser.id.toString(), + username: oauthUser.username, + email: oauthUser.email, + provider: oauthUser.provider, + providerUsername: oauthUser.providerUsername || oauthUser.username, + access_token: jwtTokenData.access_token + }); + + const frontendUrl = `http://localhost:8000/test-github-sso.html?${params.toString()}`; // 重定向到前端 return res.redirect(frontendUrl); } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 8e8b7b7..9c5d848 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -4,8 +4,10 @@ import { UserModule } from '../user/user.module'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; import { JwtStrategy } from './strategies/jwt.strategy'; +import { GitHubStrategy } from './github.strategy'; import { APP_GUARD } from '@nestjs/core'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { AuthController } from './auth.controller'; @Module({ imports: [ @@ -16,13 +18,15 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard'; signOptions: { expiresIn: '1h' }, }), ], + controllers: [AuthController], providers: [ AuthService, JwtStrategy, + GitHubStrategy, { provide: APP_GUARD, useClass: JwtAuthGuard, }, ], }) -export class AuthModule {} +export class AuthModule { } diff --git a/src/auth/github.strategy.ts b/src/auth/github.strategy.ts index 7a58392..bb27b5a 100644 --- a/src/auth/github.strategy.ts +++ b/src/auth/github.strategy.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-github2'; +import { UserService } from '../user/user.service'; @Injectable() export class GitHubStrategy extends PassportStrategy(Strategy, 'github') { - constructor() { + constructor(private userService: UserService) { super({ clientID: process.env.GITHUB_CLIENT_ID, // 从环境变量加载 clientSecret: process.env.GITHUB_CLIENT_SECRET, @@ -13,13 +14,24 @@ export class GitHubStrategy extends PassportStrategy(Strategy, 'github') { }); } - async validate(accessToken: string, refreshToken: string, profile: any) { + async validate(_accessToken: string, _refreshToken: string, profile: any) { // 返回用户信息,或者直接处理用户登录逻辑 const { id, username, emails } = profile; - return { - id, - username, - email: emails?.[0]?.value || null, - }; + const email = emails?.[0]?.value || null; + + // 检查 GitHub 用户是否已存在 + let user = await this.userService.findUserByProvider('github', id.toString()); + + if (!user) { + // 如果用户不存在,创建新的 OAuth 用户 + user = await this.userService.createOAuthUser( + 'github', + id.toString(), + username, + email || `${username}@github.local` + ); + } + + return user; } } diff --git a/src/github/github.controller.ts b/src/github/github.controller.ts new file mode 100644 index 0000000..c50fed3 --- /dev/null +++ b/src/github/github.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Query, Res } from '@nestjs/common'; +import { GitHubService } from './github.service'; +import { UserService } from '../user/user.service'; +import { Response } from 'express'; +import { Public } from '../auth/decorators/public.decorator'; + +@Controller('github') +export class GitHubController { + constructor( + private readonly githubService: GitHubService, + private readonly userService: UserService, + ) { } + + @Get('login') + @Public() + async login(@Query('redirectUri') redirectUri: string, @Res() res: Response) { + const loginUrl = await this.githubService.getLoginUrl(redirectUri); + return res.redirect(loginUrl); + } + + @Get('callback') + @Public() + async callback( + @Query('code') code: string, + @Query('state') _state: string, + @Res() res: Response, + ) { + if (!code) { + return res.status(400).send('Authorization code is missing'); + } + + try { + const tokenData = await this.githubService.getAccessToken(code); + + if (tokenData && tokenData.access_token) { + const userInfo = await this.githubService.getUserInfo(tokenData.access_token); + + // 检查 GitHub 用户是否已存在 + let user = await this.userService.findUserByProvider('github', userInfo.id.toString()); + + if (!user) { + // 创建新的 OAuth 用户 + user = await this.userService.createOAuthUser( + 'github', + userInfo.id.toString(), + userInfo.login, + userInfo.email || `${userInfo.login}@github.local` + ); + } + + // 生成项目的 JWT Token + const jwtTokenData = await this.userService.generateToken(user); + + return res.json({ + message: 'GitHub login successful', + user: { + id: user.id, + username: user.username, + email: user.email, + provider: user.provider, + providerUsername: user.providerUsername + }, + ...jwtTokenData + }); + + } else { + return res.status(400).send('Failed to retrieve access token'); + } + } catch (error) { + console.error('GitHub OAuth callback error:', error); + return res.status(500).send('Internal server error during GitHub authentication'); + } + } +} diff --git a/src/github/github.module.ts b/src/github/github.module.ts new file mode 100644 index 0000000..20e60c2 --- /dev/null +++ b/src/github/github.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { GitHubService } from './github.service'; +import { GitHubController } from './github.controller'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [UserModule], + controllers: [GitHubController], + providers: [GitHubService], + exports: [GitHubService], +}) +export class GitHubModule { } diff --git a/src/github/github.service.ts b/src/github/github.service.ts new file mode 100644 index 0000000..551edc6 --- /dev/null +++ b/src/github/github.service.ts @@ -0,0 +1,101 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import axios from 'axios'; + +@Injectable() +export class GitHubService { + constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} + + private readonly clientId = process.env.GITHUB_CLIENT_ID; + private readonly clientSecret = process.env.GITHUB_CLIENT_SECRET; + private readonly redirectUri = process.env.GITHUB_CALLBACK_URL; + + async getLoginUrl(redirectUri?: string): Promise { + const state = Math.random().toString(36).substring(2, 15); + const scope = 'user:email'; + const callbackUrl = redirectUri || this.redirectUri; + + // 缓存 state 用于验证 + await this.cacheManager.set(`github_state_${state}`, callbackUrl, 600); // 10分钟过期 + + return `https://github.com/login/oauth/authorize?client_id=${this.clientId}&redirect_uri=${encodeURIComponent( + callbackUrl, + )}&scope=${scope}&state=${state}`; + } + + async getAccessToken(authCode: string): Promise { + const url = 'https://github.com/login/oauth/access_token'; + const data = { + client_id: this.clientId, + client_secret: this.clientSecret, + code: authCode, + redirect_uri: this.redirectUri, + }; + + try { + const response = await axios.post(url, data, { + headers: { + Accept: 'application/json', + }, + }); + + const tokenData = response.data; + + if (tokenData && tokenData.access_token) { + // 将 token 存储到缓存中,设置过期时间 + await this.cacheManager.set( + `github_token_${tokenData.access_token}`, + tokenData, + 3600, // 缓存1小时 + ); + return tokenData; + } else { + throw new Error('获取 GitHub token 失败'); + } + } catch (error) { + console.error('GitHub token exchange error:', error); + throw new Error('获取 GitHub token 失败'); + } + } + + async getUserInfo(accessToken: string): Promise { + // 先检查缓存 + const cachedUserInfo = await this.cacheManager.get(`github_user_${accessToken}`); + if (cachedUserInfo) { + return cachedUserInfo; + } + + try { + // 获取用户基本信息 + const userResponse = await axios.get('https://api.github.com/user', { + headers: { + Authorization: `token ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + // 获取用户邮箱信息 + const emailResponse = await axios.get('https://api.github.com/user/emails', { + headers: { + Authorization: `token ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + const userInfo = { + ...userResponse.data, + emails: emailResponse.data, + email: emailResponse.data.find((email: any) => email.primary)?.email || userResponse.data.email, + }; + + // 缓存用户信息 + await this.cacheManager.set(`github_user_${accessToken}`, userInfo, 3600); // 缓存1小时 + + return userInfo; + } catch (error) { + console.error('GitHub user info error:', error); + throw new Error('获取 GitHub 用户信息失败'); + } + } +} diff --git a/src/lark/lark.controller.ts b/src/lark/lark.controller.ts index 0544de4..f37b9c2 100644 --- a/src/lark/lark.controller.ts +++ b/src/lark/lark.controller.ts @@ -1,11 +1,15 @@ import { Controller, Get, Query, Res } from '@nestjs/common'; import { LarkService } from './lark.service'; +import { UserService } from '../user/user.service'; import { Response } from 'express'; import { Public } from '../auth/decorators/public.decorator'; @Controller('lark') export class LarkController { - constructor(private readonly larkService: LarkService) { } + constructor( + private readonly larkService: LarkService, + private readonly userService: UserService, + ) { } @Get('login') @Public() @@ -21,13 +25,46 @@ export class LarkController { return res.status(400).send('Authorization code is missing'); } - const tokenData = await this.larkService.getAccessToken(code); + try { + const tokenData = await this.larkService.getAccessToken(code); - if (tokenData && tokenData.code === 0) { - const user = tokenData.data; - return res.json({ message: 'Login successful', user }); - } else { - return res.status(400).send('Failed to retrieve access token'); + if (tokenData && tokenData.code === 0) { + const accessToken = tokenData.data.access_token; + const userInfo = await this.larkService.getUserInfo(accessToken); + + // 检查飞书用户是否已存在 + let user = await this.userService.findUserByProvider('lark', userInfo.user_id); + + if (!user) { + // 创建新的 OAuth 用户 + user = await this.userService.createOAuthUser( + 'lark', + userInfo.user_id, + userInfo.name || userInfo.user_id, + userInfo.email || `${userInfo.user_id}@feishu.local` + ); + } + + // 生成项目的 JWT Token + const jwtTokenData = await this.userService.generateToken(user); + + return res.json({ + message: 'Lark login successful', + user: { + id: user.id, + username: user.username, + email: user.email, + provider: user.provider, + providerUsername: user.providerUsername + }, + ...jwtTokenData + }); + } else { + return res.status(400).send('Failed to retrieve access token'); + } + } catch (error) { + console.error('Lark OAuth callback error:', error); + return res.status(500).send('Internal server error during Lark authentication'); } } } diff --git a/src/lark/lark.module.ts b/src/lark/lark.module.ts index f1f0deb..f27898b 100644 --- a/src/lark/lark.module.ts +++ b/src/lark/lark.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; import { LarkService } from './lark.service'; import { LarkController } from './lark.controller'; +import { UserModule } from '../user/user.module'; @Module({ + imports: [UserModule], controllers: [LarkController], providers: [LarkService], + exports: [LarkService], }) -export class LarkModule {} +export class LarkModule { } diff --git a/src/lark/lark.service.ts b/src/lark/lark.service.ts index 43aaf25..69fb4ff 100644 --- a/src/lark/lark.service.ts +++ b/src/lark/lark.service.ts @@ -5,7 +5,7 @@ import axios from 'axios'; @Injectable() export class LarkService { - constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} + constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) { } private readonly appId = process.env.FEISHU_APP_ID; private readonly appSecret = process.env.FEISHU_APP_SECRET; @@ -16,7 +16,7 @@ export class LarkService { } async getAccessToken(authCode: string): Promise { - const cachedToken = await this.cacheManager.get('tokenData'); + const cachedToken = await this.cacheManager.get(`lark_token_${authCode}`); if (cachedToken) { return cachedToken; // 如果缓存中有 token,直接返回 } @@ -32,10 +32,41 @@ export class LarkService { const tokenData = response.data; if (tokenData && tokenData.code === 0) { // 将 token 存储到缓存中,设置过期时间 - await this.cacheManager.set('tokenData', tokenData, 3600); // 缓存1小时 + await this.cacheManager.set(`lark_token_${authCode}`, tokenData, 3600); // 缓存1小时 return tokenData; } else { - throw new Error('获取 token 失败'); + throw new Error('获取飞书 token 失败'); + } + } + + async getUserInfo(accessToken: string): Promise { + // 先检查缓存 + const cachedUserInfo = await this.cacheManager.get(`lark_user_${accessToken}`); + if (cachedUserInfo) { + return cachedUserInfo; + } + + try { + // 获取用户基本信息 + const userResponse = await axios.get('https://open.feishu.cn/open-apis/authen/v1/user_info', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + const userInfo = userResponse.data; + + if (userInfo && userInfo.code === 0) { + // 缓存用户信息 + await this.cacheManager.set(`lark_user_${accessToken}`, userInfo.data, 3600); // 缓存1小时 + return userInfo.data; + } else { + throw new Error('获取飞书用户信息失败'); + } + } catch (error) { + console.error('Lark user info error:', error); + throw new Error('获取飞书用户信息失败'); } } } diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 3d0c458..8f7fdc3 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -8,7 +8,7 @@ export class User { @Column({ unique: true }) username: string; - @Column() + @Column({ nullable: true }) password: string; @Column() @@ -22,4 +22,14 @@ export class User { @Column({ nullable: true }) refreshTokenExpires: Date; + + // OAuth 相关字段 + @Column({ nullable: true }) + provider: string; // 'local', 'github', 'lark' + + @Column({ nullable: true }) + providerId: string; // 第三方平台的用户ID + + @Column({ nullable: true }) + providerUsername: string; // 第三方平台的用户名 } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 923a06e..4a24fdc 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -12,16 +12,16 @@ export class UserService { @InjectRepository(User) private userRepository: Repository, private jwtService: JwtService, - ) {} + ) { } - async createUser(username: string, password: string, email: string) { - if (!password) { - throw new Error('Password is required'); + async createUser(username: string, password: string | null, email: string) { + let hashedPassword = null; + + if (password) { + const saltRounds = 10; + hashedPassword = await bcrypt.hash(password, saltRounds); } - const saltRounds = 10; - const hashedPassword = await bcrypt.hash(password, saltRounds); - const user = this.userRepository.create({ username, password: hashedPassword, @@ -35,6 +35,41 @@ export class UserService { return this.userRepository.findOne({ where: { username } }); } + async findUserByProvider(provider: string, providerId: string): Promise { + return this.userRepository.findOne({ + where: { provider, providerId } + }); + } + + async createOAuthUser( + provider: string, + providerId: string, + providerUsername: string, + email: string + ): Promise { + // 生成唯一的用户名:provider_providerUsername + const username = `${provider}_${providerUsername}`; + + // 检查用户名是否已存在,如果存在则添加随机后缀 + let finalUsername = username; + let counter = 1; + while (await this.findUserByUsername(finalUsername)) { + finalUsername = `${username}_${counter}`; + counter++; + } + + const user = this.userRepository.create({ + username: finalUsername, + password: null, // OAuth 用户不需要密码 + email, + provider, + providerId, + providerUsername, + }); + + return await this.userRepository.save(user); + } + async validatePassword( plainPassword: string, hashedPassword: string, @@ -50,6 +85,7 @@ export class UserService { { sub: user.id, username: user.username, + email: user.email, }, { expiresIn: `${accessTokenExpiresIn}s` }, );