iBetter Books
수정

인증과 JWT

지금까지는 인증 헤더 값을 검사하지 않았습니다. Bearer dummy 같은 임의 값도 통과했습니다. 이번 챕터에서는 JWT(JSON Web Token)로 실제 인증을 구현합니다.

JWT는 서버가 서명한 토큰입니다. 클라이언트는 이 토큰을 요청 헤더에 포함합니다. 서버는 서명을 검증해 토큰이 자신이 발급한 것인지 확인합니다. 데이터베이스에 세션을 저장할 필요가 없습니다.

dart_jsonwebtoken 패키지 추가

# 수정: pubspec.yamldependencies:  dart_frog: ^1.4.0  sqlite3: ^2.4.3  dart_jsonwebtoken: ^2.14.0  crypto: ^3.0.3
dart pub get

dart_jsonwebtoken은 JWT 생성/검증, crypto는 비밀번호 해시에 사용합니다.

AuthService 구현

// 새 파일: lib/src/services/auth_service.dart
import 'dart:convert';

import 'package:crypto/crypto.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';

import '../dto/auth_dto.dart';
import '../exceptions.dart';
import '../models/user.dart';
import '../repositories/user_repository.dart';

class AuthService {
  AuthService({
    required UserRepository userRepository,
    required String jwtSecret,
    Duration tokenExpiry = const Duration(hours: 24),
  })  : _userRepository = userRepository,
        _jwtSecret = jwtSecret,
        _tokenExpiry = tokenExpiry;

  final UserRepository _userRepository;
  final String _jwtSecret;
  final Duration _tokenExpiry;

  /// 회원가입 후 JWT를 반환합니다.
  AuthResponse register(AuthRequest request) {
    // 이메일 중복 확인
    if (_userRepository.findByEmail(request.email) != null) {
      throw const ValidationException('이미 사용 중인 이메일입니다.');
    }

    final passwordHash = _hashPassword(request.password);
    final user = _userRepository.create(
      email: request.email,
      passwordHash: passwordHash,
    );

    final token = _generateToken(user);
    return AuthResponse(token: token, user: user.toJson());
  }

  /// 로그인 후 JWT를 반환합니다.
  AuthResponse login(AuthRequest request) {
    final user = _userRepository.findByEmail(request.email);
    if (user == null) {
      throw const UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다.');
    }

    if (!_verifyPassword(request.password, user.passwordHash)) {
      throw const UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다.');
    }

    final token = _generateToken(user);
    return AuthResponse(token: token, user: user.toJson());
  }

  /// JWT를 검증하고 사용자 ID를 반환합니다.
  int verifyToken(String token) {
    try {
      final jwt = JWT.verify(token, SecretKey(_jwtSecret));
      final payload = jwt.payload as Map<String, dynamic>;
      return payload['sub'] as int;
    } on JWTExpiredException {
      throw const UnauthorizedException('토큰이 만료되었습니다.');
    } on JWTException {
      throw const UnauthorizedException('유효하지 않은 토큰입니다.');
    }
  }

  String _generateToken(User user) {
    final jwt = JWT(
      {
        'sub': user.id,
        'email': user.email,
        'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000,
      },
    );

    return jwt.sign(
      SecretKey(_jwtSecret),
      expiresIn: _tokenExpiry,
    );
  }

  String _hashPassword(String password) {
    final bytes = utf8.encode(password);
    final hash = sha256.convert(bytes);
    return hash.toString();
  }

  bool _verifyPassword(String password, String hash) {
    return _hashPassword(password) == hash;
  }
}

환경 변수에서 JWT 시크릿 로드

JWT 시크릿은 코드에 하드코딩하면 안 됩니다. 환경 변수로 주입합니다.

// 새 파일: lib/src/config.dart
import 'dart:io';

/// 애플리케이션 설정.
class AppConfig {
  const AppConfig._({
    required this.jwtSecret,
    required this.dbPath,
    required this.port,
  });

  final String jwtSecret;
  final String dbPath;
  final int port;

