Spring Security- JWT 적용하기(Json Web Token)

Kyu0·2022년 11월 22일
0

Spring-Security

목록 보기
2/2

개발환경 💻

  • 운영체제 : macOS Montery 12.4
  • Spring F/W
    • Spring Boot 2.7.5(Java)
    • Spring Starter Security 2.7.5 (Spring Security 5.7.4)
    • Spring Start JPA 2.7.5
    • Lombok 1.8.24
    • jjwt 0.9.1
    • 그 외 기타 dependency 는 생략
  • 데이터베이스 : MySQL
  • Java 11 (JDK: Amazon corretto)

구현할 기능 정의 ✏️

  • 로그인에 성공한 유저에게 JWT 발급
  • API 요청 시 토큰을 확인해 페이지 접근 권한 처리

기존에 Spring Boot 를 이용한 웹 사이트를 개발하면서 사용한 세션 기반 인증 방식은 서버를 재시작할 때마다 로그인 세션이 초기화되기 때문에 로그인을 새로 해줘야해서 불편한 점이 있었습니다.
이를 개선하기 위해, 서버 확장에 용이하고 클라이언트 측에 인증 관련 데이터를 넘겨준 뒤 이후에 클라이언트가 요청을 보낼 때마다 함께 첨부한 인증 데이터를 서버에서 검증하는 JWT(JSON Web Tokens)를 이용한 토큰 기반 인증 방식을 구현하겠습니다.


예상 동작 과정 🌊

다음은 간략하게 그린 JWT 발급 및 사용 과정에 대한 그림입니다.



코드

사용자의 요청을 처리할 Filter, 토큰을 만들고 검증하는 Provider, 인증과 인가처리를 할 Handler 각각 1개 씩 총 4개의 클래스를 새로 작성했습니다.

JwtFilter

// JwtFilter.java
import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

public class JwtFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    public JwtFilter (JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = jwtProvider.resolveToken(request);

        if (jwtProvider.validateToken(token)) {
            Authentication authentication = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
    
}

먼저 JwtFilter 클래스입니다. 사용자가 보낸 요청 헤더에 올바른 token 이 있다면, 해당 token 으로부터 usernameauthority 가 포함된 Authentication 객체를 생성해 SecurityContext 에 등록합니다. (Security Context에 대한 자세한 글)

SecurityContext 에 등록된 Authentication 객체는 주로 사용자의 접근 권한을 확인하는 데에 사용됩니다.


JwtProvider

// JwtProvider.java
import java.util.*;

import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.*;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.*;

import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList;


@PropertySource("security.properties")
@Component
public class JwtProvider {
    
    private final String SECRET_KEY;
    private final long EXPIRE_TIME;

    // 생성자 메소드
    public JwtProvider(@Value("${jwt.token.secret-key}") String secretKey, @Value("${jwt.token.expire-time}") long expireTime) {
        this.SECRET_KEY = secretKey;
        this.EXPIRE_TIME = expireTime;
    }

    /**
     * Authentication 기반 토큰 생성 메소드.
     * {@link #generateToken(String, Collection)}
     * @param authentication
     * @return JWT(String)
     */
    public String generateToken(Authentication authentication) {
        return generateToken(authentication.getName(), authentication.getAuthorities());
    }

    /**
     * Username 및 Authorities 기반 토큰 생성 메소드.
     * @param username
     * @param authorities
     * @return JWT(String)
     */
    public String generateToken(String username, Collection<? extends GrantedAuthority> authorities) {
        return Jwts.builder()
            .setSubject(username)
            .claim("role", authorities.stream().findFirst().get().toString())
            .setExpiration(getExpireDate())
            .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
        .compact();
    }

    /**
     * 토큰으로부터 받은 정보를 기반으로 Authentication 객체를 반환하는 메소드.
     * @param accessToken
     * @return Authentication
     */
    public Authentication getAuthentication(String accessToken) {
        return new UsernamePasswordAuthenticationToken(getUsername(accessToken), "", createAuthorityList(getRole(accessToken)));
    }

    /**
     * 사용자가 보낸 요청 헤더의 'Authorization' 필드에서 토큰을 추출하는 메소드.
     * @param request
     * @return token(String)
     */
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }

    public boolean validateToken(String accessToken) {
        if (accessToken == null) {
            return false;
        }
        
        try {
            return Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .parseClaimsJws(accessToken)
                .getBody()
                .getExpiration()
                .after(new Date());
        }
        catch (Exception e) {
            return false;
        }
    }

    private String getUsername(String accessToken) {
        return Jwts.parser()
            .setSigningKey(SECRET_KEY)
            .parseClaimsJws(accessToken)
            .getBody()
            .getSubject();
    }

    private String getRole(String accessToken) {
        return (String) Jwts.parser()
            .setSigningKey(SECRET_KEY)
            .parseClaimsJws(accessToken)
            .getBody()
            .get("role", String.class);

    }

    private Date getExpireDate() {
        Date now = new Date();
        return new Date(now.getTime() + EXPIRE_TIME);
    }
}

