작업일지에 들어가며
이전 프로젝트에서는 다른 참고 자료들의 코드들을 보고 따라하는 수준으로 스프링 시큐리티와 JWT를 적용했다면
이번에는 시큐리티와 JWT에 대해 알고있던 얕은 지식과 학원 수업을 더해 진행해보겠다.
학원에서는 리프레쉬 토큰까지 배우지는 않았지만 이번 작업에서는 리프레쉬 토큰까지 구현해보도록 하겠다.
무난히 개발을 완료했으면 좋겠지만 오히려 많은 에러를 만나보는 것이 더 좋을 것 같다는 생각이 든다.
JWT 개발 과정
1. bulid.gradle
JWT 의존성 추가
dependencies {
... 생략
// JWT
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
}
2. JwtUtil
@Component
public class JwtUtil {
@Value("${secret-key}") // @Value : application.properties에 등록한 secretKey를 가져온다.
private String secretKey;
// 액세스 토큰 만료 시간 (1시간)
private static final long ACCESS_TOKEN_EXPIRE_TIME = 60 * 60 * 1000L;
// 리프레시 토큰 만료 시간 (7일)
public static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L;
// SecretKey 객체로 변환
private SecretKey getSecretKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes()); // 비밀 키를 SecretKey 객체로 변환
}
// 액세스 토큰 생성
public String createAccessToken(String email) {
return create(email, ACCESS_TOKEN_EXPIRE_TIME);
}
// 리프레시 토큰 생성
public String createRefreshToken(String email) {
return create(email, REFRESH_TOKEN_EXPIRE_TIME);
}
// JWT 생성
public String create(String email, long expireTime) {
Date expiredDate = new Date(System.currentTimeMillis() + expireTime);
return Jwts.builder()
.signWith(getSecretKey(), SignatureAlgorithm.HS256) // SecretKey 객체 사용, 서명 및 알고리즘 설정
.setSubject(email) // JWT 주체 설정
.setIssuedAt(new Date()) // JWT 생성 시간 설정
.setExpiration(expiredDate) // JWT 만료 시간 설정
.compact(); // JWT 문자열로 반환
}
// 페이로드(클레임) 추출
public Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build()
.parseClaimsJws(token)
.getBody();
}
// 로그인 주체 추출
public String extractUseremail(String token) {
return extractClaims(token).getSubject();
}
// 토큰 검증
public boolean validate(String token) {
try {
Claims claims = extractClaims(token);
return !claims.getExpiration().before(new Date());
} catch (ExpiredJwtException e) {
System.err.println("JWT has expired: " + e.getMessage());
return false;
} catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
System.err.println("Invalid JWT: " + e.getMessage());
return false;
}
}
}
💡 JwtUtil : JWT 처리에 필요한 로직을 제공하는 클래스, 토큰의 생성과 유효성을 검증한다.
3. JwtAuthenticationFilter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String accessToken = parseAccessToken(request);
//System.out.println("request accessToken : " + accessToken);
if (accessToken == null) {
String refreshToken = parseRefreshToken(request);
//System.out.println("request refreshToken : " + refreshToken);
if (refreshToken != null && jwtUtil.validate(refreshToken)) {
String email = jwtUtil.extractUseremail(refreshToken);
if (email != null) {
String newAccessToken = jwtUtil.createAccessToken(email);
//System.out.println("newAccessToken : " + newAccessToken);
Cookie newAccessTokenCookie = new Cookie("accessToken", newAccessToken);
newAccessTokenCookie.setHttpOnly(true);
newAccessTokenCookie.setSecure(false);
newAccessTokenCookie.setPath("/");
newAccessTokenCookie.setMaxAge(3600);
response.addCookie(newAccessTokenCookie);
response.setHeader("Authorization", "Bearer " + newAccessToken);
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
return;
}
}
} else if (jwtUtil.validate(accessToken)) {
String email = jwtUtil.extractUseremail(accessToken);
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
System.out.println("필터 처리 중 오류 발생: " + e.getMessage());
e.printStackTrace();
}
filterChain.doFilter(request, response);
}
// 엑세스 토큰 추출
private String parseAccessToken(HttpServletRequest request) {
String accessToken = null;
String authHeader = request.getHeader("Authorization");
System.out.println("authHeader : " + authHeader);
// 토큰을 얻는 방법 두 가지: 다양한 클라이언트 환경 및 요청에 대해 유연한 대처가 필요하다.
// 헤더에서 토큰 얻기
if (authHeader != null && authHeader.startsWith("Bearer ")) {
accessToken = authHeader.substring(7);
// Authorization: Bearer <JWT_TOKEN>에서 Bearer 접두어를 제거하고 실제 토큰만 추출한다.
} else {
// 쿠키에서 토큰 얻기
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("accessToken".equals(cookie.getName())) {
accessToken = cookie.getValue();
}
}
}
}
return accessToken;
}
// 리프레시 토큰 추출
private String parseRefreshToken(HttpServletRequest request) {
String refreshToken = null;
String authHeader = request.getHeader("Authorization");
System.out.println("authHeader : " + authHeader);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
refreshToken = authHeader.substring(7);
} else {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
refreshToken = cookie.getValue();
}
}
}
}
return refreshToken;
}
}
💡 JwtAuthenticationFilter : Spring Security의 필터로, JWT를 사용하여 요청을 인증하는 역할, 이 필터는 모든 요청에 대해 JWT가 유효한지 확인하고, 유효한 경우 인증 정보를 SecurityContext에 설정한다.
💡 OncePerRequestFilter : Spring Framework에서 제공하는 추상 클래스, 이 클래스는 필터가 요청당 한 번만 실행되도록 보장하는 기능을 제공한다. 즉, 동일한 요청에 대해 여러 번 호출되지 않도록 하는 역할을 한다.
4. CustomUserDetailService
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final MembersRepository membersRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Members members = membersRepository.findByEmail(email);
if (members == null) {
throw new UsernameNotFoundException("가입된 계정을 찾을 수 없습니다.");
}
List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
authorities.add(new SimpleGrantedAuthority(members.getRole()));
UserDetails userDetails = new User(members.getEmail(), members.getPassword(), authorities);
return userDetails;
}
}
💡 CustomUserDetailService : 사용자 인증 시 사용자 정보(아이디와 비밀번호)를 로드하는 클래스
💡 UserDetailsService : 사용자 정보를 DB 또는 기타 저장소에서 로드 후 UserDetails 객체를 반환
💡 loadUserByUsername : AuthenticationManager에 의해 호출 당할 메소드, 사용자 인증을 위한 메소드이다.
5. SecurityConfig
@Configuration // 스프링 설정 클래스
@EnableWebSecurity // 스프링 시큐리티 기능을 활성화
@RequiredArgsConstructor
public class SecurityConfig implements WebMvcConfigurer {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
// CORS 설정과 CSRF 비활성화, 세션 관리 정책 설정
httpSecurity
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정
.csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화
.httpBasic(httpBasic -> httpBasic.disable()) // HTTP Basic 인증 비활성화
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 관리 정책 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/auth/**").permitAll()
.requestMatchers("/main/mypage/**").hasRole("USER")
.anyRequest().authenticated() // 그외 다른 요청은 인증 필요
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // JWT 인증 필터를 UsernamePasswordAuthenticationFilter 이전에 추가
.exceptionHandling(exception -> exception.authenticationEntryPoint(new FailedAuthenticationEntryPoint())); // 인증 실패 시 처리 로직 설정
return httpSecurity.build(); // 설정 완료 후 SecurityFilterChain 반환
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
// CORS 설정 메서드
private CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOriginPattern("http://localhost:3000"); // React의 로컬 서버 주소
configuration.setAllowedMethods(List.of("*")); // 모든 HTTP 메서드 허용
configuration.setAllowedHeaders(List.of("*")); // 모든 헤더 허용
configuration.setAllowCredentials(true); // 인증 정보 포함 요청 허용 (쿠키 등)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정을 적용
return source;
}
@Bean
public PasswordEncoder PasswordEncoder() {
return new BCryptPasswordEncoder(); // BCryptPasswordEncoder 인스턴스 반환
}
// FailedAuthenticationEntryPoint: 예외 상황(인증 실패)에 발생한 이벤트 처리를 위한 클래스
// AuthenticationEntryPoint :
// 스프링 시큐리티에서 인증이 필요한 리소스에 접근할 때 사용자가 인증되지 않았을 경우의 진입점을 정의하는 인터페이스, 인증 실패 시 어떤 응답을 반환할지를 결정한다.
class FailedAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json"); // 응답 타입을 JSON으로 설정
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // HTTP 상태 코드를 401로 설정
response.getWriter().write("{\"code\": \"AF\", \"message\": \"Authorization Failed\"}"); // 예외 상황 발생 시 JSON 응답 작성
}
}
💡 FailedAuthenticationEntryPoint : 예외 상황(인증 실패)에 발생한 이벤트 처리를 위한 클래스를 생성
💡 AuthenticationEntryPoint : 스프링 시큐리티에서 인증이 필요한 리소스에 접근할 때 사용자가 인증되지 않았을 경우의 진입점을 정의하는 인터페이스, 인증 실패 시 어떤 응답을 반환할지를 결정한다.
6. AuthProcess
public interface AuthProcess {
.. 생략
ResponseEntity<? super LoginRepsonseDto> login(LoginRequestDto dto, HttpServletResponse response);
ResponseEntity<?> logout(HttpServletResponse response);
}
7. AuthProcessImpl
@Service
@RequiredArgsConstructor
public class AuthProcessImpl implements AuthProcess {
private final JwtUtil jwtUtil;
private final AuthenticationManager authenticationManager;
private final MembersRepository membersRepository;
private final CustomUserDetailService customUserDetailService;
private final RefreshTokenRepository refreshTokenRepository;
... 생략
// 로그인
@Override
public ResponseEntity<? super LoginRepsonseDto> login(@RequestBody LoginRequestDto dto,
HttpServletResponse response) {
String email = dto.getEmail();
String password = dto.getPassword();
try {
// 이메일 유효성 검사
UserDetails userDetails = customUserDetailService.loadUserByUsername(email);
if (userDetails == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("가입된 계정이 없습니다.");
}
// 비밀번호 유효성 검사
if (!passwordEncoder.matches(password, userDetails.getPassword())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("이메일 또는 비밀번호가 잘못되었습니다.");
}
// JWT 토큰 생성
String accessToken = jwtUtil.createAccessToken(email);
String refreshToken = jwtUtil.createRefreshToken(email);
saveRefreshToken(email, refreshToken);
//System.out.println("Saving refresh token for email: " + email);
Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setSecure(false); // HTTPS 사용하는 경우 true로 변경
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(3600);
response.addCookie(accessTokenCookie);
Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setSecure(false); // HTTPS 사용하는 경우 true로 변경
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(604800);
response.addCookie(refreshTokenCookie);
return ResponseEntity.ok(LoginRepsonseDto.success(accessToken, refreshToken));
} catch (BadCredentialsException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("이메일 또는 비밀번호가 잘못되었습니다.");
} catch (UsernameNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("가입된 계정이 없습니다.");
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("로그인 중 오류 발생: " + e.getMessage());
}
}
// 로그아웃
@Override
public ResponseEntity<?> logout(HttpServletResponse response) {
try {
// 현재 인증된 사용자 정보 가져오기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
System.out.println("authentication : " + authentication);
// 사용자 이메일 추출
String email = authentication.getName();
System.out.println("사용자 로그아웃 이메일 : " + email);
// 리프레시 토큰 삭제
Optional<RefreshToken> refreshTokenOptional = refreshTokenRepository.findByEmail(email);
refreshTokenOptional.ifPresent(refreshTokenRepository::delete);
System.out.println("Refresh token deleted successfully!");
// 엑세스 토큰 쿠키 제거
Cookie accessTokenCookie = new Cookie("accessToken", null);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setSecure(false);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(0);
response.addCookie(accessTokenCookie);
// 엑세스 토큰 쿠키 제거
Cookie refreshTokenCookie = new Cookie("refreshToken", null);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setSecure(false);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(0);
response.addCookie(refreshTokenCookie);
SecurityContextHolder.clearContext();
System.out.println(
"Logout accessToken : " + accessTokenCookie.getName() + "=" + accessTokenCookie.getValue());
System.out.println(
"Logout refreshToken : " + refreshTokenCookie.getName() + "=" + refreshTokenCookie.getValue());
return ResponseEntity.ok().body("로그아웃 성공"); // 로그아웃 성공 시 응답
} catch (Exception e) {
return ResponseDto.databaseError();
}
}
... 생략
// 리프레쉬 토큰 DB 저장
private void saveRefreshToken(String email, String refreshToken) {
// 기존에 리프레시 토큰이 있으면 삭제하고 새로 저장
Optional<RefreshToken> existingToken = refreshTokenRepository.findByEmail(email);
existingToken.ifPresent(refreshTokenRepository::delete); // 기존 리프레시 토큰 삭제
// 새로운 리프레시 토큰 저장
RefreshToken refreshTokenEntity = new RefreshToken();
refreshTokenEntity.setEmail(email);
refreshTokenEntity.setRefreshToken(refreshToken);
refreshTokenEntity.setExpiryDate(LocalDateTime.now().plusWeeks(1)); // 리프레시 토큰의 만료일을 설정 (1주일)
//System.out.println("Before saving refresh token: " + refreshTokenEntity);
refreshTokenRepository.save(refreshTokenEntity);
//System.out.println("Refresh token saved successfully!");
}
8. AuthController
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtUtil jwtUtil;
private final AuthProcess authProcess;
... 생략
// 로그인
@PostMapping("/login")
public ResponseEntity<? super LoginRepsonseDto> login(@RequestBody @Valid LoginRequestDto dto, HttpServletResponse response) {
ResponseEntity<? super LoginRepsonseDto> loginResponse = authProcess.login(dto, response);
return loginResponse;
}
// 로그아웃
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
ResponseEntity<?> logout = authProcess.logout(response);
return logout;
}
}
9. 로그인 테스트
🚨 에러
*************************** APPLICATION FAILED TO START ***************************
Description: The dependencies of some of the beans in the application context form a cycle:
Action: Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
JwtAuthenticationFilter → CustomUserDetailService
CustomUserDetailService → SecurityConfig
SecurityConfig → JwtAuthenticationFilter
JwtAuthenticationFilter, CustomUserDetailService, SecurityConfig
이 세개의 클래스들이 서로를 참조하고 있어 무한 루프에 빠졌다. 배운대로 그대로 따라했다고 생각했지만 SecurityConfig에서 JwtAuthenticationFilter 의존성을 부여해주고 있어서 순환 참조에 걸린 것 같다.
JwtAuthenticationFilter를 SecurityFilterChain의 매개변수로 넣어주어 에러 해결
9-1 SecurityConfig 수정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig implements WebMvcConfigurer {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
// CORS 설정과 CSRF 비활성화, 세션 관리 정책 설정
httpSecurity
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정
.csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화
.httpBasic(httpBasic -> httpBasic.disable()) // HTTP Basic 인증 비활성화
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 관리 정책 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/auth/**", "/main/**", "/address/**", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll() // 특정 요청 허용
.anyRequest().authenticated() // 그외 다른 요청은 인증 필요
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // JWT 인증 필터를 UsernamePasswordAuthenticationFilter 이전에 추가
.exceptionHandling(exception -> exception.authenticationEntryPoint(new FailedAuthenticationEntryPoint())); // 인증 실패 시 처리 로직 설정
return httpSecurity.build(); // 설정 완료 후 SecurityFilterChain 반환
}
... 생략
10. 로그인 테스트2
🚩 보완점
로그아웃 시 토큰을 저장했던 쿠키의 흔적이 브라우저에 계속 남아있다.
당장 기능상에는 문제가 없지만 상당히 거슬리므로 방법을 강구하여 쿠키를 삭제해봐야겠다.
작업일지를 마치며
✨ 나의 생각
시큐리티는 배우면 배울수록 어렵고 난해한 것 같다.
학원 수업을 나가기전 스프링 시큐리티와 JWT를 적용했어야 했기에
유튜브나 블로그를 많이 서치했지만, 보고 따라하는 수준에 멈추기 싫어 시큐리티에 관해 공부를 따로 했었다.
그 결과 작동 원리를 조금 이해하고 학원 수업에 들어가니 좀 더 이해가 쉽게 되는 느낌이 들었다.
스프링 개발에 있어 인증, 인가 처리는 거의 대부분 스프링 시큐리티에 의존하기 때문에
현재 수준에 안주하지 말고 더 공부를 깊게 해야겠다는 생각이 들었다.
Reference
🙏 서타몽 : [Springboot + Reactjs (ts) + MySQL] 게시판형 블로그 만들기 Reboot
'Project > Team' 카테고리의 다른 글
프로젝트 작업일지 : 로그인 (아이디 찾기) (0) | 2024.12.30 |
---|---|
프로젝트 작업일지 : 로그인 (아이디 기억하기) (2) | 2024.12.28 |
프로젝트 작업일지 : 로그인 (폼 만들기_React Hook Form) (2) | 2024.12.28 |
프로젝트 작업일지 : 회원가입 (이메일 중복 검사) (0) | 2024.12.27 |
프로젝트 작업일지 : 회원가입 (스프링 - 리액트 회원가입) (0) | 2024.12.24 |