Compare commits

..

No commits in common. "46f69e1c53dcd4d64394f9088fb9f0bb3221e0c9" and "ab1368553fb777179d4687b4e53cf0b9d6d79b9d" have entirely different histories.

33 changed files with 176 additions and 623 deletions

View File

@ -35,22 +35,19 @@
"argon2": "^0.40.1", "argon2": "^0.40.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie-parser": "^1.4.6",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.11.5", "pg": "^8.11.5",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"typeorm": "^0.3.20", "typeorm": "^0.3.20"
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0", "@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "^10.0.0",
"@types/argon2": "^0.15.0", "@types/argon2": "^0.15.0",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",

View File

@ -41,9 +41,6 @@ dependencies:
class-validator: class-validator:
specifier: ^0.14.1 specifier: ^0.14.1
version: 0.14.1 version: 0.14.1
cookie-parser:
specifier: ^1.4.6
version: 1.4.6
passport: passport:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@ -65,9 +62,6 @@ dependencies:
typeorm: typeorm:
specifier: ^0.3.20 specifier: ^0.3.20
version: 0.3.20(pg@8.11.5)(ts-node@10.9.2) version: 0.3.20(pg@8.11.5)(ts-node@10.9.2)
uuid:
specifier: ^9.0.1
version: 9.0.1
devDependencies: devDependencies:
'@nestjs/cli': '@nestjs/cli':
@ -82,9 +76,6 @@ devDependencies:
'@types/argon2': '@types/argon2':
specifier: ^0.15.0 specifier: ^0.15.0
version: 0.15.0 version: 0.15.0
'@types/cookie-parser':
specifier: ^1.4.7
version: 1.4.7
'@types/express': '@types/express':
specifier: ^4.17.17 specifier: ^4.17.17
version: 4.17.21 version: 4.17.21
@ -1297,12 +1288,6 @@ packages:
'@types/node': 20.12.4 '@types/node': 20.12.4
dev: true 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: /@types/cookiejar@2.1.5:
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
dev: true dev: true
@ -2252,22 +2237,9 @@ packages:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
dev: true 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: /cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} 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: /cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}

View File

@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { CorsMiddleware } from './middleware/cors-middleware/cors.middlware';
import { CspMiddleware } from './middleware/csp-middleware/csp.middleware'; import { CspMiddleware } from './middleware/csp-middleware/csp.middleware';
import { HttpsRedirectMiddleware } from './middleware/https-middlware/https-redirect.middleware'; import { HttpsRedirectMiddleware } from './middleware/https-middlware/https-redirect.middleware';
import { SecurityHeadersMiddleware } from './middleware/security-middleware/security.middleware'; import { SecurityHeadersMiddleware } from './middleware/security-middleware/security.middleware';
@ -34,8 +35,8 @@ export class AppModule {
.apply( .apply(
CspMiddleware, CspMiddleware,
SecurityHeadersMiddleware, SecurityHeadersMiddleware,
HttpsRedirectMiddleware HttpsRedirectMiddleware,
//CorsMiddleware CorsMiddleware
) )
.forRoutes({ path: '*', method: RequestMethod.ALL }); .forRoutes({ path: '*', method: RequestMethod.ALL });
} }

View File

@ -1,4 +1,3 @@
export * from './user-credentials.entity'; export * from './user-credentials.entity';
export * from './user-data.entity'; export * from './user-data.entity';
export * from './email-verification.entity'; export * from './email-verification.entity';
export * from './session.entity';

View File

@ -1,38 +0,0 @@
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;
}

View File