JWT 토큰을 생성하고 검증하는 JwtProvider 클래스입니다.

토큰 생성에 필요한 비밀키(SECRET_KEY), 만료 시간(EXPIRE_TIME) 은 보안을 위해 별도의 프로퍼티 파일을 생성해 관리하는 방법을 선택했습니다.

jsonwebtoken 에서 제공하는 암호화 알고리즘은 여러가지가 있으나 본 예제에서는 복잡성을 줄이기 위해 비밀키 하나만 사용하는 대칭키 알고리즘HMAC SHA 알고리즘 을 사용했습니다. (참고로, 이 게시글에서는 부인 방지 기능이 있는 RSA 알고리즘 을 추천합니다.)


Handler

EntryPointUnauthorizedHandler

// EntryPointUnauthorizedHandler.java

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import com.kyu0.jungo.util.ApiUtils;
import com.kyu0.jungo.util.FormatConverter;

@Component
public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authException) throws IOException, ServletException {
        
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setHeader("Content-Type", "application/json");
        response.getWriter().write(FormatConverter.toJson(
            ApiUtils.error("Unauthorized", HttpStatus.UNAUTHORIZED)
        ));
        response.getWriter().flush();
        response.getWriter().close();
    }
}

인증이 필요한 URI 에 게스트가 접근하면 처리할 동작을 정의한 EntryPointUnauthorizedHandler 클래스입니다.

게스트가 접근했을 때 로그인 페이지로 리디렉션을 해야할 지, 에러 응답을 보내야할 지 고민을 했으나 에러 응답에 UNAUTHORIZED(401) 이라는 응답 상태가 같이 보내지므로 후속 처리는 클라이언트에서 하는 것이 맞다고 판단해 에러 응답을 보내는 것으로 구현했습니다. (리디렉션을 원하신다면 response.sendRedirect(url); 와 같이 작성하시면 됩니다.)

CustomAccessDeniedHandler

// CustomAccessDeniedHandler.java

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import com.kyu0.jungo.util.ApiUtils;
import com.kyu0.jungo.util.FormatConverter;

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {
        
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setHeader("Content-Type", "applicaiton/json");
        response.getWriter().write(FormatConverter.toJson(
            ApiUtils.error("Forbidden", HttpStatus.FORBIDDEN)
        ));
        response.getWriter().flush();
        response.getWriter().close();
    }
    
}

인증된 사용자가 인가되지 않은 페이지에 접근했을 경우 처리할 동작을 정의한 CustomAccessDeniedHandler 클래스입니다. (인증과 인가의 차이)


설정

이제 만들어둔 클래스들을 활용해 Spring Security 관련 설정을 하겠습니다.

// SecurityConfiguration.java

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.kyu0.jungo.member.role.MemberRole;
import com.kyu0.jungo.system.auth.*;

@Configuration
public class SecurityConfiguration {

    private final JwtProvider jwtProvider;
    private final CustomAccessDeniedHandler accessDeniedhandler;
    private final EntryPointUnauthorizedHandler unauthorizedHandler;

    public SecurityConfiguration(JwtProvider jwtProvider, CustomAccessDeniedHandler accessDeniedHandler, EntryPointUnauthorizedHandler unauthorizedHandler) {
        this.jwtProvider = jwtProvider;
        this.accessDeniedhandler = accessDeniedHandler;
        this.unauthorizedHandler = unauthorizedHandler;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
            .exceptionHandling()
                .accessDeniedHandler(accessDeniedhandler)
                .authenticationEntryPoint(unauthorizedHandler)
                .and()
            .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        httpSecurity
            .authorizeRequests() // 요청에 대한 권한 설정
            .antMatchers("/").authenticated()
            .antMatchers("/test/user").hasRole(MemberRole.ROLE_USER.getNameWithoutPrefix())
            .antMatchers("/test/admin").hasRole(MemberRole.ROLE_ADMIN.getNameWithoutPrefix())
            .anyRequest().permitAll();

        httpSecurity
            .formLogin()
                .disable()
            .httpBasic()
                .disable()
            .csrf()
                .disable();

        return httpSecurity.build();
    }
}

