Skip to content

Commit 14540c1

Browse files
authored
feat(server): support github login (#1542)
1 parent e8b8380 commit 14540c1

12 files changed

+717
-0
lines changed

server/package-lock.json

+442
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
"@nestjs/schedule": "^2.1.0",
4242
"@nestjs/swagger": "^6.1.3",
4343
"@nestjs/throttler": "^3.1.0",
44+
"@octokit/auth-oauth-app": "^7.0.0",
45+
"@octokit/rest": "^20.0.1",
4446
"class-transformer": "^0.5.1",
4547
"class-validator": "^0.14.0",
4648
"compression": "^1.7.4",

server/src/authentication/authentication.module.ts

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { AccountService } from 'src/account/account.service'
1818
import { EmailService } from './email/email.service'
1919
import { EmailController } from './email/email.controller'
2020
import { MailerService } from './email/mailer.service'
21+
import { GithubAuthController } from './github/github.controller'
22+
import { GithubService } from './github/github.service'
2123

2224
@Global()
2325
@Module({
@@ -41,13 +43,15 @@ import { MailerService } from './email/mailer.service'
4143
AuthenticationService,
4244
AccountService,
4345
MailerService,
46+
GithubService,
4447
],
4548
exports: [SmsService, EmailService],
4649
controllers: [
4750
UserPasswordController,
4851
PhoneController,
4952
AuthenticationController,
5053
EmailController,
54+
GithubAuthController,
5155
],
5256
})
5357
export class AuthenticationModule {}

server/src/authentication/authentication.service.ts

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { JwtService } from '@nestjs/jwt'
22
import { Injectable, Logger } from '@nestjs/common'
33
import {
44
EMAIL_AUTH_PROVIDER_NAME,
5+
GITHUB_AUTH_PROVIDER_NAME,
56
PASSWORD_AUTH_PROVIDER_NAME,
67
PHONE_AUTH_PROVIDER_NAME,
78
} from 'src/constants'
@@ -42,6 +43,10 @@ export class AuthenticationService {
4243
return await this.getProvider(PASSWORD_AUTH_PROVIDER_NAME)
4344
}
4445