@ -18,7 +18,7 @@ export class UserCredentials {
public hash: string; public hash: string;
@Column({ nullable: true }) @Column({ nullable: true })
public refreshToken?: string; public hashedRt?: string;
@CreateDateColumn() @CreateDateColumn()
public createdAt: Date; public createdAt: Date;

View File

@ -4,7 +4,6 @@ import { join } from 'path';
import { INestApplication, ValidationPipe } from '@nestjs/common'; import { INestApplication, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
@ -31,10 +30,6 @@ async function setupSwagger(app: INestApplication): Promise<void> {
); );
} }
async function setupCookieParser(app: INestApplication): Promise<void> {
app.use(cookieParser());
}
async function setupPrefix(app: INestApplication): Promise<void> { async function setupPrefix(app: INestApplication): Promise<void> {
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
} }
@ -46,7 +41,6 @@ async function setupClassValidator(app: INestApplication): Promise<void> {
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
await setupCookieParser(app);
await setupSwagger(app); await setupSwagger(app);
await setupPrefix(app); await setupPrefix(app);
await setupClassValidator(app); await setupClassValidator(app);

View File

@ -1,17 +1,15 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Session, UserCredentials } from 'src/entities'; import { UserCredentials } from 'src/entities';
import { SendgridModule } from '../sendgrid-module/sendgrid.module'; import { SendgridModule } from '../sendgrid-module/sendgrid.module';
import { UserModule } from '../user-module/user.module'; import { UserModule } from '../user-module/user.module';
import { VerifyModule } from '../verify-module/verify.module'; import { VerifyModule } from '../verify-module/verify.module';
import { AuthController } from './controller/auth.controller'; import { AuthController } from './controller/auth.controller';
import { SessionRepository } from './repositories/session.repository';
import { UserCredentialsRepository } from './repositories/user-credentials.repository'; import { UserCredentialsRepository } from './repositories/user-credentials.repository';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { SessionService } from './services/session.service';
import { TokenManagementService } from './services/token-management.service'; import { TokenManagementService } from './services/token-management.service';
import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies'; import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies';
@ -21,14 +19,12 @@ import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies';
SendgridModule, SendgridModule,
VerifyModule, VerifyModule,
JwtModule.register({}), JwtModule.register({}),
TypeOrmModule.forFeature([UserCredentials, Session]), TypeOrmModule.forFeature([UserCredentials]),
], ],
providers: [ providers: [
AuthService, AuthService,
SessionService,
TokenManagementService, TokenManagementService,
UserCredentialsRepository, UserCredentialsRepository,
SessionRepository,
AccessTokenStrategy, AccessTokenStrategy,
RefreshTokenStrategy, RefreshTokenStrategy,
], ],

View File

@ -1,13 +1,14 @@
//import { JwtPayloadWithRefreshToken } from 'src/modules/auth-module/models/types'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayloadWithRefreshToken } from 'src/modules/auth-module/models/types';
// export const GetCurrentUser = createParamDecorator( export const GetCurrentUser = createParamDecorator(
// ( (
// data: keyof JwtPayloadWithRefreshToken | undefined, data: keyof JwtPayloadWithRefreshToken | undefined,
// context: ExecutionContext context: ExecutionContext
// ) => { ) => {
// const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
// if (!data) return request.user; if (!data) return request.user;
// return request.user[data]; return request.user[data];
// } }
// ); );

View File

@ -1,2 +1,2 @@
export * from './get-user-id.decorator'; export * from './get-user-id.decorator';
// export * from './get-user.decorator'; export * from './get-user.decorator';

View File

