Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Authentication - identyfikacja legionariuszy

strażniku bramy! Konsul Caesar.js zauważył, że na nasze forty przedostają się niechciani goście, a niektórzy legioniści podszywają się pod członków legionu. Czas nauczyć się authentication - magicznej sztuki rozpoznawania prawdziwych legionariuszy od samozwańców!

Czym jest Authentication w świecie legionariuszy?

Wyobraź sobie, że jesteś głównym strażnikiem największej warowni w Imperium. Każdego dnia dziesiątki osób próbuje wejść na fort:

  • Prawdziwi członkowie legionu - mają prawo dostępu
  • Goście z sojuszniczych fortów - potrzebują specjalnego pozwolenia
  • Kupcy i dostawcy - czasowy dostęp do określonych części
  • Szpiedzy i wrogowie - absolutnie nie mogą dostać się do systemu!

Authentication to proces weryfikacji tożsamości:

  • Kto próbuje się zalogować? (Identity)
  • Czy ma prawo dostępu? (Authentication)
  • Do czego ma dostęp? (Authorization)
  • Jak długo może zostać? (Session Management)

Podstawy Authentication w NestJS

Instalacja niezbędnych pakietów

1# Podstawowe pakiety do authentication
2npm install @nestjs/passport passport passport-local passport-jwt
3npm install @nestjs/jwt bcrypt
4npm install --save-dev @types/passport-local @types/passport-jwt @types/bcrypt

User Entity - rejestr legionu

1// entities/user.entity.ts
2import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
3import { Exclude } from 'class-transformer';
4
5@Entity('users')
6export class User {
7 @PrimaryGeneratedColumn()
8 id: number;
9
10 @Column({ unique: true })
11 username: string;
12
13 @Column({ unique: true })
14 email: string;
15
16 @Column()
17 @Exclude() // Nigdy nie zwracaj hasła w response
18 password: string;
19
20 @Column({ default: 'LEGION_MEMBER' })
21 role: string; // CENTURION, OPTIO, LEGION_MEMBER, GUEST
22
23 @Column({ nullable: true })
24 firstName: string;
25
26 @Column({ nullable: true })
27 lastName: string;
28
29 @Column({ nullable: true })
30 cohortName: string;
31
32 @Column({ default: true })
33 isActive: boolean;
34
35 @CreateDateColumn()
36 joinedAt: Date;
37
38 @UpdateDateColumn()
39 lastLoginAt: Date;
40
41 @Column({ nullable: true })
42 lastLoginIp: string;
43
44 @Column({ default: 0 })
45 failedLoginAttempts: number;
46
47 @Column({ nullable: true })
48 lockedUntil: Date;
49}

DTO dla logowania

1// dto/auth.dto.ts
2import { IsEmail, IsNotEmpty, MinLength, IsOptional } from 'class-validator';
3
4export class RegisterDto {
5 @IsNotEmpty({ message: 'Nazwa legionariusza jest wymagana!' })
6 @MinLength(3, { message: 'Nazwa musi mieć co najmniej 3 znaki' })
7 username: string;
8
9 @IsEmail({}, { message: 'Podaj poprawny adres email' })
10 email: string;
11
12 @IsNotEmpty({ message: 'Hasło jest wymagane!' })
13 @MinLength(6, { message: 'Hasło musi mieć co najmniej 6 znaków' })
14 password: string;
15
16 @IsOptional()
17 firstName?: string;
18
19 @IsOptional()
20 lastName?: string;
21
22 @IsOptional()
23 cohortName?: string;
24}
25
26export class LoginDto {
27 @IsNotEmpty({ message: 'Nazwa użytkownika jest wymagana!' })
28 username: string;
29
30 @IsNotEmpty({ message: 'Hasło jest wymagane!' })
31 password: string;
32}
33
34export class ChangePasswordDto {
35 @IsNotEmpty({ message: 'Aktualne hasło jest wymagane!' })
36 currentPassword: string;
37
38 @IsNotEmpty({ message: 'Nowe hasło jest wymagane!' })
39 @MinLength(6, { message: 'Nowe hasło musi mieć co najmniej 6 znaków' })
40 newPassword: string;
41}

Hash'owanie haseł - szyfrowanie kodów dostępu

