[Spring] Spring Security, JWT, 인증, 인가

Spring Security, JWT, 인증, 인가 #

  • Spring Security를 베이스로 JWT를 사용해서 해당 프로젝트의 인증과 인가를 구현한다.
  • 이와 관련돼서 생성된 Class는 다음과 같다.
    • SecurityConfig : Spring Security관련 설정
    • UserAccount : Spring Security에서 인증 요소(principal)로 사용되는 객체. Userdetails를 상속받고 Account의 정보를 갖는다.
    • PrincipalDetailService : 인증 시, DB에서 Account를 찾고 UserAccount로 반환하는 loadUserByUsername Method를 갖는다.
    • JwtAutienticationFilter : jwt를 사용해서 인증 처리
    • JwtAutiorizationFilter : jwt를 사용해서 인가 처리

SecurityConfig #

  • Spring Security가 사용할 정책, 필터, 인가 권한 등을 설정한다.
 1@Configuration
 2@EnableWebSecurity
 3@RequiredArgsConstructor
 4public class SecurityConfig extends WebSecurityConfigurerAdapter {
 5
 6  private final AccountRepository accountRepository;
 7  private final JwtProcessor jwtProcessor;
 8
 9  @Override
10  protected void configure(HttpSecurity http) throws Exception {
11    http
12          .csrf().disable()
13          .formLogin().disable()
14          .httpBasic().disable();
15
16    http
17          .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
18
19    http
20          .addFilter(corsFilter())
21          .addFilter(new JwtAuthenticationFilter(authenticationManager(), jwtProcessor))
22          .addFilter(new JwtAuthorizationFilter(authenticationManager(), accountRepository, jwtProcessor));
23
24    http
25          .authorizeRequests()
26          .mvcMatchers("/home", "/login").permitAll() //** 홈페이지, login
27          .anyRequest().hasAuthority("ROLE_USER");
28  }
29
30  @Override
31  public void configure(WebSecurity web) throws Exception {
32    web
33          .ignoring()
34          .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
35  }
36
37  @Bean
38  public CorsFilter corsFilter() {
39    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
40    CorsConfiguration config = new CorsConfiguration();
41    config.setAllowCredentials(true);
42    config.addAllowedOriginPattern("*");
43    config.addAllowedHeader("*");
44    config.addAllowedMethod("*");
45    source.registerCorsConfiguration("/**", config);
46    return new CorsFilter(source);
47  }
48
49  @Bean
50  @Override
51  public AuthenticationManager authenticationManagerBean() throws Exception {
52    return super.authenticationManagerBean();
53  }

기본 설정 #

1http
2      .csrf().disable()
3      .formLogin().disable()
4      .httpBasic().disable();
5
6http
7      .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
  • csrf.disable() : API를 작성하는데 프런트가 정해져있지 않기 때문에 csrf설정은 우선 꺼놓는다.
  • formLogin.disable() : formLogin 대신 Jwt를 사용하기 때문에 disable로 설정
  • httpBasic.disable() : httpBasic 방식 대신 Jwt를 사용하기 때문에 disable로 설정
  • SessionCreationPolicy.STATELESS : Jwt를 사용하기 때문에 session을 stateless로 설정한다. stateless로 설정 시 Spring Security는 세션을 사용하지 않는다.

추가 필터 #

1http
2      .addFilter(corsFilter())
3      .addFilter(new JwtAuthenticationFilter(authenticationManager(), jwtProcessor))
4      .addFilter(new JwtAuthorizationFilter(authenticationManager(), accountRepository, jwtProcessor));

corsFilter #

 1@Bean
 2public CorsFilter corsFilter() {
 3  UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
 4  CorsConfiguration config = new CorsConfiguration();
 5  config.setAllowCredentials(true);
 6  config.addAllowedOriginPattern("*");
 7  config.addAllowedHeader("*");
 8  config.addAllowedMethod("*");
 9  source.registerCorsConfiguration("/**", config);
10  return new CorsFilter(source);
11}
  • cors관련 설정을 포함한 필터.
  • 기본적으로 서버 또는 지정된 특정 도메인의 요청만 허용하지만 프런트가 정해져있지 않기 때문에 모든 도메인을 허용하는 방식으로 설정.
    • setAllowCredentials : 내 서버가 응답을 할 때 json을 자바스크립트에서 처리할수 있게 할지를 설정
    • addAllowedOriginPattern : 허용할 도메인 목록
    • addAllowedHeader : 허용할 헤더 목록
    • addAllowedMethod : 허용할 Method(GET, PUT, 등) 목록
    • source.registerCorsConfiguration : 지정한 url에 config 적용

JwtAuthenticationFilter #

  • Jwt를 사용한 인증을 구현한 필터

JwtAuthorizationFilter #

  • Jwt를 사용한 인가를 구현한 필터

인가 #

1http
2          .authorizeRequests()
3          .mvcMatchers("/home", "/login").permitAll() //** 홈페이지, 로그인
4          .anyRequest().hasAuthority("ROLE_USER");
  • authorizationRequest : 요청에 따른 인가 설정
    • 기본적으로 모든 uri은 ROLE_USER의 권한만 허용
    • 홈페이지와 로그인, 스웨거 관련 uri은 모두 허용
    • configure(WebSecurity web) : HttpSecurity에서 설정하지 않은 정적리소스와 HTML 등에 관한 권한을 설정한다.
      • web.ignoring().requestMathers(PathRequest.toStaticResources().atCommonLocations())
        • static 리소스의 자원을 Security에서 제외(Security에서 걸러지지 않고 접근 가능)

authenticationManagerBean #

1@Override
2public void configure(WebSecurity web) throws Exception {
3  web
4        .ignoring()
5        .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
6}
  • WebSecurityConfigurerAdepter를 상속받은 SecurityConfigure 외에서 AuthenticationManager를 사용하려면 authenticationManagerBean()을 오버라이드 해서 @Bean으로 직접 등록해야 한다.

UserDetails, UserDetailsService #

  • Spring Security에서 인증, 인가를 할 때 사용되는 Principal과 관련 서비스 Class를 만든다.

UserAccount #

  • Principal은 인증, 인가시 검증되는 객체이기 때문에 본 프로젝트에서 사용되는 회원의 정보인 Account를 갖고 있어야 한다.
 1@Getter
 2public class UserAccount implements UserDetails {
 3
 4  private Account account;
 5
 6  public UserAccount(Account account) {
 7    this.account = account;
 8  }
 9
10  @Override
11  public Collection<? extends GrantedAuthority> getAuthorities() {
12    Collection<GrantedAuthority> authorities = new ArrayList<>();
13    String roleName = account.getRole().getRoleName();
14    authorities.add(() -> roleName);
15    return authorities;
16  }
17
18  @Override
19  public String getPassword() {
20    return account.getPassword();
21  }
22
23  @Override
24  public String getUsername() {
25    return account.getUsername();
26  }
27
28  @Override
29  public boolean isAccountNonExpired() {
30    return true;
31  }
32
33  @Override
34  public boolean isAccountNonLocked() {
35    return true;
36  }
37
38  @Override
39  public boolean isCredentialsNonExpired() {
40    return true;
41  }
42
43  @Override
44  public boolean isEnabled() {
45    return true;
46  }
47}
  • UserDetails를 상속받는다.
  • 회원 계정 엔티티인 Account를 필드로 갖는다.
  • getAuthorities() : account의 Role에 저장된 권한 정보를 authorities에 담고 반환한다.
  • isAccountNonExpired() : 계정이 만료되지 않았는지를 리턴(true => 만료되지 않음을 의미)
  • isAccountNonLocked() : 계정이 잠겨있는지를 리턴(true => 잠겨있지 않음을 의미)
  • isCredentialNonExpired() : 계정의 패스워드가 만료되어있는지 를 리턴(true => 만료되지 않음을 의미)
  • isEnabled() : 계정이 사용 가능한지를 리턴

PrincipalDetailService #

 1@Service
 2@RequiredArgsConstructor
 3public class PrincipalDetailService implements UserDetailsService {
 4
 5  private final AccountRepository accountRepository;
 6
 7  @Override
 8  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
 9    Account account = accountRepository.findByUsername(username)
10          .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));
11    return new UserAccount(account);
12  }
13}
  • UserDetailsService를 상속받는다.

