diff --git a/backend/package.json b/backend/package.json index e2b1cd4..ea55b01 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,19 +35,22 @@ "argon2": "^0.40.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie-parser": "^1.4.6", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.11.5", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", - "typeorm": "^0.3.20" + "typeorm": "^0.3.20", + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/argon2": "^0.15.0", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 9f8d4b8..80cfa46 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -41,6 +41,9 @@ dependencies: class-validator: specifier: ^0.14.1 version: 0.14.1 + cookie-parser: + specifier: ^1.4.6 + version: 1.4.6 passport: specifier: ^0.7.0 version: 0.7.0 @@ -62,6 +65,9 @@ dependencies: typeorm: specifier: ^0.3.20 version: 0.3.20(pg@8.11.5)(ts-node@10.9.2) + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@nestjs/cli': @@ -76,6 +82,9 @@ devDependencies: '@types/argon2': specifier: ^0.15.0 version: 0.15.0 + '@types/cookie-parser': + specifier: ^1.4.7 + version: 1.4.7 '@types/express': specifier: ^4.17.17 version: 4.17.21 @@ -1288,6 +1297,12 @@ packages: '@types/node': 20.12.4 dev: true + /@types/cookie-parser@1.4.7: + resolution: {integrity: sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==} + dependencies: + '@types/express': 4.17.21 + dev: true + /@types/cookiejar@2.1.5: resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} dev: true @@ -2237,9 +2252,22 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /cookie-parser@1.4.6: + resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==} + engines: {node: '>= 0.8.0'} + dependencies: + cookie: 0.4.1 + cookie-signature: 1.0.6 + dev: false + /cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + /cookie@0.4.1: + resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==} + engines: {node: '>= 0.6'} + dev: false + /cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e6167c8..24c2884 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,7 +3,6 @@ import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { CorsMiddleware } from './middleware/cors-middleware/cors.middlware'; import { CspMiddleware } from './middleware/csp-middleware/csp.middleware'; import { HttpsRedirectMiddleware } from './middleware/https-middlware/https-redirect.middleware'; import { SecurityHeadersMiddleware } from './middleware/security-middleware/security.middleware'; @@ -35,8 +34,8 @@ export class AppModule { .apply( CspMiddleware, SecurityHeadersMiddleware, - HttpsRedirectMiddleware, - CorsMiddleware + HttpsRedirectMiddleware + //CorsMiddleware ) .forRoutes({ path: '*', method: RequestMethod.ALL }); } diff --git a/backend/src/entities/index.ts b/backend/src/entities/index.ts index 6d8bde7..cdebf32 100644 --- a/backend/src/entities/index.ts +++ b/backend/src/entities/index.ts @@ -1,3 +1,4 @@ export * from './user-credentials.entity'; export * from './user-data.entity'; export * from './email-verification.entity'; +export * from './session.entity'; diff --git a/backend/src/entities/session.entity.ts b/backend/src/entities/session.entity.ts new file mode 100644 index 0000000..80a3bfb --- /dev/null +++ b/backend/src/entities/session.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { UserCredentials } from './user-credentials.entity'; + +@Entity() +export class Session { + @PrimaryGeneratedColumn('uuid') + public id: string; + + @Column() + public sessionId: string; + + @Column({ type: 'timestamp' }) + public expiresAt: Date; + + @Column({}) + public userAgent: string; + + @ManyToOne(() => UserCredentials, (userCredentials) => userCredentials.id, { + nullable: false, + }) + @JoinColumn({ name: 'userCredentialsId' }) + public userCredentials: UserCredentials['id']; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 7c72a88..b07d58f 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import * as cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; @@ -30,6 +31,10 @@ async function setupSwagger(app: INestApplication): Promise { ); } +async function setupCookieParser(app: INestApplication): Promise { + app.use(cookieParser()); +} + async function setupPrefix(app: INestApplication): Promise { app.setGlobalPrefix('api'); } @@ -41,6 +46,7 @@ async function setupClassValidator(app: INestApplication): Promise { async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); + await setupCookieParser(app); await setupSwagger(app); await setupPrefix(app); await setupClassValidator(app); diff --git a/backend/src/modules/auth-module/auth.module.ts b/backend/src/modules/auth-module/auth.module.ts index 5a1dccb..9f440c3 100644 --- a/backend/src/modules/auth-module/auth.module.ts +++ b/backend/src/modules/auth-module/auth.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { UserCredentials } from 'src/entities'; +import { Session, UserCredentials } from 'src/entities'; import { SendgridModule } from '../sendgrid-module/sendgrid.module'; import { UserModule } from '../user-module/user.module'; @@ -10,6 +10,7 @@ import { VerifyModule } from '../verify-module/verify.module'; import { AuthController } from './controller/auth.controller'; import { UserCredentialsRepository } from './repositories/user-credentials.repository'; import { AuthService } from './services/auth.service'; +import { SessionService } from './services/session.service'; import { TokenManagementService } from './services/token-management.service'; import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies'; @@ -19,10 +20,11 @@ import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies'; SendgridModule, VerifyModule, JwtModule.register({}), - TypeOrmModule.forFeature([UserCredentials]), + TypeOrmModule.forFeature([UserCredentials, Session]), ], providers: [ AuthService, + SessionService, TokenManagementService, UserCredentialsRepository, AccessTokenStrategy, diff --git a/backend/src/modules/auth-module/controller/auth.controller.ts b/backend/src/modules/auth-module/controller/auth.controller.ts index bc48206..2e25b21 100644 --- a/backend/src/modules/auth-module/controller/auth.controller.ts +++ b/backend/src/modules/auth-module/controller/auth.controller.ts @@ -4,14 +4,19 @@ import { Body, HttpCode, HttpStatus, - UseGuards, + Res, + Req, } from '@nestjs/common'; -import { ApiCreatedResponse, ApiHeader, ApiTags } from '@nestjs/swagger'; +import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; +import { Response, Request } from 'express'; import { Public } from 'src/shared/decorator'; -import { GetCurrentUser, GetCurrentUserId } from '../common/decorators'; -import { RefreshTokenGuard } from '../common/guards'; -import { TokensDto, UserCredentialsDto } from '../models/dto'; +import { GetCurrentUserId } from '../common/decorators'; +import { + AccessTokenDto, + LoginResponseDto, + UserCredentialsDto, +} from '../models/dto'; import { AuthService } from '../services/auth.service'; @ApiTags('Authentication') @@ -21,28 +26,36 @@ export class AuthController { @ApiCreatedResponse({ description: 'User signed up successfully', - type: TokensDto, + type: LoginResponseDto, }) @Public() @Post('signup') @HttpCode(HttpStatus.CREATED) public async signup( @Body() userCredentials: UserCredentialsDto - ): Promise { + ): Promise { return this.authService.signup(userCredentials); } @ApiCreatedResponse({ description: 'User signin successfully', - type: TokensDto, + type: LoginResponseDto, }) @Public() @Post('signin') @HttpCode(HttpStatus.OK) public async signin( + @Res({ passthrough: true }) response: Response, + @Req() request: Request, @Body() userCredentials: UserCredentialsDto - ): Promise { - return this.authService.signin(userCredentials); + ): Promise { + return await this.authService.signin(userCredentials, response, request); + } + + @Public() + @Post('refresh') + public async refreshToken(@Req() request: Request): Promise { + return await this.authService.refresh(request); } @ApiCreatedResponse({ @@ -55,25 +68,42 @@ export class AuthController { return this.authService.logout(userId); } - @ApiHeader({ - name: 'Authorization', - required: true, - schema: { - example: 'Bearer ', - }, - }) - @ApiCreatedResponse({ - description: 'User tokens refreshed successfully', - type: TokensDto, - }) - @Public() - @UseGuards(RefreshTokenGuard) - @Post('refresh') - @HttpCode(HttpStatus.OK) - public async refresh( - @GetCurrentUserId() userId: string, - @GetCurrentUser('refresh_token') refresh_token: string - ): Promise { - return this.authService.refresh(userId, refresh_token); - } + // @ApiHeader({ + // name: 'Authorization', + // required: true, + // schema: { + // example: 'Bearer ', + // }, + // }) + // @ApiCreatedResponse({ + // description: 'User tokens refreshed successfully', + // type: TokensDto, + // }) + // @Public() + // @UseGuards(RefreshTokenGuard) + // @Post('refresh') + // @HttpCode(HttpStatus.OK) + // public async refresh( + // @GetCurrentUserId() userId: string, + // @GetCurrentUser('refresh_token') refresh_token: string + // ): Promise { + // return this.authService.refresh(userId, refresh_token); + // } + + // @ApiHeader({ + // name: 'Authorization', + // required: true, + // schema: { + // example: 'Bearer ', + // }, + // }) + // @ApiCreatedResponse({ + // description: 'Token validity checked successfully', + // type: Boolean, + // }) + // @Post('check-token') + // @HttpCode(HttpStatus.OK) + // public checkTokenValidity(): Promise { + // return this.authService.checkTokenValidity(); + // } } diff --git a/backend/src/modules/auth-module/models/dto/tokens.dto.ts b/backend/src/modules/auth-module/models/dto/access-token.dto.ts similarity index 58% rename from backend/src/modules/auth-module/models/dto/tokens.dto.ts rename to backend/src/modules/auth-module/models/dto/access-token.dto.ts index 2d6e111..4a8ea9a 100644 --- a/backend/src/modules/auth-module/models/dto/tokens.dto.ts +++ b/backend/src/modules/auth-module/models/dto/access-token.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; -export class TokensDto { +export class AccessTokenDto { @ApiProperty({ title: 'Access token', description: 'Access token', @@ -10,13 +10,4 @@ export class TokensDto { @IsNotEmpty() @IsString() public access_token: string; - - @ApiProperty({ - title: 'Refresh token', - description: 'Refresh token', - example: 'eyJhbGci', - }) - @IsNotEmpty() - @IsString() - public refresh_token: string; } diff --git a/backend/src/modules/auth-module/models/dto/index.ts b/backend/src/modules/auth-module/models/dto/index.ts index bd47ab5..4b4a3fc 100644 --- a/backend/src/modules/auth-module/models/dto/index.ts +++ b/backend/src/modules/auth-module/models/dto/index.ts @@ -1,2 +1,3 @@ export * from './user-credentials.dto'; -export * from './tokens.dto'; +export * from './login-response.dto'; +export * from './access-token.dto'; diff --git a/backend/src/modules/auth-module/models/dto/login-response.dto.ts b/backend/src/modules/auth-module/models/dto/login-response.dto.ts new file mode 100644 index 0000000..1f908d5 --- /dev/null +++ b/backend/src/modules/auth-module/models/dto/login-response.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; + +export class LoginResponseDto { + @ApiProperty({ + title: 'Access token', + description: 'Access token', + example: 'eyJhbGci', + }) + @IsNotEmpty() + @IsString() + public access_token: string; + + @ApiProperty({ + title: 'Email', + description: 'User Email', + }) + @IsNotEmpty() + @IsString() + @IsEmail() + public email: string; + + @ApiProperty({ + title: 'User ID', + description: 'User ID', + }) + @IsNotEmpty() + @IsString() + @IsEmail() + public userId: string; +} diff --git a/backend/src/modules/auth-module/services/auth.service.ts b/backend/src/modules/auth-module/services/auth.service.ts index 414ecca..df0ecef 100644 --- a/backend/src/modules/auth-module/services/auth.service.ts +++ b/backend/src/modules/auth-module/services/auth.service.ts @@ -1,13 +1,23 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; +import { Response, Request } from 'express'; +import { Session } from 'src/entities'; import { EncryptionService } from 'src/shared'; import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; import { EmailVerificationService } from '../../verify-module/services/email-verification.service'; -import { TokensDto, UserCredentialsDto } from '../models/dto'; +import { + AccessTokenDto, + LoginResponseDto, + UserCredentialsDto, +} from '../models/dto'; import { UserCredentialsRepository } from '../repositories/user-credentials.repository'; -import { TokenManagementService } from './token-management.service'; +import { SessionService } from './session.service'; +import { + TokenManagementService, + TokenPayload, +} from './token-management.service'; @Injectable() export class AuthService { @@ -16,10 +26,13 @@ export class AuthService { private readonly userDataRepository: UserDataRepository, private readonly tokenManagementService: TokenManagementService, private readonly passwordConfirmationMailService: PasswordConfirmationMailService, - private readonly emailVerificationService: EmailVerificationService + private readonly emailVerificationService: EmailVerificationService, + private readonly sessionService: SessionService ) {} - public async signup(userCredentials: UserCredentialsDto): Promise { + public async signup( + userCredentials: UserCredentialsDto + ): Promise { const passwordHashed = await EncryptionService.hashData( userCredentials.password ); @@ -44,7 +57,11 @@ export class AuthService { return this.generateAndPersistTokens(user.id, user.email); } - public async signin(userCredentials: UserCredentialsDto): Promise { + public async signin( + userCredentials: UserCredentialsDto, + response: Response, + request: Request + ): Promise { const user = await this.userCredentialsRepository.findUserByEmail( userCredentials.email ); @@ -62,32 +79,18 @@ export class AuthService { throw new ForbiddenException('Access Denied'); } - return this.generateAndPersistTokens(user.id, user.email); - } - - public async refresh( - userId: string, - refreshToken: string - ): Promise { - const user = await this.userCredentialsRepository.findUserById(userId); - - if (!user || !user.hashedRt) { - throw new ForbiddenException('Access Denied'); - } - - const refreshTokenMatch = await EncryptionService.compareHash( - refreshToken, - user.hashedRt + const sesseionId = await this.sessionService.createSession( + user.id, + request.headers['user-agent'] ); - if (!refreshTokenMatch) { - throw new ForbiddenException('Access Denied'); - } + this.sessionService.attachSessionToResponse(response, sesseionId.sessionId); return this.generateAndPersistTokens(user.id, user.email); } public async logout(userId: string): Promise { + // TODO Check if the user is logged out already const affected = await this.userCredentialsRepository.updateUserTokenHash( userId, null @@ -96,22 +99,77 @@ export class AuthService { return affected > 0; } + public async refresh(request: Request): Promise { + const sessionId = request.cookies['session_id']; + + if (!sessionId) { + throw new ForbiddenException('Session ID missing'); + } + + const session: Session = + await this.sessionService.findSessionBySessionId(sessionId); + + if (!session) { + throw new ForbiddenException('Invalid session'); + } + + const isUserAgentValid = await this.sessionService.validateSessionUserAgent( + sessionId, + request.headers['user-agent'] + ); + + // TODO expand session expiration + + if (!isUserAgentValid) { + throw new ForbiddenException('Invalid session - User agent mismatch'); + } + + const decodedToken: TokenPayload = await this.validateRefreshToken( + session.userCredentials['id'] + ); + + const newTokens = await this.generateAndPersistTokens( + decodedToken.sub, + decodedToken.email + ); + + return { access_token: newTokens.access_token }; + } + private async generateAndPersistTokens( userId: string, email: string - ): Promise { + ): Promise { const tokens = await this.tokenManagementService.generateTokens( userId, email ); - const hashedRefreshToken = await EncryptionService.hashData( - tokens.refresh_token - ); await this.userCredentialsRepository.updateUserTokenHash( userId, - hashedRefreshToken + tokens.refresh_token ); - return tokens; + return { access_token: tokens.access_token, email: email, userId: userId }; + } + + private async validateRefreshToken(userId: string): Promise { + const user = await this.userCredentialsRepository.findUserById(userId); + + if (!user || !user.hashedRt) { + throw new Error('No refresh token found'); + } + + const decodedToken = await this.tokenManagementService.verifyRefreshToken( + user.hashedRt + ); + + if (decodedToken.exp < Date.now() / 1000) { + throw new Error('Token expired'); + } + + if (decodedToken.sub !== user.id) { + throw new Error('Token subject mismatch'); + } + return decodedToken; } } diff --git a/backend/src/modules/auth-module/services/session.service.ts b/backend/src/modules/auth-module/services/session.service.ts new file mode 100644 index 0000000..49e14f5 --- /dev/null +++ b/backend/src/modules/auth-module/services/session.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Response } from 'express'; +import { Session } from 'src/entities'; +import { LessThan, Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class SessionService { + public constructor( + @InjectRepository(Session) + private sessionRepository: Repository + ) {} + + public async createSession( + userId: string, + userAgent: string + ): Promise { + const sessionId = uuidv4(); + const expirationDate = new Date(); + + expirationDate.setHours(expirationDate.getHours() + 1); + + const session = this.sessionRepository.create({ + userCredentials: userId, + sessionId: sessionId, + expiresAt: expirationDate, + userAgent: userAgent, + }); + + await this.sessionRepository.save(session); + return session; + } + + public async validateSessionUserAgent( + sessionId: string, + currentUserAgent: string + ): Promise { + const session = await this.sessionRepository.findOne({ + where: { sessionId: sessionId }, + select: ['userAgent'], + }); + + if (!session) { + return false; + } + + return session.userAgent === currentUserAgent; + } + + // TODO use method to invalidate session + public async invalidateSession(sessionId: string): Promise { + await this.sessionRepository.delete({ sessionId: sessionId }); + } + + // TODO use method to invalidate all sessions for user + public async invalidateAllSessionsForUser(userId: string): Promise { + await this.sessionRepository.delete({ userCredentials: userId }); + } + + // TODO use method to clear expired sessions + public async clearExpiredSessions(): Promise { + const now = new Date(); + + await this.sessionRepository.delete({ expiresAt: LessThan(now) }); + } + + public async findSessionBySessionId(sessionId: string): Promise { + return this.sessionRepository.findOne({ + where: { sessionId: sessionId }, + relations: ['userCredentials'], + }); + } + + public attachSessionToResponse(response: Response, sessionId: string): void { + response.cookie('session_id', sessionId, { + httpOnly: true, + secure: true, + sameSite: 'strict', + }); + } +} diff --git a/backend/src/modules/auth-module/services/token-management.service.ts b/backend/src/modules/auth-module/services/token-management.service.ts index fd91669..5e3e7b3 100644 --- a/backend/src/modules/auth-module/services/token-management.service.ts +++ b/backend/src/modules/auth-module/services/token-management.service.ts @@ -2,7 +2,17 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -import { TokensDto } from '../models/dto'; +type Tokens = { + access_token: string; + refresh_token: string; +}; + +export type TokenPayload = { + sub: string; + email: string; + iat: number; + exp: number; +}; @Injectable() export class TokenManagementService { @@ -25,16 +35,19 @@ export class TokenManagementService { this.JWT_SECRET_RT = this.configService.get('JWT_SECRET_RT'); } - public async generateTokens( - userId: string, - email: string - ): Promise { + public async generateTokens(userId: string, email: string): Promise { const access_token: string = await this.createAccessToken(userId, email); const refresh_token: string = await this.createRefreshToken(userId, email); return { access_token, refresh_token }; } + public async verifyRefreshToken(token: string): Promise { + return this.jwt.verifyAsync(token, { + secret: this.JWT_SECRET_RT, + }); + } + private async createAccessToken( userId: string, email: string diff --git a/backend/src/modules/database-module/database-config.ts b/backend/src/modules/database-module/database-config.ts index 066f829..301cd97 100644 --- a/backend/src/modules/database-module/database-config.ts +++ b/backend/src/modules/database-module/database-config.ts @@ -1,6 +1,11 @@ import { ConfigService } from '@nestjs/config'; import { TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { EmailVerification, UserCredentials, UserData } from 'src/entities'; +import { + EmailVerification, + UserCredentials, + UserData, + Session, +} from 'src/entities'; export const databaseConfigFactory = ( configService: ConfigService @@ -13,5 +18,5 @@ export const databaseConfigFactory = ( database: configService.get('DB_NAME'), synchronize: true, logging: true, - entities: [UserCredentials, UserData, EmailVerification], + entities: [UserCredentials, UserData, EmailVerification, Session], });