Intercepter로 JWT 인증 구현 & Spring Security 없이 현재 로그인한 사용자 Id 얻어오기

박철현·2024년 6월 24일

스프링부트

목록 보기
4/9

들어가기 전

ThreadLocal

  • ThreadLocal : 멀티 스레드 환경에서 각 스레드마다 구분된 필드값을 가질 수 있는 객체
    • 해당 스레드만 접근할 수 있는 특별한 저장소
      • 창구, 여러 사람이 한 공간에 물건을 보관해도 창구 직원이 사용자별로 물건을 구별
    • 스레드 로컬은 사용 후 반드시 제거를 해줘야 함
      • 요청이 끝날 때 쓰레드 로컬의 값을 ThreadLocal.remove()를 통해 제거해야 함
        1. 사용자 A가 HTTP 저장 요청을 보내고 thread-A를 할당받아서 스레드 로컬에 사용자 A의 데이터 저장.
        2. WAS는 사용이 끝난 thread-A를 제거하지 않고 스레드풀에 다시 반납. 따라서 thread-A와 함께 스레드 로컬의 데이터도 살아있게 됨.
        3. 이후 사용자 B가 HTTP 조회 요청을 보내고 thread-A 스레드를 할당받게 된다.
        4. thread-A는 쓰레드 로컬에서 데이터를 조회하는데 사용자 A의 데이터가 저장되어 있어 사용자 B의 요청이지만 A값을 반환하는 문제 발생
  • 왜 필요한 것인가? JWT 인증에서 현재 로그인한 사용자 정보를 담는 LoginMember 클래스를 싱글톤으로 관리하기 때문에 여러 스레드가 동시에 요청하면 동시성 문제로 나중에 접속한 사용자 정보로 사용될 수 있음
    • 이를 방지하고자 ThreadLocal을 도입하여 동시성 문제 해결
  • 출처 : [Spring] ThreadLocal 개념과 동작 방식

Spring Intercepter

  • Spring이 제공하는 기술로 디스패처 서블릿이 컨트롤러를 호출하기 전/후 요청에 대해 부가적 작업 처리하는 객체

  • 디폴트 메서드 3개

    • preHandle : 핸들러 실행 전 동작하는 메서드
      • 비즈니스 로직에서 공통 로직
    • postHandle : Handler 이후 실행 메서드
      • 파라미터로 ModelAndView가 있음
      • ModelAndView에 대해 추가적 작업 하고싶을 경우 사용
    • afterCompletion : Handler 이후 실행 메서드
      • 파라미터로 Exception이 있음
        • 비즈니스 로직에서 예외 발생 시 처리 가능
      • 리소스를 정리할때도 사용할 수 있음
  • 스프링 인터셉터 - 호출

    • 핸들러 조회 -> 알맞은 핸들러 어댑터 가져옴 -> preHandle -> 핸들러 어댑터를 통해 핸들러 실행 -> postHandle -> view관련 처리 -> afterCompletion
  • 출처 : [영상후기] [10분 테코톡] 조시, 쿤의 서블릿 필터 & 스프링 인터셉터

Handler Adapter 동작 방식

  • 애노테이션 기반 컨트롤러가 다양한 파라미터를 사용할 수 있는 이유는 ArgumentResolver 덕분임
    • RequestMappingHandlerAdaptorHandlerMethodArgumentResolver를 호출해서 컨트롤러가 필요로 하는 다양한 파라미터의 값(객체) 생성
    • 파라미터의 값이 모두 준비되면 컨트롤러를 호출하면서 값을 넘겨줌
  • HandlerMethodArgumentResolver 동작 방식
    • supportsParameter()를 호출해서 해당 파라미터를 지원하는지 체크
    • resolveArgument()를 호출해서 실제 객체를 생성하고 이 객체는 컨트롤러 호출시 넘어감
  • 출처 : [Inflearn] 스프링 MVC (7) HttpMessageConverte

