Spring Security를 공부하다가!!
도저히 정리를 안 하고는 이해가 안 될 것 같아서 블로그에 정리를 해 보려 한다.
먼저 build.gradle에 security 디펜던시 추가를 해준다.
물론, JWT token을 사용할 것이기에 JWT 종속성도 추가해 준다.
JWT 사용 흐름
Client 가 username, password로 로그인 성공 시
- 서버에서 "로그인 정보" → JWT로 암호화 (Secret Key 사용)
- JWT를 Client 응답 Header에 전달
- 응답 Header 에 아래 형태로 JWT 전달
- ex) **Authorization: Bearer** eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzcGFydGEiLCJVU0VSTkFNRSI6IuultO2DhOydtCIsIlVTRVJfUk9MRSI6IlJPTEVfVVNFUiIsIkVYUCI6MTYxODU1Mzg5OH0.9WTrWxCWx3YvaKZG14khp21fjkU1VjZV4e9VEf05Hok
- Client에서 JWT 저장 (쿠키)
Client 에서 JWT 통해 인증방법
- JWT를 API 요청 시마다 Header에 포함 예) HTTP Headers
Content-Type: application/json **Authorization: Bearer** **<JWT> ...**
- Server
- Client 가 전달한 JWT 위조 여부 검증 (Secret Key 사용)
- JWT 유효기간이 지나지 않았는지 검증
- 검증 성공 시,
- JWT → 에서 사용자 정보를 가져와 확인
- ex) GET /api/products : JWT 보낸 사용자의 관심상품 목록 조회
이런 식으로 JWT 토큰을 Header에 담아서 검증하고 전달할 예정이다.
JwtUtil - JWT 생성, 검증, 파싱
먼저, JWT를 생성, 검증, 파싱 하는 유틸리티 클래스 JwtUtil 클래스이다.
package com.sparta.myselectshop.jwt;
import com.sparta.myselectshop.entity.UserRoleEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
// header 에서 JWT 가져오기
public String getJwtFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
@PostConstruct 어노테이션은 해당 메서드가 빈 초기화 시점에 호출되도록 한다. 초기화 작업을 수행하는 것.
init 메서드는 상단에서 @Value로 주입받은 'secret.key'를 디코딩하여 원래의 바이트 배열로 변환한다.
기존 secretKey는 Base64로 인코딩 되어 있기에 디코딩하여 JWT 서명에 필요한 키 객체를 생성한다.
해당 바이트 배열은 HMAC 알고리즘에서 사용하는 실제 비밀 키가 된다.
key = Keys.hmacShaKeyFor(bytes);
디코딩된 바이트 배열을 통해 Key 객체를 생성한다. 해당 객체는 JWT 토큰을 서명하고 검증하는 데 사용된다.
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
createToken 메서드는 주어진 username과 role을 기반으로 JWT를 생성한다.
사용자가 성공적으로 인증된 후 호출되어, 클라이언트에게 JWT를 반환하는 역할을 수행한다. claim을 통해 사용자 커스텀 클레임을 추가할 수 있고 권한 정보인 auth로 role을 추가하였다. 그리고 만료 시간, 발급일, 암호화 알고리즘 등을 설정한다.
BEAR_PREFIX는 'Bearer' 접두사를 추가하여 반환하는 것으로 이는 일반적인 JWT 토큰 형식이다. 이는 서버에 요청을 보낼 때, Authorization 헤더에 포함된다.
public String getJwtFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
위의 함수는 HTTP 요청 헤더에서 JWT를 추출한다! Authorization_Header에 해당하는 'Authorization' 헤더 값을 가져온다. 해당 값이 존재한다면 "Bearer " 뒤의 값만 추출하여. 실제 JWT 토큰만을 반환한다.
여기서 HttpServletRequest가 뭐냐 하면!!!
Java Servlet API에서 제공하는 인터페이스로, 클라이언트에서 서버로 전송되는 HTTP 요청을 나타내며 HTTP 요청의 메타데이터와 내용을 읽을 수 있는 다양한 메서드를 제공한다. 우리는 해당 api에서 제공하는 메서드를 통해 헤더를 읽어올 수 있는 것이다.
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
해당 메서드는 주어진 JWT 유효성을 검증하는 기능을 담당한다.
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
JWT 파서 빌더 객체를 생성하고 아까 생성한 key 객체를 통해 서명 키를 설정하여 검증한다. 서명이 유효하지 않다면 예외가 발생할 것이다. try catch문을 통해 다양한 예외를 처리하고 있다.
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
JwtUtil의 마지막 메서드는 토큰에서 사용자 정보를 가져온다. paresClaimsJws는 서명을 검증하고 성공하면 토큰의 claim를 반환하는데 getBody()를 통해 사용자 정보(사용자 식별값, 권한, 만료시간 등)를 가져오고 있다.
UserDetailsImpl - 사용자 정보 통합
다음은 Spring Security에서 제공하는 'UserDetails' 인터페이스를 구현한 UserDetailsImpl 클래스이다.
package com.sparta.myselectshop.security;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.entity.UserRoleEnum;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
해당 클래스는 우리 애플리케이션의 사용자 정보를 Security와 통합하기 위해 사용된다.
UserDetails 인터페이스를 구현함으로써 사용자 인증과 권한 부여를 처리할 수 있다.
private final User user;
우리가 구현한 엔티티 객체 User 필드가 필요하다. 해당 필드는 생성자에서 초기화한다.
계정 상태를 나타내는 여러 메서드(isAccountNonExpired, isAccountNonLocked, isCredentialsNonExpired, isEnabled)는 모두 true를 반환하여 계정이 유효함을 나타냅니다.
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
해당 메서드는 사용자의 권한 정보를 반환하는 역할을 한다. 역할을 통해 접근 제어를 수행하기에 해당 메서드는 중요하다.
getAuthority 메서드를 사용하여 해당 역할에 대응하는 권한 문자열을 반환할 수 있다.
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
반환 받은 권한 문자열을 통해 SimpleGrantedAuthority 객체를 생성하여 Security 권한을 표현한다.
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
권한 객체를 Collection에 추가하여 권한 목록을 만들어 return 한다. 반환된 목록은 Security에서 인증된 사용자의 권한을 확인하고 접근 제어를 수행하는 데 사용된다.
여기서 Collection을 만들고 add로 추가하여 반환하는 이유는 무엇일까?
Spring Security는 사용자의 권한을 GrantedAuthority 인터페이스를 구현한 객체들의 컬렉션으로 관리한다. 따라서 getAuthorities 메서드는 사용자의 역할을 기반으로 Spring Security가 이해할 수 있는 형태로 권한 정보를 제공해야 한다. 그러므로 Security 가 이해할 수 있는 컬렉션으로 변환하여 반환한다.
UserDetailsServiceImpl - 사용자 인증 (DB 연결)
package com.sparta.myselectshop.security;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user);
}
}
UserDetailsServiceImpl 메서드는 UserDetailsService 인터페이스를 구현한 클래스이다. 해당 클래스는 사용자의 로그인 정보를 가져오고, 해당 정보를 Security에 제공하여 사용자를 인증한다.
UserRepository 주입을 통해 사용자 정보를 데이터베이스에서 조회할 수 있다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user);
}
해당 메서드는 사용자의 로그인 ID를 받아 해당 사용자 정보를 데이터베이스에서 조회한다. 만약 존재하지 않으면 예외를 던진다.
public UserDetailsImpl(User user) {
this.user = user;
}
찾은 사용자 정보를 기반으로 UserDetailsImpl 객체를 생성하여 반환한다. 위의 코드는 UserDetailsImpl의 생성자이다.
JwtAuthenticationFilter - 로그인 진행 및 JWT 생성하여 헤더에 추가
package com.sparta.myselectshop.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.myselectshop.dto.LoginRequestDto;
import com.sparta.myselectshop.entity.UserRoleEnum;
import com.sparta.myselectshop.jwt.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) {
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, token);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
response.setStatus(401);
}
}
이번엔 JwtAuthenticationFilter 클래스를 살펴보겠다.
해당 필터는 사용자의 로그인 요청을 받아 인증을 시도하고, 성공하면 JWT 토큰을 생성하여 응답 헤더에 추가한다.
UsernamePasswordAuthenticationFilter는 Security에서 제공하는 기본적인 인증 필터 중 하나로, 사용자의 로그인 요청을 처리한다. 사용자가 제공한 로그인 정보를 추출하고, 인증 매니저에게 사용자 인증을 요청한다. 해당 필터를 상속 받아 Security의 기본 로그인 처리 기능을 확장하여 JWT 기반의 사용자 인증을 구현할 수 있다.
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login");
}
생성자를 통해 JwtUtil 객체를 주입 받는다. JwtUtil 클래스는 @Component로 Bean으로 등록 돼 있기에 주입 받을 수 있다.
setFilterProcessesUrl을 사용하여 로그인 요청 URL을 설정한다.
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
해당 메서드는 Security의 클래스에서 상속받은 메서드로, 사용자의 로그인 시도를 처리하는 역할을 한다.
주어진 요청에서 사용자의 로그인 정보를 추출하여 인증 매니저에게 전달하기 위해 UsernamePasswordAuthenticationToken 객체를 생성한다.
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
클라이언트가 전송한 로그인 정보를 바탕으로 ObjectMapper를 사용하여 LoginRequestDto 객체를 생성한다.
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
추출한 로그인 정보를 기반으로 authenticate를 호출하여 UsernamePasswordAuthenticationToken 객체를 생성한다. 생성한 객체를 인증 매니저에게 전달하여 실제 인증 처리를 수행한다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) {
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, token);
}
successfulAuthentication 메서드는 성공적으로 로그인한 경우에 호출된다.
사용자가 로그인에 성공하면 JWT 토큰을 생성하고 응답 헤더에 추가하는 역할을 수행한다.
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
Authentication 객체에서 사용자 정보를 추출한다.
String token = jwtUtil.createToken(username, role);
그리고 추출한 사용자 정보를 바탕으로 JWT 토큰을 생성한다. jwtUtil 는 우리가 이전에 생성한 클래스이다!!
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, token);
생성한 토큰을 응답 헤더에 추가한다.
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
response.setStatus(401);
}
해당 메서드는 사용자의 로그인 인증이 실패한 경우에 호출된다. 응답의 상태 코드를 401로 설정한다!
UsernamePasswordAuthenticationFilter에는 위의 메서드들 외에도 다양한 메서드들이 존재한다. 이를 통해 다양한 동작을 조정하고 사용자 정의할 수 있다.
JwtAuthorizationFilter - API에 전달되는 JWT 유효성 검증 및 인가 처리
package com.sparta.myselectshop.security;
import com.sparta.myselectshop.jwt.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getJwtFromHeader(req);
if (StringUtils.hasText(tokenValue)) {
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
// 인증 처리
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(username);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
JwtAuthorizationFilter 클래스는 JWT를 사용하여 사용자의 인증을 처리하고, 요청에 따른 인가를 수행한다.
OncePerRequestFilter를 상속받는 이유는 해당 필터가 각 요청당 한 번씩만 실행되도록 보장하므로 클라이언트 요청이 들어올때마다 JWT를 검증하고 인증하는 작업이 중복되지 않도록 하기 위해서이다.
JwtAuthorizationFilter 클래스에는 JwtUtil 객체와 UserDetailsImpl 객체가 필요하다. 생성자를 통해 주입 받는다.
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getJwtFromHeader(req);
if (StringUtils.hasText(tokenValue)) {
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
doFilterInternal 메서드는 필터가 요청을 처리하는 메인 메서드이다. 각 요청에 대해 한 번씩 실행된다.
request에서 getJwtFromHeader 메서드를 통해 JWT 토큰을 추출한다.
tokenValue가 비어있지 않다면 추출한 JWT 토큰의 유효성을 검증한다.
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
유효한 토큰인 경우, JWT에서 사용자 정보를 추출한다.
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error(e.getMessage());
return;
}
추출한 정보를 기반으로 인증을 수행한다.
filterChain.doFilter(req, res);
인증 처리가 완료되면 요청을 다음 필터로 전달한다.
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(username);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
해당 메서드는 사용자를 인증하고, 해당 사용자를 현재 실행 중인 스레드의 SecurityContext에 설정한다.
새로운 SecurityContext를 만들고 해당 컨텍스트에 사용자의 인증 객체를 설정한다.
사용자의 인증 정보가 현재 스레드의 SecurityContext에 설정되어, 해당 사용자의 보안 및 인증 정보가 요청을 처리하는 동안 유지된다. 이후에는 이 정보를 사용하여 해당 사용자의 인가 및 보안 작업을 수행할 수 있다.
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
createAuthentication 메서드는 주어진 사용자 이름을 기반으로 사용자의 인증 객체를 생성한다.
주어진 사용자 이름을 기반으로 사용자의 정보를 검색하고 해당 정보를 사용하여 UsernamePasswordAuthenticationToken 객체를 생성한다. 해당 토큰은 사용자의 인증을 나타낸다. 그래서 반환된 Authentication 객체로 위의 setAuthentication 메서드에서 사용자 인증 객체를 생성한 것이다.
loadUserByUsername 메서드는 이전에 UserDetailsServiceImpl에서 Override한 메서드이다. 해당 메서드는 username을 통해 사용자를 찾아 찾은 user 객체로 UserDetailsImpl을 반환한다.
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
UsernamePasswordAuthenticationToken 클래스의 생성자를 살펴보니 UserDetails 객체와 사용자 권한을 넣어주는 듯 하다.
이렇게 JwtAuthorizationFilter는 요청으로 넘어온 JWT 토큰을 통해 사용자를 인증하고 사용자 인증 객체를 설정하여 일정 시간동안 권한을 부여한다.
WebSecurityConfig - Spring Security 설정 및 인증/인가 필터 등록
package com.sparta.myselectshop.config;
import com.sparta.myselectshop.jwt.JwtUtil;
import com.sparta.myselectshop.security.JwtAuthenticationFilter;
import com.sparta.myselectshop.security.JwtAuthorizationFilter;
import com.sparta.myselectshop.security.UserDetailsServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/").permitAll() // 메인 페이지 요청 허가
.requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page").permitAll()
);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
마지막으로 Security를 사용하여 웹 보안을 설정하는 'WebSecurityConfig' 클래스를 볼 것이다.
해당 클래스는 JWT(JSON Web Token)을 사용하여 인증과 인가를 처리한다.
한 부분씩 살펴 보겠다.
@EnableWebSecurity 어노테이션을 붙여 Spring Security를 활성하 한다.
또 @Configuration 어노테이션을 통해 이 클래스가 Spring 설정 클래스임을 나타냈다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean 애노테이션으로 BCryptPasswordEncoder 빈을 등록한다. 이는 비밀번호 암호화에 사용된다.
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean 애노테이션으로 AuthenticationManager 빈을 등록한다. 이는 인증 처리에 사용된다.
getAuthenticationManager() 메서드는 Spring Security에서 인증 처리를 담당하는 AuthenticationManager를 반환한다. AuthenticationConfiguration configuration은 매개변수로 받아 사용하고 있다. 이전에 JwtAuthenticationFilter에서 사용할때는 Username 어쩌구 클래스가 AbstractAuthenticationProcessingFilter 클래스를 상속 받고 있어서 그냥 사용이 가능했다. 근데 여기는 매개변수로 넣어줘서 사용할 수 있는 것 !
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean 애노테이션으로 우리가 이전에 만든 JwtAuthenticationFilter을 빈으로 등록한다. 이 필터는 JWT 인증 요청을 처리한다!
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
앞서 정의한 authenticationManager 빈을 JwtAuthenticationFilter에 주입한다. setAuthenticationManager 메서드는 JwtAuthenticationFilter에서 인증 처리를 위해 AuthenticationManager를 사용할 수 있도록 한다! 인증 매니저를 등록해주는 듯 하다.
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean 애노테이션으로 우리가 이전에 만든 JwtAuthorizationFilter를 빈으로 등록한다. 이 필터는 JWT 인가 요청을 처리한다. (사용자에게 권한을 주는 것) jwtUtil이나 userDetailsService는 상단에서 생성자로 주입 받아 쓴다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/").permitAll() // 메인 페이지 요청 허가
.requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page").permitAll()
);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
마지막으로 securityFilterChain 메서드는 Spring Boot 애플리케이션의 보안 설정을 담당한다. 이 메서드는 요청을 어떻게 보호할지, 세션 정책을 어떻게 관리할지, JWT 기반 인증 및 권한 부여를 어떻게 통합할지 등을 설정한다.
http.csrf((csrf) -> csrf.disable());
CSRF 보호를 비활성화한다. 보통 유용하지만 JWT 기반의 무상태 API 에서는 보통 비활성화 한다고 한다.
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
세션을 사용하지 않고, JWT 방식을 사용하기 위해 세션 관리 정책을 무상태(STATELESS)로 설정한다. 이를 통해 서버는 클라이언트의 상태를 유지하지 않고, 각 요청은 독립적으로 처리된다.
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // 리소스 접근 허용 설정
.requestMatchers("/").permitAll() // 메인 페이지 요청 허가
.requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
요청 권한을 설정한다. 리소스 접근을 허용하고 메인 페이지에 대한 접근과 user API에 대한 접근을 제외한 모든 요청에 대해 인증을 요구하도록 설정해준다.
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page").permitAll()
);
사용자 정의 로그인 페이지(/api/user/login-page)를 사용하도록 설정하며, 그 페이지에 누구나 접근할 수 있도록 허용한다. 이를 통해 사용자가 인증이 필요할 때 자동으로 "/api/user/login-page"로 리다이렉션 된다.
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
마지막으로 우리가 만든 JwtAuthorizationFilter와 JwtAuthenticationFilter를 필터로 설정한다.
JwtAuthorization 필터를 통해 Header로 넘어온 JWT로 사용자 권한을 검사하고 JwtAuthenticationFilter를 통해 사용자의 인증을 처리한다. 그 후 User~ 필터가 실행되어 추가적인 인증 필요 시 인증이 수행된다.
결국 순서는 JwtAuthorization - JwtAuthentication - UsernamePasswordAuthentication 순으로 필터가 실행된다.
만약 첫 로그인 시도를 하는 상황이라면 JwtAuthorization 에서 넘어온 JWT 토큰이 없기 때문에 맨 아래의 filterChain.doFilter 코드로 인해 다음 필터 체인인 JwtAuthentication 으로 넘어간다.
토큰이 이미 있는 상황이라면 토큰에서 사용자 정보를 추출하여 인증을 설정한 후 다음으로 넘어간다.
음.. 여기서 궁금한 점이 생겼는데 JwtAuthorizationFilter는 토큰을 검증하는 필터이고 JwtAuthenticationFilter는 토큰 검증 후 아이디, 비밀번호를 통해 로그인을 시도하는 필터이다. 그렇다면 만약 토큰이 있는 상태에서 잘못된 아이디, 비밀번호를 입력했다면 어떻게 되는것일까? GPT에 물어보니 유효한 토큰이 이미 있더라도 새로운 인증 시도를 한다고 한다.
오잉 토큰이 있어도 새로운 로그인 시도를 하는거면 JwtAuthorizationFilter는 필요 없는 거 아냐?
아니다. 로그인 시 토큰의 유효성을 확인하고 권한을 검사하기 때문에 로그인에서도 필요하다. 예를 들어 만료된 토큰을 가진 사용자가 로그인 시도를 한다면 해당 필터를 통해 해당 계정에서 로그아웃 처리를 할 수 있다.
정리하자면
JwtAuthorizationFilter는 모든 요청에 대해 실행되는 보안 필터이다. 이 필터는 클라이언트로부터 받은 JWT 토큰을 사용하여 사용자의 인증과 권한 부여를 담당한다. 따라서 로그인 뿐 아니라 모든 페이지에서 토큰의 유효성을 검사하고 권한을 확인한다.
JwtAuthenticationFilter는 로그인 요청을 처리하는 필터이다. 이 필터는 모든 요청에 대해 실행되지만, 실제로 attemptAuthentication 메서드는 일반적으로 로그인 요청에서만 호출되고 이외의 요청에서는 해당 메서드가 실행되지 않는다!! JwtAuthenticationFilter는 새로운 로그인 시도를 처리하고, 유효한 사용자의 경우에는 추가적인 로그인 과정을 거치지 않는다.
만약 유효하지 않은 토큰을 가진 사용자가 로그인 외 다른 요청을 시도한다면 JwtAuthorizationFilter에서 걸러지고
토큰이 없는 사용자가 다른 요청을 시도한다면 Security의 다른 부분에서 수행된다. 예를 들어, Spring Security의 ExceptionTranslationFilter는 인증이 필요한 요청에서 인증되지 않은 경우를 처리하며, 이때 로그인 페이지로의 리디렉션을 수행할 수 있다.
으악! 너무ㅜ 어렵다!!!!!!!!!!!!!!!!!!!!!!! ㅋㅋㅋ 그래도 이렇게 정리하고 나니 전보다는 조~금 이해가 간 것 같다.
까먹을때쯤 정독해야겠다.
혹시 틀린 부분이 있다면 알려주십쇼!!!!!!!!!!
'공부 > Spring' 카테고리의 다른 글
[Spring Cloud] 클라이언트 사이드 로드 밸런싱 (FeignClient와 Ribbon) (0) | 2024.08.05 |
---|---|
[Spring Cloud] MSA Spring Cloud (Eureka) (0) | 2024.08.01 |
Spring Framework 각 계층의 역할 / 비즈니스 로직은 누구의 역할일까? (0) | 2024.07.08 |
Springboot 의존성 주입이란? @Autowired의 원리와 동작 과정 (0) | 2024.06.26 |
@Entity 에 @NoArgsConstructor, @AllArgsConstructor는 언제 붙이는걸까? (0) | 2024.06.20 |