@ -4,19 +4,14 @@ import {
Body, Body,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Res, UseGuards,
Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; import { ApiCreatedResponse, ApiHeader, ApiTags } from '@nestjs/swagger';
import { Response, Request } from 'express';
import { Public } from 'src/shared/decorator'; import { Public } from 'src/shared/decorator';
import { GetCurrentUserId } from '../common/decorators'; import { GetCurrentUser, GetCurrentUserId } from '../common/decorators';
import { import { RefreshTokenGuard } from '../common/guards';
AccessTokenDto, import { TokensDto, UserCredentialsDto } from '../models/dto';
LoginResponseDto,
UserCredentialsDto,
} from '../models/dto';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
@ApiTags('Authentication') @ApiTags('Authentication')
@ -26,50 +21,59 @@ export class AuthController {
@ApiCreatedResponse({ @ApiCreatedResponse({
description: 'User signed up successfully', description: 'User signed up successfully',
type: LoginResponseDto, type: TokensDto,
}) })
@Public() @Public()
@Post('signup') @Post('signup')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
public async signup( public async signup(
@Body() userCredentials: UserCredentialsDto @Body() userCredentials: UserCredentialsDto
): Promise<LoginResponseDto> { ): Promise<TokensDto> {
return this.authService.signup(userCredentials); return this.authService.signup(userCredentials);
} }
@ApiCreatedResponse({ @ApiCreatedResponse({
description: 'User signin successfully', description: 'User signin successfully',
type: LoginResponseDto, type: TokensDto,
}) })
@HttpCode(HttpStatus.OK)
@Public() @Public()
@Post('signin') @Post('signin')
public async signin(
@Res({ passthrough: true }) response: Response,
@Req() request: Request,
@Body() userCredentials: UserCredentialsDto
): Promise<LoginResponseDto> {
return await this.authService.signin(userCredentials, response, request);
}
@ApiCreatedResponse({
description: 'User tokens refreshed successfully',
type: AccessTokenDto,
})
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Public() public async signin(
@Post('refresh') @Body() userCredentials: UserCredentialsDto
public async refreshToken(@Req() request: Request): Promise<AccessTokenDto> { ): Promise<TokensDto> {
return await this.authService.refresh(request); return this.authService.signin(userCredentials);
} }
@ApiCreatedResponse({ @ApiCreatedResponse({
description: 'User signed out successfully', description: 'User signed out successfully',
type: Boolean, type: Boolean,
}) })
@HttpCode(HttpStatus.OK)
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK)
public async logout(@GetCurrentUserId() userId: string): Promise<boolean> { public async logout(@GetCurrentUserId() userId: string): Promise<boolean> {
return this.authService.logout(userId); return this.authService.logout(userId);
} }
@ApiHeader({
name: 'Authorization',
required: true,
schema: {
example: 'Bearer <refresh_token>',
},
})
@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<TokensDto> {
return this.authService.refresh(userId, refresh_token);
}
} }

View File

@ -1,3 +1,2 @@
export * from './user-credentials.dto'; export * from './user-credentials.dto';
export * from './login-response.dto'; export * from './tokens.dto';
export * from './access-token.dto';

View File

@ -1,32 +0,0 @@
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',
example: 'foo@bar.de',
})
@IsNotEmpty()
@IsString()
@IsEmail()
public email: string;
@ApiProperty({
title: 'User ID',
description: 'User ID',
})
@IsNotEmpty()
@IsString()
@IsEmail()
public userId: string;
}

