NestJS + PostgreSQL + JWT
Postęp kursu0%

Zbuduj bezpieczne API użytkowników od zera.

Praktyczny kurs od pustego folderu: NestJS 11, PostgreSQL w Dockerze, Prisma 7, bcrypt oraz access i refresh tokeny z rotacją.

Efekt końcowy

API rejestruje i loguje użytkownika, chroni endpointy przez Bearer JWT, bezpiecznie odświeża sesję i umie wylogować jedno albo wszystkie urządzenia.

  • PostgreSQL + Prisma 7
  • JWT access token: 15 minut
  • Refresh token: hash w bazie + rotacja
Baza

Najpierw: jak myśleć o backendzie?

Backend to program działający na serwerze. Przyjmuje prośby od aplikacji (frontend, aplikacja mobilna albo inny serwer), sprawdza je, wykonuje reguły aplikacji, rozmawia z bazą danych i odsyła odpowiedź — najczęściej JSON.

Użytkownik klika „Zaloguj” w aplikacji
        ↓
Frontend wysyła POST /auth/login z e-mailem i hasłem
        ↓
Controller odbiera HTTP request i przekazuje dane dalej
        ↓
Service sprawdza hasło i wykonuje logikę logowania
        ↓
Prisma odczytuje użytkownika z PostgreSQL
        ↓
Backend zwraca JSON z użytkownikiem i tokenami

Frontend, backend, API i baza danych

Frontend to ekran i interakcje użytkownika. Backend jest strażnikiem danych i reguł. API to umowa komunikacji między nimi, np. POST /auth/login. Baza danych przechowuje trwałe informacje: użytkowników, tokeny, zamówienia czy wpisy.

HTTP w praktyce

GETPobierz dane, np. GET /users/123.
POSTUtwórz rzecz albo wykonaj akcję, np. rejestrację i login.
PATCHZmień fragment istniejących danych, np. nazwę profilu.
DELETEUsuń albo unieważnij zasób.
Request zawiera metodę, adres URL, opcjonalne body i headers. Response ma status oraz dane. Najczęstsze statusy: 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 409 Conflict i 500 Internal Server Error.
Nest

Controller, service i moduł — dlaczego to rozdzielamy?

NestJS używa architektury podobnej do MVC, ale backend API zwykle nie renderuje widoków HTML. Najważniejsze są Controller i Service: pierwszy obsługuje HTTP, drugi zawiera reguły biznesowe.

To nie jest podział „dla porządku”

Controller: „Dostałem POST /auth/login. Oto dane z body.”
Service:    „Znajdę użytkownika, sprawdzę hasło, wydam tokeny.”
Prisma:     „Wykonam zapytanie do PostgreSQL.”
Database:   „Zwracam zapisany rekord użytkownika.”

Dzięki temu controller nie miesza HTTP, haseł, tokenów i SQL w jednej metodzie. Tę samą logikę z serwisu możesz później wywołać z endpointu, zadania cyklicznego albo testu — bez kopiowania kodu.

Mapa komponentów NestJS

ModuleGrupuje funkcję aplikacji i mówi Nestowi, jakie controllery oraz serwisy należą do danego obszaru, np. AuthModule.
ControllerDefiniuje endpointy, odbiera @Body(), @Param(), query i nagłówki; powinien być cienki.
ServiceTrzyma logikę biznesową: „czy można?”, „co utworzyć?”, „co zapisać?”, „jaki błąd zwrócić?”.
DTOOpisuje dane wejściowe i ich walidację, np. poprawny e-mail oraz minimum 8 znaków hasła.
GuardDecyduje, czy request może wejść do controllera; JWT guard przepuszcza tylko poprawnie zalogowaną osobę.
StrategyMówi guardowi, jak sprawdzić konkretny mechanizm auth, np. Bearer JWT.
DIDependency Injection: Nest sam tworzy i przekazuje potrzebne obiekty przez konstruktor, np. PrismaService do UsersService.
Kiedy widzisz constructor(private readonly prisma: PrismaService), czytaj to jako: „ten serwis potrzebuje narzędzia do rozmowy z bazą; Nest dostarczy jedną gotową instancję”. Nie tworzysz ręcznie new PrismaService() w każdym pliku.
Plan

Jak dodawać endpoint i nową funkcję?

Nie zaczynaj od pisania controllera. Zacznij od zdania opisującego potrzebę użytkownika, a potem przejdź przez stały schemat decyzji.

