JAVA/Spring

[Spring Security] JWT + OAuth2.0 적용해보기 - (1)

민트맛녹차 2022. 12. 1. 15:52

spring security 학습과 Spring boot 에  JWT, OAuth2.0  적용을 위해 간단한 프로젝트를 진행해 보았습니다. 이번 프로젝트로 filter 기반의 Spring Security 아키텍처를 이해하고, JWT 와 OAuth2.0 의 이해 및 적용을 목표로 하였습니다.

 

JWT 기반 인증

이번 프로젝트는 access token 과 refresh token 을 모두 사용하는 방법을 채택했습니다. access token 과 refresh token 을 사용한 flow 는 다음과 같습니다. 

AT :  Access Token. 만료기간 짧음, RT : Refresh Token. 만료기간 김

case 0 ) login

/login

  • 로그인 성공 시, 200 - memberId, AT1, RT1 response. RT1 은 cookie 에 세팅된다.
  • 로그인 실패 시, 401 - 인증 실패 error response

 

case 1)  AT1 만료 X

/request with AT1

  • Authorization header 에 Bearer AT1 을 첨부하여 request 한다.
  • AT1 검증 후 정상적으로 response

 

case 2)  AT1 만료, RT1 만료 X

/request with AT1

  • Authorization header 에 Bearer  AT1 을 첨부하여 request 한다.
  • 401 - token 만료 error response
  • client 는 token 이 만료되었다는 response 확인 후 Authorization header 에 Bearer RT1 첨부하여 request 한다.

/refresh with RT1

  • RT1 검증 후 AT2 response

 

case 3) AT1 만료, RT1 만료

/request with AT1

  • 401 - token 만료 error response
  • client 는 token 이 만료되었다는 response 확인 후 Authorization header 에 Bearer RT1 첨부하여 request 한다.

/refresh with RT1

  • 401 - token 만료 error response
  • RT1 이 만료되었다는 것을 확인했으므로, client 는 /login 을 통해 새로운 AT 와 RT 를 받아야 한다.

간단한 구현을 위해 refresh token 의 갱신 주기는 고려하지 않았지만, refresh token 에 갱신 주기를 추가하여 인증 과정을 설계할 수도 있습니다.

 

Spring Security 에서의 인증 과정

spring security 를 사용한 인증과정에서의 핵심은 AuthenticationManager 인터페이스 입니다. AuthenticationManager 의 authenticate 메서드는 Authentication 객체를 인자로 받아 인증을 시도합니다. 인증이 성공하면 권한을 포함한 인증된 Authentication 객체를 return 하고, 인증이 실패하면 exception 을 throw 합니다. 인증의 과정은 다음과 같습니다.

  • AuthenticationManager 인터페이스의 authenticate 메서드를 실행하면 AuthenticationManager 의 구현체의 authenticate 메서드를 실행합니다. 보통 ProviderManager 가 사용됩니다.
  • ProviderManager 는 List of AuthenticationProvder 에게 인증을 위임합니다.
  • AuthenticationManager 인터페이스의 authenticate 의 인자로 UserPasswordAuthenticationToken 을 받는다면 AuthenticationProvder 의 구현체인 DaoAuthenticationProvider 를 사용합니다.
  • DaoAuthenticationProvider 는 UserDetailService 를 사용해 UserDetails 객체를 생성하여 UserPasswordAuthenticationToken  과 비교합니다.
  • 인증이 성공하면 권한과 사용자 정보가 담긴 Authentication 객체를 return 합니다.

authenticate 를 사용하여 받아온 Authentication 객체는 authenticatie 를 호출한 곳에서 SecurityContextHolder 에 설정하도록 합니다.

Spring Security 에서 예외가 발생하면 ExceptionTranslationFilter 에서 Exception 을 감지하고 처리합니다. 특히 AuthenticationException 과 AccessDeniedException 을 감지하여 각각 AuthenticationEntrypoint 와 AccessDeniedHandler 에서 Exception 이 다뤄집니다.

 

구현 코드

 

CustomUserDetails