View File

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
export class AccessTokenDto { export class TokensDto {
@ApiProperty({ @ApiProperty({
title: 'Access token', title: 'Access token',
description: 'Access token', description: 'Access token',
@ -10,4 +10,13 @@ export class AccessTokenDto {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
public access_token: string; public access_token: string;
@ApiProperty({
title: 'Refresh token',
description: 'Refresh token',
example: 'eyJhbGci',
})
@IsNotEmpty()
@IsString()
public refresh_token: string;
} }

View File

@ -1,4 +1,2 @@
export * from './jwt-payload.type'; export * from './jwt-payload.type';
// export * from './jwt-payload-with-refresh-token.type'; export * from './jwt-payload-with-refresh-token.type';
export * from './token-payload.type';
export * from './tokens.type';

View File

@ -1,3 +1,3 @@
// import { JwtPayload } from './jwt-payload.type'; import { JwtPayload } from './jwt-payload.type';
// export type JwtPayloadWithRefreshToken = JwtPayload & { refresh_token: string }; export type JwtPayloadWithRefreshToken = JwtPayload & { refresh_token: string };

View File

@ -1,6 +0,0 @@
export type TokenPayload = {
sub: string;
email: string;
iat: number;
exp: number;
};

View File

@ -1,4 +0,0 @@
export type Tokens = {
access_token: string;
refresh_token: string;
};

View File

@ -1,101 +0,0 @@
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 SessionRepository {
public constructor(
@InjectRepository(Session)
private sessionRepository: Repository<Session>
) {}
public async createSession(
userId: string,
userAgent: string
): Promise<Session> {
const sessionId = uuidv4();
const expirationDate = new Date();
expirationDate.setHours(expirationDate.getHours() + 1);
const session = this.sessionRepository.create({
userCredentials: userId,
sessionId,
expiresAt: expirationDate,
userAgent,
});
await this.sessionRepository.save(session);
return session;
}
public async findSessionBySessionId(sessionId: string): Promise<Session> {
return await 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',
});
}
public async validateSessionUserAgent(
sessionId: string,
currentUserAgent: string
): Promise<boolean> {
const session = await this.sessionRepository.findOne({
where: { sessionId: sessionId },
select: ['userAgent'],
});
if (!session) {
return false;
}
return session.userAgent === currentUserAgent;
}
public async checkSessionLimit(userId: string): Promise<void> {
const userSessions = await this.sessionRepository
.createQueryBuilder('session')
.leftJoinAndSelect('session.userCredentials', 'userCredentials')
.where('userCredentials.id = :userId', { userId })
.orderBy('session.expiresAt', 'ASC')
.getMany();
if (userSessions.length >= 5) {
await this.sessionRepository.delete(userSessions[0].id);
}
}
public async invalidateAllSessionsForUser(userId: string): Promise<void> {
await this.sessionRepository.delete({ userCredentials: userId });
}
public async extendSessionExpiration(sessionId: string): Promise<void> {
const session = await this.sessionRepository.findOne({
where: { sessionId },
});
if (session) {
session.expiresAt = new Date(
session.expiresAt.setMinutes(session.expiresAt.getMinutes() + 30)
);
await this.sessionRepository.save(session);
}
}
// TODO Add cron job to clear expired sessions
public async clearExpiredSessions(): Promise<void> {
const now = new Date();
await this.sessionRepository.delete({ expiresAt: LessThan(now) });
}
}

View File

@ -31,11 +31,11 @@ export class UserCredentialsRepository {
return this.repository.findOne({ where: { id: userId } }); return this.repository.findOne({ where: { id: userId } });
} }
public async updateUserRefreshToken( public async updateUserTokenHash(
userId: string, userId: string,
refreshToken: string | null hashedRt: string | null
): Promise<number> { ): Promise<number> {
const result = await this.repository.update(userId, { refreshToken }); const result = await this.repository.update(userId, { hashedRt });
return result.affected ?? 0; return result.affected ?? 0;
} }

View File

@ -1,20 +1,12 @@
import { ForbiddenException, Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { Response, Request } from 'express';
import { Session } from 'src/entities';
import { EncryptionService } from 'src/shared'; import { EncryptionService } from 'src/shared';
import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service'; import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service';
import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository';
import { EmailVerificationService } from '../../verify-module/services/email-verification.service'; import { EmailVerificationService } from '../../verify-module/services/email-verification.service';
import { import { TokensDto, UserCredentialsDto } from '../models/dto';
AccessTokenDto,
LoginResponseDto,
UserCredentialsDto,
} from '../models/dto';
import { TokenPayload } from '../models/types';
import { UserCredentialsRepository } from '../repositories/user-credentials.repository'; import { UserCredentialsRepository } from '../repositories/user-credentials.repository';
import { SessionService } from './session.service';
import { TokenManagementService } from './token-management.service'; import { TokenManagementService } from './token-management.service';
@Injectable() @Injectable()
@ -24,13 +16,10 @@ export class AuthService {
private readonly userDataRepository: UserDataRepository, private readonly userDataRepository: UserDataRepository,
private readonly tokenManagementService: TokenManagementService, private readonly tokenManagementService: TokenManagementService,
private readonly passwordConfirmationMailService: PasswordConfirmationMailService, private readonly passwordConfirmationMailService: PasswordConfirmationMailService,
private readonly emailVerificationService: EmailVerificationService, private readonly emailVerificationService: EmailVerificationService
private readonly sessionService: SessionService
) {} ) {}
public async signup( public async signup(userCredentials: UserCredentialsDto): Promise<TokensDto> {
userCredentials: UserCredentialsDto
): Promise<LoginResponseDto> {
const passwordHashed = await EncryptionService.hashData( const passwordHashed = await EncryptionService.hashData(
userCredentials.password userCredentials.password
); );
@ -55,11 +44,7 @@ export class AuthService {
return this.generateAndPersistTokens(user.id, user.email); return this.generateAndPersistTokens(user.id, user.email);
} }
public async signin( public async signin(userCredentials: UserCredentialsDto): Promise<TokensDto> {
userCredentials: UserCredentialsDto,
response: Response,
request: Request
): Promise<LoginResponseDto> {
const user = await this.userCredentialsRepository.findUserByEmail( const user = await this.userCredentialsRepository.findUserByEmail(
userCredentials.email userCredentials.email
); );
@ -77,104 +62,56 @@ export class AuthService {
throw new ForbiddenException('Access Denied'); throw new ForbiddenException('Access Denied');
} }
await this.sessionService.checkSessionLimit(user.id); return this.generateAndPersistTokens(user.id, user.email);
}
const sesseionId = await this.sessionService.createSession( public async refresh(
user.id, userId: string,
request.headers['user-agent'] refreshToken: string
): Promise<TokensDto> {
const user = await this.userCredentialsRepository.findUserById(userId);
if (!user || !user.hashedRt) {
throw new ForbiddenException('Access Denied');
}
const refreshTokenMatch = await EncryptionService.compareHash(
refreshToken,
user.hashedRt
); );
this.sessionService.attachSessionToResponse(response, sesseionId.sessionId); if (!refreshTokenMatch) {
throw new ForbiddenException('Access Denied');
}
return this.generateAndPersistTokens(user.id, user.email, true); return this.generateAndPersistTokens(user.id, user.email);
} }
public async logout(userId: string): Promise<boolean> { public async logout(userId: string): Promise<boolean> {
const affected = const affected = await this.userCredentialsRepository.updateUserTokenHash(
await this.userCredentialsRepository.updateUserRefreshToken(userId, null); userId,
null
await this.sessionService.invalidateAllSessionsForUser(userId); );
return affected > 0; return affected > 0;
} }
public async refresh(request: Request): Promise<AccessTokenDto> {
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']
);
if (!isUserAgentValid) {
throw new ForbiddenException('Invalid session - User agent mismatch');
}
await this.sessionService.extendSessionExpiration(sessionId);
const decodedToken: TokenPayload = await this.validateRefreshToken(
session.userCredentials['id']
);
const newTokens = await this.generateAndPersistTokens(
decodedToken.sub,
decodedToken.email,
false
);
return { access_token: newTokens.access_token };
}
private async generateAndPersistTokens( private async generateAndPersistTokens(
userId: string, userId: string,
email: string, email: string
updateRefreshToken: boolean = false ): Promise<TokensDto> {
): Promise<LoginResponseDto> {
const tokens = await this.tokenManagementService.generateTokens( const tokens = await this.tokenManagementService.generateTokens(
userId, userId,
email email
); );
const hashedRefreshToken = await EncryptionService.hashData(
if (updateRefreshToken) {
await this.userCredentialsRepository.updateUserRefreshToken(
userId,
tokens.refresh_token tokens.refresh_token
); );
}
return { access_token: tokens.access_token, email: email, userId: userId }; await this.userCredentialsRepository.updateUserTokenHash(
} userId,
hashedRefreshToken
private async validateRefreshToken(userId: string): Promise<TokenPayload> {
const user = await this.userCredentialsRepository.findUserById(userId);
if (!user || !user.refreshToken) {
throw new Error('No refresh token found');
}
const decodedToken = await this.tokenManagementService.verifyRefreshToken(
user.refreshToken
); );
return tokens;
if (decodedToken.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
if (decodedToken.sub !== user.id) {
throw new Error('Token subject mismatch');
}
return decodedToken;
} }
} }

