Merge branch 'user-info' into 'main'

Add user info/profile page

See merge request root/fileserver!5
This commit is contained in:
Mutzi 2022-08-25 16:24:03 +00:00
commit 762c1c84c9
20 changed files with 460 additions and 50 deletions

View File

@ -23,14 +23,28 @@ export class LoginRequest extends SignUpRequest {
otp?: string; otp?: string;
} }
export class TfaComplete extends BaseRequest {
@IsNotEmpty()
@IsString()
code: string;
}
export class TfaSetup extends BaseRequest { export class TfaSetup extends BaseRequest {
@IsNotEmpty() @IsNotEmpty()
@IsBoolean() @IsBoolean()
mail: boolean; mail: boolean;
} }
export class TfaComplete extends BaseRequest {
@IsNotEmpty()
@IsBoolean()
mail: boolean;
@IsNotEmpty()
@IsString()
code: string;
}
export class ChangePasswordRequest extends BaseRequest {
@IsNotEmpty()
@IsString()
oldPassword: string;
@IsNotEmpty()
@IsString()
newPassword: string;
}

View File

@ -35,4 +35,6 @@ export class RemoveTfaResponse extends SuccessResponse {}
export class RequestEmailTfaResponse extends SuccessResponse {} export class RequestEmailTfaResponse extends SuccessResponse {}
export class TfaCompletedResponse extends SuccessResponse {} export class TfaCompletedResponse extends SuccessResponse {}
export class SignupResponse extends SuccessResponse {} export class SignupResponse extends SuccessResponse {}
export class ChangePasswordResponse extends SuccessResponse {}
export class LogoutAllResponse extends SuccessResponse {}
export class RefreshResponse extends LoginResponse {} export class RefreshResponse extends LoginResponse {}

View File

@ -1,3 +1,4 @@
export * from './base'; export * from './base';
export * as Auth from './auth'; export * as Auth from './auth';
export * as FS from './fs'; export * as FS from './fs';
export * as User from './user';

27
dto/src/responses/user.ts Normal file
View File

@ -0,0 +1,27 @@
import { SuccessResponse } from './base';
import { ValidateConstructor } from '../utils';
import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
@ValidateConstructor
export class UserInfoResponse extends SuccessResponse {
constructor(name: string, gitlab: boolean, tfaEnabled: boolean) {
super();
this.name = name;
this.gitlab = gitlab;
this.tfaEnabled = tfaEnabled;
}
@IsNotEmpty()
@IsString()
name: string;
@IsBoolean()
gitlab: boolean;
@IsBoolean()
tfaEnabled: boolean;
}
export class DeleteUserResponse extends SuccessResponse {}
export class ChangePasswordResponse extends SuccessResponse {}
export class LogoutAllResponse extends SuccessResponse {}

View File