public class CustomUserDetails implements OAuth2User, UserDetails {
    private Long id;
    private String email;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;
    private Map<String, Object> attributes;

    public CustomUserDetails(Long id, String email, String password, Collection<? extends GrantedAuthority> authorities) {
        this.id = id;
        this.email = email;
        this.password = password;
        this.authorities = authorities;
    }

    public static CustomUserDetails create(Member member) {
        List<SimpleGrantedAuthority> authorities = Collections
                .singletonList(new SimpleGrantedAuthority(member.getRole().getValue()));

        return new CustomUserDetails(member.getId(),
                member.getEmail(),
                member.getPassword(),
                authorities);
    }

    public static CustomUserDetails create(Member member, Map<String, Object> attributes) {
        CustomUserDetails userDetails = CustomUserDetails.create(member);
        userDetails.setAttributes(attributes);
        return userDetails;
    }

    public Long getId() {
        return id;
    }

    // UserDetail Override
    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    // OAuth2User Override
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public String getName() {
        return String.valueOf(id);
    }

    public void setAttributes(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

}

추후 OAuth2 적용 시 같은 객체를 사용하기 위해서 CustomUserDetails 는 UserDetails 와 OAuth2User 모두 구현했습니다.  getUsername 이 email 을 return 하도록 하여 추후 UserDetailService 의 loadByUsername 의 인자로 email 을 받을 수 있도록 합니다.

 

CustomUserDeatilService

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(email)
                .orElseThrow(() -> new RuntimeException("no member"));
        return CustomUserDetails.create(member);
    }

    public UserDetails loadUserById(Long id) throws UsernameNotFoundException {
        Member member = memberRepository.findOne(id)
                .orElseThrow(RuntimeException::new);
        return CustomUserDetails.create(member);
    }

}

loadUserByUsername 메서드는 email 을 사용해 member 를 찾고, member 를 사용해 CustomUserDetails 객체를 생성합니다. 인증과정에서 쓰이지는 않지만 id 를 사용해 CustomUserDetails 객체를 생성해야하는 경우가 있어 loadUserById 메서드도 추가적으로 구현했습니다.

 

JwtProvider 

@Component
@RequiredArgsConstructor
public class JwtProvider {

    @Value("${auth.token.secret-key}")
    private String SECRET_KEY;
    private final Long ACCESS_TOKEN_EXPIRE_LENGTH = 1000L * 60 * 60;        // 1hour
    private final Long REFRESH_TOKEN_EXPIRE_LENGTH = 1000L * 60 * 60 * 24 * 7;    // 1week
    private final String AUTHORITIES_KEY = "role";

    private final CustomUserDetailService customUserDetailService;

    private String createToken(Authentication authentication, Long tokenExpireLength) {

        Date now = new Date();
        Date expiration = new Date(now.getTime() + tokenExpireLength);

        CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
        String userId = user.getName();
        String role = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        return Jwts.builder()
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
                .setSubject(userId)
                .claim(AUTHORITIES_KEY, role)
                .setIssuedAt(now)
                .setExpiration(expiration)
                .compact();

    }

    public String createAccessToken(Authentication authentication) {
        return createToken(authentication, ACCESS_TOKEN_EXPIRE_LENGTH);
    }

    public String createRefreshToken(Authentication authentication) {
        return createToken(authentication, REFRESH_TOKEN_EXPIRE_LENGTH);
    }

    public Long findMemberId(String token) {
        return Long.valueOf(parseClaims(token).getSubject());
    }

    public Authentication getAuthentication(String token) {

        UserDetails userDetails = customUserDetailService.loadUserById(findMemberIdByJwt(token));
        return new UsernamePasswordAuthenticationToken(userDetails,
                "",
                userDetails.getAuthorities());
    }

    public JwtResultType validateToken(String token) {

        try {
            Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
            return JwtResultType.VALID_JWT;
        } catch (ExpiredJwtException e) {
            System.out.println("만료된 JWT 토큰입니다.");
            return JwtResultType.EXPIRED_JWT;
        } catch (Exception e) {
            System.out.println("JWT 토큰이 잘못되었습니다");
            return JwtResultType.INVALID_JWT;
        }

    }