View File

@ -1,55 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Session } from 'src/entities';
import { SessionRepository } from '../repositories/session.repository';
@Injectable()
export class SessionService {
public constructor(
@InjectRepository(Session)
private readonly sessionRepository: SessionRepository
) {}
public async createSession(
userId: string,
userAgent: string
): Promise<Session> {
return await this.sessionRepository.createSession(userId, userAgent);
}
public async validateSessionUserAgent(
sessionId: string,
currentUserAgent: string
): Promise<boolean> {
return await this.sessionRepository.validateSessionUserAgent(
sessionId,
currentUserAgent
);
}
public async checkSessionLimit(userId: string): Promise<void> {
await this.sessionRepository.checkSessionLimit(userId);
}
public async invalidateAllSessionsForUser(userId: string): Promise<void> {
await this.sessionRepository.invalidateAllSessionsForUser(userId);
}
public async clearExpiredSessions(): Promise<void> {
await this.sessionRepository.clearExpiredSessions();
}
public async extendSessionExpiration(sessionId: string): Promise<void> {
await this.sessionRepository.extendSessionExpiration(sessionId);
}
public async findSessionBySessionId(sessionId: string): Promise<Session> {
return await this.sessionRepository.findSessionBySessionId(sessionId);
}
public attachSessionToResponse(response: Response, sessionId: string): void {
this.sessionRepository.attachSessionToResponse(response, sessionId);
}
}

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { TokenPayload, Tokens } from '../models/types'; import { TokensDto } from '../models/dto';
@Injectable() @Injectable()
export class TokenManagementService { export class TokenManagementService {
@ -25,19 +25,16 @@ export class TokenManagementService {
this.JWT_SECRET_RT = this.configService.get<string>('JWT_SECRET_RT'); this.JWT_SECRET_RT = this.configService.get<string>('JWT_SECRET_RT');
} }
public async generateTokens(userId: string, email: string): Promise<Tokens> { public async generateTokens(
userId: string,
email: string
): Promise<TokensDto> {
const access_token: string = await this.createAccessToken(userId, email); const access_token: string = await this.createAccessToken(userId, email);
const refresh_token: string = await this.createRefreshToken(userId, email); const refresh_token: string = await this.createRefreshToken(userId, email);
return { access_token, refresh_token }; return { access_token, refresh_token };
} }
public async verifyRefreshToken(token: string): Promise<TokenPayload> {
return this.jwt.verifyAsync(token, {
secret: this.JWT_SECRET_RT,
});
}
private async createAccessToken( private async createAccessToken(
userId: string, userId: string,
email: string email: string

View File

@ -1,11 +1,6 @@
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { import { EmailVerification, UserCredentials, UserData } from 'src/entities';
EmailVerification,
UserCredentials,
UserData,
Session,
} from 'src/entities';
export const databaseConfigFactory = ( export const databaseConfigFactory = (
configService: ConfigService configService: ConfigService
@ -18,5 +13,5 @@ export const databaseConfigFactory = (
database: configService.get('DB_NAME'), database: configService.get('DB_NAME'),
synchronize: true, synchronize: true,
logging: true, logging: true,
entities: [UserCredentials, UserData, EmailVerification, Session], entities: [UserCredentials, UserData, EmailVerification],
}); });

View File

@ -1,33 +1,15 @@
import { Component, OnInit } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterOutlet, Router } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { AuthService } from './shared/service'; import { AuthService } from './shared/service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
providers: [], providers: [AuthService],
imports: [RouterOutlet], imports: [RouterOutlet],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
}) })
export class AppComponent implements OnInit { export class AppComponent {
public constructor( private readonly authService: AuthService = inject(AuthService);
private readonly authService: AuthService,
private readonly router: Router
) {}
public ngOnInit(): void {
this.checkAuthentication();
}
private checkAuthentication(): void {
this.authService.isAuthenticated$.subscribe((isAuthenticated: boolean) => {
if (isAuthenticated) {
console.log('User is authenticated');
this.router.navigateByUrl('dashboard');
} else {
this.router.navigateByUrl('signup');
}
});
}
} }

