Spring Security로 간단한 JWT인증 구현

KIYOUNG KWON·2021년 12월 30일
0

개요

매번 구현할 때 마다 헷갈려서 한번 쯤 정리해야겠다고 생각을 해서 블로그 개발에도 적용했던 JWT인증을 기준으로 Spring Security를 간단하게 살펴보고자 한다.

Spring Security에는 매우 많은 기능이 있지만 레퍼런스를 모두 훑어볼 용기는 없기에 필요한 부분은 하나씩 보도록 하자. 우선 Spring Security를 사용하기에 앞서 알아야할 내용은 서블릿의 필터와 Threadlocal 정도가 될듯 하다. 사실 Threadlocal은 나도 아직 잘 파악이 안되지만 일단은 간단하게 동일 thread 내에서 유효한 동안 남아있는 변수정도로 생각하면 될듯하다.

Spring Security로 검색하면 쉽게 구할 수 있는 이미지이다. 그만큼 Spring Security의 구조를 잘표현했다고 생각한다. 순서를 한번 살펴보자.

서블릿으로 요청이 들어오기 전에 필터를 통과하는데 AuthenticationFilter도 이와 동일하다. Spring Security를 설치하는 순간부터 이 필터가 동작한다고 생각하면 된다.

해당 필터는 기본적으로 Spring Security에서 지원하는 다양한 인증방식의 필터를 통과하는데 UsernamePasswordAuthenticationFilter는 Form방식 로그인, BasicAuthenticationFilter는 Basic방식의 로그인을 지원한다.

이러한 필터중에 실제 동작시킬 방식을 WebSecurityConfigurerAdapter를 상속받아 설정을 하고 설정된 필터에서 AuthenticationManager에 등록된 Provider에서 인증을 수행할 수단(DB에서 불러오거나, 인메모리에서 불러오거나 여러가지 방법이 있을 수 있음)으로 부터 검증을 하고 UserDetail을 반환하여 UsernamePasswordAuthenticationToken을 SecurityContext의 Threadlocal변수에 넣어준다. 이렇게 되면 해당 request(thread)동안 인증정보를 가진 상태로 작업을 수행할 수 있게된다.

만약 Session방식이라면 logout을 수행하기 전까지 서버가 이정보를 갖고있게 된다. 아마 이경우에는 Threadlocal이 아닌 Global 변수에 UsernamePasswordAuthenticationToken을 갖고있게 될 것이다. 해당 글에선 JWT 토큰방식의 인증을 사용할 것이기에 session을 사용하지 않을 것이다.

구현

gradle을 기준으로 다음과 같은 패키지가 필요하다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'com.auth0:java-jwt:3.18.2'
}

jwt라이브러리의 경우 마음에 드는 것이 있다면 다른 것을 사용해도 좋을 것 같다. 해당 구현방식은 인프런의 스프링부트 시큐리티 & JWT 강의를 참고하여 작성했다. 공짜이기도 하고 Spring Security가 처음이라면 한번 들어보는 것을 추천한다.

AccountDetail.java

import com.example.blog.entity.AccountEntity;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

@Data
public class AccountDetail implements UserDetails {

    private final AccountEntity accountEntity;

    public AccountDetail(AccountEntity accountEntity) {
        this.accountEntity = accountEntity;
    }

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

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return accountEntity.getAccountName();
    }

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

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

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

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

    public long getId() {
        return accountEntity.getId();
    }
}

UserDetails는 SpringSecurityContext가 들고다닐 인증정보를 가진 인터페이스이다. UserDetails를 상속받아 내부에 우리가 사용하는 사용자의 엔티티(보통은?)의 정보와 인터페이스에서 구현해주어야 할 함수들을 매핑해주면 된다.

AccountDetailService.java

import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.google.common.base.Preconditions;

import com.example.blog.repository.AccountRepository;
import com.example.blog.entity.AccountEntity;

@Service
public class AccountDetailService implements UserDetailsService {

    private final AccountRepository accountRepository;

    AccountDetailService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Override
    public AccountDetail loadUserByUsername(String accountName) throws UsernameNotFoundException {
        AccountEntity account = accountRepository.findByAccountName(accountName);
        Optional.ofNullable(account).orElseThrow(() -> new UsernameNotFoundException("로그인이 필요합니다"));
        return new AccountDetail(account);
    }
}