    private Claims parseClaims(String accessToken) {

        try {
            return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }

    }

}

JWT 를 생성, 검증하는 클래스입니다. 추가적으로 토큰을 입력받아 MemberId 를 return 하는 findMemberId 메서드와 Authentication 을 return 하는 getAuthentication 메서드가 있습니다.

 

JwtResultType

public enum JwtResultType {
    VALID_JWT, EXPIRED_JWT, INVALID_JWT
}

JWT 를 검증한 결과를 정의한 enum 입니다. ExpiredJwtException 은 다른 Exception 들과 다른 로직을 처리해야하기 때문에, enum 을 정의하여 JwtProvider 클래스의 validateToken 메서드의 return 값으로 사용했습니다. 

 

JwtFilter

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final String TOKEN_PREFIX = "Bearer ";
    private final JwtProvider jwtProvider;
    private final List<String> EXCLUDE_URL_PATTERN = List.of(
            "/api/login",
            "/api/signup",
            "/api/admin/signup",
            "/oauth2/**"
    );

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String pathName = request.getServletPath();
        return EXCLUDE_URL_PATTERN.stream().anyMatch(url -> {
            if (url.endsWith("/**")) {
                return pathName.startsWith(url.substring(0, url.length() - 3));
            } else {
                return url.equalsIgnoreCase(pathName);
            }
        });
    }

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

        String token = getJwtFromRequest(request);

        if (StringUtils.hasText(token)) {

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

                filterChain.doFilter(request, response);
            } else if (jwtResultType == JwtResultType.EXPIRED_JWT) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.setContentType(APPLICATION_JSON_VALUE);
                response.setCharacterEncoding("utf-8");

                JSONObject json = new JSONObject();
                json.put("code", 401);
                json.put("message", "Access Token 이 만료되엇습니다.");
                response.getWriter().print(json);
            } else {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.setContentType(APPLICATION_JSON_VALUE);
                response.setCharacterEncoding("utf-8");

                JSONObject json = new JSONObject();
                json.put("code", 400);
                json.put("message", "잘못된 JWT 입니다.");
                response.getWriter().print(json);
            }
        } else {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.setContentType(APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("utf-8");

            JSONObject json = new JSONObject();
            json.put("code", 400);
            json.put("message", "JWT 가 존재하지 않습니다.");
            response.getWriter().print(json);
        }

    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
            return bearerToken.substring(TOKEN_PREFIX.length());
        }
        return null;
    }

}

request 의 header 에서 token 을 가져와 검증하는 filter 입니다. shouldNotFilter 메서드는 request 의 path name 이 EXCLUDE_URL_PATTERN 과 매칭된다면 true 를 return 하여 filtering 를 하지 않도록 합니다.

doFilterInternal 메서드는 JWT 를 가져와 검증하여, 유효한 JWT 라면 Authentication 을 생성해 SecurityContext 에 저장합니다. 검증되지 않는다면 error 를 response 합니다.

 

CustomAuthenticationEntryPoint

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("utf-8");

        JSONObject json = new JSONObject();
        json.put("code", 401);
        json.put("message", authException.getLocalizedMessage());

        response.getWriter().print(json);
    }

}

인증이 실패한 경우 401 status 와 error message 를 response 하도록 설정했습니다.

 

CustomDeniedHandler

@Component
public class CustomDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("utf-8");

        JSONObject json = new JSONObject();
        json.put("code", 403);
        json.put("message", accessDeniedException.getLocalizedMessage());

        response.getWriter().print(json);
    }
}

