Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticat
.httpBasic(basic -> basic.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/accounts/signup", "/api/accounts/login", "/").permitAll()
.requestMatchers("/api/accounts/signup", "/api/accounts/login", "/", "/api/accounts/token/reissue").permitAll()
.anyRequest().authenticated()
)
.addFilterAt(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.synapse.account_service.controller;

import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.synapse.account_service.dto.RefreshTokenResponse;
import com.synapse.account_service.dto.response.TokenResponse;
import com.synapse.account_service.service.TokenManagementService;
import com.synapse.account_service.util.AuthResponseWriter;

import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/api/accounts/token")
@RequiredArgsConstructor
public class TokenReissueController {
private final TokenManagementService tokenManagementService;
private final AuthResponseWriter authResponseWriter;

@PostMapping("/reissue")
public ResponseEntity<?> reissue(@CookieValue(name = "refreshToken") String refreshToken) {
TokenResponse newTokens = tokenManagementService.reissueTokens(refreshToken);

RefreshTokenResponse response = authResponseWriter.writeSuccessResponse(newTokens);

return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, response.cookie().toString()).body(response.responseBody());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.synapse.account_service.domain;

import java.util.UUID;

import com.synapse.account_service.common.BaseTimeEntity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "refresh_token")
public class RefreshToken extends BaseTimeEntity {
@Id
@Column(columnDefinition = "uuid")
private UUID memberId;

@Column(nullable = false, length = 512)
private String token;

@Builder
public RefreshToken(UUID memberId, String token) {
this.memberId = memberId;
this.token = token;
}

public void updateToken(String newToken) {
this.token = newToken;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.synapse.account_service.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

public record AccessTokenResponse(
@JsonProperty("accessToken") String token,
@JsonProperty("expiresAt") long expiresAt
) {
public static AccessTokenResponse from(TokenResult tokenResult) {
return new AccessTokenResponse(
tokenResult.token(),
tokenResult.expiresAt().toEpochMilli());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.synapse.account_service.dto;

import org.springframework.http.ResponseCookie;

public record RefreshTokenResponse(
ResponseCookie cookie,
AccessTokenResponse responseBody
) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
* @param token 생성된 JWT 문자열
* @param expiresAt 토큰의 만료 시간
*/
public record TokenResult(String token, Instant expiresAt) {
public record TokenResult(
String token,
Instant expiresAt
) {

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
package com.synapse.account_service.dto;
package com.synapse.account_service.dto.response;

import com.synapse.account_service.dto.TokenResult;

/**
* JwtService가 최종적으로 생성하여 반환할 인증 토큰 DTO
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public enum ExceptionType {
NOT_FOUND_MEMBER(NOT_FOUND, "004", "존재하지 않는 사용자입니다."),
DUPLICATED_USERNAME_AND_EMAIL(CONFLICT, "005", "이미 존재하는 사용자 이름과 이메일입니다."),

INVALID_REFRESH_TOKEN(UNAUTHORIZED, "006", "유효하지 않은 리프레시 토큰입니다."),
TAMPERED_REFRESH_TOKEN(UNAUTHORIZED, "007", "리프레시 토큰이 변조되었습니다."),

INVALID_TOKEN(UNAUTHORIZED, "005", "유효하지 않은 토큰입니다."),
EXPIRED_TOKEN(UNAUTHORIZED, "006", "만료된 토큰입니다."),
FAIL_LOGIN(UNAUTHORIZED, "007", "아이디 또는 비밀번호가 일치하지 않습니다.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.synapse.account_service.repository;

import java.util.UUID;

import org.springframework.data.jpa.repository.JpaRepository;

import com.synapse.account_service.domain.RefreshToken;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID> {

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.synapse.account_service.dto.TokenResponse;
import com.synapse.account_service.dto.TokenResult;
import com.synapse.account_service.dto.response.TokenResponse;
import com.synapse.account_service.exception.ExceptionType;
import com.synapse.account_service.exception.JWTTokenExpiredException;
import com.synapse.account_service.exception.JWTValidationException;
Expand Down Expand Up @@ -47,4 +47,9 @@ public UUID getMemberIdFrom(String token) {
throw new JWTValidationException(ExceptionType.INVALID_TOKEN);
}
}

// 테스트용 메서드
public String createExpiredTokenForTest(String subject) {
return jwtTokenTemplate.createExpiredTokenForTest(subject);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.synapse.account_service.service;

import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
Expand Down Expand Up @@ -41,4 +42,16 @@ public final TokenResult createToken(String subject, Map<String, ?> claims, long
public final DecodedJWT verifyAndDecode(String token) throws JWTVerificationException {
return verifier.verify(token);
}

// 테스트용 메서드
public final String createExpiredTokenForTest(String subject) {
Instant now = Instant.now();
Instant past = now.minus(Duration.ofMinutes(10)); // 10분 전 만료

return JWT.create()
.withSubject(subject)
.withIssuedAt(past)
.withExpiresAt(past)
.sign(algorithm);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.synapse.account_service.service;

import java.util.UUID;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.synapse.account_service.domain.Member;
import com.synapse.account_service.domain.RefreshToken;
import com.synapse.account_service.dto.TokenResult;
import com.synapse.account_service.dto.response.TokenResponse;
import com.synapse.account_service.exception.ExceptionType;
import com.synapse.account_service.exception.JWTValidationException;
import com.synapse.account_service.exception.NotFoundException;
import com.synapse.account_service.repository.MemberRepository;
import com.synapse.account_service.repository.RefreshTokenRepository;

import lombok.RequiredArgsConstructor;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class TokenManagementService {
private final JwtTokenService jwtTokenService;
private final RefreshTokenRepository refreshTokenRepository;
private final MemberRepository memberRepository;

public void saveOrUpdateRefreshToken(UUID memberId, TokenResult refreshToken) {
refreshTokenRepository.findById(memberId)
.ifPresentOrElse(
// 기존 토큰이 있으면, 새 토큰으로 값을 업데이트 (재로그인 시)
existingToken -> existingToken.updateToken(refreshToken.token()),
// 기존 토큰이 없으면, 새로 생성하여 저장 (최초 로그인)
() -> {
RefreshToken newRefreshToken = new RefreshToken(memberId, refreshToken.token());
refreshTokenRepository.save(newRefreshToken);
});
}

public TokenResponse reissueTokens(String requestRefreshToken) {
UUID memberId = jwtTokenService.getMemberIdFrom(requestRefreshToken);

RefreshToken storedToken = refreshTokenRepository.findById(memberId)
.orElseThrow(() -> new JWTValidationException(ExceptionType.INVALID_REFRESH_TOKEN));

if (!storedToken.getToken().equals(requestRefreshToken)) {
refreshTokenRepository.delete(storedToken);
throw new JWTValidationException(ExceptionType.TAMPERED_REFRESH_TOKEN);
}

Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ExceptionType.NOT_FOUND_MEMBER));

String role = member.getRole().name();

TokenResponse newTokens = jwtTokenService.createTokenResponse(memberId.toString(), role);

storedToken.updateToken(newTokens.refreshToken().token());

return newTokens;
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
package com.synapse.account_service.service.handler;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.synapse.account_service.domain.PrincipalUser;
import com.synapse.account_service.dto.TokenResponse;
import com.synapse.account_service.dto.TokenResult;
import com.synapse.account_service.dto.response.TokenResponse;
import com.synapse.account_service.service.JwtTokenService;
import com.synapse.account_service.service.TokenManagementService;
import com.synapse.account_service.util.AuthResponseWriter;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
Expand All @@ -30,7 +24,8 @@
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
private final JwtTokenService jwtTokenService;
private final ObjectMapper objectMapper;
private final TokenManagementService tokenManagementService;
private final AuthResponseWriter authResponseWriter;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Expand All @@ -46,29 +41,9 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo

TokenResponse tokenResponse = jwtTokenService.createTokenResponse(memberId, role);

TokenResult refreshToken = tokenResponse.refreshToken();
long maxAge = Duration.between(Instant.now(), refreshToken.expiresAt()).getSeconds();
tokenManagementService.saveOrUpdateRefreshToken(UUID.fromString(memberId), tokenResponse.refreshToken());

ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken.token())
.maxAge(maxAge)
.path("/")
.httpOnly(true)
.secure(false) // 프로덕션 환경에서는 true로 설정
.sameSite("None") // 프론트/백엔드 도메인이 다른 경우
.build();

response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

TokenResult accessToken = tokenResponse.accessToken();
Map<String, Object> responseBody = Map.of(
"accessToken", accessToken.token(),
"expiresAt", accessToken.expiresAt().toEpochMilli()
);

response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(responseBody));
authResponseWriter.writeSuccessResponse(response, tokenResponse);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.synapse.account_service.util;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.synapse.account_service.dto.AccessTokenResponse;
import com.synapse.account_service.dto.RefreshTokenResponse;
import com.synapse.account_service.dto.response.TokenResponse;

import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class AuthResponseWriter {
private final ObjectMapper objectMapper;

public RefreshTokenResponse writeSuccessResponse(TokenResponse tokenResponse) {
AccessTokenResponse accessTokenResponse = AccessTokenResponse.from(tokenResponse.accessToken());
long maxAge = Duration.between(Instant.now(), tokenResponse.refreshToken().expiresAt()).getSeconds();
ResponseCookie cookie = ResponseCookie.from("refreshToken", tokenResponse.refreshToken().token())
.maxAge(maxAge)
.path("/")
.httpOnly(true)
.secure(false)
.sameSite("None")
.build();

return new RefreshTokenResponse(cookie, accessTokenResponse);
}

public void writeSuccessResponse(HttpServletResponse response, TokenResponse tokenResponse) throws IOException {
long maxAge = Duration.between(Instant.now(), tokenResponse.refreshToken().expiresAt()).getSeconds();
ResponseCookie cookie = ResponseCookie.from("refreshToken", tokenResponse.refreshToken().token())
.maxAge(maxAge)
.path("/")
.httpOnly(true)
.secure(false)
.sameSite("None")
.build();

response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

AccessTokenResponse responseBody = AccessTokenResponse.from(tokenResponse.accessToken());

response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(responseBody));
}
}
Loading
Loading