feat: 修复第三方登录的问题

This commit is contained in:
lichao 2025-06-04 10:40:40 +01:00
parent 7d5e734e39
commit 8b5d7ed6dc
14 changed files with 542 additions and 34 deletions

2
.env
View File

@ -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
# 飞书

172
OAuth-JWT-Integration.md Normal file
View File

@ -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 最佳实践

View File

@ -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,6 +36,7 @@ import { DeepseekModule } from './deepseek/deepseek.module';
UserModule,
AuthModule,
LarkModule,
GitHubModule,
DeepseekModule,
],
controllers: [AppController],

View File

@ -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);
}

View File

@ -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,9 +18,11 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
signOptions: { expiresIn: '1h' },
}),
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
GitHubStrategy,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,

View File

@ -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,
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: emails?.[0]?.value || null,
};
email || `${username}@github.local`
);
}
return user;
}
}

View File

@ -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');
}
}
}

View File

@ -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 { }

View File

@ -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<string> {
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<any> {
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<any> {
// 先检查缓存
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 用户信息失败');
}
}
}

View File

@ -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');
}
try {
const tokenData = await this.larkService.getAccessToken(code);
if (tokenData && tokenData.code === 0) {
const user = tokenData.data;
return res.json({ message: 'Login successful', user });
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');
}
}
}

View File

@ -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 { }

View File

@ -16,7 +16,7 @@ export class LarkService {
}
async getAccessToken(authCode: string): Promise<any> {
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<any> {
// 先检查缓存
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('获取飞书用户信息失败');
}
}
}

View File

@ -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; // 第三方平台的用户名
}

View File

@ -14,13 +14,13 @@ export class UserService {
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;
const hashedPassword = await bcrypt.hash(password, saltRounds);
hashedPassword = await bcrypt.hash(password, saltRounds);
}
const user = this.userRepository.create({
username,
@ -35,6 +35,41 @@ export class UserService {
return this.userRepository.findOne({ where: { username } });
}
async findUserByProvider(provider: string, providerId: string): Promise<User | undefined> {
return this.userRepository.findOne({
where: { provider, providerId }
});
}
async createOAuthUser(
provider: string,
providerId: string,
providerUsername: string,
email: string
): Promise<User> {
// 生成唯一的用户名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` },
);