  /// 환경 변수에서 설정을 로드합니다.
  factory AppConfig.fromEnvironment() {
    final jwtSecret = Platform.environment['JWT_SECRET'];
    if (jwtSecret == null || jwtSecret.isEmpty) {
      throw StateError(
        'JWT_SECRET 환경 변수가 설정되지 않았습니다.\n'
        '예: export JWT_SECRET="your-secret-key-at-least-32-chars"',
      );
    }
    if (jwtSecret.length < 32) {
      throw StateError('JWT_SECRET은 최소 32자 이상이어야 합니다.');
    }

    return AppConfig._(
      jwtSecret: jwtSecret,
      dbPath: Platform.environment['DB_PATH'] ?? 'todo.db',
      port: int.tryParse(Platform.environment['PORT'] ?? '8080') ?? 8080,
    );
  }
}

글로벌 미들웨어에 AuthService 주입

// 수정: routes/_middleware.dart
import 'package:dart_frog/dart_frog.dart';

import '../lib/src/config.dart';
import '../lib/src/database.dart';
import '../lib/src/middleware/cors_middleware.dart';
import '../lib/src/middleware/error_middleware.dart';
import '../lib/src/middleware/logging_middleware.dart';
import '../lib/src/repositories/todo_repository.dart';
import '../lib/src/repositories/user_repository.dart';
import '../lib/src/services/auth_service.dart';

final _config = AppConfig.fromEnvironment();
final _db = Database.open(_config.dbPath);

Handler middleware(Handler handler) {
  return handler
      .use(errorHandler())
      .use(requestLogging())
      .use(cors())
      .use(provider<AppConfig>((_) => _config))
      .use(provider<Database>((_) => _db))
      .use(provider<UserRepository>(
        (context) => UserRepository(context.read<Database>()),
      ))
      .use(provider<TodoRepository>(
        (context) => TodoRepository(context.read<Database>()),
      ))
      .use(provider<AuthService>(
        (context) => AuthService(
          userRepository: context.read<UserRepository>(),
          jwtSecret: context.read<AppConfig>().jwtSecret,
        ),
      ));
}

인증 라우트 구현

// 새 파일: routes/auth/register.dart
import 'package:dart_frog/dart_frog.dart';

import '../../lib/src/dto/auth_dto.dart';
import '../../lib/src/services/auth_service.dart';
import '../../lib/src/utils/response_utils.dart';

Future<Response> onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.post) {
    return Response(statusCode: 405);
  }

  final json = await context.request.json() as Map<String, dynamic>;
  final request = AuthRequest.fromJson(json);

  final authService = context.read<AuthService>();
  final response = authService.register(request);

  return createdResponse(response.toJson());
}
// 새 파일: routes/auth/login.dart
import 'package:dart_frog/dart_frog.dart';

import '../../lib/src/dto/auth_dto.dart';
import '../../lib/src/services/auth_service.dart';
import '../../lib/src/utils/response_utils.dart';

Future<Response> onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.post) {
    return Response(statusCode: 405);
  }

  final json = await context.request.json() as Map<String, dynamic>;
  final request = AuthRequest.fromJson(json);

  final authService = context.read<AuthService>();
  final response = authService.login(request);

  return okResponse(response.toJson());
}

JWT 검증 미들웨어 완성

// 수정: routes/todos/_middleware.dart
import 'package:dart_frog/dart_frog.dart';

import '../../lib/src/exceptions.dart';
import '../../lib/src/services/auth_service.dart';

Handler middleware(Handler handler) {
  return handler.use(_jwtAuthMiddleware());
}

Middleware _jwtAuthMiddleware() {
  return (Handler handler) {
    return (RequestContext context) async {
      final authHeader = context.request.headers['Authorization'];

      if (authHeader == null || !authHeader.startsWith('Bearer ')) {
        throw const UnauthorizedException();
      }

      final token = authHeader.substring(7);
      final authService = context.read<AuthService>();

      // JWT 검증 및 userId 추출
      final userId = authService.verifyToken(token);

      // userId를 다운스트림 핸들러에 주입
      return handler(
        context.provide<int>(() => userId),
      );
    };
  };
}

