Merge branch 'gitlab-auth' into 'main'
Gitlab authentication See merge request root/fileserver!3
This commit is contained in:
commit
98acfa2e33
@ -1,11 +1,11 @@
|
|||||||
<script setup async lang="ts">
|
<script setup async lang="ts">
|
||||||
import { provide, ref } from 'vue';
|
import { provide, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { Auth, TokenInjectType, isErrorResponse } from '@/api';
|
import { TokenInjectType } from '@/api';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const jwt = ref<string | null>(null);
|
const jwt = ref<string | null>(localStorage.getItem('token'));
|
||||||
|
|
||||||
function setToken(token: string) {
|
function setToken(token: string) {
|
||||||
jwt.value = token;
|
jwt.value = token;
|
||||||
@ -18,14 +18,6 @@ function logout() {
|
|||||||
router.push({ name: 'login' });
|
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', {
|
provide<TokenInjectType>('jwt', {
|
||||||
jwt,
|
jwt,
|
||||||
setToken,
|
setToken,
|
||||||
|
@ -4,10 +4,12 @@ import SignupView from '@/views/SignupView.vue';
|
|||||||
import HomeView from '@/views/HomeView.vue';
|
import HomeView from '@/views/HomeView.vue';
|
||||||
import AboutView from '@/views/AboutView.vue';
|
import AboutView from '@/views/AboutView.vue';
|
||||||
import FSView from '@/views/FSView.vue';
|
import FSView from '@/views/FSView.vue';
|
||||||
|
import SetTokenView from '@/views/SetTokenView.vue';
|
||||||
|
|
||||||
const routes: Array<RouteRecordRaw> = [
|
const routes: Array<RouteRecordRaw> = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
component: HomeView
|
component: HomeView
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -25,6 +27,10 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: 'signup',
|
name: 'signup',
|
||||||
component: SignupView
|
component: SignupView
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/set_token',
|
||||||
|
component: SetTokenView
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/fs/:node_id',
|
path: '/fs/:node_id',
|
||||||
name: 'fs',
|
name: 'fs',
|
||||||
|
@ -54,6 +54,7 @@ async function login() {
|
|||||||
<input type="text" placeholder="Code" v-model="otp" />
|
<input type="text" placeholder="Code" v-model="otp" />
|
||||||
</template>
|
</template>
|
||||||
<button @click="login()">Login</button>
|
<button @click="login()">Login</button>
|
||||||
|
<a href="/api/auth/gitlab">Login with gitlab</a>
|
||||||
<router-link to="signup">Signup instead?</router-link>
|
<router-link to="signup">Signup instead?</router-link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
19
frontend/src/views/SetTokenView.vue
Normal file
19
frontend/src/views/SetTokenView.vue
Normal 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>
|
@ -31,6 +31,7 @@
|
|||||||
"@nestjs/serve-static": "^3.0.0",
|
"@nestjs/serve-static": "^3.0.0",
|
||||||
"@nestjs/typeorm": "^9.0.0",
|
"@nestjs/typeorm": "^9.0.0",
|
||||||
"argon2": "^0.28.7",
|
"argon2": "^0.28.7",
|
||||||
|
"axios": "^0.27.2",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
"nodemailer": "^6.7.8",
|
"nodemailer": "^6.7.8",
|
||||||
"notp": "^2.0.3",
|
"notp": "^2.0.3",
|
||||||
|
@ -6,6 +6,8 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
ParseBoolPipe,
|
ParseBoolPipe,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
|
Redirect,
|
||||||
Request,
|
Request,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
UseGuards
|
UseGuards
|
||||||
@ -45,7 +47,7 @@ export default class AuthController {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
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('username') username,
|
||||||
@Body('password') password
|
@Body('password') password
|
||||||
): Promise<Responses.Auth.SignupResponse> {
|
): 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');
|
throw new BadRequestException('Username already taken');
|
||||||
await this.authService.signup(username, password);
|
await this.authService.signup(username, password);
|
||||||
return {
|
return {
|
||||||
@ -120,11 +122,31 @@ export default class AuthController {
|
|||||||
|
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
async refresh(@Request() req): Promise<Responses.Auth.RefreshResponse> {
|
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);
|
await this.authService.revoke(req.token);
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
jwt: token
|
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}`
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -47,6 +47,8 @@ export class INode {
|
|||||||
export class User {
|
export class User {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id: number;
|
id: number;
|
||||||
|
@Column({ default: false })
|
||||||
|
isGitlabUser: boolean;
|
||||||
@Column()
|
@Column()
|
||||||
name: string;
|
name: string;
|
||||||
@Column()
|
@Column()
|
||||||
@ -78,6 +80,11 @@ export class User {
|
|||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
tfaSecret: string;
|
tfaSecret: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
gitlabAT: string;
|
||||||
|
@Column({ nullable: true })
|
||||||
|
gitlabRT: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
|
@ -15,6 +15,15 @@ import * as jwt from 'jsonwebtoken';
|
|||||||
import { createTransport } from 'nodemailer';
|
import { createTransport } from 'nodemailer';
|
||||||
import * as notp from 'notp';
|
import * as notp from 'notp';
|
||||||
import { randomBytes } from 'crypto';
|
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';
|
const jwtSecret = 'CUM';
|
||||||
|
|
||||||
@ -35,6 +44,19 @@ interface jwtPayload {
|
|||||||
iat?: number;
|
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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(
|
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({
|
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> {
|
async validateUser(username: string, pass: string): Promise<User | null> {
|
||||||
const user = await this.findUser(username);
|
const user = await this.findUser(username, false);
|
||||||
if (!user)
|
if (!user)
|
||||||
throw new UnauthorizedException('Invalid username or password');
|
throw new UnauthorizedException('Invalid username or password');
|
||||||
if (!(await argon2.verify(user.password, pass)))
|
if (!(await argon2.verify(user.password, pass)))
|
||||||
@ -136,7 +159,11 @@ export class AuthService {
|
|||||||
await this.userRepo.save(user);
|
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();
|
const token = new JWTToken();
|
||||||
token.ownerId = user.id;
|
token.ownerId = user.id;
|
||||||
const db_token = await this.tokenRepo.save(token);
|
const db_token = await this.tokenRepo.save(token);
|
||||||
@ -153,16 +180,31 @@ export class AuthService {
|
|||||||
return jwtToken;
|
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) {
|
async signup(username: string, password: string) {
|
||||||
if (await this.findUser(username))
|
if (await this.findUser(username, false))
|
||||||
throw new BadRequestException('User already exists');
|
throw new BadRequestException('User already exists');
|
||||||
const user = new User();
|
const user = new User();
|
||||||
user.name = username;
|
user.name = username;
|
||||||
user.password = await argon2.hash(password);
|
user.password = await argon2.hash(password);
|
||||||
const dbUser = await this.userRepo.save(user);
|
await this.singupInternal(await this.userRepo.save(user));
|
||||||
const root = await this.fsService.generateRoot(dbUser);
|
|
||||||
dbUser.rootId = root.id;
|
|
||||||
await this.userRepo.save(dbUser);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async revoke(token: JWTToken) {
|
async revoke(token: JWTToken) {
|
||||||
@ -176,6 +218,87 @@ export class AuthService {
|
|||||||
ownerId: user.id
|
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()
|
@Injectable()
|
||||||
@ -218,6 +341,13 @@ export class AuthJwtService extends PassportStrategy(JWTStrategy) {
|
|||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'Invalid token, please log in again'
|
'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
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
req.token = token;
|
req.token = token;
|
||||||
|
13
yarn.lock
13
yarn.lock
@ -1699,6 +1699,14 @@ avvio@^8.1.3:
|
|||||||
debug "^4.0.0"
|
debug "^4.0.0"
|
||||||
fastq "^1.6.1"
|
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:
|
babel-jest@^28.1.3:
|
||||||
version "28.1.3"
|
version "28.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-28.1.3.tgz#c1187258197c099072156a0a121c11ee1e3917d5"
|
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"
|
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787"
|
||||||
integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
|
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:
|
fork-ts-checker-webpack-plugin@7.2.11:
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.11.tgz#aff3febbc11544ba3ad0ae4d5aa4055bd15cd26d"
|
||||||
|
Loading…
Reference in New Issue
Block a user