1. Potrzeba: „Zalogowany użytkownik ma zobaczyć swój profil”.
2. Dane: potrzebuję User { id, email, name, bio }.
3. Umowa API: GET /users/:id albo GET /auth/me?
4. Uprawnienia: kto może to zobaczyć? Czy endpoint wymaga JWT?
5. DTO: jakie dane przyjmuję i jak je waliduję?
6. Service: jakie reguły wykonuję i jakie błędy zwracam?
7. Baza: czy model już zawiera wymagane dane i indeksy?
8. Testy: sukces, brak danych, brak uprawnień, błędne dane.

Przykład: endpoint pobierający użytkownika

// users.controller.ts — warstwa HTTP
@Get(':id')
findOne(@Param('id') id: string) {
  return this.usersService.findById(id);
}

// users.service.ts — logika i baza
async findById(id: string) {
  const user = await this.prisma.user.findUnique({ where: { id } });
  if (!user) throw new NotFoundException('User not found');
  return this.toPublicUser(user);
}

Gdy endpoint ma zmieniać dane, np. PATCH /users/me, dodajesz osobne DTO z polami, które wolno aktualizować. Nie przyjmuj całego obiektu użytkownika „w ciemno”, bo klient mógłby spróbować przesłać passwordHash, rolę lub cudze ID.

  • Umiem opisać potrzebę użytkownika jednym zdaniem.
  • Wiem, jaka metoda HTTP i URL pasują do tej potrzeby.
  • Rozdzielam HTTP w controllerze od reguł w serwisie.
DB

Jak myśleć o bazie danych i modelach Prisma?

Model Prisma zwykle odpowiada tabeli w PostgreSQL. Jeden obiekt User to rekord (wiersz), a email lub createdAt to kolumny.

@idKlucz główny: stabilny identyfikator jednego rekordu, np. UUID użytkownika.
@uniqueOgraniczenie unikalności: dwa konta nie mogą mieć tego samego e-maila.
@@indexIndeks przyspiesza częste wyszukiwanie, np. refresh tokenów po userId.
relationRelacja łączy tabele. Jeden User ma wiele RefreshTokenów, lecz token należy do jednego Usera.

Zmiana modelu to proces, nie tylko dopisanie pola

// 1. Schema: potrzebujemy opisu użytkownika
model User {
  id String @id @default(uuid())
  email String @unique
  name String?
  bio String? // nowe opcjonalne pole
}

// 2. Zmieniamy prawdziwą bazę przez migrację
npx.cmd prisma migrate dev --name add_user_bio

// 3. Odświeżamy typy klienta Prisma
npx.cmd prisma generate

Potem aktualizujesz DTO, serwis, endpoint i testy. To ważne: schema.prisma jest opisem zamiaru, migracja zmienia prawdziwą bazę, a generate tworzy typowanego klienta do użycia w TypeScripcie.

Praca

Debugowanie, narzędzia i mini-zadania

Backend rozwijasz małymi pętlami: uruchom, wyślij request, przeczytaj odpowiedź, obejrzyj dane, dopisz test.

# logi aplikacji
npm.cmd run start:dev

# status bazy i kontenera
docker compose ps
docker compose logs -f postgres

# wygodny podgląd danych w przeglądarce
npx.cmd prisma studio

Mini-zadania

  • Dodaj do User opcjonalne pole bio, wykonaj migrację i zwróć je w publicznym profilu.
  • Dodaj PATCH /users/me z DTO zawierającym tylko name i bio.
  • Napisz test: request bez JWT do chronionego endpointu zwraca 401.

Mini-słowniczek

Endpoint — adres i metoda API. Payload — dane w requestcie lub tokenie. ORM — narzędzie mapujące obiekty kodu na dane bazy, tu Prisma. Migracja — wersjonowana zmiana struktury bazy. Hash — jednokierunkowy wynik funkcji, używany do bezpiecznego przechowywania haseł i refresh tokenów.

0

Utwórz projekt NestJS od zera

Zainstaluj Node.js 20+ i Docker Desktop. Następnie utwórz nowy katalog, a Nest CLI przygotuje pełny szkielet aplikacji oraz zainstaluje jej zależności.

mkdir nest-auth-api
cd nest-auth-api
npx @nestjs/cli new api
cd api

# Jeśli PowerShell blokuje npm.ps1, używaj wariantu .cmd:
npx.cmd @nestjs/cli new api
npm.cmd run start:dev
# Otwórz potem: http://localhost:3000
Od tego momentu wszystkie kolejne polecenia wykonujesz w katalogu api. W kreatorze Nest wybierz menedżer pakietów npm.
1

Uruchom PostgreSQL w Dockerze

W katalogu api utwórz plik docker-compose.yml. Kontener daje powtarzalną lokalną bazę. Dane są trzymane w volume, więc restart kontenera ich nie usuwa.

services:
  postgres:
    image: postgres:17
    container_name: nest-auth-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
