본문 바로가기
Spring

[Spring Boot] Spring Boot 2.7.5 version, JWT를 활용한 인증(Authentication), 인가(Authorization) 구축하기

by whereisco 2022. 12. 26.

개요

모든 웹 사이트에게 필수적으로 필요한 것이 바로 로그인과 회원가입 일 것입니다. 스프링에서는 이러한 기능을 비교적 쉽게 구현하는 것을 지원하기 위해 스프링 시큐리티라는 스프링 하위 프레임워크를 제공합니다. 이번 포스팅에서는 Spring Boot에서의 JWT를 활용한 인증(Authentication)과 인가(Authorization)를 구축하는 방법에 대해 알아보도록 하겠습니다.

목차

  1. 스프링 시큐리티 라이브러리 추가
  2. 스프링 시큐리티 설정 파일 수정
  3. JwtProvider, JwtFilter 구현

스프링 시큐리티 라이브러리 추가

가장 먼저 해야할 일은, 스프링 시큐리티 라이브러리를 추가하는 것입니다. 아래와 같이 build.gradle에 추가해 주면, 관련한 의존성을 추가해 줍니다.

    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.security:spring-security-test'

SpringSecurity 설정 파일 구현하기

위와 같이 의존성을 추가했다면, 프로젝트를 실행할 경우 모든 사이트로의 접근 권한이 허용되지 않을 것입니다. 왜냐하면, 스프링 시큐리티가 자동으로 로그인 요청을 가로채기 때문인데요. 따라서, 스프링 시큐리티와 관련된 내용을 설정해주기 위해 SecurityConfig라는 클래스를 하나 생성해 줍니다.

@RequiredArgsConstructor
@EnableWebSecurity 
public class SecurityConfig {

    private final String PERMIT_URL[] = {
            "/api/v1/users/join", "/api/v1/users/login", "/api/v1/hello"};

    private final JwtTokenProvider tokenProvider;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .cors()

                .and()
                .exceptionHandling()
                .accessDeniedHandler(jwtAccessDeniedHandler)
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)

                .and()
                .authorizeRequests()
                .antMatchers(PERMIT_URL).permitAll()
                .antMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
                .anyRequest().authenticated()

                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .addFilterBefore(new JwtTokenFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

 

Spring Boot 2.7.x 버전 이상부터는, WebSecurityConfigurerAdapter가 Deprecated 되었기 때문에, SecurityFilterChain을 빈으로 등록하여 구현해 주었습니다. 스프링은 WebSecurityConfigurerAdapter를 상속해서 Http security의 속성들을 커스텀하기보다는, 최근 버전에서는 컴포넌트 기반의 시큐리티 설정을 권장한다고 합니다.

 

접근 권한이 없는 에러 처리를 담당하는 JwtAccessDeniedHandler

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        //필요한 권한 없이 접근하려 할 때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

위 클래스는, JWT를 가지고 접근할 때에 토큰은 정상적으로 검증되었지만, 접근 권한이 없어 발생하는 예외를 처리하기 위한 클래스입니다. response의 sendError() 메서드를 통해, 403 에러가 반환되도록 구현해 줍니다.

 

유효한 자격 증명을 제공하지 않고 접근하려는 예외 처리를 담당하는 JwtAuthenticationEntryPoint

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        //유효한 자격 증명을 제공하지 않고 접근하려 할 때 401 Unauthorized
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

위 클래스는 인증되지 않은 유저가 접근하려고 할 때 발생하는 예외를 처리하기 위한 클래스입니다. response의 sendError() 메서드를 통해, 401 에러가 반환되도록 구현해 줍니다.

 

CustomFilter를 만들어보자, JwtFilter

@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {

    private final JwtTokenProvider tokenProvider;

    //실제 필터링 로직 : Jwt Token의 Authentication 정보를 현재 실행 중인 Security Context에 저장하기 위함
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String jwt = resolveToken(request);
        
        if(StringUtils.hasText(jwt) && !tokenProvider.isExpired(jwt)){ 
            Authentication authentication = tokenProvider.getAuthentication(jwt); 
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }

    public String resolveToken(HttpServletRequest request){
        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        log.info("authentication Header:{}", header);

        if(StringUtils.hasText(header) && header.startsWith("Bearer ")){
            return header.substring(7);
        }
        return null;
    }
}

위 클래스는, UsernamePasswordFilter 앞단에 배치할 커스텀 필터인 JWTFilter입니다. 구축할 애플리케이션은 화면을 구현하지 않은 Restul API 이기 때문에, 위와 같이 커스텀 필터를 배치하여 인증 및 인가 처리가 수행되도록 합니다.

JWT(Json Web Token)과 관련한 설정 추가 하기 [application.yml  | application.properties ]

jwt:
  token:
    secret: hello
    expireTimeMs: 36000000

우리는 인증, 인가 처리를 위해 앞서 언급했던 것과 같이 JWT를 활용할 것입니다. 따라서, 그에 맞는 필요한 설정들을 해줘야 합니다. secretKey의 이름은 환경 변수에서 설정값으로 따로 지정하여 활용할 것이기 때문에 간단히 hello로 설정해 줍니다. 만료시간은 10시간으로 설정해 두었습니다. (임의로 원하는 시간으로 설정해서 두셔도 됩니다.)

JWT 라이브러리 추가하기 [build.gradle]

//jwt
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'

build.gradle의 dependencies 내부에, 위와 같이 설정해줌으로써 JWT와 관련된 라이브러리를 추가해 줍니다.

JWT(Json Web Token)의 여러 기능들을 담당하는 클래스, JwtTokenProvider

//Token 생성과 유효성 검증 담당 클래스
@Slf4j
@Component
public class JwtTokenProvider {

    private final UserDetailsServiceImpl userDetailsService;
    private final Long expireTimeMs;
    private String secretKey;

    public JwtTokenProvider(
            UserDetailsServiceImpl userDetailsService,
            @Value("${jwt.token.secret}")String secretKey,
            @Value("${jwt.token.expireTimeMs}")long expireTimeMs) {
        this.userDetailsService = userDetailsService;
        this.secretKey = secretKey;
        this.expireTimeMs = expireTimeMs;
    }

    @PostConstruct
    protected void init(){
        log.info("[init] JwtTokenProvider 내 secretKey 초기화 시작 : {} ", this.secretKey);
        this.secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        log.info("[init] JwtTokenProvider 내 secretKey 초기화 완료 : {}", secretKey);
    }

    public String createToken(String userName, String authorities) {
        Claims claims = Jwts.claims().setSubject(userName);
        claims.put("role", authorities);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    //토큰의 만료여부 검증
    public boolean isExpired(String token){
        Date expiredDate = extractClaims(token).getExpiration();
        return expiredDate.before(new Date());
    }

    //토큰에 저장된 userName 반환
    public String getUserName(String token){
        return extractClaims(token).getSubject();
    }

    //토큰 내용 추출
    private Claims extractClaims(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
    }

    //인증 객체 생성
    public Authentication getAuthentication(String token){
        UserDetails userDetails = userDetailsService.loadUserByUsername(getUserName(token));
        return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), "", userDetails.getAuthorities()); //userName, Role이 담기도록 구현
    }
}