loadUserByUsername(String username) #

1@Override
2public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
3  Account account = accountRepository.findByUsername(username)
4        .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));
5  return new UserAccount(account);
6}
  • Spring Security에서 AutenticationManager가 authenticate()를 통해서 인증을 할 때, 지정된 repository에서 인증 대상 객체를 찾아서 Principal 형태로 반환

AccountRepository #

1public interface AccountRepository extends JpaRepository<Account, Long> {
2
3  Optional<Account> findByUsername(String username);
4}
  • Spring 데이터 JPA를 사용해서 Repository를 생성한다.
  • findByUsername : username(= id)으로 Account를 찾아서 반환한다.

Jwt #

  • Jwt를 생성하고 디코딩하는 클래스와 Jwt를 사용한 인증, 인가 필터를 구현한 클래스를 만든다.

JwtProcessor #

 1@Component
 2public class JwtProcessor {
 3
 4  public String createAuthJwtToken(UserAccount userAccount) {
 5    return JWT.create()
 6          .withSubject(userAccount.getUsername())
 7          .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
 8          .withClaim("id", userAccount.getAccount().getId())
 9          .withClaim("username", userAccount.getAccount().getUsername())
10          .sign(Algorithm.HMAC512(JwtProperties.SECRET));
11  }
12
13  public String decodeJwtToken(String jwtToken, String secretKey, String claim) {
14    return JWT.require(Algorithm.HMAC512(secretKey)).build()
15          .verify(jwtToken)
16          .getClaim(claim)
17          .asString();
18  }
19
20  public String extractBearer(String jwtHeader) {
21    int pos = jwtHeader.lastIndexOf(" ");
22    return jwtHeader.substring(pos + 1);
23  }
24}

