Skip to content

Commit 59603cf

Browse files
committed
feat: github login
1 parent c74cd0b commit 59603cf

26 files changed

+1480
-495
lines changed

openapi.json

Lines changed: 935 additions & 403 deletions
Large diffs are not rendered by default.

src/app.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { RegionModule } from './region';
1919
import { RoleModule } from './role';
2020
import { SessionModule } from './session';
2121
import { SmsModule } from './sms';
22+
import { ThirdPartyModule } from './third-party';
2223
import { UserModule } from './user';
2324

2425
@Module({
@@ -50,7 +51,7 @@ import { UserModule } from './user';
5051
IndustryModule,
5152
NamespaceModule,
5253
GroupModule,
53-
54+
ThirdPartyModule,
5455
RegionModule,
5556
SessionModule,
5657
SmsModule,

src/auth/auth.controller.ts

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ import { addShortTimeSpan } from 'src/lib/lang/time';
2222
import { NamespaceService } from 'src/namespace';
2323
import { ErrorCodes as SessionErrorCodes, SessionService } from 'src/session';
2424
import { SmsRecordService } from 'src/sms';
25+
import { ThirdPartyService, ThirdPartySource } from 'src/third-party';
2526
import { User, UserDocument, ErrorCodes as UserErrorCodes, UserService } from 'src/user';
2627

2728
import { AuthService } from './auth.service';
2829
import { ErrorCodes } from './constants';
30+
import { GithubDto } from './dto/github.dto';
2931
import { LoginByEmailDto, LoginByPhoneDto, LoginDto, LogoutDto } from './dto/login.dto';
3032
import { RefreshTokenDto } from './dto/refresh-token.dto';
3133
import { RegisterByEmailDto, RegisterbyPhoneDto, RegisterDto } from './dto/register.dto';
@@ -47,20 +49,24 @@ export class AuthController {
4749
private readonly captchaService: CaptchaService,
4850
private readonly emailRecordService: EmailRecordService,
4951
private readonly smsRecordService: SmsRecordService,
50-
private readonly authService: AuthService
52+
private readonly authService: AuthService,
53+
private readonly thirdPartyService: ThirdPartyService
5154
) {}
5255

5356
_login = async (user: UserDocument): Promise<SessionWithToken> => {
5457
const session = await this.sessionService.create({
5558
uid: user.id,
56-
expireAt: addShortTimeSpan(SESSION_EXPIRES_IN), // session 先固定 7 天过期吧
59+
ns: user.ns,
60+
type: user.type,
61+
permissions: user.permissions,
62+
refreshTokenExpireAt: addShortTimeSpan(SESSION_EXPIRES_IN), // session 先固定 7 天过期吧
5763
});
5864

5965
const jwtpayload: JwtPayload = {
6066
uid: user.id,
61-
roles: user.roles,
6267
ns: user.ns,
63-
super: user.super,
68+
type: user.type,
69+
permissions: user.permissions,
6470
};
6571

6672
const tokenExpireAt = addShortTimeSpan(TOKEN_EXPIRES_IN);
@@ -119,7 +125,74 @@ export class AuthController {
119125
}
120126

121127
/**
122-
* login with email and code
128+
* login by Github
129+
*/
130+
@ApiOperation({ operationId: 'loginByGithub' })
131+
@HttpCode(HttpStatus.OK)
132+
@ApiOkResponse({
133+
description: 'The session with token has been successfully created.',
134+
type: SessionWithToken,
135+
})
136+
@Post('@loginByGithub')
137+
async loginByGithub(@Body() githubDto: GithubDto): Promise<SessionWithToken> {
138+
const { code } = githubDto;
139+
const githubAccessToken = await this.authService.getGithubAccessToken(code);
140+
if (!githubAccessToken) {
141+
throw new UnauthorizedException({
142+
code: ErrorCodes.AUTH_FAILED,
143+
message: `github access token not found.`,
144+
});
145+
}
146+
const githubUser = await this.authService.getGithubUser(code);
147+
if (!githubUser) {
148+
throw new UnauthorizedException({
149+
code: ErrorCodes.AUTH_FAILED,
150+
message: `github user not found.`,
151+
});
152+
}
153+
154+
// github 已绑定用户
155+
if (githubUser.uid) {
156+
const user = await this.userService.get(githubUser.uid);
157+
if (!user) {
158+
throw new UnauthorizedException({
159+
code: ErrorCodes.AUTH_FAILED,
160+
message: `user not found.`,
161+
});
162+
}
163+
164+
return this._login(user);
165+
}
166+
167+
// github 未绑定用户
168+
const session = await this.sessionService.create({
169+
uid: githubUser.login,
170+
source: ThirdPartySource.GITHUB,
171+
refreshTokenExpireAt: addShortTimeSpan(SESSION_EXPIRES_IN), // session 先固定 7 天过期吧
172+
});
173+
174+
const jwtpayload: JwtPayload = {
175+
uid: githubUser.login,
176+
source: ThirdPartySource.GITHUB,
177+
};
178+
179+
const tokenExpireAt = addShortTimeSpan(TOKEN_EXPIRES_IN);
180+
const token = this.jwtService.sign(jwtpayload, {
181+
expiresIn: TOKEN_EXPIRES_IN,
182+
subject: githubUser.login,
183+
});
184+
185+
const res: SessionWithToken = {
186+
...session.toJSON(),
187+
token,
188+
tokenExpireAt,
189+
};
190+
191+
return res;
192+
}
193+
194+
/**
195+
* login by email and code
123196
*/
124197
@ApiOperation({ operationId: 'loginByEmail' })
125198
@HttpCode(HttpStatus.OK)
@@ -289,10 +362,9 @@ export class AuthController {
289362

290363
const jwtpayload: JwtPayload = {
291364
uid: user.id,
292-
acl: dto.acl,
293-
roles: user.roles,
294365
ns: user.ns,
295-
super: user.super,
366+
type: user.type,
367+
permissions: user.permissions,
296368
};
297369

298370
const token = this.jwtService.sign(jwtpayload, {
@@ -326,7 +398,9 @@ export class AuthController {
326398
});
327399
}
328400

329-
if (session.expireAt.getTime() < Date.now()) {
401+
const user = await this.userService.get(session.uid);
402+
403+
if (session.refreshTokenExpireAt.getTime() < Date.now()) {
330404
throw new UnauthorizedException({
331405
code: SessionErrorCodes.SESSION_EXPIRED,
332406
message: 'Session has been expired.',
@@ -335,24 +409,25 @@ export class AuthController {
335409

336410
if (session.shouldRotate()) {
337411
session = await this.sessionService.create({
338-
uid: session.user.id,
339-
expireAt: addShortTimeSpan(SESSION_EXPIRES_IN),
340-
acl: session.acl,
412+
uid: user.id,
413+
ns: user.ns,
414+
type: user.type,
415+
permissions: user.permissions,
416+
refreshTokenExpireAt: addShortTimeSpan(SESSION_EXPIRES_IN),
341417
});
342418
}
343419

344420
const jwtpayload: JwtPayload = {
345-
uid: session.user.id,
346-
acl: session.acl,
347-
roles: session.user.roles,
348-
ns: session.user.ns,
349-
super: session.user.super,
421+
uid: user.id,
422+
ns: user.ns,
423+
type: user.type,
424+
permissions: user.permissions,
350425
};
351426

352427
const tokenExpireAt = addShortTimeSpan(TOKEN_EXPIRES_IN);
353428
const token = this.jwtService.sign(jwtpayload, {
354429
expiresIn: TOKEN_EXPIRES_IN,
355-
subject: session.user.id,
430+
subject: user.id,
356431
});
357432

358433
return {

src/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { NamespaceModule } from 'src/namespace';
1010
import { RedisModule } from 'src/redis';
1111
import { SessionModule } from 'src/session';
1212
import { SmsModule } from 'src/sms';
13+
import { ThirdPartyModule } from 'src/third-party/third-party.module';
1314
import { UserModule } from 'src/user';
1415

1516
import { AuthController } from './auth.controller';
@@ -34,6 +35,7 @@ import { jwtSecretKey } from './config';
3435
RedisModule,
3536
EmailModule,
3637
SmsModule,
38+
ThirdPartyModule,
3739
],
3840
controllers: [AuthController],
3941
providers: [AuthService],

src/auth/auth.service.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,24 @@
11
import { Inject, Injectable } from '@nestjs/common';
22
import { RedisClientType } from 'redis';
33

4+
import { createThirdPartyDto } from 'src/third-party/dto/create-third-party.dto';
5+
import { ThirdPartyDoc, ThirdPartySource } from 'src/third-party/entities/third-party.entity';
6+
import { ThirdPartyService } from 'src/third-party/third-party.service';
7+
48
import * as config from './config';
9+
import {
10+
GithubAccessTokenUrl,
11+
GithubClientId,
12+
GithubClientSecret,
13+
GithubUserUrl,
14+
} from './constants';
515

616
@Injectable()
717
export class AuthService {
8-
constructor(@Inject('REDIS_CLIENT') private readonly redisClient: RedisClientType) {}
18+
constructor(
19+
@Inject('REDIS_CLIENT') private readonly redisClient: RedisClientType,
20+
private readonly thirdPartyService: ThirdPartyService
21+
) {}
922

1023
async isLocked(login: string): Promise<boolean> {
1124
const lock = await this.redisClient.hGetAll(`loginLock:${login}`);
@@ -27,4 +40,64 @@ export class AuthService {
2740
// 设置过期时间为登录锁定时长(秒)
2841
await this.redisClient.expire(lockKey, config.loginLockInS);
2942
}
43+
44+
async getGithubAccessToken(code: string): Promise<string> {
45+
try {
46+
// POST 请求到 GitHub 交换 access_token
47+
const response = await fetch(GithubAccessTokenUrl, {
48+
method: 'POST',
49+
headers: {
50+
'Accept': 'application/json',
51+
'Content-Type': 'application/json',
52+
},
53+
body: JSON.stringify({
54+
client_id: GithubClientId,
55+
client_secret: GithubClientSecret,
56+
code,
57+
}),
58+
});
59+
const data = await response.json();
60+
if (!data.access_token) {
61+
throw new Error('Failed to get access token');
62+
}
63+
return data.access_token;
64+
} catch (e) {
65+
console.error(e);
66+
return '';
67+
}
68+
}
69+
70+
async getGithubUser(accessToken: string): Promise<ThirdPartyDoc> {
71+
try {
72+
// 向 GitHub 用户信息 API 发送 GET 请求
73+
const response = await fetch(GithubUserUrl, {
74+
method: 'GET',
75+
headers: {
76+
// 必须在请求头中传入 Authorization,格式为:Bearer <access_token>
77+
Authorization: `Bearer ${accessToken}`,
78+
Accept: 'application/json',
79+
},
80+
});
81+
82+
// 如果请求失败,比如 token 无效或过期,处理错误
83+
if (!response.ok) {
84+
console.error(`Failed to fetch user info: ${response.status} ${response.statusText}`);
85+
return null;
86+
}
87+
88+
// 解析 JSON 返回的用户信息
89+
const userData = await response.json();
90+
// 创建或更新第三方登录信息
91+
const createDto: createThirdPartyDto = {
92+
login: userData.login,
93+
source: ThirdPartySource.GITHUB,
94+
accessToken,
95+
};
96+
return this.thirdPartyService.upsert(createDto.login, createDto.source, createDto);
97+
} catch (error) {
98+
// 捕获网络或代码错误
99+
console.error('Error while fetching GitHub user info:', error);
100+
return null;
101+
}
102+
}
30103
}

src/auth/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ export const ErrorCodes = {
44
CAPTCHA_INVALID: 'CAPTCHA_INVALID',
55
TOO_MANY_LOGIN_ATTEMPTS: 'TOO_MANY_LOGIN_ATTEMPTS',
66
};
7+
8+
export const GithubAccessTokenUrl = 'https://github.com/login/oauth/access_token';
9+
export const GithubClientId = 'Iv23lizBaVPIiABBCHaz';
10+
export const GithubClientSecret = '041f46399c1396ec27d16851c1aa2aa479a3f5a5';
11+
export const GithubUserUrl = 'https://api.github.com/user';

src/auth/dto/github.dto.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { IsNotEmpty, IsString } from 'class-validator';
2+
3+
export class GithubDto {
4+
@IsNotEmpty()
5+
@IsString()
6+
code: string;
7+
}

src/auth/dto/sign-token.dto.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
2-
3-
import { Acl } from 'src/auth';
1+
import { IsNotEmpty, IsString } from 'class-validator';
42

53
export class SignTokenDto {
64
/**
@@ -24,12 +22,6 @@ export class SignTokenDto {
2422
@IsString()
2523
expiresIn: string;
2624

27-
/**
28-
* 访问控制列表
29-
*/
30-
@IsOptional()
31-
acl?: Acl;
32-
3325
/**
3426
* user id
3527
*/

src/auth/entities/jwt.entity.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ export interface Acl {
88
*/
99
export class JwtPayload {
1010
uid: string; // 用户 ID
11-
roles: string[]; // RBAC 角色列表
11+
source?: string; // 第三方来源
12+
client?: string; // 客户端/设备
13+
permissions?: string[]; // 用户动态权限
1214
ns?: string; // 该用户或资源所属的 namespace
13-
acl?: Acl; // ACL 权限控制列表
14-
super?: boolean; // 是否是超级管理员
15+
type?: string[]; // 类型,支持设置多个
1516
}
Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,5 @@
11
import { OmitType } from '@nestjs/swagger';
2-
import { IsMongoId, IsNotEmpty } from 'class-validator';
32

43
import { SessionDoc } from '../entities/session.entity';
54

6-
export class CreateSessionDto extends OmitType(SessionDoc, ['key', 'user'] as const) {
7-
/**
8-
* 用户 ID
9-
*/
10-
@IsNotEmpty()
11-
@IsMongoId()
12-
uid: string;
13-
}
5+
export class CreateSessionDto extends OmitType(SessionDoc, ['refreshToken'] as const) {}

0 commit comments

Comments
 (0)