Skip to content

Commit

Permalink
feat(server): support github login (#1542)
Browse files Browse the repository at this point in the history
  • Loading branch information
0fatal authored Oct 11, 2023
1 parent e8b8380 commit 14540c1
Show file tree
Hide file tree
Showing 12 changed files with 717 additions and 0 deletions.
442 changes: 442 additions & 0 deletions server/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
"@nestjs/schedule": "^2.1.0",
"@nestjs/swagger": "^6.1.3",
"@nestjs/throttler": "^3.1.0",
"@octokit/auth-oauth-app": "^7.0.0",
"@octokit/rest": "^20.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"compression": "^1.7.4",
Expand Down
4 changes: 4 additions & 0 deletions server/src/authentication/authentication.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { AccountService } from 'src/account/account.service'
import { EmailService } from './email/email.service'
import { EmailController } from './email/email.controller'
import { MailerService } from './email/mailer.service'
import { GithubAuthController } from './github/github.controller'
import { GithubService } from './github/github.service'

@Global()
@Module({
Expand All @@ -41,13 +43,15 @@ import { MailerService } from './email/mailer.service'
AuthenticationService,
AccountService,
MailerService,
GithubService,
],
exports: [SmsService, EmailService],
controllers: [
UserPasswordController,
PhoneController,
AuthenticationController,
EmailController,
GithubAuthController,
],
})
export class AuthenticationModule {}
5 changes: 5 additions & 0 deletions server/src/authentication/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { JwtService } from '@nestjs/jwt'
import { Injectable, Logger } from '@nestjs/common'
import {
EMAIL_AUTH_PROVIDER_NAME,
GITHUB_AUTH_PROVIDER_NAME,
PASSWORD_AUTH_PROVIDER_NAME,
PHONE_AUTH_PROVIDER_NAME,
} from 'src/constants'
Expand Down Expand Up @@ -42,6 +43,10 @@ export class AuthenticationService {
return await this.getProvider(PASSWORD_AUTH_PROVIDER_NAME)
}

async getGithubProvider() {
return await this.getProvider(GITHUB_AUTH_PROVIDER_NAME)
}

async getEmailProvider() {
return await this.getProvider(EMAIL_AUTH_PROVIDER_NAME)
}
Expand Down
14 changes: 14 additions & 0 deletions server/src/authentication/dto/github-bind.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'

export class GithubBind {
@ApiProperty({ description: 'temporary token signed for github bindings' })
@IsString()
@IsNotEmpty()
token: string

@ApiProperty({ description: 'Is a newly registered use' })
@IsBoolean()
@IsOptional()
isRegister: boolean
}
9 changes: 9 additions & 0 deletions server/src/authentication/dto/github-jump-login.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsNotEmpty, IsString } from 'class-validator'

export class GithubJumpLoginDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
redirectUri: string
}
9 changes: 9 additions & 0 deletions server/src/authentication/dto/github-signin.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsNotEmpty, IsString } from 'class-validator'

export class GithubSigninDto {
@ApiProperty()
@IsNotEmpty()
@IsString()
code: string
}
153 changes: 153 additions & 0 deletions server/src/authentication/github/github.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import {
Body,
Controller,
Get,
Post,
Query,
Response,
UseGuards,
} from '@nestjs/common'
import { Octokit } from '@octokit/rest'
import { IResponse } from 'src/utils/interface'
import { createOAuthAppAuth } from '@octokit/auth-oauth-app'
import { AuthenticationService } from '../authentication.service'
import { UserService } from 'src/user/user.service'
import { ApiResponseObject, ResponseUtil } from 'src/utils/response'
import { GithubService } from './github.service'
import { InjectUser } from 'src/utils/decorator'
import { User, UserWithProfile } from 'src/user/entities/user'
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'
import { GithubJumpLoginDto } from '../dto/github-jump-login.dto'
import { JwtAuthGuard } from '../jwt.auth.guard'
import { GithubBind } from '../dto/github-bind.dto'
import { GithubSigninDto } from '../dto/github-signin.dto'
import { AuthProviderState } from '../entities/auth-provider'