creatAuthJwtToken #

1public String createAuthJwtToken(UserAccount userAccount) {
2  return JWT.create()
3        .withSubject(userAccount.getUsername())
4        .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
5        .withClaim("id", userAccount.getAccount().getId())
6        .withClaim("username", userAccount.getAccount().getUsername())
7        .sign(Algorithm.HMAC512(JwtProperties.SECRET));
8}
  • userAccount를 받아서 JwtToken을 생성하고 반환.
  • Account의 id(엔티티의 id)와 username(로그인 시 id로 사용됨)을 HMAC512 알고리즘으로 암호화한다.
  • 만료시간은 현재 시간으로부터 JwtProperties(Jwt관련 설정 정보를 모아놓은 클래스)에 정의된 EXPIRATION_TIME까지로 설정

만료시간은 밀리 세컨드로 설정됨 {: .prompt-info }

decodeJwtToken #

1public String decodeJwtToken(String jwtToken, String secretKey, String claim) {
2  return JWT.require(Algorithm.HMAC512(secretKey)).build()
3        .verify(jwtToken)
4        .getClaim(claim)
5        .asString();
6}
  • JwtToken을 받으면 secretKey를 사용해서 지정된 claim을 반환한다

extractBearer #

1public String extractBearer(String jwtHeader) {
2  int pos = jwtHeader.lastIndexOf(" ");
3  return jwtHeader.substring(pos + 1);
4}
  • Authentication 해더의 Jwt Token은 앞에 “Bearer “가 붙기 때문에 “Bearer “를 제거하고 뒤의 순수한 Jwt Token만을 추출한다.

JwtProperties #

1public interface JwtProperties {
2  String SECRET = (JWT 암호화시 사용할 SecretKey);
3  int EXPIRATION_TIME = 60000 * 60;
4  String TOKEN_PREFIX = "Bearer";
5  String HEADER_STRING = "Authorization";
6}
  • JWT와 관련된 설정 수치들을 지정한 인터페이스
  • JWT 암호화 시 사용되는 SecretKey의 값이 있기 때문에 gitIgnore 설정

JwtAuthenticationFilter #

 1@RequiredArgsConstructor
 2public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
 3  private final AuthenticationManager authenticationManager;
 4  private final JwtProcessor jwtProcessor;
 5
 6  @SneakyThrows
 7  @Override
 8  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
 9        throws AuthenticationException {
10    ObjectMapper objectMapper = new ObjectMapper();
11    Account account = objectMapper.readValue(request.getInputStream(), Account.class);
12
13    UsernamePasswordAuthenticationToken authenticationToken =
14          new UsernamePasswordAuthenticationToken(account.getUsername(), account.getPassword());
15
16    Authentication authentication = authenticationManager.authenticate(authenticationToken);
17    return authentication;
18  }
19
20  @Override
21  protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
22                                          Authentication authResult) throws IOException, ServletException {
23    UserAccount userAccount = (UserAccount) authResult.getPrincipal();
24
25    String jwtToken = jwtProcessor.createAuthJwtToken(userAccount);
26
27    response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + " " + jwtToken);
28  }
29}
  • JWT로 인증을 하기 위한 클래스
  • Spring Security 로그인 시 인증을 담당하는 UsernamePasswordAuthenticationFilter를 상속받는다.