1// services/crypto.service.ts
2import { Injectable } from '@nestjs/common';
3import * as bcrypt from 'bcrypt';
4
5@Injectable()
6export class CryptoService {
7 private readonly saltRounds = 12;
8
9 async hashPassword(password: string): Promise<string> {
10 // Generuj sól dla każdego hasła osobno
11 const salt = await bcrypt.genSalt(this.saltRounds);
12 return await bcrypt.hash(password, salt);
13 }
14
15 async comparePasswords(plainPassword: string, hashedPassword: string): Promise<boolean> {
16 return await bcrypt.compare(plainPassword, hashedPassword);
17 }
18
19 generateRandomToken(length: number = 32): string {
20 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
21 let result = '';
22 for (let i = 0; i < length; i++) {
23 result += chars.charAt(Math.floor(Math.random() * chars.length));
24 }
25 return result;
26 }
27
28 // Sprawdź siłę hasła
29 checkPasswordStrength(password: string): {
30 score: number;
31 feedback: string[];
32 isStrong: boolean;
33 } {
34 const feedback: string[] = [];
35 let score = 0;
36
37 // Długość
38 if (password.length >= 8) score += 1;
39 else feedback.push('Hasło powinno mieć co najmniej 8 znaków');
40
41 // Małe litery
42 if (/[a-z]/.test(password)) score += 1;
43 else feedback.push('Dodaj małe litery');
44
45 // Duże litery 
46 if (/[A-Z]/.test(password)) score += 1;
47 else feedback.push('Dodaj duże litery');
48
49 // Cyfry
50 if (/d/.test(password)) score += 1;
51 else feedback.push('Dodaj cyfry');
52
53 // Znaki specjalne
54 if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score += 1;
55 else feedback.push('Dodaj znaki specjalne');
56
57 return {
58 score,
59 feedback,
60 isStrong: score >= 4,
61 };
62 }
63}

User Service - zarządzanie legionem