@ApiTags('Authentication')
@Controller('auth/github')
export class GithubAuthController {
constructor(
private readonly authService: AuthenticationService,
private readonly userService: UserService,
private readonly githubService: GithubService,
) {}

@ApiOperation({ summary: 'Redirect to the login page of github' })
@Get('jump_login')
async jumpLogin(
@Query() dto: GithubJumpLoginDto,
@Response() res: IResponse,
) {
const provider = await this.authService.getGithubProvider()
if (provider.state !== AuthProviderState.Enabled) {
return ResponseUtil.error('github signin not allowed')
}

res.redirect(
`https://github.com/login/oauth/authorize?client_id=${
provider.config.clientId
}&redirect_uri=${encodeURIComponent(dto.redirectUri)}`,
)
}

@ApiOperation({ summary: 'Signin by github' })
@ApiResponse({ type: ResponseUtil })
@Post('signin')
async signin(@Body() dto: GithubSigninDto) {
const provider = await this.authService.getGithubProvider()
if (provider.state !== AuthProviderState.Enabled) {
return ResponseUtil.error('github signin not allowed')
}

const githubAuth = createOAuthAppAuth({
clientId: provider.config.clientId,
clientSecret: provider.config.clientSecret,
clientType: 'oauth-app',
})

let auth
try {
auth = await githubAuth({
type: 'oauth-user',
code: dto.code,
})
} catch (e) {
console.log(e)
return ResponseUtil.error(e.message)
}

if (!auth) {
return ResponseUtil.error('github auth failed')
}

const octokit = new Octokit({
auth: auth.token,
})

const _profile = await octokit.rest.users.getAuthenticated()
if (!_profile.data) {
return ResponseUtil.error('github auth failed')
}

const githubProfile = {
gid: _profile.data.id,
name: _profile.data.name,
avatar: _profile.data.avatar_url,
}

const user = await this.userService.findOneByGithub(githubProfile.gid)
if (!user) {
const token = this.githubService.signGithubTemporaryToken(githubProfile)
return ResponseUtil.build(token, 'should bind user')
}

const token = this.authService.getAccessTokenByUser(user)
return ResponseUtil.ok(token)
}

@ApiOperation({ summary: 'Bind github' })
@ApiResponseObject(UserWithProfile)
@UseGuards(JwtAuthGuard)
@Post('bind')
async bind(@Body() dto: GithubBind, @InjectUser() user: User) {
const [ok, githubProfile] = this.githubService.verifyGithubTemporaryToken(
dto.token,
)
if (!ok) {
return ResponseUtil.error('invalid token')
}

if (user.github) {
return ResponseUtil.error('duplicate bindings to github')
}

const _user = await this.userService.findOneByGithub(githubProfile.gid)
if (_user) return ResponseUtil.error('user has been bound')

await this.userService.updateUser(user._id, {
github: githubProfile.gid,
})

if (dto.isRegister) {
await this.userService.updateAvatarUrl(githubProfile.avatar, user._id)
}

const res = await this.userService.findOneById(user._id)
return ResponseUtil.ok(res)
}

@ApiOperation({ summary: 'Unbind github' })
@ApiResponseObject(UserWithProfile)
@UseGuards(JwtAuthGuard)
@Post('unbind')
async unbind(@InjectUser() user: User) {
if (!user.github) {
return ResponseUtil.error('not yet bound to github')
}

const res = await this.userService.updateUser(user._id, {
github: null,
})
return ResponseUtil.ok(res)
}
}
50 changes: 50 additions & 0 deletions server/src/authentication/github/github.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common'

import { JwtService } from '@nestjs/jwt'
import { UserService } from 'src/user/user.service'
import { User } from 'src/user/entities/user'
import { GITHUB_SIGNIN_TOKEN_VALIDITY } from 'src/constants'

interface GithubProfile {
gid: number
name: string
avatar: string
}

@Injectable()
export class GithubService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
) {}

async bindGithub(gid: number, user: User) {
const _user = await this.userService.findOneByGithub(gid)

if (_user) throw new Error('user has been bound')

const res = await this.userService.updateUser(user._id, {
github: gid,
})

return res
}

signGithubTemporaryToken(githubProfile: GithubProfile) {
const payload = { sub: githubProfile }
const token = this.jwtService.sign(payload, {
expiresIn: GITHUB_SIGNIN_TOKEN_VALIDITY,
})
return token
}

verifyGithubTemporaryToken(token: string): [boolean, GithubProfile | null] {
try {
const payload = this.jwtService.verify(token)
const githubProfile = payload.sub
return [true, githubProfile]
} catch {
return [false, null]
}
}
}
4 changes: 4 additions & 0 deletions server/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,17 @@ export const GB = 1024 * MB
export const PHONE_AUTH_PROVIDER_NAME = 'phone'
export const PASSWORD_AUTH_PROVIDER_NAME = 'user-password'
export const EMAIL_AUTH_PROVIDER_NAME = 'email'
export const GITHUB_AUTH_PROVIDER_NAME = 'github'

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

// Github constants
export const GITHUB_SIGNIN_TOKEN_VALIDITY = 5 * 60 * 1000

// Recycle bin constants
export const STORAGE_LIMIT = 1000 // 1000 items

Expand Down
3 changes: 3 additions & 0 deletions server/src/user/entities/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export class User {
@ApiPropertyOptional()
phone?: string

@ApiPropertyOptional()
github?: number

@ApiProperty()
createdAt: Date

Expand Down
22 changes: 22 additions & 0 deletions server/src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ export class UserService {
return user
}

// find user by github id
async findOneByGithub(gid: number) {
const user = await this.db.collection<User>('User').findOne({
github: gid,
})

return user
}

// find user by username | phone | email
async findOneByUsernameOrPhoneOrEmail(key: string) {
// match either username or phone or email
Expand All @@ -77,6 +86,19 @@ export class UserService {
return await this.findOneById(id)
}

async updateAvatarUrl(url: string, userid: ObjectId) {
await this.db.collection<UserProfile>('UserProfile').updateOne(
{ uid: userid },
{
$set: {
avatar: url,
},
},
)

return await this.findOneById(userid)
}

async updateAvatar(image: Express.Multer.File, userid: ObjectId) {
const buffer = await sharp(image.buffer).resize(100, 100).webp().toBuffer()

Expand Down

0 comments on commit 14540c1

Please sign in to comment.