diff --git a/dto/src/requests/auth.ts b/dto/src/requests/auth.ts index 5526e9d..50ed258 100644 --- a/dto/src/requests/auth.ts +++ b/dto/src/requests/auth.ts @@ -23,14 +23,28 @@ export class LoginRequest extends SignUpRequest { otp?: string; } -export class TfaComplete extends BaseRequest { - @IsNotEmpty() - @IsString() - code: string; -} - export class TfaSetup extends BaseRequest { @IsNotEmpty() @IsBoolean() 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; +} diff --git a/dto/src/responses/auth.ts b/dto/src/responses/auth.ts index e9177cd..76de70b 100644 --- a/dto/src/responses/auth.ts +++ b/dto/src/responses/auth.ts @@ -35,4 +35,6 @@ export class RemoveTfaResponse extends SuccessResponse {} export class RequestEmailTfaResponse extends SuccessResponse {} export class TfaCompletedResponse extends SuccessResponse {} export class SignupResponse extends SuccessResponse {} +export class ChangePasswordResponse extends SuccessResponse {} +export class LogoutAllResponse extends SuccessResponse {} export class RefreshResponse extends LoginResponse {} diff --git a/dto/src/responses/index.ts b/dto/src/responses/index.ts index 8f26264..c1d0397 100644 --- a/dto/src/responses/index.ts +++ b/dto/src/responses/index.ts @@ -1,3 +1,4 @@ export * from './base'; export * as Auth from './auth'; export * as FS from './fs'; +export * as User from './user'; diff --git a/dto/src/responses/user.ts b/dto/src/responses/user.ts new file mode 100644 index 0000000..706526b --- /dev/null +++ b/dto/src/responses/user.ts @@ -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 {} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 924349a..089e1c2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -27,9 +27,13 @@ provide('jwt', { diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index a2fea85..c4d9afd 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -28,3 +28,66 @@ export const refresh_token = ( token: string ): Promise => post_token('/api/auth/refresh', {}, token); + +export const change_password = ( + oldPw: string, + newPw: string, + token: string +): Promise => + post_token( + '/api/auth/change_password', + { + oldPassword: oldPw, + newPassword: newPw + }, + token + ); + +export const logout_all = ( + token: string +): Promise => + post_token('/api/auth/logout_all', {}, token); + +export function tfa_setup( + mail: false, + token: string +): Promise; +export function tfa_setup( + mail: true, + token: string +): Promise; +export function tfa_setup( + mail: boolean, + token: string +): Promise< + | Responses.Auth.RequestEmailTfaResponse + | Responses.Auth.RequestTotpTfaResponse + | Responses.ErrorResponse +> { + return post_token( + '/api/auth/2fa/setup', + { + mail + }, + token + ); +} + +export const tfa_complete = ( + mail: boolean, + code: string, + token: string +): Promise => + post_token( + '/api/auth/2fa/complete', + { + mail, + code + }, + token + ); + +export const tfa_disable = ( + token: string +): Promise => + post_token('/api/auth/2fa/disable', {}, token); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4790bba..29c0f1d 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -3,4 +3,5 @@ export { Requests, Responses } from 'dto'; export { isErrorResponse } from './base'; export * as Auth from './auth'; export * as FS from './fs'; +export * as User from './user'; export * from './util'; diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts new file mode 100644 index 0000000..af40f3b --- /dev/null +++ b/frontend/src/api/user.ts @@ -0,0 +1,12 @@ +import { get_token, post_token } from '@/api/base'; +import { Responses } from 'dto'; + +export const get_user_info = ( + token: string +): Promise => + get_token('/api/user/info', token); + +export const delete_user = ( + token: string +): Promise => + post_token('/api/user/delete', {}, token); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 12066ff..f006059 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -5,6 +5,8 @@ import HomeView from '@/views/HomeView.vue'; import AboutView from '@/views/AboutView.vue'; import FSView from '@/views/FSView.vue'; import SetTokenView from '@/views/SetTokenView.vue'; +import ProfileView from '@/views/ProfileView.vue'; +import TFAView from '@/views/TFAView.vue'; const routes: Array = [ { @@ -12,9 +14,18 @@ const routes: Array = [ name: 'home', component: HomeView }, + { + path: '/profile', + name: 'profile', + component: ProfileView + }, + { + path: '/profile/2fa-enable', + name: '2fa', + component: TFAView + }, { path: '/about', - name: 'about', component: AboutView }, { @@ -27,14 +38,15 @@ const routes: Array = [ name: 'signup', component: SignupView }, - { - path: '/set_token', - component: SetTokenView - }, { path: '/fs/:node_id', name: 'fs', component: FSView + }, + + { + path: '/set_token', + component: SetTokenView } ]; diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 090de60..0140b62 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -13,7 +13,7 @@ async function start_redirect() { if (!token) return; const root = await FS.get_root(token); if (isErrorResponse(root)) return jwt.logout(); - await router.push({ + await router.replace({ name: 'fs', params: { node_id: root.rootId } }); diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 22c0eec..be6a3d0 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -48,14 +48,14 @@ async function login() { - Login with gitlab - Signup instead? diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue new file mode 100644 index 0000000..f4cb406 --- /dev/null +++ b/frontend/src/views/ProfileView.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/src/views/SignupView.vue b/frontend/src/views/SignupView.vue index 438ef63..34491f9 100644 --- a/frontend/src/views/SignupView.vue +++ b/frontend/src/views/SignupView.vue @@ -2,9 +2,9 @@ import { ref } from 'vue'; import { Auth, isErrorResponse } from '@/api'; -let username = ref(''); -let password = ref(''); -let password2 = ref(''); +const username = ref(''); +const password = ref(''); +const password2 = ref(''); const error = ref(''); async function signup() { diff --git a/frontend/src/views/TFAView.vue b/frontend/src/views/TFAView.vue new file mode 100644 index 0000000..cf7582b --- /dev/null +++ b/frontend/src/views/TFAView.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/package.json b/package.json index 558f98f..a240b92 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "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", "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": { "@fastify/multipart": "^7.1.0", diff --git a/src/controller/auth.ts b/src/controller/auth.ts index 756a9a7..19b74cf 100644 --- a/src/controller/auth.ts +++ b/src/controller/auth.ts @@ -15,7 +15,7 @@ import { import { AuthService } from '../services/auth'; import { AuthGuard } from '@nestjs/passport'; import { Public } from '../authguards'; -import { Responses, Requests } from 'dto'; +import { Requests, Responses } from 'dto'; import { tfaTypes } from '../entities'; import { toDataURL } from 'qrcode'; import * as base32 from 'thirty-two'; @@ -48,12 +48,22 @@ export default class AuthController { ); } - async tfa( - req, - code: string, - type: tfaTypes + @Post('2fa/disable') + async tfaDisable( + @Request() req + ): Promise { + 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 { - 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'); } await this.authService.setTfaType(req.user, type); @@ -61,23 +71,7 @@ export default class AuthController { return new Responses.Auth.TfaCompletedResponse(); } - @Post('2fa/complete/mail') - async tfaMail( - @Request() req, - @Body(new ValidationPipe()) data: Requests.Auth.TfaComplete - ): Promise { - 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 { - return await this.tfa(req, data.code, tfaTypes.TOTP); - } - - @Get('2fa/setup') + @Post('2fa/setup') async setupTotp( @Request() req, @Body(new ValidationPipe()) data: Requests.Auth.TfaSetup @@ -93,7 +87,7 @@ export default class AuthController { .encode(secret) .toString()}&issuer=MFileserver` ), - secret + base32.encode(secret).toString() ); } @@ -134,4 +128,23 @@ export default class AuthController { url: `/set_token?token=${token}` }; } + + @Post('change_password') + async changePassword( + @Request() req, + @Body(new ValidationPipe()) data: Requests.Auth.ChangePasswordRequest + ): Promise { + await this.authService.changePassword( + req.user, + data.oldPassword, + data.newPassword + ); + return new Responses.Auth.ChangePasswordResponse(); + } + + @Post('logout_all') + async logoutAll(@Request() req): Promise { + await this.authService.revokeAll(req.user); + return new Responses.Auth.LogoutAllResponse(); + } } diff --git a/src/controller/user.ts b/src/controller/user.ts new file mode 100644 index 0000000..9f674a2 --- /dev/null +++ b/src/controller/user.ts @@ -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 { + return new Responses.User.UserInfoResponse( + req.user.name, + req.user.isGitlabUser, + this.authService.requiresTfa(req.user) + ); + } + + @Post('delete') + async deleteUser( + @Request() req + ): Promise { + await this.authService.deleteUser(req.user); + return new Responses.User.DeleteUserResponse(); + } +} diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 82d5743..d35540d 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -6,8 +6,9 @@ import { AuthLocalService, AuthJwtService } from '../services/auth'; -import AuthController from '../controller/auth'; import FileSystemService from '../services/filesystem'; +import AuthController from '../controller/auth'; +import UserController from '../controller/user'; @Module({ imports: [TypeOrmModule.forFeature([User, INode, JWTToken])], @@ -17,6 +18,6 @@ import FileSystemService from '../services/filesystem'; AuthJwtService, FileSystemService ], - controllers: [AuthController] + controllers: [AuthController, UserController] }) export default class AuthModule {} diff --git a/src/services/auth.ts b/src/services/auth.ts index 0362e06..224edcb 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; @@ -312,6 +313,23 @@ export class AuthService { } return info && info.username == user.name; } + + async deleteUser(user: User): Promise { + 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 { + 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() diff --git a/src/services/filesystem.ts b/src/services/filesystem.ts index a74f756..16c3a28 100644 --- a/src/services/filesystem.ts +++ b/src/services/filesystem.ts @@ -51,12 +51,12 @@ export default class FileSystemService { ); } - async delete(node: INode): Promise { - if (node.parentId == null) + async delete(node: INode, force = false): Promise { + if (node.parentId == null || force) throw new BadRequestException("Can't delete root"); if (!node.isFile) await Promise.all( - (await node.children).map((child) => this.delete(child)) + (await node.children).map((child) => this.delete(child, force)) ); else unlink(`files/${node.id}`, (err) => {