인증과 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>()로 미들웨어에서 핸들러로 값을 전달합니다.
다음 챕터에서는 이 서버에 대한 테스트를 작성합니다.