1// services/user.service.ts
2import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4import { Repository } from 'typeorm';
5import { User } from '../entities/user.entity';
6import { RegisterDto, ChangePasswordDto } from '../dto/auth.dto';
7import { CryptoService } from './crypto.service';
8
9@Injectable()
10export class UserService {
11 constructor(
12 @InjectRepository(User)
13 private userRepository: Repository<User>,
14 private cryptoService: CryptoService,
15 ) {}
16
17 async createUser(registerDto: RegisterDto): Promise<User> {
18 // Sprawdź czy użytkownik już istnieje
19 const existingUser = await this.userRepository.findOne({
20 where: [{ username: registerDto.username }, { email: registerDto.email }],
21 });
22
23 if (existingUser) {
24 if (existingUser.username === registerDto.username) {
25 throw new ConflictException('Legionariusz o tej nazwie już istnieje w legionie!');
26 }
27 if (existingUser.email === registerDto.email) {
28 throw new ConflictException('Ten adres email jest już używany!');
29 }
30 }
31
32 // Sprawdź siłę hasła
33 const passwordStrength = this.cryptoService.checkPasswordStrength(registerDto.password);
34 if (!passwordStrength.isStrong) {
35 throw new BadRequestException(`Hasło jest za słabe: ${passwordStrength.feedback.join(', ')}`);
36 }
37
38 // Hash hasła
39 const hashedPassword = await this.cryptoService.hashPassword(registerDto.password);
40
41 // Utwórz nowego użytkownika
42 const user = this.userRepository.create({
43 ...registerDto,
44 password: hashedPassword,
45 role: 'LEGION_MEMBER', // Domyślna rola
46 });
47
48 return await this.userRepository.save(user);
49 }
50
51 async findByUsername(username: string): Promise<User | null> {
52 return await this.userRepository.findOne({
53 where: { username },
54 });
55 }
56
57 async findByEmail(email: string): Promise<User | null> {
58 return await this.userRepository.findOne({
59 where: { email },
60 });
61 }
62
63 async findById(id: number): Promise<User | null> {
64 return await this.userRepository.findOne({
65 where: { id },
66 });
67 }
68
69 async validateUser(username: string, password: string): Promise<User | null> {
70 const user = await this.findByUsername(username);
71 
72 if (!user) {
73 return null;
74 }
75
76 // Sprawdź czy konto nie jest zablokowane
77 if (user.lockedUntil && user.lockedUntil > new Date()) {
78 throw new BadRequestException('Konto zostało tymczasowo zablokowane. Spróbuj ponownie później.');
79 }
80
81 // Sprawdź czy konto jest aktywne
82 if (!user.isActive) {
83 throw new BadRequestException('Konto zostało dezaktywowane.');
84 }
85
86 // Zweryfikuj hasło
87 const isPasswordValid = await this.cryptoService.comparePasswords(password, user.password);
88 
89 if (!isPasswordValid) {
90 // Zwiększ licznik nieudanych prób
91 await this.incrementFailedLoginAttempts(user.id);
92 return null;
93 }
94
95 // Reset licznika nieudanych prób przy udanym logowaniu
96 await this.resetFailedLoginAttempts(user.id);
97 
98 return user;
99 }
100
101 async updateLastLogin(userId: number, ip: string): Promise<void> {
102 await this.userRepository.update(userId, {
103 lastLoginAt: new Date(),
104 lastLoginIp: ip,
105 });
106 }
107
108 async changePassword(userId: number, changePasswordDto: ChangePasswordDto): Promise<void> {
109 const user = await this.findById(userId);
110 
111 if (!user) {
112 throw new NotFoundException('Użytkownik nie znaleziony');
113 }
114
115 // Sprawdź aktualne hasło
116 const isCurrentPasswordValid = await this.cryptoService.comparePasswords(
117 changePasswordDto.currentPassword,
118 user.password
119 );
120
121 if (!isCurrentPasswordValid) {
122 throw new BadRequestException('Aktualne hasło jest niepoprawne!');
123 }
124
125 // Sprawdź siłę nowego hasła
126 const passwordStrength = this.cryptoService.checkPasswordStrength(changePasswordDto.newPassword);
127 if (!passwordStrength.isStrong) {
128 throw new BadRequestException(`Nowe hasło jest za słabe: ${passwordStrength.feedback.join(', ')}`);
129 }
130
131 // Hash nowego hasła
132 const hashedPassword = await this.cryptoService.hashPassword(changePasswordDto.newPassword);
133 
134 await this.userRepository.update(userId, {
135 password: hashedPassword,
136 });
137 }
138
139 private async incrementFailedLoginAttempts(userId: number): Promise<void> {
140 const user = await this.findById(userId);
141 const newAttempts = user.failedLoginAttempts + 1;
142 
143 const updateData: Partial<User> = {
144 failedLoginAttempts: newAttempts,
145 };
146
147 // Zablokuj konto po 5 nieudanych próbach na 15 minut
148 if (newAttempts >= 5) {
149 const lockDuration = 15 * 60 * 1000; // 15 minut
150 updateData.lockedUntil = new Date(Date.now() + lockDuration);
151 }
152
153 await this.userRepository.update(userId, updateData);
154 }
155
156 private async resetFailedLoginAttempts(userId: number): Promise<void> {
157 await this.userRepository.update(userId, {
158 failedLoginAttempts: 0,
159 lockedUntil: null,
160 });
161 }
162
163 async getUserProfile(userId: number): Promise<Partial<User>> {
164 const user = await this.findById(userId);
165 
166 if (!user) {
167 throw new NotFoundException('Profil legionariusza nie został znaleziony');
168 }
169
170 // Nie zwracaj hasła i wrażliwych danych
171 const { password, failedLoginAttempts, lockedUntil, ...profile } = user;
172 return profile;
173 }
174
175 async updateProfile(userId: number, updateData: Partial<User>): Promise<User> {
176 // Usuń pola, których użytkownik nie może zmieniać
177 const { password, role, isActive, failedLoginAttempts, lockedUntil, ...allowedUpdates } = updateData;
178 
179 await this.userRepository.update(userId, allowedUpdates);
180 return await this.findById(userId);
181 }
182
183 async getAllUsers(): Promise<Partial<User>[]> {
184 const users = await this.userRepository.find({
185 order: { joinedAt: 'DESC' },
186 });
187
188 // Usuń wrażliwe dane
189 return users.map(({ password, ...user }) => user);
190 }
191
192 async deactivateUser(userId: number): Promise<void> {
193 await this.userRepository.update(userId, { isActive: false });
194 }
195
196 async activateUser(userId: number): Promise<void> {
197 await this.userRepository.update(userId, { 
198 isActive: true,
199 failedLoginAttempts: 0,
200 lockedUntil: null,
201 });
202 }
203}

