- 사용자 A가 HTTP 저장 요청을 보내고
thread-A를 할당받아서 스레드 로컬에 사용자 A의 데이터 저장.- WAS는 사용이 끝난
thread-A를 제거하지 않고 스레드풀에 다시 반납. 따라서thread-A와 함께 스레드 로컬의 데이터도 살아있게 됨.- 이후 사용자 B가 HTTP 조회 요청을 보내고
thread-A스레드를 할당받게 된다.thread-A는 쓰레드 로컬에서 데이터를 조회하는데 사용자 A의 데이터가 저장되어 있어 사용자 B의 요청이지만 A값을 반환하는 문제 발생
LoginMember 클래스를 싱글톤으로 관리하기 때문에 여러 스레드가 동시에 요청하면 동시성 문제로 나중에 접속한 사용자 정보로 사용될 수 있음Spring이 제공하는 기술로 디스패처 서블릿이 컨트롤러를 호출하기 전/후 요청에 대해 부가적 작업 처리하는 객체
디폴트 메서드 3개
스프링 인터셉터 - 호출
출처 : [영상후기] [10분 테코톡] 조시, 쿤의 서블릿 필터 & 스프링 인터셉터
RequestMappingHandlerAdaptor가 HandlerMethodArgumentResolver를 호출해서 컨트롤러가 필요로 하는 다양한 파라미터의 값(객체) 생성supportsParameter()를 호출해서 해당 파라미터를 지원하는지 체크resolveArgument()를 호출해서 실제 객체를 생성하고 이 객체는 컨트롤러 호출시 넘어감
HandlerMethodArgumentResolver를 동작시켜 현재 로그인한 사용자 객체 얻어오기 // JWT 라이브러리
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
@Component
public class JwtProvider {
private SecretKey cachedSecretKey;
@Value("${custom.jwt.secretKey}")
private String secretKeyPlain;
private SecretKey _getSecretKey() {
// 시크릿 키 객체 생성 : 시크릿 키 평문 Base64 인코딩 -> hmac 인코딩
String keyBase64Encoded = Base64.getEncoder().encodeToString(secretKeyPlain.getBytes());
return Keys.hmacShaKeyFor(keyBase64Encoded.getBytes());
}
// 시크릿키 객체가 있으면 저장된거 사용, 없으면 생성
public SecretKey getSecretKey() {
if (cachedSecretKey == null) cachedSecretKey = _getSecretKey();
return cachedSecretKey;
}
// 토큰에 들어갈 정보와 만료 시간을 받으면 토큰을 생성하는 메서드
public String genToken(Map<String, Object> claims, int seconds) {
long now = new Date().getTime();
Date accessTokenExpiresIn = new Date(now + 1000L * seconds);
return Jwts.builder()
.claim("body", Ut.json.toStr(claims))
.setExpiration(accessTokenExpiresIn)
// 추후 알고리즘과 비밀키를 사용하여 서버가 서명했는지 검증함
.signWith(getSecretKey(), SignatureAlgorithm.HS512)
.compact();
}
// 토큰이 유효한지 검사하는 메서드
public boolean verify(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build()
.parseClaimsJws(token);
} catch (Exception e) {
return false;
}
return true;
}
// 토큰으로부터 정보를 추출하는 메서드
public Map<String, Object> getClaims(String token) {
String body = Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build()
.parseClaimsJws(token)
.getBody()
.get("body", String.class);
return Ut.json.toMap(body);
}
}
public class Ut {
public static class json {
// map을 Json 형태 변환 매서드
public static Object toStr(Map<String, Object> map) {
try {
// Jackson 라이브러리 ObjectMapper 클래스 사용하여 map 객체를 Json형태
// Java <-> Json 처리 작업 간단하게 해주는 클래스
return new ObjectMapper().writeValueAsString(map);
} catch (JsonProcessingException e) {
return null;
}
}
// json 형태 데이터를 map 형태로 변환
public static Map<String, Object> toMap(String jsonStr) {
try {
return new ObjectMapper().readValue(jsonStr, LinkedHashMap.class);
} catch (JsonProcessingException e) {
return null;
}
}
// map을 JSONObject 변환
public static JSONObject mapToJSONObject(Map<String, Object> map) {
JSONObject jsonObject = new JSONObject(map);
System.out.println(jsonObject);
return jsonObject;
}
}
@Component
@RequiredArgsConstructor
public class LoginMemberContext {
private final ThreadLocal<LoginMember> loginMemberThreadLocal = new ThreadLocal<>();
public void save(Member member){
loginMemberThreadLocal.set(LoginMember.of(member));
}
public LoginMember getLoginMember(){
return loginMemberThreadLocal.get();
}
public void remove(){
loginMemberThreadLocal.remove();
}
}
@Component
@RequiredArgsConstructor
public class AuthorizationInterceptor implements HandlerInterceptor {
private final JwtProvider jwtProvider;
private final MemberRepository memberRepository;
private final LoginMemberContext loginMemberContext;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws
Exception {
String token = extractToken(request);
if (token == null)
throw new AuthenticationException("JWT를 요청 헤더에 넣어주세요");
token = token.substring("Bearer ".length());
if (!jwtProvider.verify(token))
throw new AuthenticationException("유효하지 않은 토큰입니다.");
Optional<Member> memberOptional = extractMember(token);
if (memberOptional.isEmpty())
throw new AuthenticationException("존재하지 않는 사용자입니다.");
if (!verifyAccessToken(token, memberOptional))
throw new AuthenticationException("저장된 토큰 정보가 유효하지 않습니다.");
//사용자 정보 context 등록
registerLoginMemberContext(memberOptional);
return HandlerInterceptor.super.preHandle(request, response, handler);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
Exception ex) throws Exception {
releaseLoginMemberContext();
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
private String extractToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
private Optional<Member> extractMember(String token) {
return memberRepository.findById(Long.valueOf((Integer)jwtProvider.getClaims(token).get("id")));
}
private boolean verifyAccessToken(String token, Optional<Member> memberOptional) {
if (memberOptional.isEmpty())
throw new AuthenticationException("존재하지 않는 사용자입니다.");
return token.equals(memberOptional.get().getAccessToken());
}
private void registerLoginMemberContext(Optional<Member> memberOptional) {
if (memberOptional.isEmpty())
return;
loginMemberContext.save(memberOptional.get());
}
private void releaseLoginMemberContext() {
loginMemberContext.remove();
}
}
@Component
@RequiredArgsConstructor
public class MvcConfig implements WebMvcConfigurer {
private final AuthorizationInterceptor authorizationInterceptor;
private final LoginUserResolver loginUserResolver;
@Override
public void addInterceptors(InterceptorRegistry registry){
registry.addInterceptor(authorizationInterceptor).excludePathPatterns("/member/signup","/member/signin", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/api/event");
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginUserResolver);
}
}
supportsParameterresolveArgument@Component
@RequiredArgsConstructor
public class LoginUserResolver implements HandlerMethodArgumentResolver {
private final LoginMemberContext loginMemberContext;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(LoginUser.class) && parameter.getParameterType().equals(LoginMember.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return loginMemberContext.getLoginMember();
}
}
@Retention(RetentionPolicy.RUNTIME) // 런타임 사용
@Target(ElementType.PARAMETER) // 파라미터로 사용
public @interface LoginUser {
}
@Getter
@AllArgsConstructor
public class LoginMember {
private Long id;
private String account;
private String accessToken;
public static LoginMember of(Member member){
return new LoginMember(member.getId(),member.getAccount(),member.getAccessToken());
}
}
public class AuthenticationException extends RuntimeException {
public AuthenticationException(String message) {
super(message);
}
}
@RestController
@RequestMapping("/v1/authorization/test")
@Tag(name = "TestController", description = "현재 로그인한 사용자 정보 추출 어노테이션 테스트용 API")
public class AuthorizationTestController {
@GetMapping("")
@Operation(summary = "현재 로그인한 사용자의 account를 반환한다.")
public String authorizationTest(@LoginUser LoginMember loginMember){
return loginMember.getAccount();
}
}
@LoginUser LoginMember loginMember 조건 만족 확인