Spring Security 프레임워크는 Spring 서버에 필요한 인증 및 인가를 위한 기능 제공
Spring MVC 기반 어플리케이션에 보안을 적용하기 위한 표준
Interceptor나 Servlet Filter를 직접 구현하지 않아도 됨
UserDetailsservice와 UserDetails를 직접 구현해서 사용하게 되면 Security의 default로그인 기능을 사용하지 않겠다는 의미
-> Security의 password 제공x
JwtAuthorizationFilter.java
@Slf4j(topic = "인가 필터")
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String accessToken = jwtUtil.getJwtFromCookie(request);
if (StringUtils.hasText(accessToken)) {
accessToken = jwtUtil.substringToken(accessToken);
log.info("액세스 토큰 값 : " + accessToken);
String newAccessToken = jwtUtil.reissueAccessToken(accessToken);
if (newAccessToken != null) {
jwtUtil.addJwtToCookie(newAccessToken, response);
}
if (!jwtUtil.validateToken(accessToken)) {
log.info("액세스 토큰 유효하지 않음");
return;
}
log.info("body의 사용자 정보 꺼내기");
Claims info = jwtUtil.getUserInfoFromToken(accessToken);
try {
// token 생성 시 subject에 username 넣어둠
log.info(info.getSubject());
setAuthentication(info.getSubject());
} catch (Exception e) {
log.info("오류 발생");
return;
}
}
filterChain.doFilter(request, response);
}
// token -> authentication 객체에 담기 -> SecurityContext에 담기 -> ContextHolder에 담기
// 인증 처리
public void setAuthentication(String username) {
log.info("인증 성공");
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());
}
}
UserDetailsImpl.java
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 null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails 인터페이스를 구현한 클래스
Spring Security에서 관리하는 User 정보 관리
권한은 사용하지 않았음
UserDetailsServiceImpl.java
@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(username + "는 존재하지 않는 사용자입니다."));
return new UserDetailsImpl(user);
}
}
UserDetailsService 인터페이스를 구현한 클래스
Spring Security에서 인증 정보를 조회하기 위해 사용
Json 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web token
JWT를 사용하면 로그인 정보를 클라이언트에 암호화하여 저장하는 것
동시 접속자가 많을 때 서버 측 부하 낮춤
세션 기반 인증 방식은 사용자의 로그인 정보를 서버에서 관리해야하기 때문에 서버에 부담
JWT 에 담는 내용이 커질수록 네트워크 비용 증가(클 -> 서)
JwtUtil.java
@Component
@RequiredArgsConstructor
@Slf4j(topic = "JwtUtil")
public class JwtUtil {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
public static final long REFRESH_TOKEN_TIME = 7 * 24 * 60 * 60 * 1000L; // 7일
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey(application.properties)
private String secretKey;
private Key key;
// 사용할 암호화 알고리즘
public final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
private final RedisUtil redisUtil;
@PostConstruct
public void init() {
// secretKey : 이미 base64로 인코딩 된 값
// 사용하려면 디코딩
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// access token 생성
public String createToken(String username) {
log.info(username + "의 액세스 토큰 생성");
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 id
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급 시간
.signWith(key, signatureAlgorithm) // 키, 암호화 알고리즘
.compact(); // 완성
}
// refresh token 생성
public String createRefreshToken() {
log.info("리프레시 토큰 생성");
Date date = new Date();
return Jwts.builder()
.setExpiration(new Date(date.getTime() + REFRESH_TOKEN_TIME))
.signWith(key, signatureAlgorithm)
.compact();
}
// access token 재발급
public String reissueAccessToken(String token) {
log.info("액세스 토큰 재발급");
if (validateToken(token)) {
Claims info = getUserInfoFromToken(token);
String username = info.getSubject();
log.info("재발급 요청자 : " + username);
// refresh token 가져오기
String refreshToken = redisUtil.getRefreshToken(username);
// refresh token 존재하고 유효하다면
if (StringUtils.hasText(refreshToken) && validateToken(refreshToken)) {
log.info("리프레시 토큰 존재하고 유효함");
return createToken(username);
}
}
return null;
}
// 토큰 쿠키에 담기
public void addJwtToCookie(String token, HttpServletResponse response) {
log.info("토큰 쿠키에 담기");
token = URLEncoder.encode(token, StandardCharsets.UTF_8).replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
log.info("token = " + token);
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");
// Response 객체에 Cookie 추가
response.addCookie(cookie);
}
// Cookie에서 토큰 가져오기
public String getJwtFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
log.info("쿠키에서 토큰 꺼내기" + URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8));
return URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8); // Encode 되어 넘어간 Value 다시 Decode
}
}
}
return null;
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
if (redisUtil.isBlackList(token)) {
// blacklist에 존재하는 access token이면
throw new IllegalArgumentException("로그아웃된 토큰입니다");
}
return true;
} catch (SecurityException | MalformedJwtException e) {
throw new IllegalArgumentException("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.info("만료");
return false;
} catch (UnsupportedJwtException e) {
throw new IllegalArgumentException("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
}
public String substringToken(String token) {
if (StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)) {
return token.substring(7);
}
throw new NullPointerException("token 비었거나 bearer로 시작하지 않습니다.");
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
// 액세스 토큰 남은 만료시간 계산
public Long remainExpireTime(String token) {
// 토큰 만료 시간
Long expirationTime = getUserInfoFromToken(token).getExpiration().getTime();
// 현재 시간
Long dateTime = new Date().getTime();
return expirationTime - dateTime;
}
}
Jwt 생성, 검증 담당
WebSecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/api/auth/**").permitAll() // '/api/auth/'로 시작하는 요청 모두 접근 허가
.requestMatchers(HttpMethod.GET,"/api/**").permitAll()
.requestMatchers(HttpMethod.GET,"/view/**").permitAll()//view페이지 모두 허용
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
SecurityFilterChain의