View File

@ -1,12 +1,7 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { AuthGuard } from './shared/guard/auth.guard'; export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: '' },
const publicRoutes: Routes = [
{
path: '',
loadComponent: () => import('./app.component').then((m) => m.AppComponent),
},
{ {
path: 'signup', path: 'signup',
loadComponent: () => loadComponent: () =>
@ -22,25 +17,3 @@ const publicRoutes: Routes = [
), ),
}, },
]; ];
const protectedRoutes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./pages/dashboard-root/dashboard-root.component').then(
(m) => m.DashboardRootComponent
),
canActivate: [AuthGuard],
},
];
export const routes: Routes = [
{
path: '',
children: [
...publicRoutes,
...protectedRoutes,
{ path: '', redirectTo: '', pathMatch: 'full' },
],
},
];

View File

@ -1 +0,0 @@
<h1>Hello World</h1>

View File

@ -1,14 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-dashboard-root',
standalone: true,
imports: [],
providers: [],
templateUrl: './dashboard-root.component.html',
styleUrl: './dashboard-root.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardRootComponent {
public constructor() {}
}

View File

@ -47,7 +47,7 @@ type AuthAction = 'register' | 'signup';
PasswordModule, PasswordModule,
HttpClientModule, HttpClientModule,
], ],
providers: [], providers: [AuthService],
templateUrl: './register-root.component.html', templateUrl: './register-root.component.html',
styleUrl: './register-root.component.scss', styleUrl: './register-root.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,

