JwtSecurityConfig
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
TokenProvider 주입: 클래스 생성자에서 TokenProvider를 주입
configure 메서드 오버라이드: configure 메서드를 오버라이드하여 Spring Security 설정을 구성 - JwtFilter를 생성하고 Spring Security 필터 체인에 등록
JwtFilter 등록:http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class) 코드를 사용하여 JwtFilter를 UsernamePasswordAuthenticationFilter 앞에 추가로 등록
JwtAuthenticationEntryPoint
@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
log.error("Unauthorized error: " + authException.getMessage());
}
}
- Spring Security에서 사용자의 인증이 실패한 경우 호출되는 핸들러
commence 메서드: AuthenticationEntryPoint 인터페이스를 구현한 클래스에서 제공되는 메서드로, 사용자의 인증이 실패하고 접근이 거부될 때 호출
- HTTP 응답 상태 코드 설정:
response.sendError(HttpServletResponse.SC_UNAUTHORIZED)를 사용하여 HTTP 응답의 상태 코드를 401 Unauthorized로 설정
- 로깅:
log.error("Unauthorized error: " + authException.getMessage())를 사용하여 로그에 에러 메시지를 기록
JwtAccessDeniedHandler
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
CorsConfig
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("**", config);
return new CorsFilter(source);
}
}
CorsFilter 생성: CorsFilter는 Spring Security와 함께 사용되는 필터로, CORS 구성을 처리하는 데 사용
UrlBasedCorsConfigurationSource 생성: CORS 구성을 등록하기 위한 UrlBasedCorsConfigurationSource 인스턴스를 생성
CorsConfiguration 생성: CorsConfiguration 인스턴스를 생성하여 CORS 구성을 정의
setAllowCredentials(true): 자격 증명(인증 정보)을 요청 및 응답에 포함할 것인지
addAllowedOrigin("*"): 모든 Origin(도메인)으로부터의 요청을 허용
addAllowedHeader("*"): 모든 HTTP 헤더를 허용
addAllowedMethod("*"): 모든 HTTP 메서드(GET, POST, PUT, DELETE 등)를 허용
source.registerCorsConfiguration("**", config): 모든 패턴의 URL에 대한 CORS 구성을 CorsConfiguration 객체와 함께 등록. 이렇게 함으로써 모든 경로에 대한 요청은 CORS 정책에 따라 처리
SecurityConfig(제일 중요)
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().
requestMatchers(new AntPathRequestMatcher("/h2-console/**"))
.requestMatchers(new AntPathRequestMatcher( "/favicon.ico"))
.requestMatchers(new AntPathRequestMatcher( "/css/**"))
.requestMatchers(new AntPathRequestMatcher( "/js/**"))
.requestMatchers(new AntPathRequestMatcher( "/img/**"))
.requestMatchers(new AntPathRequestMatcher( "/lib/**"));
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {
http.csrf().disable()
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
// exception handling 할 때 우리가 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.headers()
.frameOptions()
.sameOrigin()
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
.and()
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(new MvcRequestMatcher(introspector,"/auth/**")).permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.apply(new JwtSecurityConfig(tokenProvider));
return http.build();
}
}
@EnableWebSecurity: 이 어노테이션은 Spring Security를 활성화하고 웹 보안 설정을 사용하겠다는 것
PasswordEncoder 빈: 비밀번호를 암호화하기 위한 PasswordEncoder 빈을 생성
WebSecurityCustomizer 빈: Spring Security의 웹 보안을 커스터마이징하기 위한 WebSecurityCustomizer 빈을 생성합니다. 여기서는 /h2-console/, /favicon.ico, /css/, /js/, /img/, /lib/ 경로에 대한 요청을 무시하도록 설정
SecurityFilterChain 빈: Spring Security의 보안 필터 체인을 구성하는 SecurityFilterChain 빈을 생성 - Spring Security 필터들의 설정과 연결을 정의
필터 체인은 다음과 같은 역할을 수행
- CSRF 설정 비활성화
- CORS 필터 등록
- 예외 처리 설정 (인증 및 권한 부여 예외 처리)
- HTTP 헤더 설정 (X-Frame-Options 등)
- 세션 관리 설정 (세션을 사용하지 않음)
- URL 권한 설정 (로그인 및 회원가입 API에 대한 토큰 없이 요청 허용, 나머지 API에 대한 인증 필요)
- .httpBasic(Customizer.withDefaults()) : 웹 애플리케이션에 HTTP 기본 인증을 추가하고, 기본 설정을 사용하여 이를 구성
- JWT 필터 등록
SecurityUtil
public class SecurityUtil {
private SecurityUtil() {
}
// SecurityContext 에 유저 정보가 저장되는 시점
// Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장
public static Long getCurrentMemberId() {
// SecurityContext 의 Authentication 객체를 이용해 회원 정보를 가져온다.
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 인증 정보가 없는 경우
if (authentication == null || authentication.getName() == null) {
throw new RuntimeException("Security Context 에 인증 정보가 없습니다.");
}
return Long.parseLong(authentication.getName());
}
}
- getCurrentMemberId(): 현재 인증된 사용자의 ID를 반환하는 메서드 - Spring Security의 SecurityContextHolder를 사용하여 현재 사용자의 인증 정보를 가져옴
- Authentication 을 이용해 회원의 정보를 불러오고, .getName()을 이용해 회원 정보 속 memberId를 반환
TokenRequestDto
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class TokenRequestDto {
private String accessToken;
private String refreshToken;
}
RefreshTokenRepository
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
Optional<RefreshToken> findByKey(String key);
}
AuthController
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
@PostMapping("/signup")
public ResponseEntity<MemberResponseDto> signup(@RequestBody MemberRequestDto memberRequestDto) {
return ResponseEntity.ok(authService.signup(memberRequestDto));
}
@PostMapping("/login")
public ResponseEntity<TokenDto> login(@RequestBody LoginRequestDto loginRequestDto) {
return ResponseEntity.ok(authService.login(loginRequestDto));
}
@PostMapping("/reissue")
public ResponseEntity<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
return ResponseEntity.ok(authService.reissue(tokenRequestDto));
}
}
AuthService
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public MemberResponseDto signup(MemberRequestDto memberRequestDto) {
if (memberRepository.existsByEmail(memberRequestDto.getEmail())) {
throw new RuntimeException("이미 가입되어 있는 유저입니다.");
}
String password = passwordEncoder.encode(memberRequestDto.getPassword());
Member member = Member.builder()
.email(memberRequestDto.getEmail())
.name(memberRequestDto.getName())
.birthday(memberRequestDto.getBirthday())
.nickname(memberRequestDto.getNickname())
.phoneNumber(memberRequestDto.getPhoneNumber())
.password(password)
.authority(Authority.valueOf("ROLE_USER"))
.build();
memberRepository.save(member);
return MemberResponseDto.of(member);
}
@Transactional
public TokenDto login(LoginRequestDto loginRequestDto) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginRequestDto.getEmail(), loginRequestDto.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
RefreshToken refreshToken = RefreshToken.builder()
.key(authentication.getName())
.value(tokenDto.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
return tokenDto;
}
@Transactional
public TokenDto reissue(TokenRequestDto tokenRequestDto) {
if (!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
}
Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());
RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
.orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));
if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
}
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
refreshTokenRepository.save(newRefreshToken);
return tokenDto;
}
}
signup 메서드:
- 먼저 사용자의 이메일이 이미 데이터베이스에 존재하는지 확인하고, 존재한다면 예외
- 비밀번호는 암호화하여 저장
- 회원 정보를 생성(User 권한 부여)
login 메서드:
- 입력받은 이메일과 비밀번호로 UsernamePasswordAuthenticationToken을 생성하여 인증을 시도
- AuthenticationManagerBuilder를 사용하여 해당 토큰으로 인증을 수행
- 인증에 성공하면, tokenProvider를 사용하여 액세스 토큰과 리프레시 토큰을 생성
- 생성된 리프레시 토큰을 데이터베이스에 저장하고, 액세스 토큰과 리프레시 토큰을 포함한 TokenDto를 반환
reissue 메서드:
- 리프레시 토큰을 사용하여 액세스 토큰을 다시 발급하는 로직
- 입력받은 리프레시 토큰의 유효성을 검증
- 유효한 액세스 토큰에서 사용자의 정보를 추출
- 데이터베이스에서 해당 사용자의 리프레시 토큰을 가져옴
- 가져온 리프레시 토큰과 입력받은 리프레시 토큰을 비교하여 일치하는지 확인
- 일치하면 새로운 액세스 토큰을 생성하고, 리프레시 토큰 값을 업데이트한 후 데이터베이스에 저장
- 새로 발급한 액세스 토큰을 포함한 TokenDto를 반환CustomUserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return memberRepository.findByEmail(email)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(email + "사용자를 찾을 수 없습니다."));
}
private UserDetails createUserDetails(Member member) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());
return new User(
String.valueOf(member.getMemberId()),
member.getPassword(),
Collections.singleton(grantedAuthority)
);
}
}
Spring Security의 UserDetailsService 인터페이스의 구현체 UserDetailsService는 Spring Security에서 사용자 정보를 로드하는 인터페이스로, 주로 인증(authentication) 과정에서 사용
여기서 주요한 역할은 다음과 같다:
loadUserByUsername: 이 메서드는 사용자의 이름(여기서는 이메일)을 받아 해당 사용자의 정보를 로드하고 UserDetails 객체로 반환
Spring Security는 사용자의 인증을 위해 이 메서드를 호출하여 사용자 정보를 가져옴
createUserDetails: 이 메서드는 Member 엔티티를 기반으로 Spring Security의 UserDetails 객체를 생성
UserDetails는 사용자의 인증 및 권한 정보를 담는 객체 - 여기서는 Member 엔티티에서 사용자 ID, 패스워드, 권한 정보를 추출하여 User 객체로 감싸서 반환