권한이 없는 경우 403 status 와 error message 를 response 하도록 설정했습니다.

 

 

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtProvider jwtProvider;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final CustomDeniedHandler customDeniedHandler;

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();

        roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_MANAGER > ROLE_MEMBER > ROLE_GUEST");
        return roleHierarchy;
    }

    @Bean
    public AccessDecisionVoter<? extends Object> roleVoter() {
        RoleHierarchyVoter roleHierarchyVoter = new RoleHierarchyVoter(roleHierarchy());
        return roleHierarchyVoter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .formLogin().disable()
                .httpBasic().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(customAuthenticationEntryPoint)
                .accessDeniedHandler(customDeniedHandler)
                .and()
                .authorizeRequests()
                .antMatchers("/api/login", "/api/signup", "/api/admin/signup", "/oauth2/**")
                .permitAll()
                .antMatchers("/api/refresh")
                .authenticated()
                .antMatchers(HttpMethod.GET, "/api/my").hasRole("MEMBER")
                .antMatchers("/api/admin/my").hasRole("ADMIN")
                .anyRequest()
                .authenticated()
                .and()
                .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);

    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

AuthenticationManager 의 Bean 을 반드시 생성해 주어야 합니다. Bean 생성이 없으면 exception 이 발생합니다.

roleHierarchy 는 권한에 계층을 설정해 주고, roleVoter 는 roleHierachy 를 사용해 해당 계층을 등록해 줍니다. 우리는 ROLE_ADMIN 은 ROLE_MEMBER 의 모든 권한을 가지기 원합니다. 하지만 이러한 계층 설정이 없다면, ROLE_ADMIN 은 ROLE_ADMIN 의 권한만 가지게 됩니다. 따라서 권한 계층을 등록하여 상위 권한은 하위 권한을 가지도록, 즉 ROLE_ADMIN 은 ROLE_MEMBER 의 권한을 모두 가지도록 설정해줍니다. 

restful api 를 개발중이므로 csrf(), formLogin(), httpBasic() 을 모두 disable() 처리하고 session 을 stateless 하도록 설정합니다. 앞서 선언한 CustomAuthenticationEntryPoint 와 CustmDeniedHandler 를 등록해줍니다. antMatchers 를 통해 특정 Url Pattern 마다 다른 속성을 부여합니다. permitAll() 은 인증이 없이 모두 통과하고, authenticated() 는 인증 필요, hasRole() 은 인자로 받은 권한을 가지는 경우만 통과시킵니다. 마지막으로  앞서 정의한 JwtFilter 를UsernamePasswordAuthenticationFilter 앞에 등록합니다.

 

AuthController

@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;
    private final RefreshTokenService refreshTokenService;

    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManager authenticationManager;
    private final JwtProvider jwtProvider;

    @PostMapping("/api/login")
    public ResponseEntity<LoginResponseDto> login(@RequestBody LoginRequestDto loginRequestDto, HttpServletResponse response) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequestDto.getEmail(),
                        loginRequestDto.getPassword())
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);

        String accessToken = jwtProvider.createAccessToken(authentication);
        String refreshToken = jwtProvider.createRefreshToken(authentication);
        Member member = authService.getMemberByEmail(authentication.getName());

        refreshTokenService.update(member, refreshToken);
        CookieUtil.addCookie(response, "REFRESH_TOKEN", refreshToken, 60 * 60 * 24 * 7);

        return ResponseEntity.ok(new LoginResponseDto(member.getId(), accessToken));
    }

    @PostMapping("/api/signup")
    public ResponseEntity<Void> signup(@RequestBody SignupRequestDto signupRequestDto) {
        if (!authService.memberValidCheck(signupRequestDto.getEmail())) {
            throw new RuntimeException("멤버 있음");
        }
        Member member = Member.builder()
                .name(signupRequestDto.getName())
                .sex(signupRequestDto.getSex())
                .birthday(signupRequestDto.getBirthday())
                .email(signupRequestDto.getEmail())
                .password(passwordEncoder.encode(signupRequestDto.getPassword()))
                .role(MemberRole.MEMBER)
                .authProvider(AuthProvider.EMAIL)
                .build();

        Long memberId = authService.save(member);

        return ResponseEntity.created(URI.create("/api/members/" + memberId)).build();
    }

    @GetMapping("/api/refresh")
    public ResponseEntity<TokenResponseDto> reissue(HttpServletRequest request) {

        String authorizationHeader = request.getHeader("Authorization");
        String refreshToken = authorizationHeader.substring("Bearer ".length());

        Authentication authentication = jwtProvider.getAuthentication(refreshToken);
        Member member = authService.getMemberByEmail(authentication.getName());

        RefreshToken refreshTokenObj = refreshTokenService.getRefreshTokenByMember(member);
        if (!refreshTokenObj.getRefreshToken().equals(refreshToken)) {
            throw new RuntimeException("refresh token 이 다름");
        }

        String newAccessToken = jwtProvider.createAccessToken(authentication);
        TokenResponseDto tokenResponseDto = new TokenResponseDto();
        tokenResponseDto.setAccessToken(newAccessToken);

        return ResponseEntity.ok(tokenResponseDto);
    }

    @GetMapping("/api/my")
    public ResponseEntity<String> getMy() {
        return ResponseEntity.ok("mymy");
    }

}

 