위 클래스는, 토큰의 생성과 유효성 검증 등 토큰과 관련한 다양한 기능들을 정의해 줍니다.

특히, createToken() 메서드는, 파라미터로 userName과 authorities를 받아서, Token Claim에 유저가 넘겨준 authorities 값을 넣어주도록 구현할 것입니다.

또한, getAuthentication() 메서드는, 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는 역할을 합니다. Authentication을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용하는 것으로, 위의 코드와 같이 Authentication을 구현해 줄 수 있겠죠? 

 

UsernamePasswordAuthenticationToken 클래스 초기화를 위한 UserDetails - (1)

UserDetails는 스프링 시큐리티에서 제공하는 개념으로, 유저와 관련한 정보들을 쉽게 가져오도록 작성되어있는 인터페이스입니다. 우리는 토큰을 통해, 인증과 인가를 구현해야 합니다. 이를 위해선, 토큰에 유저와 관련된 정보를 담아줘야겠죠? 이때, 유저와 관련된 정보를 제공하는 역할을 UserDetails가 하게 되는 것이죠.

public class UserDetailsImpl implements UserDetails {
    private final User user;

    public UserDetailsImpl(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(() -> user.getRole().getValue());
        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUserName();
    }

    //계정이 만료되었는지 리턴하는 메서드
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    //계정이 잠겨 있는지 리턴하는 메서드
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    //비밀번호가 만료됐는지 리턴하는 메서드
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    //계정이 활성화 되어 있는지 리턴하는 메서드
    @Override
    public boolean isEnabled() {
        return true;
    }
}

주석에 작성한 것과 같이, 유저와 관련된 여러 메서드들을 설정해 줍니다. 주목해야 할 점은, 생성자를 통해 우리가 정의한 User Entity와의 의존성을 직접 주입해줄 것입니다. 

 

UsernamePasswordAuthenticationToken 클래스 초기화를 위한 UserDetails - (2) 

UserDetails 인터페이스를 활용해 여러 메서드를 설정해 주었다면, 이제, userName을 활용해 DB에 저장된 User 오브젝트를 가져와주는 역할을 담당하는 userDetailsServiceImpl 클래스를 구현해 줍니다.

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUserName(username).orElseThrow(() ->
                new AppException(ErrorCode.USERNAME_NOT_FOUND, username + "는 존재하지 않는 유저입니다."));
        if (user != null){
            UserDetailsImpl userDetails = new UserDetailsImpl(user);
            return userDetails;
        }
        return null;
    }
}
public Authentication getAuthentication(String token){
    UserDetails userDetails = userDetailsService.loadUserByUsername(getUserName(token));
    return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), "", userDetails.getAuthorities()); //userName, Role이 담기도록 구현
 }

UserDetailsService 클래스는 위와 같이 인증 객체를 생성할 때에 유저 오브젝트를 가져오도록 가능하게 해주는 역할을 합니다.

 

나가면서

위의 과정을 통해 인증, 인가를 위한 커스텀 필터 작업과 그에 따른 시큐리티 파일 설정을 모두 마쳤습니다.

이를 활용해, 회원가입과 로그인을 구현하고 우리가 구현한 CustomFitler와 UserDetails, Spring Security가 어떻게 동작하는지 다음 포스팅을 통해 작성해보도록 하겠습니다. 

'Spring' 카테고리의 다른 글

[Spring Boot] Spring Boot + React 연동하기  (1) 2023.01.08
[Spring] @RestController vs @Controller  (0) 2022.11.28