요약

  • 인터셉터로 JWT 인증하고 각 요청 thread마다 다른 사용자 객체를 갖도록 구현
  • 매개변수에 커스텀 어노테이션을 사용하여 HandlerMethodArgumentResolver를 동작시켜 현재 로그인한 사용자 객체 얻어오기
    • 스프링 시큐리티 Principal 객체와 유사
  • 컨트롤러 비즈니스 로직 동작 및 결과 반환
  • 인터셉터 이후 afterCompletion으로 ThreadLocal 정리

구현 1 - JWTProvider 및 Utility class 정의

JWTProvider

  • gradle 의존성 추가
	// 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'
  • JwtProvider Bean 등록
@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);
	}
}

유틸리티 클래스

  • 범용적 메서드 static으로 지정하고 사용
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;
		}
	}

구현2 - LoginMemberContext ThreadLocal class 정의

@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();
  }
}
  • remove는 명시적 호출 중요
  • 각 스레드마다 저장소 생성

구현3 - 인터셉터 정의

  • JWT를 AT과 RT를 구분하지 않고 단순 Token으로 사용함
  • 인증 성공 시 registerLoginMemberContext를 호출하여 ThreadLocal 객체 등록
@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();
	}
}

구현4 - Interceptor 등록

  • WebMvcConfigurer 구현체에 InterceptorRegistry의 addInterceptor 메서드 사용하여 등록
  • swagger, 로그인, 회원가입 경로에는 인터셉터 동작하지 않도록 제외
  • Resolver 등록 : 커스텀 어노테이션을 사용했을 때 동작할 리졸버 등록 -> 매개변수를 통해 현재 로그인한 사용자 정보 얻기 위함
@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);
  }
}

구현5 - Resolver 구현

  • 동작 조건 만족하는지 supportsParameter
    • 파라미터에 @LoginUser 어노테이션 사용했는지
    • 어노테이션의 파라미터 타입이 LoginMember.class인지
  • 조건 만족할 때 동작할 메서드 정의 resolveArgument
    • 정의한 ThreadLocal에서 Member 객체 꺼내오기
@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();
  }
}

구현 6 - @LoginUser + LoginMember.class

  • @LoginUser 커스텀 어노테이션 정의
@Retention(RetentionPolicy.RUNTIME) // 런타임 사용
@Target(ElementType.PARAMETER) // 파라미터로 사용
public @interface LoginUser {
}
  • LoginMember.class : 현재 로그인한 사용자 정보 저장
@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());
  }
}

구현 7 - 인증 예외 구현

public class AuthenticationException extends RuntimeException {
	public AuthenticationException(String message) {
		super(message);
	}
}
  • RunTimeException은 별도로 GlobalExceptionHandler에서 구현하였으나 글이 너무 길어져서 패스하겠습니다!

정리

테스트 컨트롤러 예시

@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();
  }
}
  • 인터셉터에 의해 JWT 인증 수행 및 LoginMemberContext에 인증된 사용자 LoginMember 등록
  • LoginUserResolver에 supportsParameter 메서드로 @LoginUser LoginMember loginMember 조건 만족 확인
  • LoginUserResolver에 resolveArgument 메서드로 LoginMember 주입
  • 컨트롤러 로직 수행
    • getAccount()로 현재 로그인한 사용자 Id 가저옴
  • 결과 반환
  • 인터셉터의 afterCompletion 호출되어 ThreadLocal 객체 명시적 삭제

회고

  • 한 7, 8개월만에 코드를 보니 새로워 공부하고자 정리하였습니다.
  • 이 당시 JWT 인증은 제가 구현했는데 Access Token, Refresh Token을 활용하는 방법을 몰라 기존 토큰 방식으로 구현했었습니다.
  • 스프링 시큐리티의 Principal로 현재 로그인한 사용자 정보 가져오는 것을 시큐리티 없이 구현하는 방법에 대해 학습해 보고자 하는 분들에게 도움이 되면 좋겠습니다!
profile
비슷한 어려움을 겪는 누군가에게 도움이 되길

0개의 댓글