GET /api/refresh 는 SecurityConfig 에서 authenticate() 처리되어, 토큰을 사용해 인증되어야 사용할 수 있습니다. 

GET /api/my 는 SecurityConfig 에서 hasRole("MEMBER") 처리되어, ROLE_MEMBER 의 권한을 가진 토큰을 사용해 인증을 해야 사용할 수 있습니다. 추가로 ""ROLE_ADMIN" 도 ROLE_MEMBER" 의 권한을 가지므로 해당 api 를 사용할 수 있습니다.

 

AdminController

@Controller
@RequiredArgsConstructor
public class AdminController {

    private final AuthService authService;
    private final PasswordEncoder passwordEncoder;

    @GetMapping("/api/admin/my")
    public ResponseEntity<String> getAdminMy() {
        return ResponseEntity.ok("admin mymy");
    }

    @PostMapping("/api/admin/signup")
    public ResponseEntity<Void> signup(@RequestBody SignupRequestDto signupRequestDto) {
        if (!authService.memberValidCheck(signupRequestDto.getEmail())) {
            throw new RuntimeException("멤버 있음");
        }
        Member member = Member.builder()
                .name(signupRequestDto.getName())
                .sex(signupRequestDto.getSex())
                .birthday(signupRequestDto.getBirthday())
                .email(signupRequestDto.getEmail())
                .password(passwordEncoder.encode(signupRequestDto.getPassword()))
                .role(MemberRole.ADMIN)
                .authProvider(AuthProvider.EMAIL)
                .build();

        Long memberId = authService.save(member);

        return ResponseEntity.created(URI.create("/api/admin/members/" + memberId)).build();
    }

}

GET /api/admin/my 는 SecurityConfig 에서 hasRole('ADMIN') 처리되어, ROLE_ADMIN 권한을 가진 토큰을 사용해 인증해야 사용할 수 있습니다. ROLE_MEMBER 권한을 가진 토큰을 사용하여 인증하면 403 error 를 response 하게 됩니다. 

 

이렇게 Spring Security 와 JWT 를 사용한 간단한 프로젝트를 마무리 하였습니다. 프로젝트를 진행하며 몇 가지 고칠 부분이 있습니다.

먼저, Exception 관련 처리입니다. 원래 RuntimeException 들이 아닌 상황에 맞는 Exception 을 생성하고, 그 Exception 들을 @ExceptionHandler 등을 사용해 처리했어야 했는데 Spring Security 와 JWT 에 초점을 맞추다 보니 간단하게 구현했습니다.

다음 JwfFilter 입니다. 현재 프로젝트에서는 인증이 필요할 때는 JwtFilter 에서 error 를 response 하거나, 인증을 거쳐Authentication 을 생성해 SecurityContext 에 저장합니다. 따라서 AuthenticationFilter 인 UsernamePasswordAuthenticationFilter 는 사실상 동작하지 않습니다. 그렇다면 JwtFilter 에서는 예외만 처리하고, UsernamePasswordAuthenticationFilter 를 상속한 Filter 를 만들어 그곳에서 인증절차를 진행하는 것이 더 좋은 방법일지도 모른다는 생각이 듭니다. 

 

참조
https://docs.spring.io/spring-security/reference/5.6/index.html
https://hungseong.tistory.com/67
https://onejunu.tistory.com/137