라우트 핸들러에서 userId 사용

// 수정: routes/todos/index.dart
import 'package:dart_frog/dart_frog.dart';

import '../../lib/src/dto/todo_dto.dart';
import '../../lib/src/repositories/todo_repository.dart';
import '../../lib/src/utils/query_params.dart';
import '../../lib/src/utils/response_utils.dart';

Future<Response> onRequest(RequestContext context) async {
  return switch (context.request.method) {
    HttpMethod.get => _getTodos(context),
    HttpMethod.post => _createTodo(context),
    _ => Response(statusCode: 405),
  };
}

Future<Response> _getTodos(RequestContext context) async {
  final repo = context.read<TodoRepository>();
  final params = context.request.uri.queryParameters;
  final userId = context.read<int>(); // JWT에서 추출된 userId

  final limit = parseIntParam(params, 'limit', defaultValue: 20, max: 100);
  final offset = parseIntParam(params, 'offset', defaultValue: 0, min: 0);
  final completed = parseBoolParam(params, 'completed');

  final todos = repo.findByUserId(
    userId,
    completed: completed,
    limit: limit,
    offset: offset,
  );
  final total = repo.countByUserId(userId, completed: completed);

  return listResponse(
    todos.map((t) => t.toJson()).toList(),
    total: total,
    page: offset ~/ limit + 1,
    limit: limit,
  );
}

Future<Response> _createTodo(RequestContext context) async {
  final repo = context.read<TodoRepository>();
  final json = await context.request.json() as Map<String, dynamic>;
  final request = CreateTodoRequest.fromJson(json);
  final userId = context.read<int>();

  final todo = repo.create(title: request.title, userId: userId);
  return createdResponse(todo.toJson());
}

실행 및 전체 흐름 테스트

# JWT_SECRET 환경 변수 설정 후 서버 실행export JWT_SECRET="my-super-secret-jwt-key-at-least-32-chars"dart_frog dev
# 1. 회원가입TOKEN=$(curl -s -X POST http://localhost:8080/auth/register \  -H "Content-Type: application/json" \  -d '{"email": "[email protected]", "password": "password123"}' \  | python3 -c "import sys, json; print(json.load(sys.stdin)['data']['token'])")echo "토큰: $TOKEN"# 2. 로그인TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \  -H "Content-Type: application/json" \  -d '{"email": "[email protected]", "password": "password123"}' \  | python3 -c "import sys, json; print(json.load(sys.stdin)['data']['token'])")# 3. 할 일 생성curl -s -X POST http://localhost:8080/todos \  -H "Content-Type: application/json" \  -H "Authorization: Bearer $TOKEN" \  -d '{"title": "JWT 인증 테스트"}' | python3 -m json.tool# 4. 목록 조회curl -s http://localhost:8080/todos \  -H "Authorization: Bearer $TOKEN" | python3 -m json.tool# 5. 인증 없이 접근 (401 예상)curl -s http://localhost:8080/todos | python3 -m json.tool# 6. 만료된/잘못된 토큰 (401 예상)curl -s http://localhost:8080/todos \  -H "Authorization: Bearer invalid.token.here" | python3 -m json.tool

정리

이번 챕터에서는 JWT 기반 인증을 구현했습니다.

  • dart_jsonwebtoken으로 JWT를 생성하고 검증합니다.
  • AuthService가 회원가입, 로그인, 토큰 검증을 담당합니다.
  • JWT 시크릿은 환경 변수로 주입하고 코드에 하드코딩하지 않습니다.
  • 미들웨어가 토큰을 검증하고 userId를 핸들러에 주입합니다.
  • context.provide<int>()context.read<int>()로 미들웨어에서 핸들러로 값을 전달합니다.

다음 챕터에서는 이 서버에 대한 테스트를 작성합니다.