Merge branch 'gitlab-auth' into 'main'

Gitlab authentication

See merge request root/fileserver!3
This commit is contained in:
Mutzi 2022-08-24 19:49:01 +00:00
commit 98acfa2e33
9 changed files with 213 additions and 22 deletions

View File

@ -1,11 +1,11 @@
<script setup async lang="ts">
import { provide, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Auth, TokenInjectType, isErrorResponse } from '@/api';
import { TokenInjectType } from '@/api';
const router = useRouter();
const jwt = ref<string | null>(null);
const jwt = ref<string | null>(localStorage.getItem('token'));
function setToken(token: string) {
jwt.value = token;
@ -18,14 +18,6 @@ function logout() {
router.push({ name: 'login' });
}
jwt.value = localStorage.getItem('token');
if (jwt.value == null) await router.push({ name: 'login' });
else {
const new_token = await Auth.refresh_token(jwt.value ?? '');
if (isErrorResponse(new_token)) logout();
else setToken(new_token.jwt);
}
provide<TokenInjectType>('jwt', {
jwt,
setToken,

View File

@ -4,10 +4,12 @@ import SignupView from '@/views/SignupView.vue';
import HomeView from '@/views/HomeView.vue';
import AboutView from '@/views/AboutView.vue';
import FSView from '@/views/FSView.vue';
import SetTokenView from '@/views/SetTokenView.vue';
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'home',
component: HomeView
},
{
@ -25,6 +27,10 @@ const routes: Array<RouteRecordRaw> = [
name: 'signup',
component: SignupView
},
{
path: '/set_token',
component: SetTokenView
},
{
path: '/fs/:node_id',
name: 'fs',

View File

@ -54,6 +54,7 @@ async function login() {
<input type="text" placeholder="Code" v-model="otp" />
</template>
<button @click="login()">Login</button>
<a href="/api/auth/gitlab">Login with gitlab</a>
<router-link to="signup">Signup instead?</router-link>
</template>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import { inject } from 'vue';
import { TokenInjectType } from '@/api';
import { useRoute, useRouter } from 'vue-router';
const router = useRouter();
const route = useRoute();
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
if ('token' in route.query) jwt.setToken(route.query['token'] as string);
router.replace({ path: '/' });
</script>
<template>
<router-link to="home">Click here to go home</router-link>
</template>
<style scoped></style>

View File

@ -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",

View File

@ -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<Responses.Auth.SignupResponse> {
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<Responses.Auth.RefreshResponse> {
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}`
};
}
}

View File

@ -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()

View File

@ -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<User | null> {
async findUser(username: string, gitlab: boolean): Promise<User | null> {
return this.userRepo.findOneBy({
name: username
name: username,
isGitlabUser: gitlab
});
}
@ -74,7 +97,7 @@ export class AuthService {
}
async validateUser(username: string, pass: string): Promise<User | null> {
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<User> {
const root = await this.fsService.generateRoot(user);
user.rootId = root.id;
return this.userRepo.save(user);
}
async singupGitlab(
info: GitlabUserResponse,
data: GitlabTokenResponse
): Promise<User> {
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> {
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<User> {
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<boolean> {
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<boolean> {
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<boolean> {
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;

View File

@ -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"