스프링 시큐리티는 스프링 기반 어플리케이션의 보안(인증과 인가)를 담당하는 스프링 하위 프레임워크이다.
보안과 관련해서 체계적으로 많은 옵션들을 제공해주기 때문에 개발자의 입장에서는 하나하나 보안 관련 로직을 작성하지 않아도 된다는 장점이 있다.
스프링 시큐리티는 인증과 권한 등의 많은 기능을 편리하게 적용할 수 있도록 제공하지만 사용법은 결코 쉽지 않다. 공부해야 할 양도 방대하고 접근도 어렵다.
JWT는 JSON Web Token의 약자로 사용자의 정보를 담아 이를 암호화한 JSON 객체이다. 기존의 Cookie와 Session는 각각 보안 취약점과 stateless 위반의 문제가 있었지만 JWT는 이를 해역하기 위해 등장했다. JWT는 암호화 알고리즘을 통해 디지털로 서명되기 때문에 인증되었고, 신뢰할 수 있는 토큰이다. JWT는 공개키 암호 방식(PKC) 즉 비대칭키 방식이다.
JWT는 클라이언트가 인증 요청(로그인 시도)을 하면 서버가 가지고 있는 Secret Key를 통해 Token을 발급한다. 클라이언트는 서버로부터 받은 토큰을 쿠키나 저장소에 저장하고, 서버에 요청을 보낼 때부터 저장해둔 토큰을 같이 전달합니다.
서버는 클라이언트로 전달받은 토큰을 검증(복호화)하고 만약 토큰이 변조되지 않았다는게 검증되면 클라이언트의 요청을 수행한다.
서버는 Refresh Token과 Access Token 두가지를 보내는데 Access Token은 요청에 대한 다양한 정보를 담고 실질적 인증 역할을 하며, Refresh Token은 Access Token의 만료 기간을 조정하는 역할을 합니다.
JWT는 Header, Payload, Signature 3가지 부분으로 구성된다.
빨간색은 Header, 보락색은 Payload, 파란색은 Signature이다.
Spring Security 5.7 버전이 되면서 SecurityConfig 클래스를 WebConfiguration을 상속하는 대신 Bean으로 등록하는 방식으로 변경되었다. 따서 나는 5.7버전으로 프로젝트를 진행했기 때문에 Bean으로 등록하였다.
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
/*
Redis를 이용하여 Logout 로직을 구현할 것이기 때문에 RedisTemplate을 DI하였다.
*/
private final TokenProvider tokenProvider;
private final RedisTemplate redisTemplate;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()//jwt는 서버에 인증정보를 보관하지 않기 때문에 csrf 코드를 작성할 필요가 없기 때문에 disable한다.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)//jwt는 stateless하게 설정
.and()
.authorizeRequests()
.antMatchers("/signup", "/login","/").permitAll()
.antMatchers("/log-out").authenticated()
.antMatchers("/luckybags/**").authenticated()
.antMatchers("/members/**").authenticated()//어떤 요청인지에 따라 인증여부를 구분하는 설정
.and()
.addFilterBefore(new JwtFilter(tokenProvider, redisTemplate), UsernamePasswordAuthenticationFilter.class)
.cors();//cors코드 적용과 JwtFilter 적용 설정
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();//패스워드 인코더를 빈으로 등록한다.
}
}
Jwt를 사용하기 위해 구현해야 할 것은 크게 TokenProvider와 JwtFilter이다. TokenProvider는 jwt 발급하고 검증하는 로직을 구현한 클래스이고 JwtFilter는 토큰을 읽어들여 정상 토큰이면 Security Context에 저장하는 클래스이다.
@Component
public class TokenProvider {
private final Key key;//Secret key
private final long validityTime;//유효 시간
public TokenProvider(
@Value("${jwt.secret}") String secretKey,
@Value("${jwt.token-validity-in-milliseconds}") long validityTime) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.validityTime = validityTime;
}
public TokenDTO createToken(Authentication authentication) {//토큰 생성
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(",")); //authentication 객체에서 권한을 반환한다.
//만료시간과 현재 시간
long now = System.currentTimeMillis();
Date tokenExpiredTime = new Date(now + validityTime);
//accessToken 생성
String accessToken = Jwts.builder()
.setSubject(authentication.getName())//id
.claim("auth", authorities)
.setExpiration(tokenExpiredTime)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
//refrehToken 생성
String refreshToken = Jwts.builder()
.setExpiration(tokenExpiredTime)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
//Toekn 반환
return TokenDTO.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshTokenExpirationTime(tokenExpiredTime)
.refreshToken(refreshToken)
.build();
}
//토큰으로부터 클레임을 만들고, 이를 통해 User 객체를 생성하여 Authentication 객체를 반환
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);//accessToken에서 클레임을 가져온다.
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 담겨있지 않은 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());//사용자의 Role과 권한을 조회하여 SimpleAuthority 목록을 authorities에 세팅한다.
UserDetails principal = new User(claims.getSubject(), "", authorities);//계정정보를 담은 User 객체를 생성한다.
return new UsernamePasswordAuthenticationToken(principal, "", authorities);//Authentication 객체를 반환한다.
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
//토큰 검증
public boolean validateToken(String token) {
try {
log.info("토큰 검증");
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException exception) {
log.info("Invalid JWT Token", exception);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.",e);
}
return false;
}
//토큰 만료시키기
public Long getExpiration(String accessToken) {
Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody().getExpiration();
long now = (new Date()).getTime();
return (expiration.getTime() - now);
}
}
JWT 토큰을 발행하고, Payload에 들어간 클레임을 통해 User객체를 생성하여 Authentication 객체를 반환하고 토큰을 검증해주는 TokenProvider를 구현했다.
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends GenericFilter {
private final TokenProvider tokenProvider;
private final RedisTemplate redisTemplate;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("인증 시도");
String token = resolveToken((HttpServletRequest) request);//http로부터 bearer 토큰을 가져온다.
if (Strings.hasText(token) && tokenProvider.validateToken(token)) {
//토큰이 존재한다면 검증해서 유효한 토큰이라면
String isLogout = (String) redisTemplate.opsForValue().get(token);
//redis에 해당 accessToken logout 여부 확인 후 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
if (ObjectUtils.isEmpty(isLogout)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);//다음 필터 체인 실행
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
JWTFilter는 사용자의 요청이 들어오면 Servlet req, res 객체가 생성되어 넘어오게 되는데, req 객체에서 JWT Token을 추출하고, token을 통해 정상 토큰인지 확인후 토큰을 통해 생성한 Authenticaiton 객체를 Security Context에 저장해준다.
Authentication 객체는 Spring Security에서 한 유저의 인증 정보를 가지고 있는 객체인데, Spring Security는 사용자의 Pricipal과 Credetial 정보를 Authentication 객체에 담아 생성한 후 보관한다.
doFilter(requets: HttpServletRequest, response: HttpResponse)
브라우저의 로그인화면에서 아이디와 비밀번호를 입력하고 서버에 로그인 요청을 보내면 스프링 시큐리티에서 Chain형태로 구성된 Filter들의 doFilter메소드들이 순서에따라 호출되어 각각의 역할 로직들이 수행되게 된다.
일반적으로 ID, PASSWORD 기반의 인증이라고 할때 가장 처음 Application Filter라는 필터 뭉치에 도달하고 그 필터 뭉치 중 Authentication Filters라는 필터 뭉치에 다시 도달한다.
attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication
form 기반 인증을 처리하는 필터인 UsernamePasswordAuthenticationFilter에 도착하게 되기 전에 구현한 JwtFilter가 작동한다. 여기서 http request를 통해 토큰이 있는지 없는지 있다면 유효한 토큰인지 검사하고 있다면 Security Context 저장해놓는다.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDTO.getMemberId(), loginDTO.getMemberPassword());
UsernamePasswordAuthenticationToken 객체를 request로 넘어온 ID와 PASSWORD를 인자로 하여 생성한다.
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
authenticate(authRequest) : Authentication
@Service
@RequiredArgsConstructor
public class jwtUserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByMemberId(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
}
private UserDetails createUserDetails(Member member) {
return User.builder()
.username(member.getMemberId())
.password(member.getPassword())
.roles(member.getRoles().toArray(new String[0]))
.build();
}
}
개발자는 UserDetailsService 인터페이스를 구현(jwtUSerDetailService)해야 한다. UserDetailsService의 구현체에는 일반적으로 회원정보가 DB에 있다고 한다면 사용자의 이름(ID)로 DB를 조회하여 비밀번호가 일치하는지 확인하여 인증을 처리한다. 인증이 완료되면 UsernamePasswordAuthenticationToken에 회원정보를 담아 리턴한다. UsernaemPasswordAuthenticationToken은 Authentication을 구현한 클래스로 Authentication 객체로 반환한다.
return tokenProvider.createToken(authentication);
[참조]
스프링시큐리티 기본개념과 동작구조의 이해
Spring Security 시큐리티 동작 원리 이해하기 - 1
Spring Security 시큐리티 동작 원리 이해하기 - 2
Spring Security - 인증 절차 인터페이스 구현 (1) UserDetailsService, UserDetails
Spring Security - JWT