Appearance
Chapter 4 - Authentication
Ikhtisar Chapter
Chapter ini membangun sistem autentikasi: user dapat mendaftar, password disimpan dengan aman, user dapat login, lalu menerima JWT untuk mengakses route yang dilindungi. Materi ini menjadi fondasi sebelum pembatasan akses per user di chapter berikutnya.
Peta Cepat
FokusSignup, signin, bcrypt, JWT, Passport.js
Masalah yang DiselesaikanMembuktikan identitas user dan melindungi route task
Hasil AkhirBearer token dapat dipakai untuk mengakses endpoint protected
Authentication vs Authorization
Authentication menjawab pertanyaan: siapa user ini? Authorization menjawab pertanyaan: apa yang boleh dilakukan user ini? Chapter ini terutama membahas authentication. Authorization yang lebih ketat, seperti memastikan user hanya melihat task miliknya sendiri, dibahas di chapter 5.
Alur Authentication
text
Signup
-> validasi credential
-> generate salt
-> hash password dengan bcrypt
-> simpan user ke database
Signin
-> cari user berdasarkan username
-> bandingkan password input dengan hash tersimpan
-> jika valid, sign JWT
-> client memakai token sebagai Authorization: Bearer <token>
Protected Route
-> AuthGuard membaca token
-> JwtStrategy memvalidasi signature
-> user ditempelkan ke request
-> handler berjalanStruktur Direktori
Chapter ini menambahkan modul auth/ yang sepenuhnya baru di samping modul tasks/ yang sudah ada:
src/
├── main.ts
├── app.module.ts ← DIUBAH: + AuthModule
├── auth/ ← MODULE BARU
│ ├── dto/
│ │ └── auth-credentials.dto.ts ← username + password dengan @Matches()
│ ├── auth.controller.ts ← POST /auth/signup, POST /auth/signin
│ ├── auth.module.ts ← import PassportModule + JwtModule
│ ├── auth.service.ts ← signUp() + signIn() dengan bcrypt
│ ├── get-user.decorator.ts ← @GetUser() custom param decorator
│ ├── jwt-payload.interface.ts ← { username: string }
│ ├── jwt.strategy.ts ← extends PassportStrategy, validate()
│ └── user.entity.ts ← @Entity @Unique(['username'])
└── tasks/
├── dto/
│ ├── create-task.dto.ts
│ ├── get-tasks-filter.dto.ts
│ └── update-task-status.dto.ts
├── task.entity.ts
├── task-status.enum.ts
├── tasks.controller.ts ← DIUBAH: + @UseGuards(AuthGuard())
├── tasks.module.ts ← DIUBAH: + import AuthModule
├── tasks.repository.ts
└── tasks.service.tsPackage Baru
bash
yarn add @nestjs/jwt @nestjs/passport passport passport-jwt bcryptjs
yarn add -D @types/passport-jwt @types/bcryptjsRingkasan Lecture
1. Intro to Authentication and Authorization
Dua konsep ini sering digunakan bersamaan tetapi memiliki peran berbeda:
- Authentication: memverifikasi siapa pengguna ini. "Apakah kamu benar-benar si Ahmad?"
- Authorization: menentukan apa yang boleh dilakukan pengguna setelah identitasnya diketahui. "Ahmad boleh membaca, tetapi tidak boleh menghapus."
Chapter ini fokus pada authentication menggunakan JWT (JSON Web Token) dan Passport.js. Authorization yang lebih spesifik — memastikan user hanya mengakses task miliknya sendiri — dibahas di chapter 5.
2. Setting up AuthModule, User Entity and User Repository
Buat module, service, controller, dan entity user:
bash
nest g module auth
nest g controller auth --no-spec
nest g service auth --no-specEntity user:
typescript
// src/auth/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, Unique } from 'typeorm'
@Entity()
@Unique(['username']) // constraint unik di level database
export class User {
@PrimaryGeneratedColumn('uuid')
id: string
@Column()
username: string
@Column()
password: string // akan disimpan sebagai hash, bukan plaintext
}Decorator @Unique(['username']) membuat constraint di database — kalau ada dua baris dengan username sama, database akan menolak INSERT-nya.
Repository user:
typescript
// src/auth/users.repository.ts
import { DataSource, Repository } from 'typeorm'
import { Injectable } from '@nestjs/common'
import { User } from './user.entity'
@Injectable()
export class UsersRepository extends Repository<User> {
constructor(private dataSource: DataSource) {
super(User, dataSource.createEntityManager())
}
}Module:
typescript
// src/auth/auth.module.ts
import { TypeOrmModule } from '@nestjs/typeorm'
import { User } from './user.entity'
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [AuthController],
providers: [AuthService, UsersRepository],
})
export class AuthModule {}3. Feature: Signing Up
Endpoint signup menerima username dan password, lalu menyimpan user baru ke database:
typescript
// src/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('/signup')
signUp(@Body() authCredentialsDto: AuthCredentialsDto): Promise<void> {
return this.authService.signUp(authCredentialsDto)
}
}typescript
// src/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(private usersRepository: UsersRepository) {}
async signUp(authCredentialsDto: AuthCredentialsDto): Promise<void> {
return this.usersRepository.createUser(authCredentialsDto)
}
}typescript
// src/auth/users.repository.ts — tambahkan method createUser
async createUser(authCredentialsDto: AuthCredentialsDto): Promise<void> {
const { username, password } = authCredentialsDto
const user = this.create({ username, password }) // password masih plaintext
await this.save(user)
}Pada tahap ini password belum di-hash — ini masalah keamanan serius yang diperbaiki di lecture 7.
4. Validation: Credentials and Password Strength
DTO dibuat untuk memvalidasi format credential:
typescript
// src/auth/dto/auth-credentials.dto.ts
import { IsString, Matches, MaxLength, MinLength } from 'class-validator'
export class AuthCredentialsDto {
@IsString()
@MinLength(4)
@MaxLength(20)
username: string
@IsString()
@MinLength(8)
@MaxLength(32)
@Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, {
message: 'Password terlalu lemah. Harus mengandung huruf besar, kecil, dan angka/karakter.',
})
password: string
}Regex @Matches() memvalidasi kekuatan password di level DTO, sebelum request masuk ke service. Ini mencegah user mendaftar dengan password mudah ditebak seperti "password123".
5. Error Handling: Username Conflicts
Jika username sudah dipakai, database akan menolak INSERT karena constraint @Unique. Error ini perlu ditangkap dan diubah menjadi response HTTP yang bermakna:
typescript
// src/auth/users.repository.ts
async createUser(authCredentialsDto: AuthCredentialsDto): Promise<void> {
const { username, password } = authCredentialsDto
const user = this.create({ username, password })
try {
await this.save(user)
} catch (error) {
if (error.code === '23505') {
// kode error PostgreSQL untuk unique constraint violation
throw new ConflictException('Username sudah digunakan')
} else {
throw new InternalServerErrorException()
}
}
}Error code '23505' adalah kode khusus PostgreSQL untuk unique violation. Jika error lain terjadi (misalnya koneksi database terputus), kita melempar InternalServerErrorException agar tidak membocorkan detail teknis ke client.
6. Securely Storing Passwords
Menyimpan password plaintext adalah keamanan terburuk. Jika database bocor, semua password langsung terbaca. Hash adalah solusinya: fungsi satu arah yang mengubah password menjadi string acak.
Masalah hash biasa (SHA-256, MD5): hacker bisa membuat rainbow table — tabel yang menyimpan hash dari jutaan password umum — lalu mencocokkannya dengan cepat.
Salt adalah solusi: string acak yang ditambahkan ke password sebelum di-hash. Karena setiap user punya salt berbeda, hash yang sama pun menghasilkan nilai berbeda. Rainbow table menjadi tidak berguna.
password + salt unik → hash yang unik per user7. Password Hashing with Bcrypt
Bcrypt dirancang khusus untuk password — lambat secara sengaja agar brute force lebih sulit:
bash
yarn add bcryptjs
yarn add -D @types/bcryptjsIntegrasi ke repository:
typescript
// src/auth/users.repository.ts
import * as bcrypt from 'bcryptjs'
async createUser(authCredentialsDto: AuthCredentialsDto): Promise<void> {
const { username, password } = authCredentialsDto
const salt = await bcrypt.genSalt() // generate salt acak
const hashedPassword = await bcrypt.hash(password, salt) // hash password + salt
const user = this.create({ username, password: hashedPassword })
try {
await this.save(user)
} catch (error) {
if (error.code === '23505') {
throw new ConflictException('Username sudah digunakan')
}
throw new InternalServerErrorException()
}
}Salt tidak perlu disimpan terpisah — bcrypt sudah menyematkannya di dalam string hasil hash. Saat verifikasi, bcrypt mengekstrak salt dari hash lama untuk membandingkan.
8. Feature: Signing In
Signin mencari user berdasarkan username, lalu membandingkan password input dengan hash tersimpan menggunakan bcrypt.compare():
typescript
// src/auth/auth.service.ts
async signIn(authCredentialsDto: AuthCredentialsDto): Promise<string> {
const { username, password } = authCredentialsDto
const user = await this.usersRepository.findOne({ where: { username } })
if (user && (await bcrypt.compare(password, user.password))) {
return 'success' // token akan dibuat di lecture berikutnya
} else {
throw new UnauthorizedException('Silakan periksa credential Anda')
}
}Pesan error sengaja dibuat umum ("Silakan periksa credential Anda") tanpa menyebut apakah username atau password yang salah. Pesan spesifik bisa membantu penyerang untuk enumeration — membuktikan apakah username tertentu terdaftar.
9. Intro to JSON Web Tokens
JWT adalah standar untuk mentransmisikan informasi secara aman antar pihak. Strukturnya terdiri dari tiga bagian dipisah titik:
header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFobWFkIn0.abc123sig- Header: algoritma signing (biasanya HS256)
- Payload: data yang disimpan, misalnya
{ username: 'ahmad' }. Bisa dibaca siapa saja! - Signature: hash dari header + payload + secret. Tidak bisa dipalsukan tanpa secret
JWT cocok untuk autentikasi stateless: server tidak perlu menyimpan session di database. Setiap request cukup membawa token, server verifikasi signature-nya.
Payload bukan rahasia
Payload JWT hanya di-encode base64, bukan dienkripsi. Jangan menyimpan data sensitif (password, informasi kartu kredit) di payload. Payload bisa dibaca siapa saja yang punya token.
10. Setting up JWT Module and Passport.js
Install dependency:
bash
yarn add @nestjs/jwt @nestjs/passport passport passport-jwt
yarn add -D @types/passport-jwtKonfigurasi di AuthModule:
typescript
// src/auth/auth.module.ts
import { JwtModule } from '@nestjs/jwt'
import { PassportModule } from '@nestjs/passport'
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: 'topSecret51', // nanti dipindah ke config/env
signOptions: {
expiresIn: 3600, // token expired dalam 3600 detik (1 jam)
},
}),
TypeOrmModule.forFeature([User]),
],
// ...
})
export class AuthModule {}11. Signing a JWT Token on Sign In
Setelah credential valid, service membuat payload dan men-sign token:
typescript
// src/auth/auth.service.ts
import { JwtService } from '@nestjs/jwt'
import { JwtPayload } from './jwt-payload.interface'
@Injectable()
export class AuthService {
constructor(
private usersRepository: UsersRepository,
private jwtService: JwtService, // di-inject dari JwtModule
) {}
async signIn(authCredentialsDto: AuthCredentialsDto): Promise<{ accessToken: string }> {
const { username, password } = authCredentialsDto
const user = await this.usersRepository.findOne({ where: { username } })
if (user && (await bcrypt.compare(password, user.password))) {
const payload: JwtPayload = { username }
const accessToken = await this.jwtService.sign(payload)
return { accessToken }
} else {
throw new UnauthorizedException('Silakan periksa credential Anda')
}
}
}typescript
// src/auth/jwt-payload.interface.ts
export interface JwtPayload {
username: string
}Sekarang response signin mengembalikan { accessToken: "eyJ..." }.
12. Implementing JWT Validation
JwtStrategy bertanggung jawab memvalidasi setiap request yang membawa token. Passport memanggil validate() setelah token berhasil diverifikasi:
typescript
// src/auth/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { UsersRepository } from './users.repository'
import { JwtPayload } from './jwt-payload.interface'
import { User } from './user.entity'
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private usersRepository: UsersRepository) {
super({
secretOrKey: 'topSecret51', // harus sama dengan JwtModule.register
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // ambil dari header Authorization
})
}
async validate(payload: JwtPayload): Promise<User> {
const { username } = payload
const user = await this.usersRepository.findOne({ where: { username } })
if (!user) {
throw new UnauthorizedException()
}
return user // user ini akan ditempel ke request object
}
}Daftarkan strategy di providers AuthModule:
typescript
providers: [AuthService, UsersRepository, JwtStrategy],
exports: [JwtStrategy, PassportModule], // export agar bisa dipakai TasksModule13. Custom GetUser Decorator
Tanpa decorator custom, untuk mengambil user dari request:
typescript
// verbose, perlu import Request dari express
getTaskById(@Param('id') id: string, @Req() req: Request): Task {
const user = req.user as User
// ...
}Dengan custom decorator, jauh lebih bersih:
typescript
// src/auth/get-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
import { User } from './user.entity'
export const GetUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): User => {
const request = ctx.switchToHttp().getRequest()
return request.user // user sudah ditempel oleh JwtStrategy.validate()
},
)Penggunaan di controller:
typescript
@Get(':id')
getTaskById(
@Param('id') id: string,
@GetUser() user: User, // bersih dan type-safe
): Promise<Task> {
return this.tasksService.getTaskById(id, user)
}14. Guarding the Tasks Routes
@UseGuards(AuthGuard()) dipasang pada controller task. Karena defaultStrategy: 'jwt' sudah dikonfigurasi, AuthGuard() tanpa argumen pun akan memakai JWT strategy:
typescript
// src/tasks/tasks.controller.ts
import { UseGuards } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
@Controller('tasks')
@UseGuards(AuthGuard()) // melindungi SEMUA route di controller ini
export class TasksController {
// ...
}Untuk melindungi hanya route tertentu, pindahkan decorator ke handler-nya. Decorator di level controller lebih umum karena biasanya seluruh endpoint task membutuhkan autentikasi.
TasksModule perlu mengimport AuthModule agar dapat menggunakan JwtStrategy dan PassportModule:
typescript
// src/tasks/tasks.module.ts
import { AuthModule } from '../auth/auth.module'
@Module({
imports: [TypeOrmModule.forFeature([Task]), AuthModule],
// ...
})
export class TasksModule {}Konsep Kunci
| Konsep | Fungsi | Risiko Jika Salah |
|---|---|---|
| Bcrypt | Hash password dengan salt | Password bocor jika disimpan plaintext |
| Salt | Membuat hash unik | Rainbow table lebih mudah menyerang tanpa salt |
| JWT | Token signed untuk auth stateless | Token bisa disalahgunakan jika secret bocor |
| Passport Strategy | Cara Passport memvalidasi request | Route bisa salah validasi jika strategy keliru |
| Guard | Menolak request sebelum handler | Endpoint protected bisa terbuka jika guard lupa |
| Custom Decorator | Mengambil user dari request | Boilerplate meningkat tanpa decorator |
Dependency Utama
bash
yarn add bcryptjs @nestjs/jwt @nestjs/passport passport passport-jwt
yarn add -D @types/passport-jwtPola Kode Penting
typescript
const salt = await bcrypt.genSalt()
const hashedPassword = await bcrypt.hash(password, salt)typescript
const isPasswordValid = await bcrypt.compare(password, user.password)
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials')
}typescript
const payload: JwtPayload = { username }
const accessToken = await this.jwtService.sign(payload)
return { accessToken }typescript
@UseGuards(AuthGuard('jwt'))
@Controller('tasks')
export class TasksController {}Catatan Keamanan
- Password harus selalu di-hash, tidak pernah disimpan plaintext.
- Pesan login gagal sebaiknya umum, misalnya
Invalid credentials, agar tidak membocorkan username yang valid. - JWT secret tidak boleh hardcoded di production. Chapter configuration management akan memindahkannya ke environment variable.
- Token sebaiknya punya expiration agar dampak token bocor tidak berlangsung tanpa batas.
- HTTPS wajib di production karena bearer token berjalan bersama request.
Pertanyaan Reflektif
- Mengapa bcrypt lebih cocok untuk password dibanding hash cepat seperti SHA-256 biasa?
- Apa yang sebenarnya divalidasi oleh signature JWT?
- Mengapa pesan error signin tidak boleh terlalu spesifik?
- Bagaimana
AuthGuard('jwt')bekerja sama denganJwtStrategy? - Mengapa custom decorator
@GetUser()membuat controller lebih maintainable?