[BOOK_P] 스프링 시큐리티 셋팅 및 Jwt 토큰 방식 설계
개요
처음 스프링을 배웠을 당시에는 단순히 http를 통해 stateless한 방식으로 수동적인 로그인을 진행하도록 구축하였었습니다. 기능 자체는 구현이 되었지만 서버가 끊기거나 통신이 멈추면 매번 다시 로그인을 해야하는 번거로움이 있었고, 개발자로서도 모든 api에서 해당 회원의 현재 로그인 상태를 확인하는 단계를 넣어주어야 해 꽤나 불필요한 코드와 작업시간이 늘어났었습니다.
쿠키나 세션을 사용하는 방법도 있겠지만 쿠키는 값을 임의로 변경하거나, 보관 정보를 탈취할 수 있어 악용의 우려가 있고, 세션 방식의 경우 서버에서 정보를 유지해야 하기에 사용량이 증가할 경우 서버에 부하가 올 수 있어 쉽게 판단하기가 어려웠습니다.
그런데 스프링 시큐리티와 Jwt 토큰 방식을 접하며 위와같은 문제점을 해결할 수 있다는 것을 알게되었고 회사에 다니는 동안 많은 프로젝트를 스프링 시큐리티를 적용해 진행하게 되었습니다. 금번 프로젝트에서도 적용해 구축하고자 합니다.
원리
스프링 시큐리티는 애플리케이션의 ‘보안’을 담당하는 스프링의 하위 프레임 워크입니다. ‘보안’이라는 개념에는 인증, 권한, 인가 등의 의미들이 포함되어 있고, 인증과 권한에 대한 부분을 스프링 시큐리티의 Filter 흐름에 따라서 처리하고 있습니다. 관련해서 많은 옵션들도 제공해주기에 개발자가 일일이 보안 로직을 작성하지 않아도 된다는 장점이 있습니다.
시큐리티의 인증 과정은 위 이미지와 같고, 저는 최종적으로 이렇게 구현을 마쳤습니다.
각 클래스들의 상세한 흐름은 구현 코드와 함께 설명드립니다.
UserPrincipal
UserDetails를 구현해서 인증된 유저를 저장하는 인터페이스입니다.
토큰에 담겨야 하는 정보들을 담아주면 스프링이 인증 토큰으로 감싸줍니다.
@Getter
@Builder
public class UserPrincipal implements UserDetails {
private final Integer userIdx;
private final String userEmail;
@JsonIgnore
private final String password;
private final Collection<? extends GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return userEmail;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
제공되는 메서드는 모두 true로 변경해주었습니다. 각 메서드의 뜻은 아래와 같습니다
isAccountNonExpired()
: 계정의 만료 여부를 리턴 해줌. true가 만료되지 않음(사용가능)을 뜻함isAccountNonLocked()
: 계정의 잠김 여부를 리턴 해줌. true가 잠기지 않음(사용가능)을 뜻함isCredentialsNonExpired()
: 비밀번호의 만료 여부를 리턴 해줌. true가 만료되지 않음(사용가능)을 뜻함isEnabled()
: 계정의 활성화 여부를 리턴 해줌. true가 활성화 되어 있음을 뜻함
UserPrincipalAuthenticationToken
AbstractAuthenticationToken을 상속받는 UserPrincipalAuthenticationToken를 만들었습니니다.
public class UserPrincipalAuthorcationToken extends AbstractAuthenticationToken {
private final UserPrincipal principal;
public UserPrincipalAuthorcationToken(UserPrincipal principal) {
super(principal.getAuthorities());
this.principal = principal;
setAuthenticated(true);
}
...
Authentication.setAuthenticated()를 true로 설정해 두었기에 향후 UserPrincipalAuthenticationToken를 호출했을 때 인증을 통과해 생성된 토큰을 SecurityContextHolder의 context로부터 얻어올 수 있게 됩니다.
JwtToPrincipalConverter
JwtToPrincipalConverter 클래스는 명칭 그대로
전달받은 JWT토큰을 앞서 만들어둔 Principal 객체로 Converter 해주는 역할을 합니다.
public UserPrincipal convert(DecodedJWT jwt) {
return UserPrincipal.builder()
.userIdx(Integer.valueOf(jwt.getSubject()))
.userEmail(jwt.getClaim("e").asString())
.authorities(extractAuthoritiesFromClaim(jwt))
.build();
}
private List<SimpleGrantedAuthority> extractAuthoritiesFromClaim(DecodedJWT jwt) {
var claim = jwt.getClaim("a");
if(claim.isNull() || claim.isMissing()) return List.of();
return claim.asList(SimpleGrantedAuthority.class);
}
토큰을 매개변수로 받아 인덱스,이메일,권한 등의 정보를 가지고 옵니다.
참고로 getClaim() 메소드에 입력하는 Key 값(ex: “e”, “a”)은 토큰을 만들 때 페이로드에에 넣어줬던 키 값을 사용해야 하고, 틀릴 경우 원하는 데이터가 올바르게 추출되지 않습니다.
JwtDecoder
매개변수로 넘어온 JWT 토큰을 디코딩 하는 클래스로,
토큰이 유효한지 ‘시크릿 키’를 이용해 검증하는 역할을 합니다.
public DecodedJWT decode(String token) {
return JWT.require(Algorithm.HMAC256(properties.getSecretKey()))
.build().verify(token);
}
시크릿 키는 함부로 오픈되면 안되는 정보이기에
사전에 JwtProperties라는 파일을 만들어 미리 환경설정 파일에서 가져오도록 셋팅했습니다.
@Getter
@Setter
@Configuration
@ConfigurationProperties("security.jwt")
public class JwtProperties {
private String secretKey;
}
JwtAuthenticaitionFilter
http 요청이 올때 먼저 토큰에 대한 인증을 하는 역할을 합니다.
앞서 생성한 JwtDecoder와 JwtPrincipalToConverter를 먼저 주입해주었습니다.
private final JwtDecoder jwtDecoder;
private final JwtPrincipalToConverter jwtPrincipalToConverter;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
extractFromRequest(request)
.map(jwtDecoder::decode)
.map(jwtPrincipalToConverter::convert)
.map(UserPrincipalAuthorcationToken::new)
.ifPresent(authentication -> SecurityContextHolder.getContext().setAuthentication(authentication));
filterChain.doFilter(request, response);
}
private Optional<String> extractFromRequest(HttpServletRequest request) {
var token = request.getHeader("Authorization");
if(StringUtils.hasText(token) && token.startsWith("Bearer ")) {
return Optional.of(token.substring(7));
}
return Optional.empty();
}
- 가장 먼저
extractFromRequest
에서 응답을 받고 헤더에서 “Authorization” 키를 찾습니다. 정상적인 접근이라면 토큰은 “Authorization”의 키를 가지고 Value값은 “Bearer {토큰값}” 의 형식으로 담겨 있을 것입니다. - 오직 토큰 값만을 전달할것이므로 “Bearer “는 지우기 위해 token.substring(7)를 거쳐 리턴해주도록 했습니다.
extractFromRequest
에서 리턴 받은 토큰을 가지고doFilterInternal
로 넘어오면JwtDecoder
와JwtPrincipalToConverter
가 각각의 역할로 토큰이 유효한지, 토큰의 값이 무엇인지 분석한 뒤UserPrincipalAuthenticationToken
에서 인증을 통과한 토큰은 (.ifPresent()
가 인증을 통과했다면~의 역할을 함)SecurityContextHolder.getContext().setAuthentication(authentication)
로 authentication를 다시 셋팅을 해줍니다. 이제부터는 스레드에 대한 인증 객체가 설정되어 현재 로그인한 유저에 대한 정보를 쉽게 얻을 수 있습니니다.
WebSecurityConfig
이제 최종적으로 앞서 만든 필터들 및 security 관련 설정을 관리하는 역할을 하는 곳입니다.
앞서 먼저 설명해야 하는 것이 있는데 기존에 다른 프로젝트를 진행할때는 줄곧 아래와 같이 코드를 구현했었습니다.
http
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.formLogin().disable()
...
그런데 금번 프로젝트를 위해새로 코드를 작성하다 보니 IDE에서 메서드들에 취소선을 출력하는 현상을 보였습니다.
최근에 변경이 있었구나싶어 정책을 다시 찾아보았고,
전체적으로 메서드의 동작이 메서드 자체에서 설정하는게 아니라 매개변수 내에서 동작을 선택하도록 바뀌었다는 것을 알게 되었습니다.
따라 최종적으로 아래처럼 바뀐 코드로 설계했습니다.
@Bean
public SecurityFilterChain applicationSecurity (HttpSecurity http) throws Exception {
http.addFilterBefore(jwtAuthenticaitionFilter, UsernamePasswordAuthenticationFilter.class);
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin((formLogin) -> formLogin.disable())
.securityMatcher("/**")
.authorizeHttpRequests(registry -> registry
.requestMatchers("/").permitAll()
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated());
return http.build();
}
.csrf(AbstractHttpConfigurer::disable)
현재 진행하는 인증,인가방식에서는 쿠키와 세션을 사용하지 않을 것이므로 disable 설정.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
: 스프링 시큐리티가 세션을 생성하지 않고, 기존 세션을 사용하지도 않음. JWT 토큰을 사용할 것이기에 Stateless 상태를 유지하도록 함.formLogin((formLogin) -> formLogin.disable())
: 스프링 시큐리티에서 제공하는 기본 로그인 창 사용하지 않음.securityMatcher("/**")
: 모든 페이지 시큐리티 적용authorizeHttpRequests
: 권한에 따른 http 요청 가능 범위를 설정한 것인데, 현재는 로그인 및 회원가입만을 임시로 테스트하기위해 셋팅된 상황이기에 향후 권한에 따라 구체적 부여가 필요함.
이렇게 전체적인 스프링 시큐리티 셋팅을 마쳤으며,
리액트로 임시적인 로그인창을 만들어 잘 구현되는지 작동해 보았습니다.
최종 테스트
로그인 시 정상적으로 토큰 발급
토큰 정보가 없는 유저가 api를 호출했을 때 (접근 불가, 403)
토큰 정보를 가지고 유저가 api를 호출했을 때 (정상 응답)
결과 및 느낀점
- 쿠키와 세션을 사용하지 않고 스프링 시큐리티와 토큰 발급만으로 로그인 및 회원권한 부여가 이루어 질 수있는 것을 확인했으며, 각 필터의 구동 원리와 작동 흐름에 대한 파악을 해 원하는 셋팅 방법을 익히게 되었습니다.
- 새롭게 개선된 문법을 파악하여 새롭게 적용할 수 있었으며, 모호했던 각 역할들의 원리를 명확히 익히게 되었습니다.
- JWT토큰 역시 기간이 정해진 토큰이기에 중간에 탈취되었을때에 대한 방안을 마련해두는 것이 좋다는 점을 떠올리게 되었으며, Redis를 통해 Refresh Token을 관리하면 보안적인 면에서 한층 뛰어나다는 점을 알게되었습니다. 이 부분도 공부를 진행해서 향후 개선시 적용해보고 싶습다.