SRP원칙을 지켜보자!
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TokenDto {
private String token;
private LocalDateTime expiredAt;
}
public interface TokenHelperIfs {
TokenDto issueAccessToken(Map<String,Object> data);
TokenDto issueRefreshToken(Map<String,Object> data);
Map<String,Object> validationTokenWithThrow(String Token);
}
token:
secret:
key: -------------------------------------------
access-token:
plus-hour: 1
refresh-token:
plus-hour: 12
@Value("${}") 안에 application.yml의 정보를 넣어서@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TokenResponse {
private String accessToken;
private LocalDateTime accessTokenExpiredAt;
private String refreshToken;
private LocalDateTime refreshTokenExpiredAt;
}
@Component
public class JwtTokenHelper implements TokenHelperIfs {
@Value("${token.secret.key}")
private String secretKey;
@Value("${token.access-token.plus-hour}")
private Long accessTokenPlusHour;
@Value("${token.refresh-token.plus-hour}")
private Long refreshTokenPlusHour;
@Override
public TokenDto issueAccessToken(Map<String, Object> data) {
LocalDateTime expiredLocalDateTime = LocalDateTime.now().plusHours(accessTokenPlusHour);
java.util.Date expiredAt = Date.from(expiredLocalDateTime.atZone(ZoneId.systemDefault()).toInstant());
SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes());
String jwtToken = Jwts.builder()
.signWith(key, SignatureAlgorithm.HS256)
.setClaims(data)
.setExpiration(expiredAt)
.compact();
return TokenDto.builder()
.token(jwtToken)
.expiredAt(expiredLocalDateTime)
.build();
}
@Override
public TokenDto issueRefreshToken(Map<String, Object> data) {
var expiredLocalDateTime = LocalDateTime.now().plusHours(refreshTokenPlusHour);
var expiredAt = Date.from(
expiredLocalDateTime.atZone(
ZoneId.systemDefault()
).toInstant()
);
var key = Keys.hmacShaKeyFor(secretKey.getBytes());
var jwtToken = Jwts.builder()
.signWith(key, SignatureAlgorithm.HS256)
.setClaims(data)
.setExpiration(expiredAt)
.compact();
return TokenDto.builder()
.token(jwtToken)
.expiredAt(expiredLocalDateTime)
.build();
}
@Override
public Map<String, Object> validationTokenWithThrow(String token) {
SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes());
JwtParser parser = Jwts.parserBuilder()
.setSigningKey(key)
.build();
try {
Jws<Claims> result = parser.parseClaimsJws(token);
return new HashMap<String, Object>(result.getBody());
}catch (Exception e){
if (e instanceof SignatureException){
// 토큰이 유효하지 않을 때
throw new ApiException(TokenErrorCode.INVALID_TOKEN, e);
}
else if (e instanceof ExpiredJwtException) {
// 만료된 토큰
throw new ApiException(TokenErrorCode.EXPIRED_TOKEN,e);
}
else {
// 그 외 처리
throw new ApiException(TokenErrorCode.INVALID_TOKEN,e);
}
}
}
}
issueAccessToken 토큰을 만다는 메서드이다setExpiration에 Date타입으로 변경해서 넣어줘야 한다issueRefreshToken은 토큰을 다시 받는 부분이다issueRefreshToken부분이다OCP부분을 잘 만족하는 코드라고 할 수 있다@AllArgsConstructor
@Getter
public enum TokenErrorCode implements ErrorCodeIfs {
INVALID_TOKEN(400,2000,"유효하지 않은 토큰입니다"),
EXPIRED_TOKEN(400,2001,"만료된 토큰입니다"),
TOKEN_EXCEPTION(400,2002,"트큰 -> 알 수 없는 에러입니다"),
AUTHORIZATION_TOKEN_NOT_FOUND(400,2003,"인증 헤더 토큰이 없습니다");
private final Integer httpStatusCode; // 상응하는 http status 코드
private final Integer errorCode; // 인터널 에러 코드
private final String description; //설명
}
@PostMapping("/login")
public Api<TokenResponse> login(@Valid @RequestBody Api<UserLoginRequest> request){
TokenResponse response = userBusiness.login(request.getBody());
return Api.OK(response);
}
@Transactional
public TokenResponse login(UserLoginRequest request) {
UserEntity userEntity = userService.login(request.getEmail(), request.getPassword());
TokenResponse tokenResponse = tokenBusiness.issueToken(userEntity);
return tokenResponse;
}
public TokenResponse issueToken(UserEntity userEntity){
return Optional.ofNullable(userEntity)
.map(ue->{
return ue.getId();
})
.map(userId->{
TokenDto accessToken = tokenService.issueAccessToken(userId);
TokenDto refreshToken = tokenService.issueRefreshToken(userId);
return tokenConverter.toResponse(accessToken,refreshToken);
})
.orElseThrow(()->new ApiException(ErrorCode.NULL_POINT));
}
Api<TokenConverter>을 반환해준다

validationTokenWithThrow을 이용하자public Long validationToken(String token){
Map<String, Object> map = tokenHelperIfs.validationTokenWithThrow(token);
Object userId = map.get("userId");
Objects.requireNonNull(userId, ()->{throw new ApiException(ErrorCode.NULL_POINT);
});
return Long.parseLong(userId.toString());
}
validationTokenWithThrow를 이용해 "userid"라고 HashMap에 집어넣은 후 반환한다public Long validationAccessToken(String accessToken){
Long userId = tokenService.validationToken(accessToken);
return userId;
}
지금까지, String token을 받아서 검증에 성공했다면, 성공한 userId를 반환하는 로직을 만들었다
그럼 어떻게, request에서 token을 헤더로 넣은 정보를 검증할까??