docker compose up -d
docker compose ps
docker exec -it nest-auth-postgres psql -U app_user -d app_db
2

Zainstaluj i połącz Prisma 7 z bazą

Najpierw dodaj pakiety i zainicjuj Prisma. W tym projekcie Prisma generuje klienta lokalnie do generated/prisma, a połączenie dostarcza adapter @prisma/adapter-pg.

npm.cmd install @prisma/client @prisma/adapter-pg pg
npm.cmd install -D prisma
npx.cmd prisma init
# .env
DATABASE_URL="postgresql://app_user:app_password@localhost:5432/app_db"
POSTGRES_USER="app_user"
POSTGRES_PASSWORD="app_password"
POSTGRES_DB="app_db"
generator client {
  provider = "prisma-client"
  output = "../generated/prisma"
  moduleFormat = "cjs"
}

datasource db { provider = "postgresql" }

model User {
  id String @id @default(uuid())
  email String @unique
  name String?
  passwordHash String
  refreshTokens RefreshToken[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model RefreshToken {
  id String @id @default(uuid())
  tokenHash String @unique
  userId String
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  revokedAt DateTime?
  createdAt DateTime @default(now())
  @@index([userId])
}
Migracje: npx prisma migrate dev --name jwt, potem npx prisma generate. Model RefreshToken pozwala obsłużyć wiele urządzeń i unieważniać konkretne sesje.
3

Ułóż moduły odpowiedzialności

Logika użytkowników nie powinna mieszkać w auth. Auth korzysta z serwisu użytkowników, a oba moduły korzystają z Prisma.

npx.cmd nest g module prisma
npx.cmd nest g service prisma
npx.cmd nest g module users
npx.cmd nest g service users
npx.cmd nest g controller users
npx.cmd nest g module auth
npx.cmd nest g service auth
npx.cmd nest g controller auth
src/
  auth/
    dto/ login.dto.ts, register.dto.ts, refresh-token.dto.ts
    guards/ jwt-access.guard.ts
    strategies/ jwt-access.strategy.ts
    auth.controller.ts
    auth.service.ts
  common/decorators/current-user.decorator.ts
  prisma/prisma.service.ts
  users/dto/create-user.dto.ts
  users/users.service.ts
4

Twórz użytkownika i hashuj hasło

Do bazy trafia tylko passwordHash. Publiczna odpowiedź zawsze pomija ten atrybut.

const existingUser = await this.prisma.user.findUnique({
  where: { email: dto.email },
});

if (existingUser) {
  throw new ConflictException('User with this email already exists');
}

const passwordHash = await bcrypt.hash(dto.password, 12);
const user = await this.prisma.user.create({
  data: { email: dto.email, name: dto.name, passwordHash },
});

return toPublicUser(user);
  • DTO waliduje e-mail i hasło o długości minimum 8 znaków.
  • Duplikat e-maila zwraca 409 Conflict.
  • Żadna odpowiedź API nie zawiera passwordHash.
5

Rejestracja i logowanie zwracają parę tokenów

Access token identyfikuje requesty. Refresh token służy wyłącznie do odnowienia sesji.

POST/auth/register
POST/auth/login
const isPasswordValid = await bcrypt.compare(
  dto.password,
  user.passwordHash,
);

if (!isPasswordValid) {
  throw new UnauthorizedException('Invalid email or password');
}

return {
  user: publicUser,
  accessToken,
  refreshToken,
};
Nie traktuj access tokenu jako trwałej sesji. Krótka ważność (tu: 15m) ogranicza szkody po wycieku.
JWT

Jak działają JWT, access token i refresh token?

JWT (JSON Web Token) to podpisany przez serwer ciąg znaków. Nie jest zaszyfrowanym sejfem na dane użytkownika: jego zawartość można odczytać, ale nie można jej zmienić bez znajomości sekretu użytego do podpisu.

Co znajduje się w tokenie?

JWT ma trzy części rozdzielone kropkami: header.payload.signature. W tym projekcie payload zawiera co najmniej sub (ID użytkownika) i email. Refresh token ma dodatkowo unikalne jti, aby każda sesja miała własną tożsamość.

{
  "sub": "uuid-uzytkownika",
  "email": "wiktor@example.com",
  "iat": 1780000000,
  "exp": 1780000900
}
Nie wkładaj do JWT hasła, passwordHash, danych karty ani innych sekretów. Payload można odczytać po stronie klienta bez klucza.

Dlaczego są dwa tokeny?

ACCESS Krótki, np. 15 minut. Klient wysyła go w każdym chronionym requestcie jako Authorization: Bearer <accessToken>.
REFRESH Dłuższy, np. 30 dni. Trafia tylko do POST /auth/refresh, gdy access token wygasł.

Krótki access token ogranicza skutki jego wycieku. Refresh token pozwala użytkownikowi pozostać zalogowanym bez wymagania hasła co kilkanaście minut.

Przepływ od logowania do odświeżenia

1. POST /auth/login
   - serwer porównuje hasło przez bcrypt.compare()
   - zwraca accessToken i refreshToken

2. GET /auth/me
   Authorization: Bearer <accessToken>
   - guard sprawdza podpis i exp access tokenu
   - strategy ustawia request.user = { id, email }

3. Access token wygasł
   - API zwraca 401 Unauthorized
   - klient wywołuje POST /auth/refresh z refreshTokenem
   - API zwraca nową parę tokenów
   - klient ponawia pierwotny request

Co znaczy „rotacja refresh tokenu”?

Podczas /auth/refresh backend nie tylko wydaje nowe tokeny. Najpierw weryfikuje podpis JWT, sprawdza datę wygaśnięcia, porównuje przesłany token z hashem w tabeli RefreshToken, ustawia revokedAt starego rekordu i zapisuje hash nowego tokenu. Stary refresh token nie może zostać użyty ponownie.

  • Access token jest wysyłany do chronionych endpointów.
  • Refresh token służy tylko do odnowienia sesji.
  • W bazie zapisuję hash refresh tokenu, nigdy jego czystą wartość.
6

Zabezpiecz endpointy przez JWT Guard

Strategy wyciąga token z nagłówka, weryfikuje podpis i umieszcza dane użytkownika w request.user. Guard wpuszcza request do kontrolera tylko po sukcesie.

@Injectable()
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: config.getOrThrow('JWT_ACCESS_SECRET'),
    });
  }

  validate(payload: { sub: string; email: string }) {
    return { id: payload.sub, email: payload.email };
  }
}
@UseGuards(JwtAccessGuard)
@Get('me')
me(@CurrentUser() user: AuthenticatedUser) {
  return user;
}
GET/auth/me — nagłówek Authorization: Bearer <accessToken>
7

