From 5c8e3d88feb48d95318755c7800811bf9ae83ab4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 24 Aug 2022 19:22:20 +0200 Subject: [PATCH 1/4] Add link to gitlab login in frontend --- frontend/src/views/LoginView.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 50338d3..22c0eec 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -54,6 +54,7 @@ async function login() { + Login with gitlab Signup instead? From afd7709fa84bdab6cf5fc63c2a141f87808876f1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 24 Aug 2022 20:06:22 +0200 Subject: [PATCH 2/4] Add axios for gitlab authentication --- package.json | 1 + yarn.lock | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/package.json b/package.json index 15f402a..6c29f73 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@nestjs/serve-static": "^3.0.0", "@nestjs/typeorm": "^9.0.0", "argon2": "^0.28.7", + "axios": "^0.27.2", "jsonwebtoken": "^8.5.1", "nodemailer": "^6.7.8", "notp": "^2.0.3", diff --git a/yarn.lock b/yarn.lock index 66858f8..7ff7215 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1699,6 +1699,14 @@ avvio@^8.1.3: debug "^4.0.0" fastq "^1.6.1" +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + babel-jest@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-28.1.3.tgz#c1187258197c099072156a0a121c11ee1e3917d5" @@ -2800,6 +2808,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== +follow-redirects@^1.14.9: + version "1.15.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" + integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== + fork-ts-checker-webpack-plugin@7.2.11: version "7.2.11" resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.11.tgz#aff3febbc11544ba3ad0ae4d5aa4055bd15cd26d" From 70dd272b5d000d37352b7e80755b4fe2ee7b2d2a Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 24 Aug 2022 21:17:35 +0200 Subject: [PATCH 3/4] Added frontend set_token --- frontend/src/App.vue | 12 ++---------- frontend/src/router/index.ts | 6 ++++++ frontend/src/views/SetTokenView.vue | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 frontend/src/views/SetTokenView.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 60c8577..924349a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,11 +1,11 @@ + + + + From 715311f5750114b2f0195444c1d7b6296c20c552 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 24 Aug 2022 21:27:04 +0200 Subject: [PATCH 4/4] Added gitlab authentication --- src/controller/auth.ts | 28 +++++++- src/entities/index.ts | 7 ++ src/services/auth.ts | 148 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 171 insertions(+), 12 deletions(-) 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;