Auth Controller - brama dostępu

1// controllers/auth.controller.ts
2import { 
3 Controller, 
4 Post, 
5 Body, 
6 Get, 
7 Put, 
8 UseGuards, 
9 Request,
10 HttpCode,
11 HttpStatus,
12 Ip
13} from '@nestjs/common';
14import { UserService } from '../services/user.service';
15import { RegisterDto, LoginDto, ChangePasswordDto } from '../dto/auth.dto';
16import { User } from '../entities/user.entity';
17
18@Controller('auth')
19export class AuthController {
20 constructor(private userService: UserService) {}
21
22 @Post('register')
23 async register(@Body() registerDto: RegisterDto): Promise<{ message: string; user: Partial<User> }> {
24 const user = await this.userService.createUser(registerDto);
25 
26 // Nie zwracaj hasła
27 const { password, ...userWithoutPassword } = user;
28 
29 return {
30 message: 'Nowy legionariusz został pomyślnie zarejestrowany w legionie!',
31 user: userWithoutPassword,
32 };
33 }
34
35 @Post('login')
36 @HttpCode(HttpStatus.OK)
37 async login(
38 @Body() loginDto: LoginDto,
39 @Ip() ip: string,
40 ): Promise<{ message: string; user: Partial<User> }> {
41 const user = await this.userService.validateUser(loginDto.username, loginDto.password);
42 
43 if (!user) {
44 throw new BadRequestException('Niepoprawne dane logowania!');
45 }
46
47 // Aktualizuj informacje o ostatnim logowaniu
48 await this.userService.updateLastLogin(user.id, ip);
49 
50 const { password, ...userWithoutPassword } = user;
51 
52 return {
53 message: `Witaj z powrotem do legionu, ${user.firstName || user.username}!`,
54 user: userWithoutPassword,
55 };
56 }
57
58 @Get('profile')
59 // @UseGuards(JwtAuthGuard) // Będziemy to dodać w następnym ćwiczeniu
60 async getProfile(@Request() req): Promise<Partial<User>> {
61 // Tymczasowo - w prawdziwej aplikacji user.id będzie z JWT
62 const userId = req.user?.id || 1;
63 return await this.userService.getUserProfile(userId);
64 }
65
66 @Put('change-password')
67 // @UseGuards(JwtAuthGuard)
68 async changePassword(
69 @Request() req,
70 @Body() changePasswordDto: ChangePasswordDto,
71 ): Promise<{ message: string }> {
72 const userId = req.user?.id || 1;
73 await this.userService.changePassword(userId, changePasswordDto);
74 
75 return {
76 message: 'Hasło zostało pomyślnie zmienione!',
77 };
78 }
79
80 @Put('profile')
81 // @UseGuards(JwtAuthGuard)
82 async updateProfile(
83 @Request() req,
84 @Body() updateData: Partial<User>,
85 ): Promise<{ message: string; user: Partial<User> }> {
86 const userId = req.user?.id || 1;
87 const updatedUser = await this.userService.updateProfile(userId, updateData);
88 
89 const { password, ...userWithoutPassword } = updatedUser;
90 
91 return {
92 message: 'Profil został zaktualizowany!',
93 user: userWithoutPassword,
94 };
95 }
96
97 @Get('users')
98 // @UseGuards(JwtAuthGuard, RolesGuard)
99 // @Roles('CENTURION', 'OPTIO')
100 async getAllUsers(): Promise<Partial<User>[]> {
101 return await this.userService.getAllUsers();
102 }
103}

Middleware do logowania

1// middleware/auth-logger.middleware.ts
2import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
3import { Request, Response, NextFunction } from 'express';
4
5@Injectable()
6export class AuthLoggerMiddleware implements NestMiddleware {
7 private readonly logger = new Logger('AuthLogger');
8
9 use(req: Request, res: Response, next: NextFunction) {
10 const { method, originalUrl, ip } = req;
11 const userAgent = req.get('User-Agent') || '';
12 
13 // Loguj tylko żądania związane z autentyfikacją
14 if (originalUrl.includes('/auth/')) {
15 this.logger.log(`${method} ${originalUrl} - IP: ${ip} - User-Agent: ${userAgent}`);
16 
17 // Loguj szczegóły po zakończeniu żądania
18 res.on('finish', () => {
19 const { statusCode } = res;
20 const contentLength = res.get('content-length');
21 
22 if (statusCode >= 400) {
23 this.logger.warn(`${method} ${originalUrl} ${statusCode} - IP: ${ip}`);
24 } else {
25 this.logger.log(`${method} ${originalUrl} ${statusCode} ${contentLength} - IP: ${ip}`);
26 }
27 });
28 }
29 
30 next();
31 }
32}

