개발일기
[리팩토링] 1부 Spring Security 리팩토링 / 회원 로그인, 로그아웃 본문
저는 이전에 Security 코드를 짤 때 느낀점은 Mockito가 없다면 절대로 테스트하기가 힘들다는 점이다.
이마저도 Security 테스트 라이브러리를 그래들에 import하여 검증하였다. 하지만, 이번에는 Service 코드만 단순하게 테스트 검증한다면, 작동하게끔 코드를 작성해보려고 한다.
누군가는 왜 Session 방식으로 하지 않고, Jwt 방식으로 진행하는지에 대한 여부를 물어볼 수 있습니다.
저는 Session방식으로 하지 않은 이유는 일단 RestAPI 원칙은 stateless 하지 않습니다.
궁극적으로 저희 프로젝트에는 추후, 결제 시스템을 붙일 예정이며, 해당 부분은 서버를 분리하여 MSA형식으로 진행할 것이기에 적당하지 않다고 판단하여, Jwt방식으로 Elastic Cache 즉, Redis 저장하는 방법으로 구현하였습니다.
만약 MSA를 하지 않았다면, JWT 방식으로 진행했을지에 대해서는 고민을 해볼 것 같습니다.
Spring Boot Security 흐름도입니다.
시큐어리티의 흐름은 위에와 같은 형식으로 흘러간다.
1. Http Request 수신
사용자가 로그인 정보와 함께 인증 요청을 한다.
2. 유저 자격을 기반으로 인증토큰 생성
AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성한다.
3. Filter를 통해 AuthenticationToken을 AuthenticationManager로 위임
AuthenticationManager 구현체인 ProviderManager에게 생성한 UsernamePasswordToken 객체를 전달한다.
4. AuthenticationProvider 목록으로 인증을 시도
AuthenticationManager 등록된 AuthenticationProvider들을 조회하며 인증을 요구한다.
5. UserDetailsService
실제 데이터베이스에서 사용자 인증정보를 가져오는 UserDetailsService에 사용자 정보를 넘겨준다.
6. UserDetails 이용해 User객체에 대한 정보 탐색
넘겨받은 사용자 정보를 통해 데이터베이스에서 찾아낸 사용자 정보인 UserDetails 객체를 만든다.
7. User 객체의 정보들을 UserDetails가 UserDetailsService(LoginService)로 전달
AuthenticationProvider들은 UserDetails 넘겨받고 사용자 정보를 비교한다.
8. 인증 객체 or AuthenticationException
인증이 완료가 되면 권한 등의 사용자 정보를 담은 Authentication객체를 반환
9. 인증끝
10. SecurityContext에 인증 객체를 설정
package com.realworld.common.config.security;
import com.realworld.application.auth.jwt.service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class SecurityConfig {
protected static final String[] exclude = new String[]{
"/favicon.ico",
"/prometheus",
"/actuator/**",
"/v2/auth/email",
"/v2/auth/email/**",
"/v2/member",
"/error",
"/v2/login",
"/swagger-ui/index.html",
"/swagger-ui/**", // Swagger UI
"/api/v3/api-docs", // Swagger API docs
"/swagger-resources/**", // Swagger resources
"/swagger-ui.html", // Swagger HTML
"/webjars/**",// Webjars for Swagger
"/swagger/**",// Swagger try it out
"/v3/api-docs/**"
};
private final JwtService jwtService;
@Order(1)
@Bean
public SecurityFilterChain customFilterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource()))
.exceptionHandling(authenticationManager -> authenticationManager
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.accessDeniedHandler(new JwtAccessDeniedFilter()))
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new JwtAuthenticationFilter(jwtService), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth.
requestMatchers(exclude).permitAll()
.anyRequest()
.authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowCredentials(true);
configuration.addAllowedOriginPattern("*");
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
1. CORS 설정 객체 생성
CorsConfiguration 객체를 생성하여 CORS 정책을 정의합니다.
2. CORS 정책 설정
configuration.setAllowCredentials(true);
setAllowCredentials(true) : 클라이언트가 인증 정보(쿠키, HTTP 인증 등) 포함하여 요청을 보낼 수 있도록 허용합니다.
configuration.addAllowedOriginPattern("*")
addAllowedOriginPattern("*") 모든 도메인에서 요청을 허용합니다.
1. "*"는 모든 출처를 허용한다는 의미입니다.
2. addAllowedOrigin 대신 addAllowedOriginPattern 사용한 이유
- addAllowedOrigin은 특정 도메인만 허용할 때, "*"와 함께 사용하면 충돌날 수 있음.
- addAllowedOriginPattern 와일드카드(*) 포함하여 더 유연한 설정이 가능함.
configuration.addAllowedHeader("*");
- addAllowedHeader("*") : 모든 요청 헤더를 허용합니다.
- 클라이언트가 요청 시 어떤 헤더를 사용해도 서버가 이를 허용하도록 설정합니다.
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
- UrlBasedCorsConfigurationSource 객체를 생성하여 URL 패턴과 CORS 정책을 연결합니다.
- "/**"
- 모든 URL 경로에 대해 설정한 CORS 정책을 적용합니다.
- 예: /api/**, /v1/** 등 모든 요청에 대해 허용.
1. @Order(1)
- 이 필터 체인의 실행 우선순위를 설정합니다.
- 숫자가 작을수록 높은 우선순위를 가지며, 여러 필터 체인이 존재할 경우 순서를 결정합니다.
2. HTTP 기본 설정 비활성화
http.httpBasic(AbstractHttpConfigurer::disable);
- HTTP 기본 인증(브라우저 팝업을 통한 사용자 인증)을 비활성화합니다.
3. CSRF 비활성화
http.csrf(AbstractHttpConfigurer::disable);
- CSRF(Cross-Site Request Forgery)보호를 비활성화
- REST API는 서버 상태 유지 x 비활성화하는게 일반적임
- 현재 REST API통신을 사용하고 있기에 비활성화처리 유지
4. 폼 로그인 비활성화
http.formLogin(AbstractHttpConfigurer::disable);
- Spring Security 기본 로그인 폼 기능을 비활성화합니다.
- JWT 사용하는 환경에서는 필요하지 않으므로 비활성화
5. CORS 설정
http.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource()));
- CORS 정책을 설정합니다.
- corsConfigurationSource() 메서드에서 정의된 CORS 정책을 필터 체인에 추가하여 적용
6. 예외 처리
http.exceptionHandling(authenticationManager -> authenticationManager
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.accessDeniedHandler(new JwtAccessDeniedFilter()));
- authenticationEntryPoint
- 인증되지 않은 사용자가 보호된 리소스에 접근할 경우 JwtAuthenticationEntryPoint에서 처리합니다.
- 주로 401 Unauthorized 응답을 반환
- accessDeniedHandler
- 인증은 되었으나 권한이 부족한 사용자가 리소스에 접근할 경우 JwtAccessDeniedFilter 처리
- 주로 403 Forbidden 응답을 반환
7. 세션 관리
http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
8. JWT 필터 추가
http.addFilterBefore(new JwtAuthenticationFilter(jwtService), UsernamePasswordAuthenticationFilter.class);
9. 요청 권한 설정
http.authorizeHttpRequests(auth -> auth
.requestMatchers(exclude).permitAll()
.anyRequest()
.authenticated());
10. 필터체인 빌드
return http.build();
Authority.class
package com.realworld.common.type.jwt;
public enum Authority {
ROLE_USER;
}
USER 권한을 주기위한, enum 클래스이다.
ROLE_USER만 있지만, 추후 또 다른 권한이 추가될 수도 있기에 Enum으로 만들어주었다.
TokenStatus.class
package com.realworld.common.type.jwt;
import lombok.Getter;
@Getter
public enum TokenStatus {
AUTHENTICATED,
EXPIRED,
INVALID;
TokenStatus() {
}
}
TokenStatus 는, 토큰 만료에 대한 상태 값이 들어가있다.
JwtProviiderHolder.class
package com.realworld.common.holder.jwt;
import com.realworld.feature.member.entity.Member;
import io.jsonwebtoken.Jwts;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtProviderHolder {
public String generateAccessToken(final Key accessSecret, final long accessExpiration, final Member member) {
return Jwts.builder()
.setHeader(createHeader())
.setClaims(createClaims(member))
.setSubject(member.getUserId())
.setExpiration(new Date(System.currentTimeMillis() + accessExpiration))
.signWith(accessSecret)
.compact();
}
private Map<String, Object> createHeader() {
Map<String, Object> header = new HashMap<>();
header.put("typ", "JWT");
header.put("alg", "HS256");
return header;
}
private Map<String, Object> createClaims(Member member) {
Map<String, Object> claims = new HashMap<>();
claims.put("id", member.getUserId());
claims.put("role", member.getAuthority());
return claims;
}
}
나는 JwtProviderHolder를 만들 때, 인터페이스로 만들지에 대해서 조금 많이 고민했던거 같다.
하지만 JwtProvider는 인터페이스로 만들이유가 없다
Why? 이름처럼 Jwt에서만 사용하는 것이 다른 것으로 갈아 끼워질 일이 없을 것이라 판단했기 때문이다.
중간에 Jwts가 아니여서 오류가 발생하였었다. 최근 Security버전에서는 Jwts를 써야지만, 암호화가 정상적으로 진행되니 Jwts로 만들어주자.
JwtService.class
package com.realworld.application.auth.jwt.service;
import com.realworld.feature.member.entity.Member;
import com.realworld.infrastructure.jwt.handler.JwtTokenHandler;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
public interface JwtService {
String generateAccessToken(Member member);
String resolveAccessToken(HttpServletRequest request);
boolean validateAccessToken(String token, JwtTokenHandler jwtTokenHandler);
Authentication getAuthentication(String token);
void logout(Member member);
}
JwtService를 만들 때, 꼭 구현해야하는 부분들을 interface로 만들어주었다.
JwtServiceImpl.class
package com.realworld.application.auth.jwt.service;
import com.realworld.application.auth.jwt.port.TokenRepository;
import com.realworld.common.holder.jwt.JwtProviderHolder;
import com.realworld.common.type.jwt.TokenStatus;
import com.realworld.feature.member.entity.Member;
import com.realworld.infrastructure.jwt.handler.JwtTokenHandler;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.Key;
@Slf4j
@Service
@Transactional(readOnly = true)
public class JwtServiceImpl implements JwtService{
private static final String AUTHORIZATION_HEADER = "Authorization";
private final UserDetailServiceImpl userDetailService;
private final TokenRepository tokenRepository;
private final JwtProviderHolder jwtProviderHolder;
private final Key accessSecretKey;
private final long accessExpiration;
public JwtServiceImpl(final UserDetailServiceImpl userDetailService,
final TokenRepository tokenRepository,
final JwtProviderHolder jwtProviderHolder,
@Value("${jwt.access-secret}") final String accessSecretKey,
@Value("${jwt.access-expiration}") final long accessExpiration
) {
this.userDetailService = userDetailService;
this.tokenRepository = tokenRepository;
this.jwtProviderHolder = jwtProviderHolder;
this.accessSecretKey = Keys.hmacShaKeyFor(accessSecretKey.getBytes(StandardCharsets.UTF_8));
this.accessExpiration = accessExpiration;
}
@Override
public String generateAccessToken(final Member member) {
// JWT AccessToken
return jwtProviderHolder.generateAccessToken(accessSecretKey, accessExpiration, member);
}
public String resolveAccessToken(final HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
@Override
public boolean validateAccessToken(final String token, final JwtTokenHandler jwtTokenHandler) {
TokenStatus tokenStatus = jwtTokenHandler.getTokenStatus(token, accessSecretKey);
log.info(tokenStatus.toString());
return tokenStatus == TokenStatus.AUTHENTICATED && tokenRepository.findById(getUserId(token, accessSecretKey)).isPresent();
}
@Override
public Authentication getAuthentication(final String token) {
UserDetails userDetails = userDetailService.loadUserByUsername(getUserId(token, accessSecretKey));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
private String getUserId(final String token, final Key secretKey) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
@Override
public void logout(final Member member) {
tokenRepository.deleteById(member.getUserId());
}
}
@Override
public String generateAccessToken(final Member member) {
// JWT AccessToken
return jwtProviderHolder.generateAccessToken(accessSecretKey, accessExpiration, member);
}
AccessToken을 만들어주는 입니다. 해당 메소드를 통해서, accessSecreyKey, accessExpiration, member 기준으로 jwtToken을 생성해줍니다.
public String resolveAccessToken(final HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
AcessToken 해당하는 값을 Authorization 헤더에서 해당 토큰을 가져와주는 메소드입니다.
@Override
public boolean validateAccessToken(final String token, final JwtTokenHandler jwtTokenHandler) {
TokenStatus tokenStatus = jwtTokenHandler.getTokenStatus(token, accessSecretKey);
log.info(tokenStatus.toString());
return tokenStatus == TokenStatus.AUTHENTICATED && tokenRepository.findById(getUserId(token, accessSecretKey)).isPresent();
}
validateAcessToken은, JwtTokenHandler에서 getTokenStatus를 통해서 토큰 상태 값을 가져와주고, 토큰 상태 값을 가져와줍니다.
또한 return 문에서 조건을 비교를 통해서 토큰이 있는지에 대해서 확인합니다.
사실 이부분이 테스트하기 가장 힘들었습니다. 왜냐면 외부 모듈이 두개가 Repository, JWT 두개가 한 메소드에 결합되어있기 때문입니다. 추후, 이부분은 테스트코드를 작성할 때, 이야기 하겠습니다.
@Override
public Authentication getAuthentication(final String token) {
UserDetails userDetails = userDetailService.loadUserByUsername(getUserId(token, accessSecretKey));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
인증정보를 가져와주는 부분입니다. 해당 코드는 , UserDetailService에 있는 loadByUsername을 호출하여 UserDetails를 가져오고
그 부분은 UsernamePasswordAuthToken 인스턴스를 생성해주어 Authntication 인증정보를 반환해줍니다.
private String getUserId(final String token, final Key secretKey) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
getUserId를 통해서 유저아이디에 해당하는 부분을 가져옵니다.
저희 시스템은 이메일을 사용하지않기에 해당 이름으로 지어주었습니다.
만약 이메일을 사용한다면 getUserEmail이 되었을 것 입니다.
@Override
public void logout(final Member member) {
tokenRepository.deleteById(member.getUserId());
}
logout 메소드입니다. 해당 코드는 로그아웃시, 토큰을 제거 해줍니다.
TokenRepository와 Token은 Security에 해당하지 않고, Redis에 해당하는 부분이라 제외하겠습니다.
'photocard backend server 개발일기' 카테고리의 다른 글
성능 테스트 k6+ Grafana +influxDB (1) | 2025.02.06 |
---|---|
SonarQube , JaCoCo 테스트 커버리지 측정하기 (1) | 2025.02.06 |
[JPA] QueryDSL No property 'xxxxx' found for type 'xxxxx' (0) | 2025.01.14 |
[DDD 모델링] 포토카드 도메인 주도 개발을 위한 회원 , 프로필 모델링 (2) | 2025.01.13 |
[Swagger] 스웨거 커스텀하여 @ApiResponses 커스텀 어노테이션으로 대체하기 (1) | 2025.01.10 |