From 02689f93d1b6f0c1928992745b73a47d96297d3c Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Wed, 13 Mar 2024 22:18:57 +0200 Subject: [PATCH] add invitations --- .adminjs/bundle.js | 7 ++ .adminjs/entry.js | 1 + src/guards/login-antispam.guard.ts | 6 +- .../api/admin/user-admin.controller.ts | 13 +++ .../objects/user/email/invitation.email.ts | 22 ++++ .../objects/user/email/registration.email.ts | 4 + src/modules/objects/user/user.module.ts | 2 + src/modules/objects/user/user.service.ts | 107 ++++++++++++++++-- .../register/register.controller.ts | 79 +++++++++++-- 9 files changed, 220 insertions(+), 21 deletions(-) create mode 100644 .adminjs/bundle.js create mode 100644 .adminjs/entry.js create mode 100644 src/modules/objects/user/email/invitation.email.ts diff --git a/.adminjs/bundle.js b/.adminjs/bundle.js new file mode 100644 index 0000000..014b9c3 --- /dev/null +++ b/.adminjs/bundle.js @@ -0,0 +1,7 @@ +(function () { + 'use strict'; + + AdminJS.UserComponents = {}; + +})(); +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVuZGxlLmpzIiwic291cmNlcyI6WyJlbnRyeS5qcyJdLCJzb3VyY2VzQ29udGVudCI6WyJBZG1pbkpTLlVzZXJDb21wb25lbnRzID0ge31cbiJdLCJuYW1lcyI6WyJBZG1pbkpTIiwiVXNlckNvbXBvbmVudHMiXSwibWFwcGluZ3MiOiI7OztDQUFBQSxPQUFPLENBQUNDLGNBQWMsR0FBRyxFQUFFOzs7Ozs7In0= diff --git a/.adminjs/entry.js b/.adminjs/entry.js new file mode 100644 index 0000000..c9edeb9 --- /dev/null +++ b/.adminjs/entry.js @@ -0,0 +1 @@ +AdminJS.UserComponents = {} diff --git a/src/guards/login-antispam.guard.ts b/src/guards/login-antispam.guard.ts index 1346784..2f179df 100644 --- a/src/guards/login-antispam.guard.ts +++ b/src/guards/login-antispam.guard.ts @@ -22,8 +22,8 @@ export class LoginAntispamGuard implements CanActivate { if (['GET', 'OPTIONS'].includes(request.method)) return true; const known = await this.iplimit.getAddressLimit(request.ip); - if (known && known.attempts > 3) { - if (known.attempts > 5) { + if (known && known.attempts > 10) { + if (known.attempts > 15) { let reported = false; if (!known.reported) { reported = true; @@ -36,7 +36,7 @@ export class LoginAntispamGuard implements CanActivate { ); } - const limitMinutes = known.attempts > 10 ? 30 : 10; // Half-Hour + const limitMinutes = known.attempts > 15 ? 30 : 10; // Half-Hour await this.iplimit.limitUntil( request.ip, limitMinutes * 60 * 1000, diff --git a/src/modules/api/admin/user-admin.controller.ts b/src/modules/api/admin/user-admin.controller.ts index 4f0270f..81d3f28 100644 --- a/src/modules/api/admin/user-admin.controller.ts +++ b/src/modules/api/admin/user-admin.controller.ts @@ -67,6 +67,19 @@ export class UserAdminController { }; } + /** + * Create a registraion invitation and send email. + * @param body + * @returns Success + */ + @Post('invite') + @Scopes('management') + @Privileges('admin', 'admin:user') + async invite(@Body() { email }: Pick) { + await this._user.issueRegistrationToken(email); + return { success: true }; + } + /** * Get a single user by ID * @param id User ID diff --git a/src/modules/objects/user/email/invitation.email.ts b/src/modules/objects/user/email/invitation.email.ts new file mode 100644 index 0000000..a0f181e --- /dev/null +++ b/src/modules/objects/user/email/invitation.email.ts @@ -0,0 +1,22 @@ +import { EmailTemplate } from 'src/modules/objects/email/email.template'; + +export const InvitationEmail = (url: string): EmailTemplate => ({ + text: ` +Icy Network + +Please click on the following link to create an account on Icy Network. + +Create your account here: ${url} + +This email was sent to you because you have requested an account on Icy Network. If you did not request this, you may safely ignore this email. + `, + html: /* html */ ` +

Icy Network

+ +

Please click on the following link to create an account on Icy Network.

+ +

Create your account here: ${url}

+ +

This email was sent to you because you have requested an account on Icy Network. If you did not request this, you may safely ignore this email.

+ `, +}); diff --git a/src/modules/objects/user/email/registration.email.ts b/src/modules/objects/user/email/registration.email.ts index 3e0a280..2bc344c 100644 --- a/src/modules/objects/user/email/registration.email.ts +++ b/src/modules/objects/user/email/registration.email.ts @@ -12,6 +12,8 @@ Welcome to Icy Network, ${username}! In order to proceed with logging in, please click on the following link to activate your account. Activate your account: ${url} + +This email was sent to you because you have created an account on Icy Network. If you did not create an account, you may contact us or just let the account expire. `, html: /* html */ `

Icy Network

@@ -21,5 +23,7 @@ Activate your account: ${url}

In order to proceed with logging in, please click on the following link to activate your account.

Activate your account: ${url}

+ +

This email was sent to you because you have created an account on Icy Network. If you did not create an account, you may contact us or just let the account expire.

`, }); diff --git a/src/modules/objects/user/user.module.ts b/src/modules/objects/user/user.module.ts index 75e826f..0a628a2 100644 --- a/src/modules/objects/user/user.module.ts +++ b/src/modules/objects/user/user.module.ts @@ -6,9 +6,11 @@ import { UploadModule } from '../upload/upload.module'; import { UserTokenModule } from '../user-token/user-token.module'; import { userProviders } from './user.providers'; import { UserService } from './user.service'; +import { CommonCacheModule } from 'src/modules/cache/cache.module'; @Module({ imports: [ + CommonCacheModule, DatabaseModule, EmailModule, UserTokenModule, diff --git a/src/modules/objects/user/user.service.ts b/src/modules/objects/user/user.service.ts index d492bb7..e86e054 100644 --- a/src/modules/objects/user/user.service.ts +++ b/src/modules/objects/user/user.service.ts @@ -1,4 +1,9 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + Logger, +} from '@nestjs/common'; import { ILike, Repository } from 'typeorm'; import * as bcrypt from 'bcrypt'; @@ -12,9 +17,14 @@ import { UserTokenService } from '../user-token/user-token.service'; import { ConfigurationService } from 'src/modules/config/config.service'; import { Upload } from '../upload/upload.entity'; import { UploadService } from '../upload/upload.service'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { InvitationEmail } from './email/invitation.email'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); + constructor( @Inject('USER_REPOSITORY') private userRepository: Repository, @@ -23,6 +33,7 @@ export class UserService { private email: EmailService, private config: ConfigurationService, private upload: UploadService, + @Inject(CACHE_MANAGER) private readonly cache: Cache, ) {} public async getById(id: number, relations?: string[]): Promise { @@ -140,12 +151,14 @@ export class UserService { user.picture = upload; await this.updateUser(user); + this.logger.log(`User ID: ${user.id} avatar has been updated`); return user; } public async deleteAvatar(user: User): Promise { if (user.picture) { await this.upload.delete(user.picture); + this.logger.log(`User ID: ${user.id} avatar has been deleted`); } } @@ -165,6 +178,9 @@ export class UserService { user: User, redirectTo?: string, ): Promise { + this.logger.log( + `Sending an activation email to User ID: ${user.id} (${user.email})`, + ); const activationToken = await this.userToken.create( user, UserTokenType.ACTIVATION, @@ -188,13 +204,22 @@ export class UserService { 'Activate your account on Icy Network', content, ); + this.logger.log( + `Sending an activation email to User ID: ${user.id} (${user.email}) was successful.`, + ); } catch (e) { + this.logger.error( + `Sending an activation email to User ID: ${user.id} (${user.email}) failed`, + ); await this.userToken.delete(activationToken); throw e; } } public async sendPasswordEmail(user: User): Promise { + this.logger.log( + `Sending a password reset email to User ID: ${user.id} (${user.email})`, + ); const passwordToken = await this.userToken.create( user, UserTokenType.PASSWORD, @@ -213,7 +238,13 @@ export class UserService { 'Reset your password on Icy Network', content, ); + this.logger.log( + `Sent a password reset email to User ID: ${user.id} (${user.email}) successfully`, + ); } catch (e) { + this.logger.error( + `Sending a password reset email to User ID: ${user.id} (${user.email}) failed: ${e}`, + ); await this.userToken.delete(passwordToken); // silently fail } @@ -236,7 +267,12 @@ export class UserService { password: string; }, redirectTo?: string, + activate = false, ): Promise { + this.logger.log( + `Starting registration of new user ${newUserInfo.username} (${newUserInfo.email})`, + ); + if (!!(await this.getByEmail(newUserInfo.email))) { throw new Error('Email is already in use!'); } @@ -253,18 +289,71 @@ export class UserService { user.display_name = newUserInfo.display_name; user.password = hashword; user.activity_at = new Date(); + user.activated = activate; - await this.userRepository.insert(user); + await this.userRepository.save(user); - try { - await this.sendActivationEmail(user, redirectTo); - } catch (e) { - await this.userRepository.remove(user); - throw new Error( - 'Failed to send activation email! Please check your email address and try again!', - ); + if (!user.activated) { + try { + await this.sendActivationEmail(user, redirectTo); + } catch (e) { + await this.userRepository.remove(user); + throw new Error( + 'Failed to send activation email! Please check your email address and try again!', + ); + } } + this.logger.log( + `Registered a new user ${newUserInfo.username} (${newUserInfo.email}) ID: ${user.id}`, + ); + return user; } + + public async issueRegistrationToken(email: string) { + this.logger.log(`Issuing a new registration token for ${email}`); + + const existingUser = await this.getByEmail(email); + if (existingUser) { + throw new BadRequestException('User by email already exists'); + } + + const newToken = this.token.generateString(64); + await this.cache.set( + `register-${newToken}`, + email, + 7 * 24 * 60 * 60 * 1000, // 7 days + ); + + try { + const content = InvitationEmail( + `${this.config.get('app.base_url')}/register?token=${newToken}`, + ); + + await this.email.sendEmailTemplate( + email, + 'You have been invited to create an account on Icy Network', + content, + ); + + this.logger.log( + `Issuing a new registration token for ${email} was successful`, + ); + } catch (error) { + this.logger.error( + `Issuing a new registration token for ${email} failed: ${error}`, + ); + await this.cache.del(`register-${newToken}`); + throw error; + } + } + + public async checkRegistrationToken(token: string) { + return await this.cache.get(`register-${token}`); + } + + public async invalidateRegistrationToken(token: string) { + return await this.cache.del(`register-${token}`); + } } diff --git a/src/modules/ssr-front-end/register/register.controller.ts b/src/modules/ssr-front-end/register/register.controller.ts index 5807d5b..824211b 100644 --- a/src/modules/ssr-front-end/register/register.controller.ts +++ b/src/modules/ssr-front-end/register/register.controller.ts @@ -4,7 +4,6 @@ import { Get, Post, Query, - Render, Req, Res, UnauthorizedException, @@ -31,11 +30,40 @@ export class RegisterController { ) {} @Get() - @Render('register') - public registerView(@Req() req: Request): Record { - return this.formUtil.populateTemplate(req, { - registrationAuthorized: this.config.get('app.registrations'), - }); + public async registerView( + @Req() req: Request, + @Res() res: Response, + @Query('token') registrationToken: string, + @Query('redirectTo') redirectTo: string, + ) { + let registrationAuthorized = this.config.get('app.registrations'); + if (registrationToken) { + const registrationEmail = + await this.userService.checkRegistrationToken(registrationToken); + + if (!registrationEmail) { + req.flash('message', { + error: true, + text: `This registration token is invalid or expired.`, + }); + + return res.redirect( + '/login' + (redirectTo ? '?redirectTo=' + redirectTo : ''), + ); + } + + req.flash('form', { email: registrationEmail }); + + // bypass limitations + registrationAuthorized = true; + } + + res.render( + 'register', + this.formUtil.populateTemplate(req, { + registrationAuthorized, + }), + ); } @Post() @@ -45,12 +73,34 @@ export class RegisterController { @Req() req: Request, @Res() res: Response, @Body() body: RegisterDto, + @Query('token') registrationToken: string, @Query('redirectTo') redirectTo?: string, ) { const { username, display_name, email, password, password_repeat } = this.formUtil.trimmed(body, ['username', 'display_name', 'email']); - if (!this.config.get('app.registrations')) { + let registrationAuthorized = this.config.get('app.registrations'); + let tokenEmail: string | undefined; + + if (registrationToken) { + tokenEmail = + await this.userService.checkRegistrationToken(registrationToken); + + if (!tokenEmail) { + req.flash('message', { + error: true, + text: `This registration token is invalid or expired.`, + }); + return res.redirect( + '/login' + (redirectTo ? '?redirectTo=' + redirectTo : ''), + ); + } + + // bypass limitations + registrationAuthorized = true; + } + + if (!registrationAuthorized) { throw new UnauthorizedException( 'Registrations are disabled by administrator.', ); @@ -94,12 +144,23 @@ export class RegisterController { throw new Error('The passwords do not match!'); } - const user = await this.userService.userRegistration(body, redirectTo); + const sendActivationEmail = tokenEmail ? tokenEmail !== email : true; + const user = await this.userService.userRegistration( + body, + redirectTo, + !sendActivationEmail, + ); await this.audit.auditRequest(req, AuditAction.REGISTRATION, null, user); + if (tokenEmail) { + await this.userService.invalidateRegistrationToken(registrationToken); + } + req.flash('message', { error: false, - text: `An activation email has been sent to ${email}!`, + text: sendActivationEmail + ? `An activation email has been sent to ${email}!` + : `Welcome, we have been expecting you! You may now log in.`, }); res.redirect('/login' + (redirectTo ? '?redirectTo=' + redirectTo : ''));