@ -27,9 +27,13 @@ provide<TokenInjectType>('jwt', {
<template> <template>
<nav> <nav>
<router-link to="/login" v-if="jwt != null" @click="logout()"> <template v-if="jwt != null">
Logout <router-link to="/">Files</router-link>
</router-link> <span style="margin-left: 2em" />
<router-link to="/profile">Profile</router-link>
<span style="margin-left: 2em" />
<router-link to="/login" @click="logout()">Logout</router-link>
</template>
</nav> </nav>
<router-view /> <router-view />
</template> </template>

View File

@ -28,3 +28,66 @@ export const refresh_token = (
token: string token: string
): Promise<Responses.Auth.RefreshResponse | Responses.ErrorResponse> => ): Promise<Responses.Auth.RefreshResponse | Responses.ErrorResponse> =>
post_token('/api/auth/refresh', {}, token); post_token('/api/auth/refresh', {}, token);
export const change_password = (
oldPw: string,
newPw: string,
token: string
): Promise<Responses.Auth.ChangePasswordResponse | Responses.ErrorResponse> =>
post_token<Requests.Auth.ChangePasswordRequest>(
'/api/auth/change_password',
{
oldPassword: oldPw,
newPassword: newPw
},
token
);
export const logout_all = (
token: string
): Promise<Responses.Auth.LogoutAllResponse | Responses.ErrorResponse> =>
post_token('/api/auth/logout_all', {}, token);
export function tfa_setup(
mail: false,
token: string
): Promise<Responses.Auth.RequestTotpTfaResponse | Responses.ErrorResponse>;
export function tfa_setup(
mail: true,
token: string
): Promise<Responses.Auth.RequestEmailTfaResponse | Responses.ErrorResponse>;
export function tfa_setup(
mail: boolean,
token: string
): Promise<
| Responses.Auth.RequestEmailTfaResponse
| Responses.Auth.RequestTotpTfaResponse
| Responses.ErrorResponse
> {
return post_token<Requests.Auth.TfaSetup>(
'/api/auth/2fa/setup',
{
mail
},
token
);
}
export const tfa_complete = (
mail: boolean,
code: string,
token: string
): Promise<Responses.Auth.TfaCompletedResponse | Responses.ErrorResponse> =>
post_token<Requests.Auth.TfaComplete>(
'/api/auth/2fa/complete',
{
mail,
code
},
token
);
export const tfa_disable = (
token: string
): Promise<Responses.Auth.RemoveTfaResponse | Responses.ErrorResponse> =>
post_token('/api/auth/2fa/disable', {}, token);

View File

@ -3,4 +3,5 @@ export { Requests, Responses } from 'dto';
export { isErrorResponse } from './base'; export { isErrorResponse } from './base';
export * as Auth from './auth'; export * as Auth from './auth';
export * as FS from './fs'; export * as FS from './fs';
export * as User from './user';
export * from './util'; export * from './util';

12
frontend/src/api/user.ts Normal file
View File

@ -0,0 +1,12 @@
import { get_token, post_token } from '@/api/base';
import { Responses } from 'dto';
export const get_user_info = (
token: string
): Promise<Responses.User.UserInfoResponse | Responses.ErrorResponse> =>
get_token('/api/user/info', token);
export const delete_user = (
token: string
): Promise<Responses.User.DeleteUserResponse | Responses.ErrorResponse> =>
post_token('/api/user/delete', {}, token);

View File

@ -5,6 +5,8 @@ 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'; import SetTokenView from '@/views/SetTokenView.vue';
import ProfileView from '@/views/ProfileView.vue';
import TFAView from '@/views/TFAView.vue';
const routes: Array<RouteRecordRaw> = [ const routes: Array<RouteRecordRaw> = [
{ {
@ -12,9 +14,18 @@ const routes: Array<RouteRecordRaw> = [
name: 'home', name: 'home',
component: HomeView component: HomeView
}, },
{
path: '/profile',
name: 'profile',
component: ProfileView
},
{
path: '/profile/2fa-enable',
name: '2fa',
component: TFAView
},
{ {
path: '/about', path: '/about',
name: 'about',
component: AboutView component: AboutView
}, },
{ {
@ -27,14 +38,15 @@ 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',
component: FSView component: FSView
},
{
path: '/set_token',
component: SetTokenView
} }
]; ];

View File

@ -13,7 +13,7 @@ async function start_redirect() {
if (!token) return; if (!token) return;
const root = await FS.get_root(token); const root = await FS.get_root(token);
if (isErrorResponse(root)) return jwt.logout(); if (isErrorResponse(root)) return jwt.logout();
await router.push({ await router.replace({
name: 'fs', name: 'fs',
params: { node_id: root.rootId } params: { node_id: root.rootId }
}); });

View File

@ -48,14 +48,14 @@ async function login() {
<template v-if="!requestOtp"> <template v-if="!requestOtp">
<input type="email" placeholder="Email" v-model="username" /> <input type="email" placeholder="Email" v-model="username" />
<input type="password" placeholder="Password" v-model="password" /> <input type="password" placeholder="Password" v-model="password" />
<a href="/api/auth/gitlab">Login with gitlab</a>
<router-link to="signup">Signup instead?</router-link>
</template> </template>
<template v-else> <template v-else>
<div>Please input your 2 factor authentication code</div> <div>Please input your 2 factor authentication code</div>
<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>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, inject, onBeforeMount } from 'vue';
import {
Auth,
User,
check_token,
isErrorResponse,
TokenInjectType,
Responses
} from '@/api';
import { onBeforeRouteUpdate } from 'vue-router';
const error = ref('');
const oldPw = ref('');
const newPw = ref('');
const newPw2 = ref('');
const user = ref<Responses.User.UserInfoResponse | null>(null);
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
onBeforeRouteUpdate(async () => {
await updateProfile();
});
onBeforeMount(async () => {
await updateProfile();
});
async function updateProfile() {
const token = await check_token(jwt);
if (!token) return;
const res = await User.get_user_info(token);
if (isErrorResponse(res)) return jwt.logout();
user.value = res;
}
async function deleteUser() {
const token = await check_token(jwt);
if (!token) return;
await User.delete_user(token);
jwt.logout();
}
async function logoutAll() {
const token = await check_token(jwt);
if (!token) return;
await Auth.logout_all(token);
jwt.logout();
}
async function changePw() {
if (oldPw.value === '' || newPw.value === '' || newPw2.value === '') {
error.value = 'Password missing';
return;
}
if (newPw.value !== newPw2.value) {
error.value = "Passwords don't match";
return;
}
const token = await check_token(jwt);
if (!token) return;
const res = await Auth.change_password(oldPw.value, newPw.value, token);
if (isErrorResponse(res))
error.value = 'Password change failed: ' + res.message;
else jwt.logout();
}
async function tfaDisable() {
const token = await check_token(jwt);
if (!token) return;
await Auth.tfa_disable(token);
jwt.logout();
}
</script>
<template>
<template v-if="user">
<div v-if="error !== ''" v-text="error"></div>
<div>User: {{ user.name }}</div>
<div>Signed in with {{ user.gitlab ? 'gitlab' : 'password' }}</div>
<template v-if="!user.gitlab">
<div>
<input
type="password"
placeholder="Old password"
v-model="oldPw"
/>
<input
type="password"
placeholder="New password"
v-model="newPw"
/>
<input
type="password"
placeholder="Repeat new password"
v-model="newPw2"
/>
<button @click="changePw">Change</button>
</div>
<div>
<div>
2 Factor authentication:
{{ user.tfaEnabled ? 'Enabled' : 'Disabled' }}
</div>
<div>
<a href="#" v-if="user.tfaEnabled" @click="tfaDisable">
Disable
</a>
<router-link to="/profile/2fa-enable" v-else>
Enable
</router-link>
</div>
</div>
</template>
<div>
<a href="#" @click="logoutAll">Logout everywhere</a>
<a href="#" @click="deleteUser">Delete Account</a>
</div>
</template>
<template v-else>
<div>Loading...</div>
</template>
</template>
<style scoped></style>

View File

@ -2,9 +2,9 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { Auth, isErrorResponse } from '@/api'; import { Auth, isErrorResponse } from '@/api';
let username = ref(''); const username = ref('');
let password = ref(''); const password = ref('');
let password2 = ref(''); const password2 = ref('');
const error = ref(''); const error = ref('');
async function signup() { async function signup() {

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import { ref, inject } from 'vue';
import { Auth, check_token, isErrorResponse, TokenInjectType } from '@/api';
enum state {
SELECT,
MAIL,
TOTP
}
const currentState = ref<state>(state.SELECT);
const error = ref('');
const qrImage = ref('');
const secret = ref('');
const code = ref('');
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
async function selectMail() {
const token = await check_token(jwt);
if (!token) return;
error.value = 'Working...';
const res = await Auth.tfa_setup(true, token);
if (isErrorResponse(res))
error.value = 'Failed to select 2fa type: ' + res.message;
else {
error.value = '';
currentState.value = state.MAIL;
}
}
async function selectTotp() {
const token = await check_token(jwt);
if (!token) return;
error.value = 'Working...';
const res = await Auth.tfa_setup(false, token);
if (isErrorResponse(res))
error.value = 'Failed to select 2fa type: ' + res.message;
else {
qrImage.value = res.qrCode;
secret.value = res.secret;
error.value = '';
currentState.value = state.TOTP;
}
}
async function submit() {
const token = await check_token(jwt);
if (!token) return;
error.value = 'Working...';
const res = await Auth.tfa_complete(
currentState.value === state.MAIL,
code.value,
token
);
if (isErrorResponse(res))
error.value = 'Failed to submit code: ' + res.message;
else jwt.logout();
}
</script>
<template>
<div v-if="error !== ''" v-text="error"></div>
<template v-if="currentState === state.SELECT">
<div>Select 2 Factor authentication type:</div>
<div>
<button @click="selectMail">Mail</button>
<button @click="selectTotp">Google Authenticator</button>
</div>
</template>
<template v-else-if="currentState === state.MAIL">
<div>Please enter the code you got by mail</div>
<input type="text" placeholder="Code" v-model="code" />
<button @click="submit()">Submit</button>
</template>
<template v-else>
<img :src="qrImage" alt="QrCode" />
<details>
<summary>Show manual input code</summary>
{{ secret }}
</details>
<div>Please enter the current code</div>
<input type="text" placeholder="Code" v-model="code" />
<button @click="submit()">Submit</button>
</template>
</template>
<style scoped></style>

View File

@ -18,7 +18,9 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"webpack": "webpack --config webpack.config.ts", "webpack": "webpack --config webpack.config.ts",
"genapi": "ts-node tools/apigen.ts" "genapi": "ts-node tools/apigen.ts",
"updateDto": "cd dto && yarn build && cd .. && yarn add ./dto && cd frontend && yarn add ../dto",
"lint-fix-all": "yarn lint-fix && cd dto && yarn lint-fix && cd ../frontend && yarn lint --fix"
}, },
"dependencies": { "dependencies": {
"@fastify/multipart": "^7.1.0", "@fastify/multipart": "^7.1.0",

View File

@ -15,7 +15,7 @@ import {
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Public } from '../authguards'; import { Public } from '../authguards';
import { Responses, Requests } from 'dto'; import { Requests, Responses } from 'dto';
import { tfaTypes } from '../entities'; import { tfaTypes } from '../entities';
import { toDataURL } from 'qrcode'; import { toDataURL } from 'qrcode';
import * as base32 from 'thirty-two'; import * as base32 from 'thirty-two';
@ -48,12 +48,22 @@ export default class AuthController {
); );
} }
async tfa( @Post('2fa/disable')
req, async tfaDisable(
code: string, @Request() req
type: tfaTypes ): Promise<Responses.Auth.RemoveTfaResponse> {
await this.authService.setTfaType(req.user, tfaTypes.NONE);
await this.authService.revokeAll(req.user);
return new Responses.Auth.RemoveTfaResponse();
}
@Post('2fa/complete')
async tfaMail(
@Request() req,
@Body(new ValidationPipe()) data: Requests.Auth.TfaComplete
): Promise<Responses.Auth.TfaCompletedResponse> { ): Promise<Responses.Auth.TfaCompletedResponse> {
if (!(await this.authService.verifyTfa(req.user, code, type))) { const type = data.mail ? tfaTypes.EMAIL : tfaTypes.TOTP;
if (!(await this.authService.verifyTfa(req.user, data.code, type))) {
throw new UnauthorizedException('Incorrect 2fa'); throw new UnauthorizedException('Incorrect 2fa');
} }
await this.authService.setTfaType(req.user, type); await this.authService.setTfaType(req.user, type);
@ -61,23 +71,7 @@ export default class AuthController {
return new Responses.Auth.TfaCompletedResponse(); return new Responses.Auth.TfaCompletedResponse();
} }
@Post('2fa/complete/mail') @Post('2fa/setup')
async tfaMail(
@Request() req,
@Body(new ValidationPipe()) data: Requests.Auth.TfaComplete
): Promise<Responses.Auth.TfaCompletedResponse> {
return await this.tfa(req, data.code, tfaTypes.EMAIL);
}
@Post('2fa/complete/totp')
async tfaTotp(
@Request() req,
@Body(new ValidationPipe()) data: Requests.Auth.TfaComplete
): Promise<Responses.Auth.TfaCompletedResponse> {
return await this.tfa(req, data.code, tfaTypes.TOTP);
}
@Get('2fa/setup')
async setupTotp( async setupTotp(
@Request() req, @Request() req,
@Body(new ValidationPipe()) data: Requests.Auth.TfaSetup @Body(new ValidationPipe()) data: Requests.Auth.TfaSetup
@ -93,7 +87,7 @@ export default class AuthController {
.encode(secret) .encode(secret)
.toString()}&issuer=MFileserver` .toString()}&issuer=MFileserver`
), ),
secret base32.encode(secret).toString()
); );
} }
@ -134,4 +128,23 @@ export default class AuthController {
url: `/set_token?token=${token}` url: `/set_token?token=${token}`
}; };
} }
@Post('change_password')
async changePassword(
@Request() req,
@Body(new ValidationPipe()) data: Requests.Auth.ChangePasswordRequest
): Promise<Responses.Auth.ChangePasswordResponse> {
await this.authService.changePassword(
req.user,
data.oldPassword,
data.newPassword
);
return new Responses.Auth.ChangePasswordResponse();
}
@Post('logout_all')
async logoutAll(@Request() req): Promise<Responses.Auth.LogoutAllResponse> {
await this.authService.revokeAll(req.user);
return new Responses.Auth.LogoutAllResponse();
}
} }