Dodaj refresh tokeny z rotacją

Po loginie zapisujesz hash refresh tokenu w bazie. Gdy klient odświeża sesję, unieważniasz stary rekord i zapisujesz nowy.

const refreshToken = await this.jwtService.signAsync(
  { sub: user.id, email: user.email, jti: randomUUID() },
  {
    secret: config.getOrThrow('JWT_REFRESH_SECRET'),
    expiresIn: config.getOrThrow('JWT_REFRESH_EXPIRES_IN'),
  },
);

await prisma.refreshToken.create({
  data: {
    tokenHash: await bcrypt.hash(refreshToken, 12),
    userId: user.id,
    expiresAt: refreshTokenExpiresAt,
  },
});

Unikalne jti sprawia, że nowy refresh JWT różni się od starego nawet wtedy, gdy oba powstaną w tej samej sekundzie.

POST/auth/refresh
POST/auth/logout
POST/auth/logout-all
  • W bazie przechowywany jest wyłącznie hash refresh tokenu.
  • Refresh sprawdza podpis, wygaśnięcie, hash i revokedAt.
  • Rotacja działa w transakcji: stary token jest unieważniany tylko raz.
8

Utrzymaj bezpieczeństwo testami

Testy jednostkowe używają mocka Prisma, a test HTTP uruchamia prawdziwy JWT strategy/guard bez zależności od Dockera.

npm.cmd test -- --runInBand
npm.cmd run test:e2e -- --runInBand
npm.cmd exec tsc -- --noEmit --incremental false
  • Hasło jest hashowane, a e-mail unikalny.
  • Login z błędnym hasłem zwraca 401.
  • Refresh token nie może zostać użyty drugi raz.
  • /auth/me bez access tokenu zwraca 401.
9

Po stronie klienta użyj jednego API clienta

fetch nie odświeża tokenów automatycznie. Umieść tę logikę w jednym miejscu i dopilnuj, aby wiele odpowiedzi 401 uruchamiało tylko jeden refresh.

let refreshPromise: Promise<void> | null = null;

async function ensureFreshTokens() {
  if (!refreshPromise) {
    refreshPromise = refreshTokens().finally(() => {
      refreshPromise = null;
    });
  }
  return refreshPromise;
}

W React Native przechowuj tokeny w Expo SecureStore, iOS Keychain lub Android Keystore — nie w zwykłym, niezaszyfrowanym storage.

10

Co dalej?

Masz bazę pod aplikację mobilną, SPA albo klasyczne API. Następne sensowne kroki to role i uprawnienia, reset hasła, rate limiting, Swagger oraz monitoring logowań.