exceptionHandling() 메소드를 호출해 주입받은 accessDeniedHandlerunauthorizedHandler 를 등록해주었습니다.

그 다음, JwtFilterUsernamePasswordAuthenticationFilter 앞에 배치해 Form Login 이 아닌 토큰 기반의 인증 필터가 먼저 수행되도록 설정했습니다.
또한, 이제 세션을 사용하지 않으므로 sessionCreationPolicy(SessionCreationPolicy.STATELESS) 메소드를 호출해 세션을 생성하지 않도록 했습니다.


MemberApiController

// MemberApiController.java

@RestController
public class MemberApiController {

// ... 생략
    @PostMapping("/api/login")
    public ApiResult<String> login(@RequestBody Member.LoginRequest requestDto) {
        Member.LoginResponse responseDto = memberService.authenticateByIdAndPassword(requestDto);
        
        return ApiUtils.success(jwtProvider.generateToken(responseDto.getId(), responseDto.getRole()));
    }
// ...
}

마지막으로 게스트의 로그인 요청이 오면 입력한 아이디, 패스워드를 검사한 뒤 생성한 토큰을 반환하는 EndPoint 도 추가해줬습니다.


테스트 👷🏻‍♂️

테스트는 다음과 같이 진행하겠습니다.

1번 테스트. ROLE_USER 역할을 가진 아이디로 로그인 → 발급받은 토큰으로 /test/user 페이지 접속

  • 예상 결과 : 성공

2번 테스트. ROLE_USER 역할을 가진 아이디로 로그인 → 발급받은 토큰으로 /test/admin 페이지 접속

  • 예상 결과 : 실패

편의를 위해 Postman 을 이용해서 테스트를 진행했습니다.

1번 테스트

  1. 로그인

response 필드에 JWT 토큰이 잘 전송되었습니다. 이 토큰을 Header의 Authorization 필드에 첨부해 /test/user 페이지로 GET 요청을 보내겠습니다.

  1. /test/user 페이지 요청

/test/user 페이지에 잘 접근했습니다.

2번 테스트

  1. 로그인
    1번 테스트에서 사용한 토큰을 그대로 사용하겠습니다.

  2. /test/admin 페이지 요청

/test/admin 페이지는 ROLE_ADMIN 역할을 가진 사용자만 접근할 수 있으므로 접근이 거부되는 것을 볼 수 있습니다.


구현 중 발생한 오류

실행 시 java.lang.IllegalArgumentException: role should not start with 'ROLE_' since it is automatically inserted. 발생

오류 인식

  • SecurityConfiguaration.java

  • MemberRole.java

접근 권한 설정 시에 ROLE_ 접두사를 같이 입력해서 생긴 문제였습니다. Spring Security 에서는 접두사를 자동으로 붙여서 비교해주기 때문에 접두사를 붙여서 입력해줄 필요가 없었습니다.

하지만 SecurityContext 에 등록한 Authentication 에는 역할(Role)에 접두사를 붙여서 저장해야 정상적으로 역할(Role)을 비교할 수 있는 것으로 추정됩니다. (아마 Authority 와 구분하기 위해서 그런 듯 합니다.)

정리하자면
1. Authentication 객체의 authorities 필드에 저장되는 역할(Role) 에는 접두사가 붙어야 한다.
2. Spring Security 설정 시 (hasRole(String)) 에는 접두사를 붙이지 않은 문자열을 전달해줘야 한다.

입니다.

제가 생각한 해결 방안으로는 다음과 같습니다.

1. hasRole() 메소드를 hasAuthority() 메소드로 대체한다.

  • 권한(Authority) 이 경우 접두사가 붙지 않기 때문에 비교적 간단하게 해결할 수 있습니다. 하지만 역할과 권한은 명백히 구분되어서 사용되고 있고 제가 구현하고자 하는 기능은 역할과 관련되어 있기에 이렇게 해결하는 방안은 바람직하지 않다고 생각했습니다.

2. 접두사를 제외한 String 을 입력해준다.

  • 이 방안이 제일 올바르다고 생각했지만 어느 클래스가 접두사를 제외하는 역할을 담당해야할 지 고민이 되었습니다.

해결 방법

당연히 MemberRole 클래스가 그 역할을 담당하는 게 맞다고 생각해 getNameWithoutPrefix() 메소드를 작성하고, 접두사가 필요없는 문자열이 필요한 곳에서 호출하여 해결했습니다.


Github 주소 : https://github.com/Kyu0/jungo/tree/c2e969c306dcd98fa729306308095979fef3651d

질문, 잘못된 내용, 오타 지적 언제나 환영입니다.

profile
개발자

0개의 댓글