Interceptior영역에서 token을 검증한다@Slf4j
@RequiredArgsConstructor
@Component
public class AuthorizationInterceptor implements HandlerInterceptor {
private final TokenBusiness tokenBusiness;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("Authorization Interceptor url : {}",request.getRequestURI());
// WEB, chrome의 경우 GET, POST OPTION 이라는 api를 요청해서, 해당 메서드를 지원하는지 체크하는 api -> pass
if (HttpMethod.OPTIONS.matches(request.getMethod())){
return true;
}
// js, htm, png같은 resources를 요청하는 경우 -> pass
if (handler instanceof ResourceHttpRequestHandler){
return true;
}
// TODO header 검증
String accessToken = request.getHeader("authorization-token");
if (accessToken == null){
throw new ApiException(TokenErrorCode.AUTHORIZATION_TOKEN_NOT_FOUND);
}
Long userId = tokenBusiness.validationAccessToken(accessToken);
if (userId !=null){
RequestAttributes requestAttributes = Objects.requireNonNull(RequestContextHolder.getRequestAttributes());
requestAttributes.setAttribute("userId", userId,RequestAttributes.SCOPE_REQUEST);
return true;
}
throw new ApiException(ErrorCode.SERVER_ERROR,"인증실패");
}
}
"authorization-token"이라는 이름으로 태워서 보내준다고 약속!!!Authorization_token_not_found 에러를 내준다RequestAttributes는 Spring에서 요청 단위의 속성을 제어하기 위한 인터페이스이다ServletRequestAttributes는 이 인터페이스를 구현한 클래스 중 하나로, HTTP Servlet 요청에서 속성을 관리하는 데 사용됩니다.Interceptor에서 setAttribute을 통해 userId를 저장해놨으므로getAttribute를 통해 찾아서 사용할 수 있다
public UserResponse me(Long userId) {
UserEntity userEntity = userService.getUserWithThrow(userId);
UserResponse response = userConverter.toResponse(userEntity);
return response;
}
결국 Interceptor영역에서, 오류가 나지 않았다면 -> token이 인증된 후
RequestAttributes에 userId라고 인증이 된 이용자의 pk값을 넣어둔다
- 로그인이 필요한 페이지에서는 이 부분을 이용해, 이 값이 비어있지 않다면 -> 그 값을 가지고 요청에 대한 응답을 내려준다
그런데,,, 항상 이렇게 RequestAttributes을 복잡하게 찾아서 값을 꺼내와야 할까???
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface UserSession {
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
private Long id;
private String name;
private String email;
private String password;
private UserStatus status;
private String address;
private LocalDateTime registeredAt;
private LocalDateTime unregisteredAt;
private LocalDateTime lastLoginAt;
}
@Component
@RequiredArgsConstructor
public class UserSessionResolver implements HandlerMethodArgumentResolver {
private final UserService userService;
@Override
public boolean supportsParameter(MethodParameter parameter) {
// 지원하는 파라미터 체크, 어노테이션 체크
//1. 어노테이션이 있는지 체크
boolean annotation = parameter.hasParameterAnnotation(UserSession.class);
//2. 파라미터 타입 체크
boolean parameterType = parameter.getParameterType().equals(User.class);
return (annotation && parameterType);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
// support parameter에서 true 반환시 여기 실행
// request context holder에서 찾아오기
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
Object userId = requestAttributes.getAttribute("userId", RequestAttributes.SCOPE_REQUEST);
UserEntity userEntity = userService.getUserWithThrow(Long.parseLong(userId.toString()));
// 사용자 정보 세팅
return User.builder()
.id(userEntity.getId())
.name(userEntity.getName())
.email(userEntity.getEmail())
.status(userEntity.getStatus())
.password(userEntity.getPassword())
.address(userEntity.getAddress())
.registeredAt(userEntity.getRegisteredAt())
.unregisteredAt(userEntity.getUnregisteredAt())
.lastLoginAt(userEntity.getLastLoginAt())
.build();
}
}
HandlerMethodArgumentResolver 를 구현한다supportsParameter를 통해 파라미터이고, 타입이 User타입인지 확인한다resolveArgument가 실행된다requestAttributes에서 id를 찾아오고 이를 통해 UserEntity를 찾고@RestController
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class UserApiController {
private final UserBusiness userBusiness;
@GetMapping("/me")
public Api<UserResponse> me(@UserSession User user){
UserResponse response = userBusiness.me(user.getId());
return Api.OK(response);
}
}
supportsParameter조건이 모두 만족되므로, resolveArgument가 실행된다@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userSessionResolver);
}
WebConfig에 다음 userSeesionResolver을 꼭 등록해줘야 한다!!@Component를 이용해 스프링 빈으로 등록을 해줘야 한다
- 지금까지 User 엔티티에 대한 로그인 기능을 성공했을 때
- token을 생성하고, 클라이언트에게 token을 보내고
- 클라이언트가 그 token을 다시 가지고 요청했을 때,
- Interceptor을 이용해, 검증 후 검증이 성공했다면
- pk를 RequestAttribute에 담아서, 인증이 필요한 컨트롤러에서 사용할 수 있게 하였다
- 이부분도 handelr을 이용해, 어노테이션을 이용해 RequestAttribute에서 값을 찾아오는 과정을 줄여서 코드의 불필요한 중복을 줄여줄 수 있다
- 만약, RequestAttribute에서 값을 가져오지 못한다면, 비지니스 로직에 따라, jwtToken을 인증하지 못하였다고 생각한다!