27
src/controller/user.ts Normal file
View File

@ -0,0 +1,27 @@
import { Controller, Get, Post, Request } from '@nestjs/common';
import { AuthService } from '../services/auth';
import { Responses } from 'dto';
@Controller('api/user')
export default class UserController {
constructor(private authService: AuthService) {}
@Get('info')
async getUserInfo(
@Request() req
): Promise<Responses.User.UserInfoResponse> {
return new Responses.User.UserInfoResponse(
req.user.name,
req.user.isGitlabUser,
this.authService.requiresTfa(req.user)
);
}
@Post('delete')
async deleteUser(
@Request() req
): Promise<Responses.User.DeleteUserResponse> {
await this.authService.deleteUser(req.user);
return new Responses.User.DeleteUserResponse();
}
}

View File

@ -6,8 +6,9 @@ import {
AuthLocalService, AuthLocalService,
AuthJwtService AuthJwtService
} from '../services/auth'; } from '../services/auth';
import AuthController from '../controller/auth';
import FileSystemService from '../services/filesystem'; import FileSystemService from '../services/filesystem';
import AuthController from '../controller/auth';
import UserController from '../controller/user';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User, INode, JWTToken])], imports: [TypeOrmModule.forFeature([User, INode, JWTToken])],
@ -17,6 +18,6 @@ import FileSystemService from '../services/filesystem';
AuthJwtService, AuthJwtService,
FileSystemService FileSystemService
], ],
controllers: [AuthController] controllers: [AuthController, UserController]
}) })
export default class AuthModule {} export default class AuthModule {}

