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 @@
+
+
+ Click here to go home
+
+
+
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/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;
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"