Spring Bean으로 등록하지 않는 이유는 해당 클래스가 AuthenticationManager를 의존성 주입받는데 해당 클래스를 사용하는 SecurityConfig에서 AuthenticationManager를 빈으로 등록하기 때문에 순환 참조가 발생하기 때문이다. {: .prompt-info }

attemptAuthentication #

 1@SneakyThrows
 2@Override
 3public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
 4      throws AuthenticationException {
 5  ObjectMapper objectMapper = new ObjectMapper();
 6  Account account = objectMapper.readValue(request.getInputStream(), Account.class);
 7
 8  UsernamePasswordAuthenticationToken authenticationToken =
 9        new UsernamePasswordAuthenticationToken(account.getUsername(), account.getPassword());
10
11  Authentication authentication = authenticationManager.authenticate(authenticationToken);
12  return authentication;
13}
  • 로그인 시 인증을 위해 실행되는 Method
  • 오버라이드 해서 Json으로 들어오는 id와 password로 인증을 하도록 변경한다.
  • 반환 값은 인증된 Authentication 객체이다.

successfulAuthentication #

1@Override
2protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
3                                      Authentication authResult) throws IOException, ServletException {
4  UserAccount userAccount = (UserAccount) authResult.getPrincipal();
5
6  String jwtToken = jwtProcessor.createAuthJwtToken(userAccount);
7
8  response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + " " + jwtToken);
9}
  • 인증에 성공할 시 실행되는 Method
  • 인증에 성공할 시 인증된 Account의 정보를 통해 JWT Token을 만들고 헤더(Authentication 헤더)에 포함시킨다.

JwtAuthorizationFilter #

 1public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
 2
 3  private final AccountRepository accountRepository;
 4  private final JwtProcessor jwtProcessor;
 5
 6  public JwtAuthorizationFilter(AuthenticationManager authenticationManager, AccountRepository accountRepository,
 7                                JwtProcessor jwtProcessor) {
 8    super(authenticationManager);
 9    this.accountRepository = accountRepository;
10    this.jwtProcessor = jwtProcessor;
11  }
12
13  @Override
14  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
15          throws IOException, ServletException {
16    String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
17
18    if (jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
19      chain.doFilter(request, response);
20      return;
21    }
22
23    String jwtToken = jwtProcessor.extractBearer(jwtHeader);
24    String username = jwtProcessor.decodeJwtToken(jwtToken, JwtProperties.SECRET, "username");
25
26    if (username != null) {
27      Account account = accountRepository.findByUsername(username)
28              .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));
29
30      UserAccount userAccount = new UserAccount(account);
31      Authentication authentication = new UsernamePasswordAuthenticationToken(userAccount, null,
32              userAccount.getAuthorities());
33
34      SecurityContextHolder.getContext().setAuthentication(authentication);
35    }
36    chain.doFilter(request, response);
37  }
38}
  • JWT로 인가를 하기 위한 클래스
  • 헤더를 통한 인증 시 적용되는 BasicAuthenticationFilter를 상속받는다.
  • BasicAuthenticationFilter는 AuthenticationManager를 사용하기 때문에 super를 사용해서 주입해준다.

doFilterInternal #

 1@Override
 2protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
 3        throws IOException, ServletException {
 4  String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
 5
 6  if (jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
 7    chain.doFilter(request, response);
 8    return;
 9  }
10
11  String jwtToken = jwtProcessor.extractBearer(jwtHeader);
12  String username = jwtProcessor.decodeJwtToken(jwtToken, JwtProperties.SECRET, "username");
13
14  if (username != null) {
15    Account account = accountRepository.findByUsername(username)
16            .orElseThrow(() -> new NonExistResourceException("해당 username을 갖는 Account를 찾을 수 없습니다."));
17
18    UserAccount userAccount = new UserAccount(account);
19    Authentication authentication = new UsernamePasswordAuthenticationToken(userAccount, null,
20            userAccount.getAuthorities());
21
22    SecurityContextHolder.getContext().setAuthentication(authentication);
23  }
24  chain.doFilter(request, response);
25}
  • 필터 적용 시 실행되는 Method.
  • 헤더에 담겨있는 JWT Token을 디코딩해서 얻은 username값이 올바른지 판단하고 username으로 DB에서 Account를 찾아온다.
  • 찾아진 Account로 만든 Authentication 객체를 SecurityContextHolder에 넣어서 인가를 처리한다.
Advertisement