46+
async getGithubProvider() {
47+
return await this.getProvider(GITHUB_AUTH_PROVIDER_NAME)
48+
}
49+
4550
async getEmailProvider() {
4651
return await this.getProvider(EMAIL_AUTH_PROVIDER_NAME)
4752
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ApiProperty } from '@nestjs/swagger'
2+
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'
3+
4+
export class GithubBind {
5+
@ApiProperty({ description: 'temporary token signed for github bindings' })
6+
@IsString()
7+
@IsNotEmpty()
8+
token: string
9+
10+
@ApiProperty({ description: 'Is a newly registered use' })
11+
@IsBoolean()
12+
@IsOptional()
13+
isRegister: boolean
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ApiProperty } from '@nestjs/swagger'
2+
import { IsNotEmpty, IsString } from 'class-validator'
3+
4+
export class GithubJumpLoginDto {
5+
@ApiProperty()
6+
@IsString()
7+
@IsNotEmpty()
8+
redirectUri: string
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ApiProperty } from '@nestjs/swagger'
2+
import { IsNotEmpty, IsString } from 'class-validator'
3+
4+
export class GithubSigninDto {
5+
@ApiProperty()
6+
@IsNotEmpty()
7+
@IsString()
8+
code: string
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import {
2+
Body,
3+
Controller,
4+
Get,
5+
Post,
6+
Query,
7+
Response,
8+
UseGuards,
9+
} from '@nestjs/common'
10+
import { Octokit } from '@octokit/rest'
11+
import { IResponse } from 'src/utils/interface'
12+
import { createOAuthAppAuth } from '@octokit/auth-oauth-app'
13+
import { AuthenticationService } from '../authentication.service'
14+
import { UserService } from 'src/user/user.service'
15+
import { ApiResponseObject, ResponseUtil } from 'src/utils/response'
16+
import { GithubService } from './github.service'
17+
import { InjectUser } from 'src/utils/decorator'
18+
import { User, UserWithProfile } from 'src/user/entities/user'
19+
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'
20+
import { GithubJumpLoginDto } from '../dto/github-jump-login.dto'
21+
import { JwtAuthGuard } from '../jwt.auth.guard'
22+
import { GithubBind } from '../dto/github-bind.dto'
23+
import { GithubSigninDto } from '../dto/github-signin.dto'
24+
import { AuthProviderState } from '../entities/auth-provider'
25+
26+
@ApiTags('Authentication')
27+
@Controller('auth/github')
28+
export class GithubAuthController {
29+
constructor(
30+
private readonly authService: AuthenticationService,
31+
private readonly userService: UserService,
32+
private readonly githubService: GithubService,
33+
) {}
34+
35+
@ApiOperation({ summary: 'Redirect to the login page of github' })
36+
@Get('jump_login')
37+
async jumpLogin(
38+
@Query() dto: GithubJumpLoginDto,
39+
@Response() res: IResponse,
40+
) {
41+
const provider = await this.authService.getGithubProvider()
42+
if (provider.state !== AuthProviderState.Enabled) {
43+
return ResponseUtil.error('github signin not allowed')
44+
}
45+
46+
res.redirect(
47+
`https://github.com/login/oauth/authorize?client_id=${
48+
provider.config.clientId
49+
}&redirect_uri=${encodeURIComponent(dto.redirectUri)}`,
50+
)
51+
}
52+
53+
@ApiOperation({ summary: 'Signin by github' })
54+
@ApiResponse({ type: ResponseUtil })
55+
@Post('signin')
56+
async signin(@Body() dto: GithubSigninDto) {
57+
const provider = await this.authService.getGithubProvider()
58+
if (provider.state !== AuthProviderState.Enabled) {
59+
return ResponseUtil.error('github signin not allowed')
60+
}
61+
62+
const githubAuth = createOAuthAppAuth({
63+
clientId: provider.config.clientId,
64+
clientSecret: provider.config.clientSecret,
65+
clientType: 'oauth-app',
66+
})
67+
68+
let auth
69+
try {
70+
auth = await githubAuth({
71+
type: 'oauth-user',
72+
code: dto.code,
73+
})
74+
} catch (e) {
75+
console.log(e)
76+
return ResponseUtil.error(e.message)
77+
}
78+
79+
if (!auth) {
80+
return ResponseUtil.error('github auth failed')
81+
}
82+
83+
const octokit = new Octokit({
84+
auth: auth.token,
85+
})
86+
87+
const _profile = await octokit.rest.users.getAuthenticated()
88+
if (!_profile.data) {
89+
return ResponseUtil.error('github auth failed')
90+
}
91+
92+
const githubProfile = {
93+
gid: _profile.data.id,
94+
name: _profile.data.name,
95+
avatar: _profile.data.avatar_url,
96+
}
97+
98+
const user = await this.userService.findOneByGithub(githubProfile.gid)
99+
if (!user) {
100+
const token = this.githubService.signGithubTemporaryToken(githubProfile)
101+
return ResponseUtil.build(token, 'should bind user')
102+
}
103+
104+
const token = this.authService.getAccessTokenByUser(user)
105+
return ResponseUtil.ok(token)
106+
}
107+
108+
@ApiOperation({ summary: 'Bind github' })
109+
@ApiResponseObject(UserWithProfile)
110+
@UseGuards(JwtAuthGuard)
111+
@Post('bind')
112+
async bind(@Body() dto: GithubBind, @InjectUser() user: User) {
113+
const [ok, githubProfile] = this.githubService.verifyGithubTemporaryToken(
114+
dto.token,
115+
)
116+
if (!ok) {
117+
return ResponseUtil.error('invalid token')
118+
}
119+
120+
if (user.github) {
121+
return ResponseUtil.error('duplicate bindings to github')
122+
}
123+
124+
const _user = await this.userService.findOneByGithub(githubProfile.gid)
125+
if (_user) return ResponseUtil.error('user has been bound')
126+
127+
await this.userService.updateUser(user._id, {
128+
github: githubProfile.gid,
129+
})
130+
131+
if (dto.isRegister) {
132+
await this.userService.updateAvatarUrl(githubProfile.avatar, user._id)
133+
}
134+
135+
const res = await this.userService.findOneById(user._id)
136+
return ResponseUtil.ok(res)
137+
}
138+
139+
@ApiOperation({ summary: 'Unbind github' })
140+
@ApiResponseObject(UserWithProfile)
141+
@UseGuards(JwtAuthGuard)
142+
@Post('unbind')
143+
async unbind(@InjectUser() user: User) {
144+
if (!user.github) {
145+
return ResponseUtil.error('not yet bound to github')
146+
}
147+
148+
const res = await this.userService.updateUser(user._id, {
149+
github: null,
150+
})
151+
return ResponseUtil.ok(res)
152+
}
153+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Injectable } from '@nestjs/common'
2+
3+
import { JwtService } from '@nestjs/jwt'
4+
import { UserService } from 'src/user/user.service'
5+
import { User } from 'src/user/entities/user'
6+
import { GITHUB_SIGNIN_TOKEN_VALIDITY } from 'src/constants'
7+
8+
interface GithubProfile {
9+
gid: number
10+
name: string
11+
avatar: string
12+
}
13+
14+
@Injectable()
15+
export class GithubService {
16+
constructor(
17+
private readonly userService: UserService,
18+
private readonly jwtService: JwtService,
19+
) {}
20+
21+
async bindGithub(gid: number, user: User) {
22+
const _user = await this.userService.findOneByGithub(gid)
23+
24+
if (_user) throw new Error('user has been bound')
25+
26+
const res = await this.userService.updateUser(user._id, {
27+
github: gid,
28+
})
29+
30+
return res
31+
}
32+
33+
signGithubTemporaryToken(githubProfile: GithubProfile) {
34+
const payload = { sub: githubProfile }
35+
const token = this.jwtService.sign(payload, {
36+
expiresIn: GITHUB_SIGNIN_TOKEN_VALIDITY,
37+
})
38+
return token
39+
}
40+
41+
verifyGithubTemporaryToken(token: string): [boolean, GithubProfile | null] {
42+
try {
43+
const payload = this.jwtService.verify(token)
44+
const githubProfile = payload.sub
45+
return [true, githubProfile]
46+
} catch {
47+
return [false, null]
48+
}
49+
}
50+
}

server/src/constants.ts

+4
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,17 @@ export const GB = 1024 * MB
221221
export const PHONE_AUTH_PROVIDER_NAME = 'phone'
222222
export const PASSWORD_AUTH_PROVIDER_NAME = 'user-password'
223223
export const EMAIL_AUTH_PROVIDER_NAME = 'email'
224+
export const GITHUB_AUTH_PROVIDER_NAME = 'github'
224225

225226
// Sms constants
226227
export const ALISMS_KEY = 'alisms'
227228
export const LIMIT_CODE_FREQUENCY = 60 * 1000 // 60 seconds (in milliseconds)
228229
export const LIMIT_CODE_PER_IP_PER_DAY = 30 // 30 times
229230
export const CODE_VALIDITY = 10 * 60 * 1000 // 10 minutes (in milliseconds)
230231

232+
// Github constants
233+
export const GITHUB_SIGNIN_TOKEN_VALIDITY = 5 * 60 * 1000
234+
231235
// Recycle bin constants
232236
export const STORAGE_LIMIT = 1000 // 1000 items
233237

server/src/user/entities/user.ts

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export class User {
1515
@ApiPropertyOptional()
1616
phone?: string
1717

18+
@ApiPropertyOptional()
19+
github?: number
20+
1821
@ApiProperty()
1922
createdAt: Date
2023

server/src/user/user.service.ts

+22
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ export class UserService {
5959
return user
6060
}
6161

62+
// find user by github id
63+
async findOneByGithub(gid: number) {
64+
const user = await this.db.collection<User>('User').findOne({
65+
github: gid,
66+
})
67+
68+
return user
69+
}
70+
6271
// find user by username | phone | email
6372
async findOneByUsernameOrPhoneOrEmail(key: string) {
6473
// match either username or phone or email
@@ -77,6 +86,19 @@ export class UserService {
7786
return await this.findOneById(id)
7887
}
7988

89+
async updateAvatarUrl(url: string, userid: ObjectId) {
90+
await this.db.collection<UserProfile>('UserProfile').updateOne(
91+
{ uid: userid },
92+
{
93+
$set: {
94+
avatar: url,
95+
},
96+
},
97+
)
98+
99+
return await this.findOneById(userid)
100+
}
101+
80102
async updateAvatar(image: Express.Multer.File, userid: ObjectId) {
81103
const buffer = await sharp(image.buffer).resize(100, 100).webp().toBuffer()
82104

0 commit comments

Comments
 (0)