Konfiguracja modułu

1// auth.module.ts
2import { Module, MiddlewareConsumer } from '@nestjs/common';
3import { TypeOrmModule } from '@nestjs/typeorm';
4import { User } from './entities/user.entity';
5import { UserService } from './services/user.service';
6import { CryptoService } from './services/crypto.service';
7import { AuthController } from './controllers/auth.controller';
8import { AuthLoggerMiddleware } from './middleware/auth-logger.middleware';
9
10@Module({
11 imports: [TypeOrmModule.forFeature([User])],
12 controllers: [AuthController],
13 providers: [UserService, CryptoService],
14 exports: [UserService, CryptoService],
15})
16export class AuthModule {
17 configure(consumer: MiddlewareConsumer) {
18 consumer
19 .apply(AuthLoggerMiddleware)
20 .forRoutes('auth/*');
21 }
22}

Testowanie Authentication

1// auth.service.spec.ts
2import { Test, TestingModule } from '@nestjs/testing';
3import { getRepositoryToken } from '@nestjs/typeorm';
4import { Repository } from 'typeorm';
5import { UserService } from './user.service';
6import { CryptoService } from './crypto.service';
7import { User } from '../entities/user.entity';
8
9describe('UserService', () => {
10 let service: UserService;
11 let mockRepository: Partial<Repository<User>>;
12 let cryptoService: CryptoService;
13
14 beforeEach(async () => {
15 mockRepository = {
16 findOne: jest.fn(),
17 create: jest.fn(),
18 save: jest.fn(),
19 update: jest.fn(),
20 };
21
22 const module: TestingModule = await Test.createTestingModule({
23 providers: [
24 UserService,
25 CryptoService,
26 {
27 provide: getRepositoryToken(User),
28 useValue: mockRepository,
29 },
30 ],
31 }).compile();
32
33 service = module.get<UserService>(UserService);
34 cryptoService = module.get<CryptoService>(CryptoService);
35 });
36
37 describe('validateUser', () => {
38 it('should return user when credentials are valid', async () => {
39 const mockUser = {
40 id: 1,
41 username: 'caesar',
42 password: await cryptoService.hashPassword('secretpassword'),
43 isActive: true,
44 failedLoginAttempts: 0,
45 lockedUntil: null,
46 };
47
48 mockRepository.findOne = jest.fn().mockResolvedValue(mockUser);
49
50 const result = await service.validateUser('caesar', 'secretpassword');
51 expect(result).toBeDefined();
52 expect(result.username).toBe('caesar');
53 });
54
55 it('should return null when password is invalid', async () => {
56 const mockUser = {
57 id: 1,
58 username: 'caesar',
59 password: await cryptoService.hashPassword('correctpassword'),
60 isActive: true,
61 failedLoginAttempts: 0,
62 lockedUntil: null,
63 };
64
65 mockRepository.findOne = jest.fn().mockResolvedValue(mockUser);
66
67 const result = await service.validateUser('caesar', 'wrongpassword');
68 expect(result).toBeNull();
69 });
70 });
71});

Praktyczne ćwiczenie

Zaimplementuj dodatkowe funkcje bezpieczeństwa:

1// Twój kod tutaj
2
3// 1. Email verification system
4// 2. Password reset functionality 
5// 3. Two-factor authentication (2FA)
6// 4. Account lockout po określonym czasie
7// 5. Rate limiting dla endpointów logowania

Podsumowanie

Gratulacje! Zostałeś mianowany głównym strażnikiem bramy! Teraz umiesz:

Tworzyć system rejestracji z walidacją danych ✓ Hash'ować hasła bezpiecznie z bcrypt ✓ Walidować użytkowników przy logowaniu ✓ Zarządzać sesjami i stanem konta ✓ Implementować zabezpieczenia przed atakami brute-force ✓ Logować aktywność authentication

W następnych lekcjach poznasz JWT tokeny, Passport.js i zaawansowane techniki autoryzacji!

Przejdź do CodeWorlds