View File

@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
UnauthorizedException UnauthorizedException
} from '@nestjs/common'; } from '@nestjs/common';
@ -312,6 +313,23 @@ export class AuthService {
} }
return info && info.username == user.name; return info && info.username == user.name;
} }
async deleteUser(user: User): Promise<void> {
await this.revokeAll(user);
await this.fsService.delete(await user.root, true);
await this.userRepo.remove(user);
}
async changePassword(
user: User,
oldPW: string,
newPw: string
): Promise<void> {
if (!(await argon2.verify(user.password, oldPW)))
throw new ForbiddenException('Old password is wrong');
user.password = await argon2.hash(newPw);
await this.revokeAll(await this.userRepo.save(user));
}
} }
@Injectable() @Injectable()

View File

@ -51,12 +51,12 @@ export default class FileSystemService {
); );
} }
async delete(node: INode): Promise<void> { async delete(node: INode, force = false): Promise<void> {
if (node.parentId == null) if (node.parentId == null || force)
throw new BadRequestException("Can't delete root"); throw new BadRequestException("Can't delete root");
if (!node.isFile) if (!node.isFile)
await Promise.all( await Promise.all(
(await node.children).map((child) => this.delete(child)) (await node.children).map((child) => this.delete(child, force))
); );
else else
unlink(`files/${node.id}`, (err) => { unlink(`files/${node.id}`, (err) => {