Argument Resolver 를 이용한 Authorization(with JWT)

Bobby·2023년 2월 19일
1

즐거운 개발일지

목록 보기
19/22
post-thumbnail

🔍 개요

유저 로그인 인증 및 인가를 JWT로 하기로 한다.

다음의 요구사항들이 있다.

  1. API에는 3가지 타입이 있다.
    • 로그인이 필요하지 않은 API
    • 로그인이 필수로 요구되는 API
    • 로그인이 필요하지 않지만 로그인을 할 경우 해당 유저에 맞는 추가 정보가 포함되는 API
  2. 토큰검증 후 payload의 데이터를 이후 비지니스 로직에 사용한다.

argument resolver를 이용해 구현해보자.


🔍 토큰 발급 및 검증

의존성

  • jjwt 라이브러리 사용
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

토큰 발급

  • secret key는 32바이트 이상의 값을 사용해야 한다.
    (ex: bobbyjsonwebtokensecretkey202302)
  • claim 에는 payload에 들어갈 값들을 지정해 준다.

JwtProvider

@Slf4j
@Component
public class JwtProvider {

    @Value("${jwt.secret-key}")
    private String secretKey;

    private static final long TOKEN_VALID_TIME = 24 * 60 * 60 * 1000L;
    private static final long REFRESH_TOKEN_VALID_TIME = 30 * 24 * 60 * 60 * 1000L;

    public String createToken(Member member) {
        Claims claims = Jwts.claims();
        claims.put("id", member.getId());
        claims.put("username", member.getUsername());

        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + TOKEN_VALID_TIME))
                .signWith(
                        Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)),
                        SignatureAlgorithm.HS256
                )
                .compact();
    }
}

토큰 검증

JwtProvider

@Slf4j
@Component
public class JwtProvider {

    ...
    
    public boolean validateToken(String token) {
        try {
            return !Jwts.parserBuilder()
                    .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)))
                    .build()
                    .parseClaimsJws(removeBearer(token))
                    .getBody()
                    .getExpiration().before(new Date());

        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("토큰 검증 실패");
        }
    }
}

🔍 @JwtAuthorization 어노테이션 생성

  • 로그인이 선택인 api가 있기 때문에 추가 정보를 받을 수 있도록 어노테이션을 이용 한다.
  • 해당 어노테이션이 있는 매개변수에 대해서 resolve하도록 한다.
  • required 의 기본값은 true(로그인 필수) , 필요할 시 false로 값을 변경

@JwtAuthorization

@Target({ ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface JwtAuthorization {

    boolean required() default true;
}

🔍 토큰 검증 후 payload 사용

MemberInfo

  • 토큰 payload 내용을 저장할 클래스
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class MemberInfo {

    private Long id;
    private String username;
}

JwtProvider

  • 토큰 검증 후에 MemberInfo를 리턴한다
@Slf4j
@Component
public class JwtProvider {

    ...
    
    public MemberInfo getClaim(String token) {
        Claims claimsBody = Jwts.parserBuilder()
                .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)))
                .build()
                .parseClaimsJws(removeBearer(token))
                .getBody();

        return MemberInfo.builder()
                .id(Long.valueOf((Integer) claimsBody.getOrDefault("id", 0L)))
                .username(claimsBody.getOrDefault("username", "").toString())
                .build();
    }

    private String removeBearer(String token) {
        return token.replace("Bearer", "").trim();
    }
}

🔍 Argument resolver 생성

  • @JwtAuthorization 어노테이션이 있을 경우에 동작
  • request 헤더에 Authorization 값을 검증한 후 유효한 경우 MemberInfo 리턴

JwtAuthorizationArgumentResolver

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthorizationArgumentResolver implements HandlerMethodArgumentResolver {

    private final JwtProvider jwtProvider;

	// @JwtAuthorization 어노테이션이 있을 경우 동작
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(JwtAuthorization.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
    
    	log.info("JwtAuthorizationArgumentResolver 동작!!");

        HttpServletRequest httpServletRequest = webRequest.getNativeRequest(HttpServletRequest.class);
		
        // 헤더 값 체크
        if (httpServletRequest != null) {
            String token = httpServletRequest.getHeader("Authorization");

            if (token != null && !token.trim().equals("")) {
				// 토큰 있을 경우 검증
                if (jwtProvider.validateToken(token)) {
                	// 검증 후 MemberInfo 리턴
                    return jwtProvider.getClaim(token);
                }
            }

			// 토큰은 없지만 필수가 아닌 경우 체크
            JwtAuthorization annotation = parameter.getParameterAnnotation(JwtAuthorization.class);
            if (annotation != null && !annotation.required()) {
            	// 필수가 아닌 경우 기본 객체 리턴
                return new MemberInfo();
            }
        }

		// 토큰 값이 없으면 에러
        throw new RuntimeException("권한 없음.");
    }
}

🔍 Argument resolver 등록

WebConfig

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

	private final JwtAuthorizationArgumentResolver jwtAuthorizationArgumentResolver;

	@Override
	public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
		resolvers.add(jwtAuthorizationArgumentResolver);
	}
}

🔍 사용

1. 토큰 필요없는 경우

@Slf4j
@RestController
@RequestMapping("/api")
public class TestController {

    @GetMapping("/token/none")
    public String none() {
        log.info("token none");
        return "success";
    }
}

실행

GET http://localhost:8080/api/token/none

  • argument resolver 동작하지 않음.

2. 토큰이 필수로 필요한 경우

@Slf4j
@RestController
@RequestMapping("/api")
public class TestController {

   	...

    @GetMapping("/token/required")
    public String required(
            @JwtAuthorization MemberInfo memberInfo
    ) {
        log.info("token payload : {}", memberInfo);
        return "success";
    }
}

실행

GET http://localhost:8080/api/token/required

  • 헤더에 토큰 미포함
  • argument resolver 동작 했고 토큰이 없으므로 에러 발생
  • 헤더에 토큰 포함
  • argument resolver 동작 했고 정상 동작하여 MemberInfo 객체에 토큰 데이터 바인딩

3. 토큰이 선택인 경우

  • @JwtAuthorization(required = false)
@Slf4j
@RestController
@RequestMapping("/api")
public class TestController {

    ...
    
    @GetMapping("/token/optional")
    public String optional(
            @JwtAuthorization(required = false) MemberInfo memberInfo
    ) {
        log.info("token payload : {}", memberInfo);
        return "success";
    }
}

실행

GET http://localhost:8080/api/token/optional

  • 헤더에 토큰 미포함
  • argument resolver 동작 했고 토큰이 없으므로 기본 객체 응답
  • 헤더에 토큰 포함
  • argument resolver 동작 했고 정상 동작하여 MemberInfo 객체에 토큰 데이터 바인딩

코드

profile
물흐르듯 개발하다 대박나기

0개의 댓글