application.yml
frontend:
oauth:
# 소셜 로그인 성공 후 토큰을 전달하며 리다이렉션될 Vue 페이지 경로
callback-url: http://localhost:8081/oauth/callback # 예시: Vue 개발 서버 + 콜백 라우트
reset-password-url: http://localhost:8081/reset-password # 실제 사용할 프론트엔드 URL로 변경
WebConfig
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**") // /api/** 경로에 대해 CORS 적용
.allowedOrigins("http://localhost:8081", "http://127.0.0.1:8081") // 허용할 출처 (Vue 개발 서버 주소)
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") // 허용할 HTTP 메서드
.allowedHeaders("*") // 모든 헤더 허용
.allowCredentials(true) // 인증 정보(쿠키, JWT 등) 허용
.maxAge(3600); // pre-flight 요청 캐시 시간 (초)
// 필요시 다른 경로 추가 (예: /oauth2/**)
registry.addMapping("/oauth2/**")
.allowedOrigins("http://localhost:8081", "http://127.0.0.1:8081")
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
registry.addMapping("/login/oauth2/code/**")
.allowedOrigins("http://localhost:8081", "http://127.0.0.1:8081")
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
/api/** 경로: http://localhost:8081 또는 http://127.0.0.1:8081 (주로 Vue 개발 서버)에서 오는 모든 종류의 API 요청(GET, POST, PUT, PATCH, DELETE, OPTIONS)을 허용한다. 모든 헤더와 인증 정보(쿠키, JWT 토큰 등) 전송도 허용한다.
/oauth2/** 경로: 소셜 로그인을 시작하는 경로에 대해서도 동일한 출처에서의 GET, POST, OPTIONS 요청 및 인증 정보 전송을 허용한다.
/login/oauth2/code/** 경로: 소셜 로그인 후 백엔드로 돌아오는 콜백 경로에 대해서도 동일한 출처에서의 GET, POST, OPTIONS 요청 및 인증 정보 전송을 허용한다.
결론적으로, 이 설정은 포트 8081에서 실행되는 프론트엔드(Vue) 애플리케이션이 백엔드 서버의 API 및 OAuth2 관련 기능과 안전하게 통신할 수 있도록 허용하는 역할을 한다.
WebSecurityConfig
// 요청별 인가 설정
.authorizeHttpRequests(authorize -> authorize
// 공개 경로 설정
// 정적 리소스, 기본 페이지, 인증/OAuth 관련 API 등 명시적 허용
.requestMatchers("/", "/index.html", "/favicon.ico", "/static/**", "/assets/**", "/js/**", "/css/**" ).permitAll()
// // --- Vue Router가 처리할 경로들 ---
.requestMatchers("/reset-password", "/oauth/callback").permitAll()
.requestMatchers("/api/auth/**").permitAll() // 일반 로그인/회원가입 API
.requestMatchers("/oauth2/**").permitAll() // OAuth2 로그인 시작 URL (e.g., /oauth2/authorization/google)
.requestMatchers("/login/oauth2/code/**").permitAll() // OAuth2 리다이렉션 URI
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 허용
.requestMatchers("/h2-console/**").permitAll() // H2 콘솔 허용
// /api 로 시작하는 경로는 인증 요구 (위에서 permitAll된 /api/auth/** 제외)
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll() // 나머지 요청은 허용
)
API 경로 외 명시적으로 허용되지 않은 경로는 permitAll() 처리하여, 해당 요청이 프론트엔드로 전달되고 Vue 라우터가 클라이언트 사이드 라우팅을 처리할 수 있도록 한다.
OAuth2LoginSuccessHandler
@Slf4j
@Component
@Transactional // 트랜잭션 추가 (사용자 생성/업데이트, 리프레시 토큰 저장)
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
private final RefreshTokenService refreshTokenService;
private final UserRepository userRepository;
private final ObjectMapper objectMapper; // 오류 응답 전송 시 필요할 수 있음
// 프론트엔드 콜백 URL 주입
@Value("${frontend.oauth.callback-url}")
private String frontendCallbackUrl;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("OAuth2 Login Success Handler: Authentication successful.");
clearAuthenticationAttributes(request); // 이전 인증 정보 클리어
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Map<String, Object> attributesMap = oAuth2User.getAttributes();
String registrationId = ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId();
log.debug("소셜 로그인 - provider: {}, attributes: {}", registrationId, attributesMap);
User user;
try {
// 네이버 로그인 특별 처리 (응답 구조가 다를 수 있음)
if ("naver".equals(registrationId)) {
user = processNaverLogin(attributesMap);
} else { // 구글, 카카오 등 다른 소셜 로그인 처리
String userNameAttributeName = getUserNameAttributeName(registrationId);
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, attributesMap);
user = findOrCreateUser(attributes);
}
} catch (Exception e) {
log.error("소셜 로그인 사용자 처리 중 오류 발생: {}", e.getMessage(), e);
// 오류 발생 시 사용자에게 알릴 오류 페이지로 리다이렉트하거나 오류 응답 전송
sendErrorResponse(response, "소셜 로그인 처리 중 오류가 발생했습니다.");
return;
}
// 사용자 정보 기반으로 JWT 생성 및 프론트엔드로 리다이렉션
sendTokenViaRedirect(user, request, response);
}
// 네이버 로그인 처리 로직
private User processNaverLogin(Map<String, Object> attributesMap) {
Map<String, Object> responseData;
if (attributesMap.containsKey("response")) {
responseData = (Map<String, Object>) attributesMap.get("response");
} else {
responseData = attributesMap; // 최상위 레벨에 데이터가 있는 경우
}
if (responseData == null || !responseData.containsKey("id")) {
log.error("네이버 응답에서 사용자 ID를 찾을 수 없습니다");
throw new IllegalStateException("네이버 로그인 처리 중 필수 정보(ID) 누락");
}
String providerId = String.valueOf(responseData.get("id"));
String email = (String) responseData.get("email");
String name = (String) responseData.get("name");
Optional<User> existingUser = userRepository.findByProviderAndProviderId("naver", providerId);
if (existingUser.isPresent()) {
User user = existingUser.get();
if (name != null) { // 이름 정보가 있으면 업데이트
user.updateOAuthInfo(name);
}
log.info("기존 네이버 사용자 로그인: providerId={}", providerId);
return user;
} else {
// 이메일 중복 체크 및 처리 (선택적이지만 권장)
String userEmail = email != null ? email : providerId + "@naver.com"; // 이메일 없으면 임시 생성
if (email != null && userRepository.existsByEmail(email)) {
log.warn("네이버 로그인 시도: 이미 가입된 이메일 {}", email);
// 이미 가입된 이메일에 네이버 계정 정보를 연결하거나, 에러 처리
// 예: 기존 계정에 provider 정보 업데이트
User userByEmail = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalStateException("이메일 존재하나 사용자 못찾음: " + email)); // 비정상 케이스
userByEmail.updateProvider("naver", providerId);
if (name != null) userByEmail.updateOAuthInfo(name);
log.info("기존 이메일({}) 계정에 네이버 계정(ID:{}) 연결", email, providerId);
return userByEmail;
// 또는 throw new IllegalStateException("이미 가입된 이메일입니다: " + email);
}
// 신규 사용자 등록
User newUser = User.builder()
.name(name != null ? name : "네이버 사용자") // 이름 없으면 기본값
.email(userEmail)
.password(UUID.randomUUID().toString()) // 비밀번호는 임의값 사용
.role(UserRole.ROLE_USER)
.enabled(true)
.provider("naver")
.providerId(providerId)
.build();
userRepository.save(newUser);
log.info("새 네이버 사용자 등록: providerId={}", providerId);
return newUser;
}
}
// 구글, 카카오 등 사용자 찾기 또는 생성 메서드
private User findOrCreateUser(OAuthAttributes attributes) {
Optional<User> existingUser = userRepository.findByProviderAndProviderId(
attributes.provider(), attributes.providerId());
if (existingUser.isPresent()) {
User user = existingUser.get();
user.updateOAuthInfo(attributes.name()); // 이름 등 변경사항 업데이트
log.info("기존 {} 사용자 로그인: providerId={}, email={}",
attributes.provider(), attributes.providerId(), user.getEmail());
return user;
}
// 이메일로 기존 사용자 찾기 (다른 방식으로 가입했을 수 있음)
if (attributes.email() != null) {
Optional<User> userByEmail = userRepository.findByEmail(attributes.email());
if (userByEmail.isPresent()) {
User user = userByEmail.get();
// 기존 계정에 소셜 로그인 정보 연결
user.updateProvider(attributes.provider(), attributes.providerId());
user.updateOAuthInfo(attributes.name()); // 이름 업데이트
log.info("기존 이메일({}) 사용자에 {} 계정 연결: providerId={}",
user.getEmail(), attributes.provider(), attributes.providerId());
return user;
}
}
// 신규 사용자 생성
User newUser = attributes.toEntity(); // OAuthAttributes에서 User 엔티티 생성
userRepository.save(newUser);
log.info("새 {} 사용자 등록: providerId={}, email={}",
attributes.provider(), attributes.providerId(), newUser.getEmail());
return newUser;
}
// JWT 토큰 생성 및 프론트엔드 콜백 URL로 리다이렉션
private void sendTokenViaRedirect(User user, HttpServletRequest request, HttpServletResponse response) throws IOException {
// 1. CustomUserDetails 생성 (JWT 페이로드에 넣을 정보 포함)
CustomUserDetails userDetails = new CustomUserDetails(
user.getId(),
user.getEmail(),
user.getPassword(), // 비밀번호는 JWT 생성 시 직접 사용되지는 않음
user.getRole().name(),
user.isEnabled()
);
// 2. Authentication 객체 생성 (JWT 생성용)
Authentication jwtAuthentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
// 3. JWT 토큰 생성 (Access Token, Refresh Token)
String accessToken = jwtProvider.createAccessToken(jwtAuthentication);
String refreshToken = jwtProvider.createRefreshToken(jwtAuthentication);
// 4. Refresh Token DB 저장/업데이트
refreshTokenService.saveRefreshToken(refreshToken, user.getId());
// 5. 토큰 URL 인코딩 (URL에 포함시키기 위함)
String encodedAccessToken = URLEncoder.encode(accessToken, StandardCharsets.UTF_8);
String encodedRefreshToken = URLEncoder.encode(refreshToken, StandardCharsets.UTF_8);
// 6. 프론트엔드 콜백 URL 생성 (토큰 포함)
String targetUrl = UriComponentsBuilder.fromUriString(frontendCallbackUrl)
.queryParam("accessToken", encodedAccessToken)
.queryParam("refreshToken", encodedRefreshToken)
// 필요시 추가 정보 전달 (예: 최초 로그인 여부 등)
// .queryParam("isNewUser", ...)
.build().toUriString();
// 7. 프론트엔드로 리다이렉션
getRedirectStrategy().sendRedirect(request, response, targetUrl);
log.info("OAuth2 로그인 성공, 프론트엔드({})로 토큰과 함께 리다이렉션 완료.", targetUrl);
}
// registrationId 기반으로 userNameAttributeName 결정
private String getUserNameAttributeName(String registrationId) {
// OIDC 표준은 'sub'를 사용.
return switch (registrationId.toLowerCase()) {
case "google" -> "sub";
case "kakao" -> "sub";
case "naver" -> "response"; // 네이버는 고유 구조 사용 (실제 ID는 response 객체 내 'id')
default -> throw new IllegalArgumentException("지원하지 않는 소셜 로그인입니다: " + registrationId);
};
}
// 오류 발생 시 응답 전송
private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
// 간단한 오류 메시지만 전달하거나, ResponseDTO 사용
ResponseDTO<Void> responseDTO = ResponseDTO.error(HttpServletResponse.SC_BAD_REQUEST, message);
response.getWriter().write(objectMapper.writeValueAsString(responseDTO));
response.getWriter().flush();
}
}
전에 작성했던 코드에서 최적화도 해주었다.
프론트엔드로 리다이렉션
생성된 Access Token과 Refresh Token을 URL 쿼리 파라미터 형태로 포함하여, 사전에 설정된 프론트엔드(Vue)의 특정 콜백 URL (rontend.oauth.callback-url 값)로 사용자의 브라우저를 리다이렉션 시킨다. (토큰 값은 URL 인코딩 처리됨)
SocialLoginDocController
@Tag(name = "소셜 로그인 (OAuth2)", description = "소셜 로그인 시작 (문서용)")
@RestController
public class SocialLoginDocController {
@Operation(summary = "소셜 로그인 시작 (Google, Kakao, Naver 등)",
description = "이 경로로 GET 요청을 보내면(브라우저에서 링크 클릭) 해당 소셜 서비스 로그인 페이지로 리다이렉션됩니다. API 클라이언트에서 직접 호출하는 용도가 아닙니다." +
"http://localhost:8081/oauth/callback로 콜백 요청을 보냅니다.")
@GetMapping("/oauth2/authorization/{provider}")
public void socialLoginRedirect(@PathVariable String provider) {
// 실제 로직은 없음. Spring Security가 처리.
// 이 메서드는 Swagger 문서 생성을 위해 존재.
}
}
Swagger 문서 표기를 위한 목적이기 때문에 로직이 없다.
sendPasswordResetEmail
// 프론트엔드 비밀번호 재설정 URL 주입
@Value("${frontend.reset-password-url}")
private String frontendResetPasswordUrl;
public void sendPasswordResetEmail(String to, String resetToken) {
String subject = "FreeMarket 비밀번호 재설정";
String resetUrl = frontendResetPasswordUrl + "?token=" + resetToken;
String content = """
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>비밀번호 재설정</h2>
<p>안녕하세요. FreeMarket 비밀번호 재설정을 요청하셨습니다.</p>
<p>아래 링크를 클릭하여 비밀번호를 재설정해 주세요:</p>
<p><a href="%s" style="display: inline-block; padding: 10px 20px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 5px;">비밀번호 재설정</a></p>
<p>이 링크는 30분 동안만 유효합니다.</p>
<p>비밀번호 재설정을 요청하지 않으셨다면 이 이메일을 무시하셔도 됩니다.</p>
<p>감사합니다.<br>FreeMarket 팀</p>
</div>
""".formatted(resetUrl);
sendEmail(to, subject, content);
}
이메일에 들어가서 비밀번호 재설정 버튼을 누르게 되면 yml에서 설정 해둔 url로 이동하게 된다. 리다이렉트 된 url에서 새 비밀번호를 입력폼에 입력하고 REST API를 호출한다.
경로 (Endpoint): /api/auth/password/reset-verify