icynet-auth-server/src/modules/ssr-front-end/settings/settings.controller.ts

335 lines
9.1 KiB
TypeScript

import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Query,
Redirect,
Render,
Req,
Res,
UnauthorizedException,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Throttle } from '@nestjs/throttler';
import { Request, Response } from 'express';
import { unlink } from 'fs/promises';
import { AuditAction } from 'src/modules/objects/audit/audit.enum';
import { AuditService } from 'src/modules/objects/audit/audit.service';
import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service';
import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service';
import { UploadService } from 'src/modules/objects/upload/upload.service';
import { UserTOTPService } from 'src/modules/objects/user-token/user-totp-token.service';
import { UserService } from 'src/modules/objects/user/user.service';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { TokenService } from 'src/modules/utility/services/token.service';
import { SettingsService } from './settings.service';
@Controller('/account')
export class SettingsController {
constructor(
private readonly _service: SettingsService,
private readonly _form: FormUtilityService,
private readonly _upload: UploadService,
private readonly _token: TokenService,
private readonly _user: UserService,
private readonly _totp: UserTOTPService,
private readonly _client: OAuth2ClientService,
private readonly _oaToken: OAuth2TokenService,
private readonly _audit: AuditService,
) {}
@Get()
@Redirect('/account/general')
public redirectGeneral() {
return;
}
@Get('general')
@Render('settings/general')
public general(@Req() req: Request) {
return this._form.populateTemplate(req, { user: req.user });
}
@Post('general')
public async updateDisplayName(
@Req() req: Request,
@Res() res: Response,
@Body() body: { display_name?: string },
) {
try {
const { display_name } = this._form.trimmed(body, ['display_name']);
if (!display_name) {
throw new Error('Display name is required.');
}
if (display_name.length < 3 || display_name.length > 32) {
throw new Error(
'Display name must be between 3 and 32 characters long.',
);
}
req.user.display_name = display_name;
await this._user.updateUser(req.user);
req.flash('message', {
error: false,
text: 'Display name has been changed!',
});
} catch (e: unknown) {
req.flash('message', {
error: true,
text: (e as Error).message,
});
}
res.redirect('/account/general');
}
@Throttle(3, 60)
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
async uploadAvatarFile(
@Req() req: Request,
@UploadedFile() file: Express.Multer.File,
) {
try {
if (!this._token.verifyCSRF(req)) {
throw new BadRequestException('Invalid session. Please try again.');
}
if (!file) {
throw new BadRequestException('Avatar upload failed');
}
const matches = await this._upload.checkImageAspect(file);
if (!matches) {
throw new BadRequestException(
'Avatar should be with a 1:1 aspect ratio.',
);
}
const upload = await this._upload.registerUploadedFile(file, req.user);
await this._user.updateAvatar(req.user, upload);
return {
file: upload.file,
};
} catch (e) {
await unlink(file.path);
throw e;
}
}
@Post('avatar/delete')
public async deleteUserAvatar(@Req() req: Request, @Res() res: Response) {
this._user.deleteAvatar(req.user);
req.flash('message', {
error: false,
text: 'Avatar removed successfully.',
});
res.redirect('/account/general');
}
@Get('oauth2')
@Render('settings/oauth2')
public async authorizations(@Req() req: Request) {
const authorizations = await this._client.getAuthorizations(req.user);
return this._form.populateTemplate(req, { authorizations });
}
@Post('oauth2/revoke/:id')
public async revokeAuthorization(
@Req() req: Request,
@Res() res: Response,
@Param('id') id: number,
) {
const getAuth = await this._client.getAuthorization(req.user, id);
const jsreq =
req.header('content-type').startsWith('application/json') ||
req.header('accept').startsWith('application/json');
if (!getAuth) {
if (jsreq) {
throw new UnauthorizedException(
'Unauthorized or invalid revokation request',
);
}
req.flash('message', {
error: true,
text: 'Unauthorized revokation.',
});
res.redirect('/account/oauth2');
return;
}
await this._oaToken.wipeClientTokens(getAuth.client, req.user);
await this._client.revokeAuthorization(getAuth);
if (jsreq) {
return res.json({ success: true });
}
res.redirect('/account/oauth2');
}
@Get('security')
@Render('settings/security')
public async security(@Req() req: Request) {
const mailSplit = req.user.email.split('@');
const asterisks = ''.padStart(mailSplit[0].substring(1).length, '*');
const emailHint = `${mailSplit[0].substring(0, 1)}${asterisks}@${
mailSplit[1]
}`;
const twofactor = await this._totp.userHasTOTP(req.user);
return this._form.populateTemplate(req, {
user: req.user,
emailHint,
twofactor,
});
}
@Post('security/password')
public async setPassword(
@Req() req: Request,
@Res() res: Response,
@Body()
body: {
password: string;
new_password: string;
password_repeat: string;
},
) {
const { password, new_password, password_repeat } = body;
try {
if (!password || !new_password || !password_repeat) {
throw new Error('Please fill out all of the fields.');
}
if (!(await this._user.comparePasswords(req.user.password, password))) {
throw new Error('Current password is invalid.');
}
if (!new_password.match(this._form.passwordRegex)) {
throw new Error(
'Password must be at least 8 characters long, contain a capital and lowercase letter and a number',
);
}
if (new_password !== password_repeat) {
throw new Error('The passwords do not match.');
}
} catch (e: unknown) {
req.flash('message', {
error: true,
text: (e as Error).message,
});
res.redirect('/account/security');
return;
}
const newPassword = await this._user.hashPassword(new_password);
req.user.password = newPassword;
await this._user.updateUser(req.user);
await this._audit.auditRequest(
req,
AuditAction.PASSWORD_CHANGE,
'settings',
);
req.flash('message', {
error: false,
text: 'Password changed successfully.',
});
res.redirect('/account/security');
}
@Post('security/email')
public async setEmail(
@Req() req: Request,
@Res() res: Response,
@Body()
body: {
current_email: string;
current_password: string;
email: string;
},
) {
const { current_email, current_password, email } = body;
try {
if (!current_email || !email) {
throw new Error('Please fill out all of the fields.');
}
if (current_email !== req.user.email) {
throw new Error('The current email address is invalid.');
}
if (!email.match(this._form.emailRegex)) {
throw new Error('The new email address is invalid.');
}
if (
!current_password ||
!(await this._user.comparePasswords(
req.user.password,
current_password,
))
) {
throw new Error('Current password is invalid.');
}
const existing = await this._user.getByEmail(email);
if (existing) {
throw new Error(
'There is already an existing user with this email address.',
);
}
} catch (e: unknown) {
req.flash('message', {
error: true,
text: (e as Error).message,
});
res.redirect('/account/security');
return;
}
req.user.email = email;
await this._user.updateUser(req.user);
await this._audit.auditRequest(req, AuditAction.EMAIL_CHANGE, 'settings');
req.flash('message', {
error: false,
text: 'Email address changed successfully.',
});
res.redirect('/account/security');
}
@Get('logout')
public logOut(
@Req() req: Request,
@Res() res: Response,
@Query('csrf') csrf: string,
) {
if (!this._token.verifyCSRF(req, csrf)) {
throw new BadRequestException('Invalid csrf token');
}
req.session.destroy(() => res.redirect('/login'));
}
@Get('logins')
@Render('login-list')
public async userLogins(@Req() req: Request) {
const logins = await this._audit.getUserLogins(req.user, req.session.id);
const creation = await this._audit.getUserAccountCreation(req.user);
return this._form.populateTemplate(req, {
logins,
creation,
});
}
}