1. 테스트
JwtToken 테스트
•
Postman을 통해 JWT 토큰을 활용한 게시글 등록 테스트 진행
◦
먼저 로그인을 통해 JWT 토큰을 발급 받는다.
◦
발급받은 JWT 토큰을 활용하여 게시글 등록을 한다.
•
게시글 수정
•
게시글 상세 조회
•
게시글 삭제
2. 소셜 로그인 (Google, Kakao)
Config 수정
•
SecurityConfig
package org.example.backendproject.security.config;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.example.backendproject.oauth2.OAuth2LoginSuccessHandler;
import org.example.backendproject.oauth2.OAuth2LogoutSuccessHandler;
import org.example.backendproject.oauth2.OAuth2UserService;
import org.example.backendproject.oauth2.RedisOAuth2AuthorizationRequestRepository;
import org.example.backendproject.security.jwt.JwtTokenFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration // 설정 클래스 등록
@EnableWebSecurity // 스프링 시큐리티 활성화
@RequiredArgsConstructor // 생성자 자동 생성
public class SecurityConfig {
private final JwtTokenFilter jwtTokenFilter;
private final OAuth2LogoutSuccessHandler oAuth2LogoutSuccessHandler;
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
private final OAuth2UserService oAuth2UserService;
private final RedisTemplate<String, Object> redisTemplate;
@Bean
public AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository() {
return new RedisOAuth2AuthorizationRequestRepository(redisTemplate);
}
// 스프링 시큐리티에서 어떤 순서로 어떤 보안 규칙의 필터를 거칠지를 정의하는 클래스
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// CSRF 보호 기능 비활성화
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/index.html", "/*.html", "/favicon.ico",
"/css/**", "/fetchWithAuth.js", "/js/**", "/images/**",
"/.well-known/**" ).permitAll() // 정적 리소스 누구나 접근
.requestMatchers("/boards/**", "/api/comments/**").authenticated()
//인증필요
.requestMatchers(
"/api/auth/**", // 로그인/회원가입/로그아웃 등 인증 없이 사용
// "/api/comments/**", // 댓글 읽기 등 인증 없이 사용
"/oauth2/**", // 소셜 로그인 엔드포인트는 누구나 접근
"/login/**", // 스프링 시큐리티 내부 로그인 관련 엔드포인트
"/ws-gpt", "/ws-chat", // 웹소켓 핸드셰이크
"/actuator/prometheus" //프로메테우스
).permitAll() // 웹소켓 핸드셰이크는 모두 허용!
.requestMatchers(
"/api/user/**",
"/api/rooms/**"
).authenticated() //인증이 필요한 경로
)
// 인증 실패시 예외 처리
.exceptionHandling(e -> e
// 인증 안 된 사용자가 접근하려고 할 때
.authenticationEntryPoint((request, response, authException) -> {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
})
// 인증은 되었지만 권한이 없을 때
.authenticationEntryPoint((request, response, authException) -> {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden");
})
)
// 스프링 시큐리티에서 세션관리정책을 설정하는 부분
// 기본적으로 스프링 시큐리티는 세션을 생성함
// 하지만 JWT 기반 인증은 세션상태를 저장하지 않는 무상태(stateless) 방식
// 인증 정보를 세션에 저장하지 않고, 매 요청마다 토큰으로 인증
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
.oauth2Login(oauth2->oauth2
.loginPage("/index.html")
.userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService))
.successHandler(oAuth2LoginSuccessHandler)
.authorizationEndpoint(authorization -> authorization
.authorizationRequestRepository(authorizationRequestRepository()))
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessHandler(oAuth2LogoutSuccessHandler)
.permitAll()
)
.build(); // 위 명시한 설정들을 적용
}
// 회원가입시 비밀번호를 암호화해주는 메서드
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Java
복사
•
RedisConfig
package org.example.backendproject.stompwebsocket.redis;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
//@Profile("!test")
@RequiredArgsConstructor
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
private final RedisSubscriber redisSubscriber;
// .... 중략 ...
// 추가
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
return template;
}
// ... 중략 ...
}
Java
복사
소셜 로그인 코드 작성
•
AuthController - 일반 로그인 로직 수정 + 로그아웃 기능 추가
package org.example.backendproject.Auth.controller;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.backendproject.Auth.dto.LoginRequestDTO;
import org.example.backendproject.Auth.dto.LoginResponseDTO;
import org.example.backendproject.Auth.dto.SignUpRequestDTO;
import org.example.backendproject.Auth.service.AuthService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/auth")
@Slf4j
public class AuthController {
private final AuthService authService;
.... 중략 ....
/** 로그인 **/
// @PostMapping("/loginSecurity")
// public ResponseEntity<LoginResponseDTO> login(@RequestBody LoginRequestDTO loginRequestDTO) {
// LoginResponseDTO loginResponseDTO = authService.login(loginRequestDTO);
// return ResponseEntity.ok(loginResponseDTO);
// }
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequestDTO loginRequestDTO) {
LoginResponseDTO loginResponseDTO = authService.login(loginRequestDTO);
return ResponseEntity.ok(loginResponseDTO);
}
.... 중략 ....
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
// accessToken 쿠키 삭제
Cookie accessTokenCookie = new Cookie("accessToken", null);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(0); // 즉시 만료!
// refreshToken 쿠키 삭제
Cookie refreshTokenCookie = new Cookie("refreshToken", null);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(0);
// 응답에 쿠키 삭제 포함
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
// (추가) 서버 세션도 있다면 만료
// request.getSession().invalidate();
return ResponseEntity.ok().body("로그아웃 완료 (쿠키 삭제됨)");
}
}
Java
복사
•
AuthRepository - Auth 엔티티에 토큰 저장
package org.example.backendproject.Auth.repository;
import java.util.Optional;
import org.example.backendproject.Auth.entity.Auth;
import org.example.backendproject.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface AuthRepository extends JpaRepository<Auth, Long> {
Optional<Auth> findByRefreshToken(String refreshToken);
boolean existsByUser(User user);
// Auth 엔티티에 토큰 저장 (User와 1:1 매핑)
Optional<Auth> findByUser(User user);
}
Java
복사
•
OAuth2LoginSuccessHandler
package org.example.backendproject.oauth2;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
// 로그인 동작을 커스텀으로 구현하고 싶을 때 사용하는 인터페이스
// OAuth2 로그인 성공시 호출되는 메서드
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication)
throws IOException, ServletException {
DefaultOAuth2User oAuth2User = (DefaultOAuth2User) authentication.getPrincipal();
Map<String, Object> attributes = oAuth2User.getAttributes();
String accessToken = (String) attributes.get("accessToken");
String refreshToken = (String) attributes.get("refreshToken");
String name = (String) attributes.get("name");
System.out.println("[OAuth2_LOG]" + "소셯 로그인 시도한 이름 = "+name);
// 사용자 ID를 안전하게 꺼내기 (null 체크 및 타입 캐스팅)
Long id = null;
Object idObj = attributes.get("id");
if (idObj != null) {
// Long 타입이 아닐 수도 있으니 안전하게 변환
id = Long.valueOf(idObj.toString());
}
//토큰 전달방식
// 또는, 보안을 강화하려면 아래처럼 HttpOnly 쿠키로 전달해도 됨
Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
// accessTokenCookie.setMaxAge(60 * 3); // 3분짜리 임시쿠키
response.addCookie(accessTokenCookie);
Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setPath("/");
// refreshTokenCookie.setMaxAge(60 * 60 * 24); // 1일짜리
response.addCookie(refreshTokenCookie);
response.sendRedirect("/main.html?" + "&id=" + id);
// response.sendRedirect("http://localhost:3000/main" + (id != null ? "?id=" + id : ""));//리액트
}
}
Java
복사
•
OAuth2LogoutSuccessHandler
package org.example.backendproject.oauth2;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Component
public class OAuth2LogoutSuccessHandler implements LogoutSuccessHandler {
// 카카오 REST API 키 (환경변수나 properties에서 가져오세요)
private final String kakaoClientId = "kakaoClientId"; // 카카오 개발자 사이트에 있는 ClientID 추가
private final String kakaoLogoutRedirectUri = "http://localhost:8080/login.html"; // 앱 환경에 맞게 변경
@Override
public void onLogoutSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 기본 리디렉션 URL → 일반 로그아웃 시 index.html로 이동
String redirectUrl = "/index.html";
if (authentication != null && authentication.getPrincipal() instanceof DefaultOAuth2User auth2User){
Map<String,Object> attributes = auth2User.getAttributes();
Object email = attributes.get("email");
if (email != null && email.toString().endsWith("@gmail.com")){
System.out.println("구글 로그아웃입니다.");
redirectUrl = "https://accounts.google.com/Logout";
}
// 카카오 로그인 사용자인 경우 (attributes에 'id' 키가 있음)
else if (attributes.containsKey("id")){
System.out.println("카카오 로그아웃입니다.");
redirectUrl = "https://kauth.kakao.com/oauth/logout?client_id=" + kakaoClientId
+ "&logout_redirect_uri=" + kakaoLogoutRedirectUri;
}
}
// 최종적으로 redirectUrl로 리디렉트
response.sendRedirect(redirectUrl);
}
}
Java
복사
•
OAuth2UserService
package org.example.backendproject.oauth2;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.backendproject.Auth.entity.Auth;
import org.example.backendproject.Auth.repository.AuthRepository;
import org.example.backendproject.security.core.CustomUserDetails;
import org.example.backendproject.security.core.Role;
import org.example.backendproject.security.jwt.JwtTokenProvider;
import org.example.backendproject.user.entity.User;
import org.example.backendproject.user.entity.UserProfile;
import org.example.backendproject.user.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@Service
@Slf4j
@RequiredArgsConstructor
public class OAuth2UserService extends DefaultOAuth2UserService {
// DefaultOAuth2UserService - spring security에서 기본적으로 제공하는 OAuth2 인증 정보를 처리하는 클래스
private final AuthRepository authRepository; // JWT 저장용 (access, refresh)
private final UserRepository userRepository; // 회원 정보 DB 접근
private final JwtTokenProvider jwtTokenProvider; // JWT 발급기
@Value("${jwt.accessTokenExpirationTime}")
private Long jwtAccessTokenExpirationTime;
@Value("${jwt.refreshTokenExpirationTime}")
private Long jwtRefreshTokenExpirationTime;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
/** 소셜 로그인 성공 시 사용자 정보를 구글이나 카카오에서 받아서 처리하는 메소드 **/
// 기본 OAuth2User 정보 가져오기 (email, name 등)
OAuth2User oAuth2User = super.loadUser(userRequest);
// 어떤 소셜 로그인 제공자인지 (google, kakao 등)
String provider = userRequest.getClientRegistration().getRegistrationId();
// 소셜 사용자 정보 추출
Map<String, Object> attributes = oAuth2User.getAttributes();
String userid, username, email;
// provider 별로 파싱 방식이 다름
if ("google".equals(provider)) {
email = (String) attributes.get("email"); // 구글의 고유 키
userid = email; // 사용자 아이디로 email 사용
username = (String) attributes.get("name");
} else if ("kakao".equals(provider)) {
userid = attributes.get("id").toString()+"@kakao"; // 고유 id + 구분자
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
email = (String) kakaoAccount.get("email");
Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
username = (String) profile.get("nickname");
} else {
// 기타 provider 처리 안함
email = null;
username = null;
userid = null;
}
System.out.println(provider + " 로그인 확인 userid = " + userid);
System.out.println(provider + " 로그인 확인 email = " + email);
System.out.println(provider + " 로그인 확인 username = " + username);
// 회원 정보가 DB에 존재하는지 확인
User user = userRepository.findByUserid(userid)
.orElseGet(() -> {
// 회원이 없다면 자동 회원가입 처리
User newUser = new User();
newUser.setUserid(userid);
newUser.setPassword(""); // 소셜 로그인은 비밀번호 없음
newUser.setRole(Role.ROLE_USER); // 기본 권한 설정
// 프로필 엔티티 생성 및 양방향 관계 설정
UserProfile profile = new UserProfile();
profile.setUsername(username != null ? username : "소셜유저");
profile.setEmail(email != null ? email : "");
profile.setPhone("");
profile.setAddress("");
profile.setUser(newUser); // 주인 쪽 설정
newUser.setUserProfile(profile); // 양방향 설정
return userRepository.save(newUser); // 회원가입 저장
});
// 시큐리티에서 사용할 인증 객체 생성
CustomUserDetails customUserDetails = new CustomUserDetails(user);
Authentication authentication =
new UsernamePasswordAuthenticationToken(
customUserDetails,
user.getPassword(),
Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())));
// JWT 액세스 & 리프레시 토큰 발급
String accessToken = jwtTokenProvider.generateToken(authentication,jwtAccessTokenExpirationTime);
String refreshToken = jwtTokenProvider.generateToken(authentication,jwtRefreshTokenExpirationTime);
log.info("accessToken = " + accessToken);
log.info("refreshToken = " + refreshToken);
// JWT는 SuccessHandler에서 쿠키/쿼리로 전달 → 여기선 속성에만 담아 둠
Map<String, Object> customAttributes = new HashMap<>(attributes);
customAttributes.put("accessToken", accessToken);
customAttributes.put("refreshToken", refreshToken);
customAttributes.put("id", user.getId()); // ← PK(id) 추가
// Auth 엔티티에 토큰 저장 (User와 1:1 매핑)
Optional<Auth> optionalAuth = authRepository.findByUser(user);
if (optionalAuth.isPresent()) {
Auth auth = optionalAuth.get();
auth.updateAccessToken(accessToken);
auth.updateRefreshToken(refreshToken);
authRepository.save(auth); // 반드시 저장!
user.setAuth(auth); // (option) 연관관계 유지
} else {
// Auth auth = new Auth(user, refreshToken, accessToken, "Bearer");
Auth auth = new Auth(user, refreshToken, accessToken, "Bearer");
authRepository.save(auth);
user.setAuth(auth);
}
// 최종적으로 Spring Security에 전달할 OAuth2User 반환
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())), // 권한
customAttributes, // 속성 정보 (JWT 포함)
"id" // PK로 사용할 식별자 (프론트에서도 사용할 수 있음)
);
}
}
Java
복사
•
RedisOAuth2AuthorizationRequestRepository
package org.example.backendproject.oauth2;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import java.time.Duration;
@RequiredArgsConstructor
public class RedisOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
// Redis에 저장할 때 사용할 key prefix (고유 식별자 역할)
private static final String PREFIX = "oauth2_auth_request:";
// RedisTemplate은 Spring에서 제공하는 Redis 클라이언트
private final RedisTemplate<String, Object> redisTemplate;
// public RedisOAuth2AuthorizationRequestRepository(RedisTemplate<String, Object> redisTemplate) {
// this.redisTemplate = redisTemplate;
// }
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
// 요청 파라미터에서 state 값을 가져옴 (OAuth2의 CSRF 방지 토큰 역할)
String state = request.getParameter("state");
if (state == null) return null;
// Redis에서 해당 state 값을 가진 AuthorizationRequest 조회
return (OAuth2AuthorizationRequest) redisTemplate.opsForValue().get(PREFIX + state);
}
@Override
public void saveAuthorizationRequest(
OAuth2AuthorizationRequest authorizationRequest,
HttpServletRequest request,
HttpServletResponse response) {
// null일 경우 저장하지 않음
if (authorizationRequest == null) return;
// 고유 식별자인 state 값을 키로 사용
String state = authorizationRequest.getState();
// Redis에 10분동안 저장(10분 뒤 삭제됨)
redisTemplate.opsForValue().set(
PREFIX +
state,
authorizationRequest,
Duration.ofMinutes(10));
}
/**
* [3] 인가 요청 삭제하기
* 인증 과정이 끝나면 Redis에서 해당 요청 정보를 제거함
*/
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(
HttpServletRequest request,
HttpServletResponse response) {
String state = request.getParameter("state");
if (state == null) return null;
String key = PREFIX + state;
// Redis에서 해당 요청을 가져오고
OAuth2AuthorizationRequest authRequest = (OAuth2AuthorizationRequest) redisTemplate.opsForValue().get(key);
// 가져온 후 삭제 (한 번 사용 후 만료되므로)
redisTemplate.delete(key);
return authRequest;
}
}
Java
복사
== OAuth2 소셜 로그인에서의 Redis 역할 ==
소셜 로그인을하면 세션이 발생하는데 세션은 하나의 서버에서만 유효한거라 서버 분산 환경에서는 세션이 공유가 되지 않아서 로그인 유지가 안 됨. 그러므로 레디스에 정보를 저장해서 서버들이랑 공유함.
•
JWtTokenFilter
package org.example.backendproject.security.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.backendproject.security.core.CustomUserDetailsService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
// JwtTokenFilter 모든 HTTP 요청을 가로채서 JWT 토큰을 검사하는 필터 역할
// OncePerRequestFilter는 한 요청당 딱 한 번만 실행되는 필터 역할
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService customUserDetailsService;
// HTTP 매 요청마다 호출
@Override
protected void doFilterInternal(HttpServletRequest request, // http 요청
HttpServletResponse response, // http 응답
FilterChain filterChain
) throws ServletException, IOException {
String accessToken = extractTokenFromRequest(request); // 이 부분 수정
if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) {
UsernamePasswordAuthenticationToken authenticationToken = getAuthentication(accessToken);
// 토큰에서 사용자를 꺼내서 담은 사용자 인증 객체
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// http 요청으로부터 부가 정보(ip, 세션 등)를 추출해서 사용자 인증 객체에 넣어줌
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 토큰에서 사용자 인증정보를 조회해서 인증정보를 현재 스레드에 인증된 사용자로 등록
String url = request.getRequestURI().toString();
log.info("현재 들어온 HTTP 요청 = " + url);
String method = request.getMethod(); // GET, POST, PUT ...
log.info("HTTP 메소드 + method = " + method);
}
/**
* CharacterEncodingFilter: 문자 인코딩 처리
* CorsFilter: CORS 정책 처리
* CsrfFilter: CSRF 보안 처리
* JWTTokenFilter: JWT 토큰 처리(핵심)
* SecurityContextFilter: 인증/인가 정보 저장
* ExceptionFilter: 예외 처리
*/
filterChain.doFilter(request, response); // JwtTokenFilter를 거치고 다음 필터로 넘어감 (이동...이동...이동)
}
// HTTP 요청 헤더에서 토큰을 추출하는 메서드
// public String getTokenFromRequest(HttpServletRequest request) {
//
// String token = null;
//
// String bearerToken = request.getHeader("Authorization");
// if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
// token = bearerToken.substring(7);
// }
//
// return token;
// }
... 중략 ...
// 쿠키에서 accessToken을 추출하는 메서드 (추가!)
private String extractTokenFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("accessToken")) {
return cookie.getValue();
}
}
}
return null;
}
}
Java
복사
application.properties 에 google, kakao 관련 정보 추가
#google
spring.security.oauth2.client.registration.google.client-id=
spring.security.oauth2.client.registration.google.client-secret=
#spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/login/oauth2/code/google
spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.google.scope=profile, email
spring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code
#kakao
spring.security.oauth2.client.registration.kakao.client-id=
spring.security.oauth2.client.registration.kakao.client-secret=
spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post
#spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.kakao.scope=profile_nickname, profile_image
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
Java
복사
구글 로그인 테스트
•
구글 로그인 클릭
•
로그인 확인
•
로그아웃 후 메인페이지 복귀
카카오톡 로그인 테스트
•
로그인 하기
•
전체 동의하기 클릭 후 “동의하고 계속하기” 클릭
•
정상 동작 확인
3. 토큰을 제대로 불러오지 못하는 에러
JwtTokenFilter 쪽에 Cookie 쪽 토큰을 바라보는 메서드 추가 후 doFilterInternal 메서드에 적용
private String extractTokenFromRequest(HttpServletRequest request) {
// 1. 쿠키에서 accessToken 확인
String cookieToken = extractTokenFromCookie(request);
if (StringUtils.hasText(cookieToken)) {
return cookieToken;
}
// 2. Authorization 헤더에서 토큰 확인
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // "Bearer " 제거
}
return null;
}
// HTTP 매 요청마다 호출
@Override
protected void doFilterInternal(HttpServletRequest request, // http 요청
HttpServletResponse response, // http 응답
FilterChain filterChain
) throws ServletException, IOException {
String accessToken = extractTokenFromRequest(request); // 요청 헤더에서 토큰 추출
if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) {
UsernamePasswordAuthenticationToken authenticationToken = getAuthentication(accessToken);
// 토큰에서 사용자를 꺼내서 담은 사용자 인증 객체
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// http 요청으로부터 부가 정보(ip, 세션 등)를 추출해서 사용자 인증 객체에 넣어줌
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 토큰에서 사용자 인증정보를 조회해서 인증정보를 현재 스레드에 인증된 사용자로 등록
String url = request.getRequestURI().toString();
log.info("현재 들어온 HTTP 요청 = " + url);
String method = request.getMethod(); // GET, POST, PUT ...
log.info("HTTP 메소드 + method = " + method);
}
/**
* CharacterEncodingFilter: 문자 인코딩 처리
* CorsFilter: CORS 정책 처리
* CsrfFilter: CSRF 보안 처리
* JWTTokenFilter: JWT 토큰 처리(핵심)
* SecurityContextFilter: 인증/인가 정보 저장
* ExceptionFilter: 예외 처리
*/
filterChain.doFilter(request, response); // JwtTokenFilter를 거치고 다음 필터로 넘어감 (이동...이동...이동)
}
Java
복사
4. 모니터링 실행
build.gradle 설정
// Spring Boot Actuator 매트릭/모니터링
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// 프로메테우스
implementation 'io.micrometer:micrometer-registry-prometheus'
Shell
복사
application-properties 설정
# actuator and metric and prometheus
# prometheus 전용 앤드포인트 생성
management.prometheus.metrics.export.enabled=true
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
# never -> 절대 상세 정보 제공 안함 (항상 status만 응답)
# when-authorized -> 인증된 사용자/로컬 요청에만 상세 정보 제공 (기본값, 권장)
# always -> 항상 상세 정보 포함 (외부/내부 관계없이 상세 정보 노출, 개발·테스트용)
Shell
복사
Docker-compose yml 파일로 Grafana, Prometheus 세팅
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml # [설정파일]
- ./volumes/prometheus:/prometheus # [데이터 볼륨]
# depends_on:
# - backend1
# - backend2
# - backend3
networks:
- prod_server
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
ports:
- "3000:3000"
depends_on:
- prometheus
volumes:
- ./volumes/grafana:/var/lib/grafana # [데이터 볼륨]
networks:
- prod_server
#
# elasticsearch:
# image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
# container_name: elasticsearch
# environment:
# - node.name=es01
# - discovery.type=single-node
# - xpack.security.enabled=false
# ulimits:
# memlock:
# soft: -1
# hard: -1
# ports:
# - "9200:9200"
# - "9300:9300"
# volumes:
# - ./volumes/esdata:/usr/share/elasticsearch/data
# networks:
# - prod_server
##
##
## kibana:
## image: docker.elastic.co/kibana/kibana:8.12.0
## container_name: kibana
## environment:
## - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
## - SERVER_SSL_ENABLED=false
## ports:
## - "5601:5601"
## volumes:
## - ./volumes/kibana-data:/usr/share/kibana/data
## depends_on:
## - elasticsearch
## networks:
## - prod_server
##
## logstash:
## image: docker.elastic.co/logstash/logstash:8.12.0
## container_name: logstash
## ports:
## - "5044:5044" # For beats (optional)
## - "5000:5000" # TCP input
## volumes:
## - ./logstash/logstash.conf:/usr/share/logstash/pipeline/logstash.conf #logstash 설정파일
## - ./logstash/logstash.yml:/usr/share/logstash/config/logstash.yml:ro # <-- 이 줄 추가!
## - ../../logs:/logs #로그 볼륨
## depends_on:
## - elasticsearch
## networks:
## - prod_server
networks:
prod_server:
external: true
#도커 자체 볼륨을 사용할떄 선언해야 함
#volumes:
# volumes:
Shell
복사
•
docker-compose 실행
Docker Container 확인
•
•
admin/admin으로 로그인 후 비밀번호 설정 → 1234
•
localhost:9090 - 프로메테우스 접속
•
SpringBoot 서버 실행한 이후에 Status > Target Health 메뉴에서 다음 화면 확인
◦
SpringBoot 켜져있지 않으면 모두 Down 처리 되어있음
•
Data sources에서 다음과 같이 설정
•
대시보드 만들기 > Import a dashboard
•
만들어진 Dashboard 확인
•
•
spring 입력 > JVM 클릭 > Download JSON
•
다운받은 json 파일을 Import 하기
•
Import 후 다음과 같이 설정
•
대시보드 내용 확인
5. 오늘 푸시한 커밋리스트
날짜 | 커밋 메시지 |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 | |
2025-06-26 |