add invitations

This commit is contained in:
Evert Prants 2024-03-13 22:18:57 +02:00
parent fb4154c9e5
commit 02689f93d1
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
9 changed files with 220 additions and 21 deletions

7
.adminjs/bundle.js Normal file
View File

@ -0,0 +1,7 @@
(function () {
'use strict';
AdminJS.UserComponents = {};
})();
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVuZGxlLmpzIiwic291cmNlcyI6WyJlbnRyeS5qcyJdLCJzb3VyY2VzQ29udGVudCI6WyJBZG1pbkpTLlVzZXJDb21wb25lbnRzID0ge31cbiJdLCJuYW1lcyI6WyJBZG1pbkpTIiwiVXNlckNvbXBvbmVudHMiXSwibWFwcGluZ3MiOiI7OztDQUFBQSxPQUFPLENBQUNDLGNBQWMsR0FBRyxFQUFFOzs7Ozs7In0=

1
.adminjs/entry.js Normal file
View File

@ -0,0 +1 @@
AdminJS.UserComponents = {}

View File

@ -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,

View File

@ -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<User, 'email'>) {
await this._user.issueRegistrationToken(email);
return { success: true };
}
/**
* Get a single user by ID
* @param id User ID

View File

@ -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 */ `
<h1>Icy Network</h1>
<p><b>Please click on the following link to create an account on Icy Network.</b></p>
<p>Create your account here: <a href="${url}" target="_blank">${url}</a></p>
<p>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.</p>
`,
});

View File

@ -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 */ `
<h1>Icy Network</h1>
@ -21,5 +23,7 @@ Activate your account: ${url}
<p>In order to proceed with logging in, please click on the following link to activate your account.</p>
<p>Activate your account: <a href="${url}" target="_blank">${url}</a></p>
<p>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.</p>
`,
});

View File

@ -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,

View File

@ -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<User>,
@ -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<User> {
@ -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<void> {
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<void> {
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<void> {
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<User> {
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<string>('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<string>(`register-${token}`);
}
public async invalidateRegistrationToken(token: string) {
return await this.cache.del(`register-${token}`);
}
}

View File

@ -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<string, unknown> {
return this.formUtil.populateTemplate(req, {
registrationAuthorized: this.config.get<boolean>('app.registrations'),
});
public async registerView(
@Req() req: Request,
@Res() res: Response,
@Query('token') registrationToken: string,
@Query('redirectTo') redirectTo: string,
) {
let registrationAuthorized = this.config.get<boolean>('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<boolean>('app.registrations')) {
let registrationAuthorized = this.config.get<boolean>('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 : ''));