diff --git a/src/controller/auth.ts b/src/controller/auth.ts index 438b2b8..30c7be4 100644 --- a/src/controller/auth.ts +++ b/src/controller/auth.ts @@ -6,6 +6,8 @@ import { HttpCode, ParseBoolPipe, Post, + Query, + Redirect, Request, UnauthorizedException, UseGuards @@ -45,7 +47,7 @@ export default class AuthController { } return { statusCode: 200, - jwt: await this.authService.login(req.user) + jwt: await this.authService.login(req, req.user) }; } @@ -110,7 +112,7 @@ export default class AuthController { @Body('username') username, @Body('password') password ): Promise { - if ((await this.authService.findUser(username)) != null) + if ((await this.authService.findUser(username, false)) != null) throw new BadRequestException('Username already taken'); await this.authService.signup(username, password); return { @@ -120,11 +122,31 @@ export default class AuthController { @Post('refresh') async refresh(@Request() req): Promise { - const token = await this.authService.login(req.user); + const token = await this.authService.login(req, req.user); await this.authService.revoke(req.token); return { statusCode: 200, jwt: token }; } + + @Public() + @Redirect() + @Get('gitlab') + async gitlab(@Request() req) { + return { + url: this.authService.getGitlabAuthUrl(req) + }; + } + + @Public() + @Redirect() + @Get('gitlab_callback') + async gitlabCallback(@Request() req, @Query('code') code) { + const user = await this.authService.getGitlabUserFromCode(req, code); + const token = await this.authService.login(req, user); + return { + url: `/set_token?token=${token}` + }; + } } diff --git a/src/entities/index.ts b/src/entities/index.ts index affa0f7..7c3f631 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -47,6 +47,8 @@ export class INode { export class User { @PrimaryGeneratedColumn() id: number; + @Column({ default: false }) + isGitlabUser: boolean; @Column() name: string; @Column() @@ -78,6 +80,11 @@ export class User { @Column({ nullable: true }) tfaSecret: string; + + @Column({ nullable: true }) + gitlabAT: string; + @Column({ nullable: true }) + gitlabRT: string; } @Entity() diff --git a/src/services/auth.ts b/src/services/auth.ts index db06bfb..b06b44e 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -15,6 +15,15 @@ import * as jwt from 'jsonwebtoken'; import { createTransport } from 'nodemailer'; import * as notp from 'notp'; import { randomBytes } from 'crypto'; +import { FastifyRequest } from 'fastify'; +import axios from 'axios'; + +const GITLAB_ID = + '98bcbad78cb1f880d1d1de62291d70a791251a7bea077bfe7df111ef3c115760'; +const GITLAB_SECRET = + '7ee01d2b204aff3a05f9d028f004d169b6d381ec873e195f314b3935fa150959'; +const GITLAB_URL = 'https://gitlab.mattv.de'; +const GITLAB_API_URL = 'https://ssh.gitlab.mattv.de'; const jwtSecret = 'CUM'; @@ -35,6 +44,19 @@ interface jwtPayload { iat?: number; } +interface GitlabTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + created_at: number; +} + +interface GitlabUserResponse { + username: string; + is_admin?: boolean; +} + @Injectable() export class AuthService { constructor( @@ -61,9 +83,10 @@ export class AuthService { }); } - async findUser(username: string): Promise { + async findUser(username: string, gitlab: boolean): Promise { return this.userRepo.findOneBy({ - name: username + name: username, + isGitlabUser: gitlab }); } @@ -74,7 +97,7 @@ export class AuthService { } async validateUser(username: string, pass: string): Promise { - const user = await this.findUser(username); + const user = await this.findUser(username, false); if (!user) throw new UnauthorizedException('Invalid username or password'); if (!(await argon2.verify(user.password, pass))) @@ -136,7 +159,11 @@ export class AuthService { await this.userRepo.save(user); } - async login(user: User) { + async login(req: Request, user: User) { + if (user.isGitlabUser && !(await this.verifyGitlabUser(req, user))) { + await this.revokeAll(user); + throw new UnauthorizedException('Invalid gitlab token'); + } const token = new JWTToken(); token.ownerId = user.id; const db_token = await this.tokenRepo.save(token); @@ -153,16 +180,31 @@ export class AuthService { return jwtToken; } + async singupInternal(user: User): Promise { + const root = await this.fsService.generateRoot(user); + user.rootId = root.id; + return this.userRepo.save(user); + } + + async singupGitlab( + info: GitlabUserResponse, + data: GitlabTokenResponse + ): Promise { + const user = new User(); + user.name = info.username; + user.password = ''; + user.isGitlabUser = true; + user.role = info.is_admin ? UserRole.ADMIN : UserRole.DISABLED; + return this.singupInternal(await this.setGitlabTokens(user, data)); + } + async signup(username: string, password: string) { - if (await this.findUser(username)) + if (await this.findUser(username, false)) throw new BadRequestException('User already exists'); const user = new User(); user.name = username; user.password = await argon2.hash(password); - const dbUser = await this.userRepo.save(user); - const root = await this.fsService.generateRoot(dbUser); - dbUser.rootId = root.id; - await this.userRepo.save(dbUser); + await this.singupInternal(await this.userRepo.save(user)); } async revoke(token: JWTToken) { @@ -176,6 +218,87 @@ export class AuthService { ownerId: user.id }); } + + async setGitlabTokens( + user: User, + data: GitlabTokenResponse + ): Promise { + user.gitlabAT = data.access_token; + user.gitlabRT = data.refresh_token; + return this.userRepo.save(user); + } + + getGitlabRedirectUrl(req: Request): string { + const _req = req as unknown as FastifyRequest; + return `${_req.protocol}://${_req.hostname}/api/auth/gitlab_callback`; + } + + getGitlabAuthUrl(req: Request): string { + const params = new URLSearchParams(); + params.append('redirect_uri', this.getGitlabRedirectUrl(req)); + params.append('response_type', 'code'); + params.append('scope', 'read_user'); + params.append('client_id', GITLAB_ID); + return `${GITLAB_URL}/oauth/authorize?${params.toString()}`; + } + + async getGitlabUserFromCode(req: Request, code: string): Promise { + const params = new URLSearchParams(); + params.append('redirect_uri', this.getGitlabRedirectUrl(req)); + params.append('client_id', GITLAB_ID); + params.append('client_secret', GITLAB_SECRET); + params.append('code', code); + params.append('grant_type', 'authorization_code'); + const resp = await axios.post( + `${GITLAB_API_URL}/oauth/token?${params.toString()}`, + {} + ); + const data: GitlabTokenResponse = resp.data; + const userInfoResp = await axios.get(`${GITLAB_API_URL}/api/v4/user`, { + headers: { Authorization: `Bearer ${data.access_token}` } + }); + const userInfo: GitlabUserResponse = userInfoResp.data; + let user = await this.findUser(userInfo.username, true); + if (!user) user = await this.singupGitlab(userInfo, data); + else user = await this.setGitlabTokens(user, data); + return user; + } + + async isGitlabATValid(user: User): Promise { + try { + await axios.get(`${GITLAB_API_URL}/oauth/token/info`, { + headers: { Authorization: `Bearer ${user.gitlabAT}` } + }); + return true; + } catch (e) { + return false; + } + } + + async tryRefreshGitlabTokens(req: Request, user: User): Promise { + const params = new URLSearchParams(); + params.append('redirect_uri', this.getGitlabRedirectUrl(req)); + params.append('client_id', GITLAB_ID); + params.append('client_secret', GITLAB_SECRET); + params.append('refresh_token', user.gitlabRT); + params.append('grant_type', 'refresh_token'); + try { + const resp = await axios.post( + `${GITLAB_API_URL}/oauth/token?${params.toString()}`, + {} + ); + const data: GitlabTokenResponse = resp.data; + await this.setGitlabTokens(user, data); + return true; + } catch (e) { + return false; + } + } + + async verifyGitlabUser(req: Request, user: User): Promise { + if (await this.isGitlabATValid(user)) return true; + return await this.tryRefreshGitlabTokens(req, user); + } } @Injectable() @@ -218,6 +341,13 @@ export class AuthJwtService extends PassportStrategy(JWTStrategy) { throw new UnauthorizedException( 'Invalid token, please log in again' ); + if ( + user.isGitlabUser && + !(await this.authService.verifyGitlabUser(req, user)) + ) { + await this.authService.revokeAll(user); + throw new UnauthorizedException('Invalid gitlab token'); + } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore req.token = token;