UserDetailsService는 UserDetail객체를 로드할 때 사용할 인터페이스이다. Spring Security에서 미리 구현된 필터의 인증방식이라면 UserDetailsService를 상속받은 객체를 설정만 해주어도 자동으로 호출될 것이다. 하지만 JWT는 미리 구현된 필터를 사용하지 않아서 직접 필터에서 호출을 해주어야 한다. 필터는 아래와 같이 구현해 보았다.

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Optional;

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private final AccountDetailService accountDetailService;

    private final JwtUtil jwtUtil;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, AccountDetailService accountDetailService, JwtUtil jwtUtil) {
        super(authenticationManager);
        this.accountDetailService = accountDetailService;
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String header = request.getHeader("Authorization"); //헤더의 Authorization 필드의 값 가져오기
        if (header == null || !header.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        String token = header.replace("Bearer ", ""); //Bearer로 시작하므로 앞부분 자르기

        try {
            String accountName = jwtUtil.getAccountNameFromToken(token);

            Optional.ofNullable(accountName).ifPresent(
                    s -> {
                        AccountDetail accountDetail = accountDetailService.loadUserByUsername(s);
                        Authentication authentication =
                                new UsernamePasswordAuthenticationToken(
                                        accountDetail,
                                        null,
                                        accountDetail.getAuthorities());

                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
            );
        } catch (RuntimeException ex) {
            //TODO Something
        }

        chain.doFilter(request, response);
    }
}

필터는 BasicAuthenticationFilter를 상속받아 구현하였다. UsernamePasswordAuthenticationFilter를 상속받아도 상관은 없을 것이다. 기존에 Spring Security의 필터의 어느 부분에 오버라이드를 할지 차이이다. 어차피 오버라이드 된 필터를 제외하면 비활성화 할 것이기 때문에 어떤 것을 사용해도 큰 문제는 없을 것이다.

여기서 AuthenticationProvier는 사용하지 않을 것이다. 인증정보를 불러오는 방법이 한가지 여서 필터에서 직접호출해도 문제가 없을 것이라 생각한다. 만약 여러가지 데이터 저장소를 사용한다면 AuthenticationProvier의 사용방법을 알아보는 것도 좋을 것 같다.

추가로 필터의 생성자에서 JwtUtil의 객체를 받는데 이 객체는 JWT의 Token을 생성하고 복호화하는 기능을 갖고있는 간단한 서비스로 구현해 보았다.

import java.util.Date;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;
    private static final long JWT_TOKEN_VALIDITY = 5 * 60 * 60;

    public String createToken(String accountName) {
        return JWT.create()
                .withSubject(accountName)
                .withExpiresAt(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000))
                .withClaim("accountName", accountName)
                .sign(Algorithm.HMAC512(secret));
    }

    public String getAccountNameFromToken(String token) {
        return JWT.require(Algorithm.HMAC512(secret)).build().verify(token).getClaim("accountName").asString();
    }
}

jwt.secret은 토큰을 생성할 때 사용하는 키값으로 중요한 정보이니 설정파일(application.properies 나 application.yml)에 저장하자.

이제 구현된 내용을 적용해보자.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final AccountDetailService jwtUserDetailsService;
    private final JwtUtil jwtUtil;

    public SecurityConfig(JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, AccountDetailService jwtUserDetailsService, JwtUtil jwtUtil) {
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtUserDetailsService = jwtUserDetailsService;
        this.jwtUtil = jwtUtil;
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //1. disable session
                .and()
                .formLogin().disable() //2. form방식 로그인 disable
                .httpBasic().disable() //3. basic방식 로그인 disable
                .addFilter(new JwtAuthorizationFilter(authenticationManager(), jwtUserDetailsService, jwtUtil)) //4. 위에서 만들어준 인증필터를 추가
                .authorizeRequests() //5. 인증 설정 시작
                .antMatchers(HttpMethod.POST, "/auth").authenticated() //6. POST /auth 는 인증 필요
                .antMatchers(HttpMethod.DELETE, "/something/**").authenticated() //7. DELETE /something/... 이후 어떤 값이 들어가도 인증 필요
                .anyRequest().permitAll() //8. 나머지는 인증하지 않음
                .and().exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint);
                //9. 인증이 필요한 endpoint에서 인증 실패시 호출할 entrypoiny설정
    }
}
  1. 세션 방식을 사용할 것이기 때문에 SessionCreationPolicy.STATELESS로 설정한다. STATELESS로 설정하면 SecurityContext의 strategy가 MODE_THREADLOCAL 방식으로 설정이 될 것이다.

  2. default로 동작하는 필터 disalbe

  3. default로 동작하는 필터 disalbe

  4. BasicAuthenticationFilter를 상속받아 구현한 JWT 인증 필터를 적용

  5. 인증 설정 시작

  6. POST /auth 는 인증이 필요, String만 antMatchers("/auth") 이렇게 넣어주면 모든 method에 대해 인증이 필요

  7. DELETE /something/... 인증이 필요

  8. 위에서 설정한 endpoint를 제외하고 모두 인증 필요없음

  9. 인증이 필요한 endpoint에서 인증이 실패할 경우 이에 대한 처리 객체

모든 설정이 끝이다. 회원가입과 로그인의 경우는 위의 SecurityConfig의 PasswordEncoder를 사용하여 암호화 한다. 서비스 단에서 아래와 같이 사용하면 될 것이다.

.....
accountRepository.save(new AccountEntity(accountName, passwordEncoder.encode(password)); //회원가입
......
AccountEntity account = accountRepository.findByAccountName(accountName);
passwordEncoder.matches(password, account.getPassword()); // 로그인할 경우 입력된 password의 hash값이 DB에 저장된 값과 동일한지 확인

입력된 accountName과 password가 유효한지 확인되면 다음과 같이 Token을 생성하고 그 값을 response 해주면 될 것이다.

String token = jwtUtil.createToken(req.getAccountName());

그리고 전달받은 토큰을 인증이 필요한 endpoint에 요청할 때 header에 'Authorization' key값에 'Bearer {token}' 과 같은 형태로 넣어서 보내주면 인증이 정상적으로 이루어 질 것이다.

좀더 해야할 것

Spring Security에 주요내용중 여기서 다루지 못한내용은 권한레벨과 이를 WebSecurityConfigurerAdapter에서 설정하는 방법과 메소드 단위로 권한레벨을 부여하기 위한 어노테이션을 사용한 시큐어 메소드 정도가 있을 것 같다. 관련 내용은 차후 여유가 있을 때 또 정리해봐야 겠다.

0개의 댓글