Skip to content

Commit

Permalink
Merge pull request #3 from Webster-FR/feature/user-management
Browse files Browse the repository at this point in the history
Merge user management to Staging
  • Loading branch information
Xen0Xys authored Jul 8, 2024
2 parents 3788612 + 9fc4ef0 commit bf10f18
Show file tree
Hide file tree
Showing 9 changed files with 162 additions and 11 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ EMAIL_PASSWORD=""
# Cookies
COOKIE_SECRET="secret"
SECURE_COOKIE=false

# Security
SESSION_DURATION=3600
LONG_SESSION_DURATION=604800
2 changes: 1 addition & 1 deletion src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class AuthController{
@Post("login")
async login(@Body() body: LoginDto, @Req() req: FastifyRequest, @Res({passthrough: true}) res: FastifyReply){
const userAgent = req.headers["user-agent"];
const sessionUUID = await this.authService.createSession(body.email, body.password, userAgent);
const sessionUUID = await this.authService.createSession(body.email, body.password, userAgent, body.remember);
res.setCookie("session", sessionUUID, {
httpOnly: true,
sameSite: "strict",
Expand Down
12 changes: 8 additions & 4 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import {Injectable, NotFoundException, UnauthorizedException} from "@nestjs/common";
import {PrismaService} from "../misc/prisma.service";
import {CipherService} from "../misc/cipher.service";
import {ConfigService} from "@nestjs/config";

@Injectable()
export class AuthService{
constructor(
private readonly prismaService: PrismaService,
private readonly cipherService: CipherService,
private readonly configService: ConfigService,
){}

async createSession(email: string, password: string, userAgent: string): Promise<string>{
async createSession(email: string, password: string, userAgent: string, remember: boolean): Promise<string>{
const user = await this.prismaService.users.findFirst({
where: {
email,
Expand All @@ -20,24 +22,26 @@ export class AuthService{
if(!await this.cipherService.compareHash(user.password, password))
throw new UnauthorizedException("Invalid password");
const sessionUuid = this.cipherService.generateUuidV7();
const duration: number = remember ? parseInt(this.configService.get("LONG_SESSION_DURATION")) : parseInt(this.configService.get("SESSION_DURATION"));
await this.prismaService.sessions.create({
data: {
user_id: user.id,
uuid: sessionUuid,
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 1 week
expires_at: new Date(Date.now() + 1000 * duration),
user_agent: userAgent,
}
});
return sessionUuid;
}

async createSessionByUserId(userId: number, userAgent: string): Promise<string>{
async createSessionByUserId(userId: number, userAgent: string, remember: boolean): Promise<string>{
const sessionUuid = this.cipherService.generateUuidV7();
const duration: number = remember ? parseInt(this.configService.get("LONG_SESSION_DURATION")) : parseInt(this.configService.get("SESSION_DURATION"));
await this.prismaService.sessions.create({
data: {
user_id: userId,
uuid: sessionUuid,
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 1 week
expires_at: new Date(Date.now() + 1000 * duration),
user_agent: userAgent,
}
});
Expand Down
6 changes: 5 additions & 1 deletion src/modules/auth/models/dto/login.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {IsEmail, IsNotEmpty, IsString} from "class-validator";
import {IsBoolean, IsEmail, IsNotEmpty, IsString} from "class-validator";
import {ApiProperty} from "@nestjs/swagger";

export class LoginDto{
Expand All @@ -11,4 +11,8 @@ export class LoginDto{
@IsNotEmpty()
@IsString()
password: string;
@ApiProperty()
@IsNotEmpty()
@IsBoolean()
remember: boolean;
}
6 changes: 4 additions & 2 deletions src/modules/misc/email.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import {Injectable} from "@nestjs/common";
import {MailerService} from "@nestjs-modules/mailer";
import {ConfigService} from "@nestjs/config";

@Injectable()
export class EmailService{
constructor(
private readonly mailerService: MailerService,
private readonly configService: ConfigService,
){}

async sendEmail(to: string, subject: string, body: string){
await this.mailerService.sendMail({
from: "Eventoria",
from: this.configService.get("EMAIL_USER"),
to,
subject,
html: body,
text: body,
});
}
}
9 changes: 9 additions & 0 deletions src/modules/users/models/dto/otp.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {ApiProperty} from "@nestjs/swagger";
import {IsString, Length} from "class-validator";

export class OtpDto{
@ApiProperty()
@IsString()
@Length(6, 6)
otp: string;
}
10 changes: 10 additions & 0 deletions src/modules/users/models/dto/username-param.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {ApiProperty} from "@nestjs/swagger";
import {IsNotEmpty, IsString, Matches} from "class-validator";

export class UsernameParamDto{
@ApiProperty()
@IsNotEmpty()
@IsString()
@Matches(/^(?=.*[a-z])[a-z._\d]{5,24}$/)
username: string;
}
34 changes: 32 additions & 2 deletions src/modules/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Body, Controller, Get, HttpStatus, Post, Req, Res, UseGuards} from "@nestjs/common";
import {Body, ConflictException, Controller, Get, HttpStatus, Param, Post, Req, Res, UseGuards} from "@nestjs/common";
import {ApiResponse, ApiTags} from "@nestjs/swagger";
import {UsersService} from "./users.service";
import {CreateUserDto} from "./models/dto/create-user.dto";
Expand All @@ -7,6 +7,8 @@ import {FastifyReply} from "fastify";
import {AuthService} from "../auth/auth.service";
import {ConfigService} from "@nestjs/config";
import {AuthGuard} from "../auth/guards/auth.guard";
import {OtpDto} from "./models/dto/otp.dto";
import {UsernameParamDto} from "./models/dto/username-param.dto";

@Controller("users")
@ApiTags("Users")
Expand All @@ -17,6 +19,14 @@ export class UsersController{
private readonly configService: ConfigService,
){}

@Get("username/availability/:username")
@ApiResponse({status: HttpStatus.OK, description: "Username available"})
@ApiResponse({status: HttpStatus.CONFLICT, description: "Username already used"})
async checkUsernameAvailability(@Req() req: any, @Param() params: UsernameParamDto){
if(!await this.usersService.isUsernameAvailable(params.username))
throw new ConflictException("Username already used");
}

@Post("register")
@ApiResponse({status: HttpStatus.CREATED, description: "User created", type: UserEntity})
@ApiResponse({status: HttpStatus.FORBIDDEN, description: "Banned email"})
Expand All @@ -25,19 +35,39 @@ export class UsersController{
async registerUser(@Body() body: CreateUserDto, @Req() req: any, @Res({passthrough: true}) res: FastifyReply){
const user = await this.usersService.createUser(body.username, body.email, body.password, body.displayName);
const userAgent = req.headers["user-agent"];
const sessionUUID = await this.authService.createSessionByUserId(user.id, userAgent);
const sessionUUID = await this.authService.createSessionByUserId(user.id, userAgent, false);
res.setCookie("session", sessionUUID, {
httpOnly: true,
sameSite: "strict",
secure: this.configService.get("SECURE_COOKIE") === "true",
path: "/" + this.configService.get("PREFIX"),
});
res.send(user);
}

@Get("me")
@UseGuards(AuthGuard)
@ApiResponse({status: HttpStatus.OK, description: "User found", type: UserEntity})
@ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Authentication required"})
async getMe(@Req() req: any): Promise<UserEntity>{
return req.user;
}

@Post("email/confirm")
@UseGuards(AuthGuard)
@ApiResponse({status: HttpStatus.NO_CONTENT, description: "Email confirmed"})
@ApiResponse({status: HttpStatus.BAD_REQUEST, description: "Invalid otp"})
@ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Authentication required"})
async confirmEmail(@Req() req: any, @Body() body: OtpDto){
await this.usersService.confirmEmail(req.user.id, body.otp);
}

@Post("email/confirm/resend")
@UseGuards(AuthGuard)
@ApiResponse({status: HttpStatus.NO_CONTENT, description: "Email confirmation resent"})
@ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Authentication required"})
async resendEmailConfirmation(@Req() req: any){
await this.usersService.resendEmailConfirmation(req.user.id);
}

}
90 changes: 89 additions & 1 deletion src/modules/users/users.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import {ConflictException, ForbiddenException, Injectable} from "@nestjs/common";
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException
} from "@nestjs/common";
import {PrismaService} from "../misc/prisma.service";
import {CipherService} from "../misc/cipher.service";
import {UserEntity} from "./models/entities/user.entity";
Expand Down Expand Up @@ -107,4 +113,86 @@ export class UsersService{
} as UserProfileEntity,
} as UserEntity;
}

async confirmEmail(userId: number, otp: string){
const otpVerification = await this.prismaService.otpVerifications.findFirst({
where: {
user_id: userId,
otp
}
});
if(!otpVerification)
throw new BadRequestException("Invalid OTP");
if(otpVerification.expires_at < new Date()){
await this.prismaService.otpVerifications.delete({
where: {
user_id: otpVerification.user_id,
}
});
throw new ForbiddenException("OTP expired");
}

await this.prismaService.$transaction(async(tx) => {
await tx.otpVerifications.delete({
where: {
user_id: otpVerification.user_id,
}
});
await tx.users.update({
where: {
id: userId,
},
data: {
verified_at: new Date(),
}
});
});
}

async resendEmailConfirmation(userId: number){
const user = await this.prismaService.users.findUnique({
where: {
id: userId,
}
});
if(!user)
throw new NotFoundException("User not found");
if(user.verified_at)
throw new BadRequestException("User already verified");

const otpVerification = await this.prismaService.otpVerifications.findFirst({
where: {
user_id: userId,
}
});
if(otpVerification){
if (otpVerification.expires_at < new Date())
await this.prismaService.otpVerifications.delete({
where: {
user_id: userId,
}
});
else
throw new BadRequestException("OTP already sent");
}

await this.prismaService.$transaction(async(tx) => {
const otpVerification = await tx.otpVerifications.create({
data: {
user_id: userId,
otp: this.cipherService.generateRandomNumbers(6),
expires_at: new Date(Date.now() + 1000 * 60 * 5), // 5 minutes
}
});
await this.emailService.sendEmail(user.email, "Verify your email", `Your OTP is ${otpVerification.otp}`);
});
}

async isUsernameAvailable(username: string){
return !await this.prismaService.users.findFirst({
where: {
username,
}
});
}
}

0 comments on commit bf10f18

Please sign in to comment.