View File

@ -1,32 +0,0 @@
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from '../service';
export const AuthGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
):
| Observable<boolean | UrlTree>
| Promise<boolean | UrlTree>
| boolean
| UrlTree => {
const authService: AuthService = inject(AuthService);
const router: Router = inject(Router);
authService.isAuthenticated$.subscribe((isAuthenticated: boolean) => {
if (!isAuthenticated) {
router.navigateByUrl('signup');
}
});
return true;
};

View File

@ -1,79 +1,51 @@
import { import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn, HttpInterceptorFn,
HttpRequest, HttpRequest,
HttpHandlerFn,
HttpEvent,
HttpErrorResponse,
} from '@angular/common/http'; } from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, throwError } from 'rxjs'; import { Observable, catchError, switchMap, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AuthService } from '../service'; import { AuthService } from '../service';
import { Tokens } from '../types';
export const AuthInterceptor: HttpInterceptorFn = ( export const AuthInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>, request: HttpRequest<unknown>,
next: HttpHandlerFn next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => { ): Observable<HttpEvent<unknown>> => {
const router = inject(Router); const authService: AuthService = inject(AuthService);
const authService = inject(AuthService); const accessToken: string | null = authService.access_token;
const handleRequest = (
req: HttpRequest<unknown>
): Observable<HttpEvent<unknown>> => {
const accessToken = authService.access_token;
if (accessToken) { if (accessToken) {
req = addAuthHeader(req, accessToken); request = request.clone({
}
return next(req);
};
const addAuthHeader = (
req: HttpRequest<unknown>,
token: string
): HttpRequest<unknown> => {
return req.clone({
setHeaders: { setHeaders: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${accessToken}`,
}, },
}); });
};
const handle401Error = (
req: HttpRequest<unknown>
): Observable<HttpEvent<unknown>> => {
console.log(authService.refresh_token);
if (!authService.refresh_token) {
router.navigateByUrl('signup');
return throwError(() => new Error('Authentication required'));
} }
return next(request).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
return authService.refreshToken().pipe( return authService.refreshToken().pipe(
switchMap((tokens) => { switchMap((tokens: Tokens) => {
req = addAuthHeader(req, tokens.access_token); request = request.clone({
return next(req); setHeaders: {
Authorization: `Bearer ${tokens.access_token}`,
},
});
return next(request);
}), }),
catchError((refreshError) => { catchError((refreshError) => {
router.navigateByUrl('signup'); authService.signout();
return throwError(() => new Error(refreshError)); return throwError(() => new Error(refreshError));
}) })
); );
};
const handleError = (
error: HttpErrorResponse,
req: HttpRequest<unknown>
): Observable<HttpEvent<unknown>> => {
if (error.status === 401) {
return handle401Error(req);
} }
return throwError(() => new Error('Unhandled error'));
};
return handleRequest(request).pipe( return throwError(() => new Error());
catchError((error) => handleError(error, request)) })
); );
}; };

View File

@ -1,3 +1,4 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, tap } from 'rxjs'; import { BehaviorSubject, Observable, tap } from 'rxjs';
@ -12,28 +13,22 @@ import { SessionStorageService } from './session-storage.service';
providedIn: 'root', providedIn: 'root',
}) })
export class AuthService { export class AuthService {
public isAuthenticated$: BehaviorSubject<boolean> =
new BehaviorSubject<boolean>(false);
private _access_token: string | null = null; private _access_token: string | null = null;
private _refresh_token: string | null = null; private _refresh_token: string | null = null;
private _isAuthenticated$: BehaviorSubject<boolean> =
new BehaviorSubject<boolean>(false);
public get access_token(): string | null { public get access_token(): string | null {
return this._access_token; return this._access_token;
} }
public get refresh_token(): string | null {
return this._refresh_token;
}
public constructor( public constructor(
private readonly httpClient: HttpClient,
private readonly localStorageService: LocalStorageService, private readonly localStorageService: LocalStorageService,
private readonly sessionStorageService: SessionStorageService, private readonly sessionStorageService: SessionStorageService,
private readonly authenticationApiService: AuthenticationApiService private readonly authenticationApiService: AuthenticationApiService
) { ) {
this._access_token = //this.autoLogin();
this.localStorageService.getItem<string>('access_token');
this._refresh_token =
this.sessionStorageService.getItem<string>('refresh_token');
} }
public signin(credentials: LoginCredentials): void { public signin(credentials: LoginCredentials): void {
@ -71,16 +66,31 @@ export class AuthService {
this._refresh_token = null; this._refresh_token = null;
this.localStorageService.removeItem('access_token'); this.localStorageService.removeItem('access_token');
this.sessionStorageService.removeItem('refresh_token'); this.sessionStorageService.removeItem('refresh_token');
this.isAuthenticated$.next(false); this._isAuthenticated$.next(false);
} }
}); });
} }
public autoLogin(): void {
const storedAccessToken: string | null =
this.localStorageService.getItem('access_token');
const storedRefreshToken: string | null =
this.sessionStorageService.getItem('refresh_token');
if (storedAccessToken && storedRefreshToken) {
this._refresh_token = storedRefreshToken;
this._isAuthenticated$.next(true);
//TODO Validate tokens with backend or decode JWT to check expiration
} else {
this.signout();
}
}
private handleSuccess(tokens: Tokens): void { private handleSuccess(tokens: Tokens): void {
this._access_token = tokens.access_token; this._access_token = tokens.access_token;
this._refresh_token = tokens.refresh_token; this._refresh_token = tokens.refresh_token;
this.localStorageService.setItem('access_token', tokens.access_token); this.localStorageService.setItem('access_token', tokens.access_token);
this.sessionStorageService.setItem('refresh_token', tokens.refresh_token); this.sessionStorageService.setItem('refresh_token', tokens.refresh_token);
this.isAuthenticated$.next(true); this._isAuthenticated$.next(true);
} }
} }