Filter 를 이용한 Authorization(with JWT)

Bobby·2023년 2월 21일
0

즐거운 개발일지

목록 보기
20/22
post-thumbnail

🔍 개요

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

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

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

Filter를 이용해 구현해보자.


🔍 토큰 발급 및 검증

의존성

  • 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("토큰 검증 실패");
        }
    }
}

🔍 url 리소스 생성

  • uri 기반으로 토큰 검증 여부를 판단한다.
필드설명
methodhttp method
pattern검증이 필요한 url 패턴
required검증 필수 여부
@Getter
@Builder
public class Resource {

    private Long id;
    private String method;
    private String pattern;
    private boolean required;
}
  • 지금은 메모리에 저장하지만 DB값을 조회한다고 가정한다.
@Repository
public class MemoryResourceRepository {

    private static final List<Resource> resources = new ArrayList<>();

    public MemoryResourceRepository() {
        resources.add(
                Resource.builder()
                        .id(1L)
                        .method(HttpMethod.GET.name())
                        .pattern("/api/token/required")
                        .required(true)
                        .build()
        );
        resources.add(
                Resource.builder()
                        .id(2L)
                        .method(HttpMethod.GET.name())
                        .pattern("/api/token/optional")
                        .required(false)
                        .build()
        );
    }

    public List<Resource> findAll() {
        return resources;
    }
}

🔍 PathMatcher

  • 요청한 url이 토큰검증이 필요한지 매칭하는 클래스
  • 스프링의 AntPathMatcher클래스를 상속받아 커스텀

CustomAntPathMatcher

@Component
public class CustomAntPathMatcher extends AntPathMatcher {

    private final MemoryResourceRepository memoryResourceRepository;
    private List<Resource> resources;

	// 생성 시 리소스 전체 리스트를 조회하여 저장
    public CustomAntPathMatcher(MemoryResourceRepository memoryResourceRepository) {
        this.memoryResourceRepository = memoryResourceRepository;
        this.resources = memoryResourceRepository.findAll();
    }

	// 리소스 변경이 일어나면 리소스 리스트 갱신
    public void refresh() {
        this.resources = memoryResourceRepository.findAll();
    }

	// 요청의 method, path를 리소스 리스트에 있는지 확인
    public boolean match(String method, String path) {
        return resources.stream()
                .anyMatch(r -> r.getMethod().equals(method) && super.match(r.getPattern(), path));
    }

	// 토큰 검증 필수 인지 확인
    public boolean required(String method, String path) {
        return resources.stream()
                .filter(r -> r.getMethod().equals(method) && super.match(r.getPattern(), path))
                .findAny()
                .orElseThrow()
                .isRequired();
    }
}

🔍 JwtContext

  • 토큰 검증후 payload 데이터를 다른 클래스에서도 사용해야 하기 때문에 JwtConext에 저장
  • ThreadLocal을 이용하여 데이터 저장

JwtContext 에 저장할 값
MemberInfo

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class MemberInfo {
    private Long id;
    private String username;
}

JwtContext

public class JwtContext {

    private static final ThreadLocal<MemberInfo> CONTEXT = new ThreadLocal<>();

	// 데이터 조회
    public static MemberInfo getMemberInfo() {
        MemberInfo memberInfo = CONTEXT.get();
        return memberInfo == null ? new MemberInfo() : memberInfo;
    }

	// 데이터 저장
    public static void setMemberInfo(MemberInfo memberInfo) {
        CONTEXT.set(memberInfo);
    }

	// 데이터 삭제(스프링은 쓰레드풀을 사용하므로 쓰레드 종료시 반드시 지워주는 작업이 필요)	
    public static void clear() {
        CONTEXT.remove();
    }
}

🔍 AuthFilter

  • 토큰검증이 필요한 url 인지 판단
  • 토큰 데이터를 JwtContext에 저장
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthFilter extends OncePerRequestFilter {

    private final CustomAntPathMatcher customAntPathMatcher;

    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        JwtContext.clear();

        log.info("auth filter");
        String requestURI = request.getRequestURI();
        String method = request.getMethod();

        if (customAntPathMatcher.match(method, requestURI)) { // 토큰 검증 필요한 uri 인지 확인

            String token = request.getHeader("Authorization");

            if (token != null) { // 헤더의 토큰 확인
                if (jwtProvider.validateToken(token)) { // 토큰 검증
                    JwtContext.setMemberInfo(jwtProvider.getClaim(token)); // 토큰 payload 값을 쓰레드 변수에 저장
                    filterChain.doFilter(request, response);
                }
            } else { // 헤더에 토큰 없을 경우
                if (!customAntPathMatcher.required(method, requestURI)){ // 토큰이 필수 인지 확인
                    JwtContext.setMemberInfo(new MemberInfo()); // 기본 객체 쓰레드 변수에 저장
                    filterChain.doFilter(request, response);
                } else { // 토큰이 없고 토큰이 필수라면 에러
                    throw new RuntimeException("권한 없음");
                }
            }
        } else { // 토큰 검증 필요 없다면 통과
            filterChain.doFilter(request, response);
        }
    }
}

🔍 테스트

1. 토큰 검증 필요없는 경우

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

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

요청

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

  • auth filter 통과하여 api 호출

2. 토큰 검증 필수인 경우

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

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

헤더에 토큰 없이 요청

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

  • 에러 발생

헤더에 토큰 포함 요청

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

  • auth filter 통과 후 JwtContext에 데이터 저장 확인

3. 토큰 검증 선택인 경우

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

	...
    
    @GetMapping("/token/optional")
    public String optional() {
        log.info("token payload : {}", JwtContext.getMemberInfo());
        return "success";
    }
}

헤더에 토큰 없이 요청

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

  • uth filter 통과 후 JwtContext에 기본 객체 저장

헤더에 토큰 포함 요청

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

  • auth filter 통과 후 JwtContext에 데이터 저장 확인

코드

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

0개의 댓글