0. Jwt
@Component
public class JwtProvider {
private SecretKey key; //대칭키 암호화 방식
// Key타입은 비대칭키(공개키/개인키) + 대칭키 포함
private static final long ACCESS_TOKEN_EXPIRE = 1000L * 60 * 60; // 1시간
private static final long REFRESH_TOKEN_EXPIRE = 1000L * 60 * 60 * 24 * 7; // 7일
public JwtProvider(@Value("${jwt.secret}") String secret) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
// secret문자열 -> byte 배열 전환 후 -> HMAC-SHA 알고리즘 키 객체로
/// 나중에 비대칭키로 변경 고려하기
}
public String createAccessToken(Long userId, String roleName) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("role", roleName)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE))
.signWith(key, SignatureAlgorithm.HS256)// 해당 알고리즘으로 서명
.compact();
}
public String createRefreshToken(Long userId) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE))
.signWith(key, SignatureAlgorithm.HS256) // 해당 알고리즘으로 서명
.compact();
}
public Claims parseClaims(String token) { // 페이로더 분석
return Jwts.parserBuilder()
.setSigningKey(key) // 검증할 키 설정, 헤더에 있는 알고리즘을 확인 먼저함. 그래서 위처럼 작성할 필요X.
.build()
.parseClaimsJws(token) // 1) header/payload Base64Url 디코딩 → JSON 변환
// 2) header의 alg 확인
// 3) (header + "." + payload)를 같은 알고리즘 + key로 다시 "서명값 계산"
// 4) 계산한 서명값과 token의 signature 비교
// 5) exp 등 유효성 검사 후 통과하면 Claims 반환
.getBody();
}
public boolean isValid(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public Long getUserId(String token) {
return Long.parseLong(parseClaims(token).getSubject());
}
public String getRole(String token) {
return parseClaims(token).get("role", String.class);
}
}
1. 회원가입
public TokenResponseDTO signUp(SignUpRequestDTO dto) {
// 1. Auth DB 중복 체크
if (localAccountRepository.existsBySignId(dto.getSignId())) {
throw new IllegalArgumentException("이미 사용 중인 아이디입니다.");
}
if (localAccountRepository.existsByEmail(dto.getEmail())) {
throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
}
// 2. User 서비스에 프로필 생성 요청 (gRPC) → userId 반환
Long userId = userGrpcClient.createProfile(
dto.getEmail(),
dto.getUserName(),
dto.getNickname(),
dto.getProfileImg()
);
// 3. 로컬 계정 저장
AuthLocalAccount account = new AuthLocalAccount();
account.setUserId(userId);
account.setSignId(dto.getSignId());
account.setEmail(dto.getEmail());
account.setPassword(passwordEncoder.encode(dto.getPassword()));
account.setRoleName(RoleStatus.USER);
localAccountRepository.save(account);
// 4. JWT 발급
return issueToken(userId, RoleStatus.USER.name());
}
// 토큰 발급 + Redis 저장
private TokenResponseDTO issueToken(Long userId, String role) {
String accessToken = jwtProvider.createAccessToken(userId, role);
String refreshToken = jwtProvider.createRefreshToken(userId);
redisTemplate.opsForValue().set(
REFRESH_KEY_PREFIX + userId,
refreshToken,
REFRESH_EXPIRE_DAYS,
TimeUnit.DAYS
);
return new TokenResponseDTO(accessToken, refreshToken, userId, role);
}
2. login (현재 생각에는 회원가입 -> login 될수 있게 하기 위해서 위 회원가입에도 token 생성이 존재.)
@Override
public TokenResponseDTO login(LoginRequestDTO dto) {
// 1. signId로 계정 조회
AuthLocalAccount account = localAccountRepository.findBySignId(dto.getSignId())
.orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 올바르지 않습니다."));
// 2. 비밀번호 검증
if (!passwordEncoder.matches(dto.getPassword(), account.getPassword())) {
throw new IllegalArgumentException("아이디 또는 비밀번호가 올바르지 않습니다.");
}
// 3. JWT 발급
return issueToken(account.getUserId(), account.getRoleName().name());
}
3. post글쓰기
@Override
public PostResponseDTO createPost(Long userId, PostCreateRequestDTO dto) {
PostEntity postEntity = new PostEntity();
postEntity.setUserId(userId);
postEntity.setTitle(dto.getTitle());
postEntity.setContent(dto.getContent());
postEntity.setThumbnail(dto.getThumbnail());
postEntity.setIsPublished(dto.getIsPublished());
postEntity.setSeriesId(dto.getSeriesId());
postEntity.setIsCrawled(false);
PostEntity savedPost = postRepository.save(postEntity);
log.info("게시글 작성 완료: postId={}, userId={}", savedPost.getPostId(), userId);
return convertToResponseDTO(postEntity);
}
// 게시글 작성 (jwt 인증 필요)
@PostMapping
public ResponseEntity<PostResponseDTO> createPost(
@RequestHeader("X-User-Id") Long userId, // ← Gateway가 넣어준 userId
@Valid @RequestBody PostCreateRequestDTO dto) {
log.info("게시글 작성 요청: userId={}, title={}", userId, dto.getTitle());
PostResponseDTO response = postService.createPost(userId, dto);
return ResponseEntity.ok(response);
}
4. config 와 filter
// 1) jwt 검증 코드
public Claims parseClaims(String token) { // 페이로더 분석
return Jwts.parserBuilder()
.setSigningKey(key) // 검증할 키 설정, 헤더에 있는 알고리즘을 확인 먼저함. 그래서 위처럼 작성할 필요X.
.build()
.parseClaimsJws(token) // 1) header/payload Base64Url 디코딩 → JSON 변환
// 2) header의 alg 확인
// 3) (header + "." + payload)를 같은 알고리즘 + key로 다시 "서명값 계산"
// 4) 계산한 서명값과 token의 signature 비교
// 5) exp 등 유효성 검사 후 통과하면 Claims 반환
.getBody();
}
public boolean isValid(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
// 2) jwtAuthFilter
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
/**
* exchange.getRequest() : 현재 요청 정보 (헤더, 경로, 메서드, 바디 등)
* exchange.getResponse() : 현재 응답 정보 (상태코드, 헤더, 바디 등)
* exchange.mutate() : 요청이나 응답을 새로 만들어서 수정할 때 사용
*/
String path = exchange.getRequest().getPath().value();
HttpMethod method = exchange.getRequest().getMethod(); // ← HTTP 메서드 가져오기
log.debug("요청: {} {}", method, path);
// 1. 인증 불필요 경로 -> 원본 그대로 다음 필터로 넘김
if (isPublicPath(path,method)) {
return chain.filter(exchange);
}
// 2. 공개 경로가 아닐시 Authorization 헤더에서 토큰 추출
String token = extractToken(exchange.getRequest());
// 3. 토큰 검증
if (token == null || !jwtProvider.isValid(token)) {
log.warn("인증 실패: {} {}", method, path);
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); // 401 응답 설정
return exchange.getResponse().setComplete(); // Mono<Void> 반환 → 요청 끝내기
}
// 4. userId, role 추출
Long userId = jwtProvider.getUserId(token);
String role = jwtProvider.getRole(token);
log.info("인증 성공: userId={}, role={}", userId, role);
// 5. 헤더 추가하여 내부 서비스로 전달
/// Spring WebFlux는 대부분 불변 객체. 그렇기에 한 번 만들어지면 내용을 바꿀 수 없다.
/// mutate() 를 호출해서 새로운 복사본 생성 -> 수정, 복사본에 변경사항을 적용.
ServerHttpRequest modifiedRequest = exchange.getRequest().mutate()
.header("X-User-Id", String.valueOf(userId))
.header("X-User-Role", role)
.build();
return chain.filter(exchange.mutate().request(modifiedRequest).build());
}
private boolean isPublicPath(String path, HttpMethod method) {
// 회원가입, 로그인, 토큰 재발급
if (path.startsWith("/auth/signup") ||
path.startsWith("/auth/login") ||
path.startsWith("/auth/refresh")) {
return true;
}
// 게시글 조회만 허용 (GET /blog/posts/*)
if (path.startsWith("/blog/posts/") && HttpMethod.GET.equals(method)) {
return true;
}
return false;
}
private String extractToken(ServerHttpRequest request) {
String bearer = request.getHeaders().getFirst("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
3) JwtAuth필러를 사용하기 위한 filter
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
/**
*RouteSpec의 주요 메서드
* .path("/auth/**") : 요청 경로 매칭
* .filters(f -> ...) :해당 라우트에 적용할 필터 등록
* .id("my-route") : 라우트 ID 명시적으로 지정 (생략 가능)
* .order(10) : 라우트 매칭 순서
* .host : ("www.google.com") 호스트 기반 라우팅
*/
// Auth Service (필터 적용 안 함)
.route("auth-service", r -> r
.path("/auth/**")
.uri("http://localhost:1001"))
// User Service (필터 적용)
.route("user-service", r -> r
.path("/users/**")
.filters(f -> f.filter(jwyAuthFilter))
.uri("http://localhost:1002"))
// Blog Service (필터 적용)
.route("blog-service", r -> r
.path("/blog/**")
.filters(f -> f.filter(jwyAuthFilter))
.uri("http://localhost:1003"))
.build();
}
- 프로젝트를 진행하면서 jwtprovider를 AuhService와 gateWay에서 공통으로 사용하니 common모듈로 분리
- 금주 목표로 진행해야할 것 : tag(2), comments(1), like(3), bookmark(5), series(4)