diff --git a/src/main/java/com/example/eatmate/global/auth/jwt/JwtAuthenticationProcessingFilter.java b/src/main/java/com/example/eatmate/global/auth/jwt/JwtAuthenticationProcessingFilter.java index 2c3a6d39..b67cad23 100644 --- a/src/main/java/com/example/eatmate/global/auth/jwt/JwtAuthenticationProcessingFilter.java +++ b/src/main/java/com/example/eatmate/global/auth/jwt/JwtAuthenticationProcessingFilter.java @@ -1,7 +1,6 @@ package com.example.eatmate.global.auth.jwt; import java.io.IOException; -import java.util.Arrays; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -18,23 +17,23 @@ import jakarta.servlet.FilterChain; 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; /** - * "/login" 이외의 URI 요청이 왔을 때 처리하는 필터 - * + * JWT 인증 필터 (헤더 기반으로 변경) * + * 1. RefreshToken이 있으면 DB에서 검증 후 AccessToken + RefreshToken 재발급 (RTR 방식) + * 2. RefreshToken이 없으면 AccessToken을 검증하여 인증 처리 */ @RequiredArgsConstructor @Slf4j @Component public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { - private static final String NO_CHECK_URL = "/login"; // 로그인 요청은 필터 제외 + private static final String NO_CHECK_URL = "/login"; // "/login" 요청은 필터 제외 private final JwtService jwtService; private final MemberRepository memberRepository; @@ -44,58 +43,62 @@ public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + String requestURI = request.getRequestURI(); log.info("Processing request URI: {}", request.getRequestURI()); + // "/login" 요청은 필터 제외 if (request.getRequestURI().equals(NO_CHECK_URL)) { - filterChain.doFilter(request, response); // /login 요청은 필터 제외 + filterChain.doFilter(request, response); return; } - if (request.getRequestURI().startsWith("/ws/chat")) { - log.info("WebSocket 핸드셰이크 요청 감지: 필터를 통과시킴 : {}", request.getRequestURI()); + // ✅ OAuth2 로그인 요청은 JWT 인증 제외 + if (requestURI.startsWith("/api/auth/google")) { + log.info("OAuth2 로그인 요청 감지, JWT 인증 필터 적용 제외: {}", requestURI); filterChain.doFilter(request, response); return; } - // 요청에서 쿠키 추출 및 디버깅 로그 - log.info("Request Cookies: {}", Arrays.toString(request.getCookies())); + // WebSocket 요청 필터 제외 + if (request.getRequestURI().startsWith("/ws/chat")) { + log.info("WebSocket 핸드셰이크 요청 감지: 필터를 통과시킴 : {}", request.getRequestURI()); + filterChain.doFilter(request, response); + return; + } - // Refresh Token 처리 - String refreshToken = jwtService.extractRefreshTokenFromCookie(request) + // Refresh Token 검사 + String refreshToken = jwtService.extractRefreshToken(request) .filter(jwtService::isTokenValid) .orElse(null); if (refreshToken != null) { log.info("Valid RefreshToken found. Processing token renewal..."); handleRefreshToken(response, refreshToken); + return; // Refresh Token을 재발급 후 인증은 처리하지 않음 } - // Access Token 처리 - try { - handleAccessToken(request, response, filterChain); - } catch (Exception e) { - log.error("Error while handling Access Token: {}", e.getMessage(), e); - sendErrorResponse(response, "Invalid Access Token"); - } + // RefreshToken이 없으면 Access Token 검사 + checkAccessTokenAndAuthenticate(request, response, filterChain); } /** - * Refresh Token 처리 및 Access Token 재발급 + * [리프레시 토큰 확인 후 Access Token + Refresh Token 재발급] */ private void handleRefreshToken(HttpServletResponse response, String refreshToken) { memberRepository.findByRefreshToken(refreshToken) .ifPresentOrElse( member -> { + // 새 Access Token & Refresh Token 발급 String newAccessToken = jwtService.createAccessToken(member.getEmail(), member.getRole().name(), member.getRole() == Role.USER ? member.getGender().name() : null); String newRefreshToken = jwtService.createRefreshToken(); + + // DB에 새 Refresh Token 저장 member.updateRefreshToken(newRefreshToken); memberRepository.saveAndFlush(member); - // 쿠키에 토큰 설정 - setTokenInCookie(response, "AccessToken", newAccessToken, - jwtService.getAccessTokenExpirationPeriod()); - setTokenInCookie(response, "RefreshToken", newRefreshToken, - jwtService.getRefreshTokenExpirationPeriod()); + // 응답 헤더에 토큰 추가 + jwtService.sendAccessAndRefreshToken(response, newAccessToken, newRefreshToken); log.info("Refresh Token 유효. Access Token 및 Refresh Token 재발급 완료."); }, @@ -104,29 +107,31 @@ private void handleRefreshToken(HttpServletResponse response, String refreshToke } /** - * Access Token 처리 및 SecurityContext 설정 + * [Access Token 검사 및 인증 수행] */ - private void handleAccessToken(HttpServletRequest request, HttpServletResponse response, + private void checkAccessTokenAndAuthenticate(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - jwtService.extractAccessTokenFromCookie(request) + + jwtService.extractAccessToken(request) .filter(jwtService::isTokenValid) // AccessToken 유효성 검증 .flatMap(jwtService::extractEmail) // 유효한 AccessToken에서 Email 추출 .flatMap(memberRepository::findByEmail) // 이메일로 Member 조회 .ifPresentOrElse( member -> { log.info("Authentication successful for user: {}", member.getEmail()); - setAuthentication(member); // 인증 정보 SecurityContext에 저장 + saveAuthentication(member); // SecurityContext에 인증 정보 저장 }, () -> log.warn("Failed to authenticate user. Either token is invalid or member not found.") ); + // 필터 체인 계속 진행 filterChain.doFilter(request, response); } /** - * 인증 정보 SecurityContext에 저장 + * [SecurityContext에 인증 정보 저장] */ - private void setAuthentication(Member member) { + private void saveAuthentication(Member member) { UserDetails userDetails = org.springframework.security.core.userdetails.User.builder() .username(member.getEmail()) .password("") // 비밀번호는 사용하지 않으므로 빈 문자열 @@ -141,7 +146,7 @@ private void setAuthentication(Member member) { } /** - * 에러 응답 전송 + * [에러 응답 전송] */ private void sendErrorResponse(HttpServletResponse response, String message) { try { @@ -152,17 +157,4 @@ private void sendErrorResponse(HttpServletResponse response, String message) { log.error("에러 응답 전송 중 오류 발생", e); } } - - /** - * 쿠키에 토큰 설정 - */ - private void setTokenInCookie(HttpServletResponse response, String name, String token, long maxAge) { - Cookie cookie = new Cookie(name, token); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/"); - cookie.setMaxAge((int)(maxAge / 1000)); // maxAge는 초 단위로 설정 - response.addCookie(cookie); - log.info("{} 쿠키 설정 완료", name); - } } diff --git a/src/main/java/com/example/eatmate/global/auth/jwt/JwtHandshakeInterceptor.java b/src/main/java/com/example/eatmate/global/auth/jwt/JwtHandshakeInterceptor.java index fb80d197..1e9fab7b 100644 --- a/src/main/java/com/example/eatmate/global/auth/jwt/JwtHandshakeInterceptor.java +++ b/src/main/java/com/example/eatmate/global/auth/jwt/JwtHandshakeInterceptor.java @@ -1,7 +1,6 @@ package com.example.eatmate.global.auth.jwt; import java.util.Map; -import java.util.Optional; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; @@ -12,7 +11,6 @@ import com.example.eatmate.app.domain.member.domain.repository.MemberRepository; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,34 +26,50 @@ public class JwtHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) throws Exception { + if (request instanceof ServletServerHttpRequest) { HttpServletRequest servletRequest = ((ServletServerHttpRequest)request).getServletRequest(); log.info("WebSocket 요청 감지: {}", servletRequest.getRequestURI()); - //요청된 쿠키 확인 - if (servletRequest.getCookies() != null) { - for (Cookie cookie : servletRequest.getCookies()) { - log.info("쿠키 확인: {} = {}", cookie.getName(), cookie.getValue()); + // [1] Authorization 헤더에서 토큰 추출 + String authHeader = servletRequest.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); // "Bearer " 제거 + + // [2] 토큰 유효성 검사 및 사용자 정보 추출 + if (jwtService.isTokenValid(token)) { + jwtService.extractEmail(token) + .flatMap(memberRepository::findByEmail) + .ifPresentOrElse(member -> { + log.info("WebSocket 인증 성공! 사용자: {}", member.getEmail()); + attributes.put("userDetails", member); // WebSocket 세션에 사용자 정보 저장 + }, () -> { + log.warn("WebSocket 인증 실패: 토큰에서 이메일 추출 실패 또는 회원 미존재"); + }); + } else { + log.warn("WebSocket 인증 실패: 토큰 유효하지 않음"); } } else { - log.warn("WebSocket 요청에 쿠키 없음!"); + log.warn("WebSocket 요청에 Authorization 헤더가 없거나 Bearer 스키마가 아님"); } - //쿠키에서 AccessToken 추출 - Optional tokenOptional = jwtService.extractAccessTokenFromCookie(servletRequest); - - tokenOptional - .filter(jwtService::isTokenValid) - .flatMap(jwtService::extractEmail) - .flatMap(memberRepository::findByEmail) - .ifPresentOrElse(member -> { - log.info("WebSocket 인증 성공! 사용자: {}", member.getEmail()); - attributes.put("userDetails", member); //WebSocket 세션에 사용자 정보 저장 - }, () -> { - log.warn("WebSocket 인증 실패: JWT 없음 또는 유효하지 않음"); - }); + // [기존 쿠키 기반 로직 제거 혹은 주석 처리] + // if (servletRequest.getCookies() != null) { + // for (Cookie cookie : servletRequest.getCookies()) { + // log.info("쿠키 확인: {} = {}", cookie.getName(), cookie.getValue()); + // } + // } else { + // log.warn("WebSocket 요청에 쿠키 없음!"); + // } + // Optional tokenOptional = jwtService.extractAccessToken(servletRequest); + // tokenOptional + // .filter(jwtService::isTokenValid) + // .flatMap(jwtService::extractEmail) + // .flatMap(memberRepository::findByEmail) + // .ifPresentOrElse(...); } + return true; } diff --git a/src/main/java/com/example/eatmate/global/auth/jwt/JwtService.java b/src/main/java/com/example/eatmate/global/auth/jwt/JwtService.java index 552af3c6..9e0e164c 100644 --- a/src/main/java/com/example/eatmate/global/auth/jwt/JwtService.java +++ b/src/main/java/com/example/eatmate/global/auth/jwt/JwtService.java @@ -1,6 +1,5 @@ package com.example.eatmate.global.auth.jwt; -import java.util.Arrays; import java.util.Date; import java.util.Optional; @@ -17,8 +16,8 @@ import com.example.eatmate.app.domain.member.domain.repository.MemberRepository; import com.example.eatmate.global.config.error.exception.custom.UserNotFoundException; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -33,6 +32,7 @@ public class JwtService { private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; private static final String EMAIL_CLAIM = "email"; private static final String ROLE_CLAIM = "role"; + private static final String BEARER = "Bearer"; private final MemberRepository memberRepository; @Value("${jwt.secretKey}") private String secretKey; @@ -40,6 +40,11 @@ public class JwtService { private Long accessTokenExpirationPeriod; @Value("${jwt.refresh.expiration}") private Long refreshTokenExpirationPeriod; + @Value("${jwt.access.header}") + private String accessHeader; + + @Value("${jwt.refresh.header}") + private String refreshHeader; /** * 토큰 생성 메서드 @@ -77,30 +82,6 @@ public String createRefreshToken() { return createToken(REFRESH_TOKEN_SUBJECT, refreshTokenExpirationPeriod, null, null, null); } - /** - * 공통: 쿠키에서 토큰 추출 - */ - private Optional extractTokenFromCookie(HttpServletRequest request, String cookieName) { - return Arrays.stream(Optional.ofNullable(request.getCookies()).orElse(new Cookie[0])) - .filter(cookie -> cookie.getName().equals(cookieName)) - .map(Cookie::getValue) - .findFirst(); - } - - /** - * 쿠키에서 Access Token 추출 - */ - public Optional extractAccessTokenFromCookie(HttpServletRequest request) { - return extractTokenFromCookie(request, "AccessToken"); - } - - /** - * 쿠키에서 Refresh Token 추출 - */ - public Optional extractRefreshTokenFromCookie(HttpServletRequest request) { - return extractTokenFromCookie(request, "RefreshToken"); - } - /** * Access Token에서 Email 추출 */ @@ -168,4 +149,63 @@ public void updateRefreshToken(String email, String refreshToken) { throw new UserNotFoundException(); }); } + + /** + * AccessToken 헤더에 실어서 보내기 + */ + public void sendAccessToken(HttpServletResponse response, String accessToken) { + response.setStatus(HttpServletResponse.SC_OK); + + response.setHeader(accessHeader, accessToken); + log.info("재발급된 Access Token : {}", accessToken); + } + + /** + * AccessToken + RefreshToken 헤더에 실어서 보내기 + */ + public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) { + response.setStatus(HttpServletResponse.SC_OK); + + setAccessTokenHeader(response, accessToken); + setRefreshTokenHeader(response, refreshToken); + log.info("Access Token, Refresh Token 헤더 설정 완료"); + } + + /** + * 헤더에서 RefreshToken 추출 + * 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서 + * 헤더를 가져온 후 "Bearer"를 삭제(""로 replace) + */ + public Optional extractRefreshToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(refreshHeader)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER, "")); + } + + /** + * 헤더에서 AccessToken 추출 + * 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서 + * 헤더를 가져온 후 "Bearer"를 삭제(""로 replace) + */ + public Optional extractAccessToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(accessHeader)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER + " ", "")); + + } + + /** + * AccessToken 헤더 설정 (Bearer 포함) + */ + public void setAccessTokenHeader(HttpServletResponse response, String accessToken) { + response.setHeader(accessHeader, "Bearer " + accessToken); + } + + /** + * RefreshToken 헤더 설정 (Bearer 포함) + */ + public void setRefreshTokenHeader(HttpServletResponse response, String refreshToken) { + response.setHeader(refreshHeader, "Bearer " + refreshToken); + } + } diff --git a/src/main/java/com/example/eatmate/global/auth/login/controller/AuthController.java b/src/main/java/com/example/eatmate/global/auth/login/controller/AuthController.java index 77083692..0659fc26 100644 --- a/src/main/java/com/example/eatmate/global/auth/login/controller/AuthController.java +++ b/src/main/java/com/example/eatmate/global/auth/login/controller/AuthController.java @@ -7,14 +7,18 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import com.example.eatmate.app.domain.member.domain.repository.MemberRepository; import com.example.eatmate.app.domain.member.dto.MemberSignUpRequestDto; import com.example.eatmate.app.domain.member.service.MemberService; import com.example.eatmate.global.auth.jwt.JwtService; +import com.example.eatmate.global.auth.login.dto.OAuthTokenResponseDto; import com.example.eatmate.global.auth.login.dto.UserLoginResponseDto; +import com.example.eatmate.global.auth.login.oauth.GoogleOAuth2Service; import com.example.eatmate.global.auth.login.service.LoginService; import com.example.eatmate.global.response.GlobalResponseDto; @@ -33,6 +37,8 @@ public class AuthController { private final MemberService memberService; private final JwtService jwtService; private final LoginService loginService; + private final MemberRepository memberRepository; + private final GoogleOAuth2Service googleOAuth2Service; @PostMapping("/signup") @Operation(summary = "회원가입", description = "회원가입을 합니다.") @@ -59,4 +65,25 @@ public ResponseEntity> getUserInfo(HttpS return ResponseEntity.ok(GlobalResponseDto.success(userInfo, HttpStatus.OK.value())); } + + @PostMapping("/google") + public ResponseEntity googleOAuthLogin(@RequestParam("code") String code) { + if (code == null || code.isEmpty()) { + return ResponseEntity.badRequest().body("인가 코드가 없습니다."); + } + + log.info("Google OAuth2 로그인 요청, 원본 인가코드: {}", code); + + OAuthTokenResponseDto tokenResponse = googleOAuth2Service.getGoogleAccessToken(code); + String googleAccessToken = tokenResponse.getAccessToken(); // 정상적으로 접근 가능 + + log.info("Google Access Token 발급 완료: {}", googleAccessToken); + + // Google 사용자 정보 가져오기 + UserLoginResponseDto response = googleOAuth2Service.processGoogleUserLogin(googleAccessToken); + + return ResponseEntity.ok().body(response); + } + } + diff --git a/src/main/java/com/example/eatmate/global/auth/login/dto/OAuthTokenResponseDto.java b/src/main/java/com/example/eatmate/global/auth/login/dto/OAuthTokenResponseDto.java new file mode 100644 index 00000000..d3090dab --- /dev/null +++ b/src/main/java/com/example/eatmate/global/auth/login/dto/OAuthTokenResponseDto.java @@ -0,0 +1,24 @@ +package com.example.eatmate.global.auth.login.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; + +@Getter +public class OAuthTokenResponseDto { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in") + private int expiresIn; + + @JsonProperty("scope") + private String scope; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("id_token") + private String idToken; +} \ No newline at end of file diff --git a/src/main/java/com/example/eatmate/global/auth/login/dto/UserLoginResponseDto.java b/src/main/java/com/example/eatmate/global/auth/login/dto/UserLoginResponseDto.java index dc0999be..34a578db 100644 --- a/src/main/java/com/example/eatmate/global/auth/login/dto/UserLoginResponseDto.java +++ b/src/main/java/com/example/eatmate/global/auth/login/dto/UserLoginResponseDto.java @@ -15,4 +15,6 @@ public class UserLoginResponseDto { private String email; private Role role; private Gender gender; + private String accessToken; + private String refreshToken; } diff --git a/src/main/java/com/example/eatmate/global/auth/login/oauth/GoogleOAuth2Properties.java b/src/main/java/com/example/eatmate/global/auth/login/oauth/GoogleOAuth2Properties.java new file mode 100644 index 00000000..f91e3884 --- /dev/null +++ b/src/main/java/com/example/eatmate/global/auth/login/oauth/GoogleOAuth2Properties.java @@ -0,0 +1,17 @@ +package com.example.eatmate.global.auth.login.oauth; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "spring.security.oauth2.client.registration.google") +public class GoogleOAuth2Properties { + private String clientId; + private String clientSecret; + private String redirectUri; +} diff --git a/src/main/java/com/example/eatmate/global/auth/login/oauth/GoogleOAuth2Service.java b/src/main/java/com/example/eatmate/global/auth/login/oauth/GoogleOAuth2Service.java new file mode 100644 index 00000000..7341e1c2 --- /dev/null +++ b/src/main/java/com/example/eatmate/global/auth/login/oauth/GoogleOAuth2Service.java @@ -0,0 +1,118 @@ +package com.example.eatmate.global.auth.login.oauth; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import com.example.eatmate.app.domain.member.domain.Member; +import com.example.eatmate.app.domain.member.domain.Role; +import com.example.eatmate.app.domain.member.domain.repository.MemberRepository; +import com.example.eatmate.global.auth.jwt.JwtService; +import com.example.eatmate.global.auth.login.dto.OAuthTokenResponseDto; +import com.example.eatmate.global.auth.login.dto.UserLoginResponseDto; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleOAuth2Service { + + private static final String GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"; + private static final String GOOGLE_USER_INFO_URI = "https://www.googleapis.com/oauth2/v2/userinfo"; + + private final RestClient restClient = RestClient.create(); + private final MemberRepository memberRepository; + private final JwtService jwtService; + private final GoogleOAuth2Properties googleOAuth2Properties; // 주입받음 + + public OAuthTokenResponseDto getGoogleAccessToken(String authCode) { + String decodedCode = URLDecoder.decode(authCode, StandardCharsets.UTF_8); + + MultiValueMap bodyParams = new LinkedMultiValueMap<>(); + bodyParams.add("code", decodedCode); + bodyParams.add("client_id", googleOAuth2Properties.getClientId()); + bodyParams.add("client_secret", googleOAuth2Properties.getClientSecret()); + bodyParams.add("redirect_uri", googleOAuth2Properties.getRedirectUri()); + bodyParams.add("grant_type", "authorization_code"); + + log.info(" [Google OAuth 요청] code={}, client_id={}, client_secret={}, redirect_uri={}, grant_type={}", + decodedCode, googleOAuth2Properties.getClientId(), + googleOAuth2Properties.getClientSecret(), + googleOAuth2Properties.getRedirectUri(), "authorization_code"); + + try { + String responseBody = restClient.post() + .uri(GOOGLE_TOKEN_URI) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(bodyParams) + .retrieve() + .body(String.class); + + log.info(" [Google OAuth 원본 응답] {}", responseBody); + + ObjectMapper objectMapper = new ObjectMapper(); + OAuthTokenResponseDto tokenResponse = objectMapper.readValue(responseBody, OAuthTokenResponseDto.class); + + if (tokenResponse == null || tokenResponse.getAccessToken() == null || tokenResponse.getAccessToken() + .isEmpty()) { + log.error(" [Google OAuth 오류] Access Token이 응답에서 비어 있음"); + throw new RuntimeException("Google Access Token 발급 실패"); + } + return tokenResponse; + } catch (Exception e) { + log.error(" [Google OAuth 요청 실패] 메시지: {}", e.getMessage(), e); + throw new RuntimeException("Google Access Token 발급 실패", e); + } + } + + public GoogleOAuthUserInfo getGoogleUserInfo(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + return restClient.get() + .uri(GOOGLE_USER_INFO_URI) + .headers(httpHeaders -> httpHeaders.addAll(headers)) + .retrieve() + .body(GoogleOAuthUserInfo.class); + } + + public UserLoginResponseDto processGoogleUserLogin(String accessToken) { + GoogleOAuthUserInfo googleUserInfo = getGoogleUserInfo(accessToken); + log.info("Google 사용자 정보: {}", googleUserInfo.getAttributes()); + + Member member = memberRepository.findByEmail(googleUserInfo.getEmail()) + .orElseGet(() -> { + Member newMember = Member.builder() + .email(googleUserInfo.getEmail()) + .name(googleUserInfo.getName()) + .role(Role.GUEST) + .build(); + return memberRepository.save(newMember); + }); + + String jwtAccessToken = jwtService.createAccessToken(member.getEmail(), member.getRole().name(), null); + String refreshToken = jwtService.createRefreshToken(); + + member.updateRefreshToken(refreshToken); + memberRepository.save(member); + + log.info("JWT 발급 완료: AccessToken={}, RefreshToken={}", jwtAccessToken, refreshToken); + + return new UserLoginResponseDto( + member.getEmail(), + member.getRole(), + member.getGender(), + jwtAccessToken, + refreshToken + ); + } +} diff --git a/src/main/java/com/example/eatmate/global/auth/login/oauth/GoogleOAuthUserInfo.java b/src/main/java/com/example/eatmate/global/auth/login/oauth/GoogleOAuthUserInfo.java index 0ef6f6d4..e0a36e3e 100644 --- a/src/main/java/com/example/eatmate/global/auth/login/oauth/GoogleOAuthUserInfo.java +++ b/src/main/java/com/example/eatmate/global/auth/login/oauth/GoogleOAuthUserInfo.java @@ -1,29 +1,40 @@ package com.example.eatmate.global.auth.login.oauth; +import java.util.HashMap; import java.util.Map; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) public class GoogleOAuthUserInfo { - private final Map attributes; // Google OAuth 사용자 정보 저장 + private Map attributes = new HashMap<>(); + // 기존 생성자 유지 (선택 사항) public GoogleOAuthUserInfo(Map attributes) { - this.attributes = attributes; // 생성자를 통해 attributes 초기화 + this.attributes = attributes; } - /** - * Google에서 반환된 사용자 정보에서 이메일 추출 - */ - public String getEmail() { - return (String)attributes.get("email"); // "email" 키로 이메일 반환 + @JsonAnySetter + public void setAttribute(String key, Object value) { + attributes.put(key, value); } - // attributes 반환 public Map getAttributes() { return attributes; } - // 이름 반환 + public String getEmail() { + Object email = attributes.get("email"); + return email != null ? email.toString() : null; + } + public String getName() { - return (String)attributes.get("name"); + Object name = attributes.get("name"); + return name != null ? name.toString() : null; } } diff --git a/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginFailureHandler.java b/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginFailureHandler.java index 91f0dc37..1e7cfffa 100644 --- a/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginFailureHandler.java +++ b/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginFailureHandler.java @@ -37,7 +37,7 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo // 그 외의 소셜 로그인 실패 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setHeader("Error-Message", "소셜 로그인 실패"); + response.setHeader("Error-Message", "fail to login"); response.setHeader("Error-Detail", exception.getMessage()); log.error("소셜 로그인 실패: {}", exception.getMessage()); } diff --git a/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginSuccessHandler.java b/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginSuccessHandler.java index 16b557d7..bcd59688 100644 --- a/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginSuccessHandler.java +++ b/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginSuccessHandler.java @@ -21,9 +21,9 @@ @RequiredArgsConstructor public class OAuthLoginSuccessHandler implements AuthenticationSuccessHandler { - private static final boolean COOKIE_HTTP_ONLY = true; - private static final boolean COOKIE_SECURE = true; // https 환경에서는 true - private static final String COOKIE_PATH = "/"; + //private static final boolean COOKIE_HTTP_ONLY = true; + //private static final boolean COOKIE_SECURE = true; // https 환경에서는 true + //private static final String COOKIE_PATH = "/"; private static final int ACCESS_TOKEN_MAX_AGE = 60 * 60; // 1시간 private static final int REFRESH_TOKEN_MAX_AGE = 60 * 60 * 24 * 7; // 7일 private final JwtService jwtService; @@ -34,26 +34,36 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo log.info("OAuth2 Login 성공"); try { CustomOAuth2User oAuth2User = (CustomOAuth2User)authentication.getPrincipal(); - // 사용자 Role 확인 Role userRole = oAuth2User.getRole(); Gender userGender = oAuth2User.getGender(); - //토큰 생성 - String accessToken = jwtService.createAccessToken(oAuth2User.getEmail(), oAuth2User.getRole().name(), - userRole == Role.USER ? userGender.name() : null); + + // Access Token 생성 + String accessToken = jwtService.createAccessToken( + oAuth2User.getEmail(), + userRole.name(), + userRole == Role.USER ? userGender.name() : null + ); + + // Refresh Token 생성 (일반 사용자에게만 부여) String refreshToken = null; if (userRole == Role.USER) { refreshToken = jwtService.createRefreshToken(); jwtService.updateRefreshToken(oAuth2User.getEmail(), refreshToken); } - logTokens(accessToken, refreshToken); - setTokensInCookie(response, accessToken, refreshToken); + + // JWT를 **응답 헤더에 추가** + jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); + + // 클라이언트로 리디렉트 (토큰은 헤더에 포함됨) response.sendRedirect("https://develop.d4u0qurydeei4.amplifyapp.com/intro/oauth2/callback"); + } catch (Exception e) { log.error("OAuth2 로그인 처리 중 오류 발생: {} ", e.getMessage()); throw e; } } +/* // 쿠키 설정 메소드 생성 private void setTokensInCookie(HttpServletResponse response, String accessToken, String refreshToken) { // Access Token 쿠키 설정 @@ -92,5 +102,7 @@ private void logTokens(String accessToken, String refreshToken) { log.info("RefreshToken: {}", refreshToken); } } + */ + } diff --git a/src/main/java/com/example/eatmate/global/auth/login/service/LoginService.java b/src/main/java/com/example/eatmate/global/auth/login/service/LoginService.java index 6e16aefc..433552fe 100644 --- a/src/main/java/com/example/eatmate/global/auth/login/service/LoginService.java +++ b/src/main/java/com/example/eatmate/global/auth/login/service/LoginService.java @@ -5,6 +5,7 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; import com.example.eatmate.app.domain.member.domain.Member; import com.example.eatmate.app.domain.member.domain.Role; @@ -24,6 +25,7 @@ public class LoginService implements UserDetailsService { private final MemberRepository memberRepository; private final JwtService jwtService; + private final RestTemplate restTemplate = new RestTemplate(); @Override public UserDetails loadUserByUsername(String email) throws UserNotFoundException { @@ -36,8 +38,8 @@ public UserDetails loadUserByUsername(String email) throws UserNotFoundException } public UserLoginResponseDto getUserInfoFromRequest(HttpServletRequest request) { - // 쿠키에서 AccessToken 추출 - String accessToken = jwtService.extractAccessTokenFromCookie(request) + // 쿠키 또는 헤더에서 AccessToken 추출 + String accessToken = jwtService.extractAccessToken(request) .orElseThrow(() -> new CommonException(ErrorCode.TOKEN_NOT_FOUND)); // AccessToken 유효성 검증 및 사용자 정보 조회 @@ -49,6 +51,7 @@ public UserLoginResponseDto getUserInfo(String accessToken) { if (!jwtService.isTokenValid(accessToken)) { throw new CommonException(ErrorCode.INVALID_TOKEN); } + // AccessToken에서 이메일과 역할(Role) 추출 String email = jwtService.extractEmail(accessToken) .orElseThrow(() -> new CommonException(ErrorCode.INVALID_TOKEN)); @@ -59,8 +62,18 @@ public UserLoginResponseDto getUserInfo(String accessToken) { Member member = memberRepository.findByEmail(email) .orElseThrow(() -> new CommonException(ErrorCode.USER_NOT_FOUND)); - // 사용자 정보 반환 - return new UserLoginResponseDto(email, Role.valueOf(role), member.getGender()); - } + // Refresh Token 발급 + String refreshToken = jwtService.createRefreshToken(); + member.updateRefreshToken(refreshToken); + memberRepository.save(member); + // UserLoginResponseDto 수정된 버전 적용 + return new UserLoginResponseDto( + email, + Role.valueOf(role), + member.getGender(), + accessToken, + refreshToken + ); + } } diff --git a/src/main/java/com/example/eatmate/global/config/SecurityConfig.java b/src/main/java/com/example/eatmate/global/config/SecurityConfig.java index fd1c85bf..0d153189 100644 --- a/src/main/java/com/example/eatmate/global/config/SecurityConfig.java +++ b/src/main/java/com/example/eatmate/global/config/SecurityConfig.java @@ -4,7 +4,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -38,20 +37,17 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) - .cors(Customizer.withDefaults()) - .headers( - headersConfigurer -> headersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests( - authorize -> authorize - .requestMatchers("/api/admin/**").hasRole("ADMIN") - //.requestMatchers("/ws/chat/**").permitAll() - // 아이콘, css, js 관련 - // 기본 페이지, css, image, js 하위 폴더에 있는 자료들은 모두 접근 가능, h2-console에 접근 가능 - // .requestMatchers("/", "/css/**", "/images/**", "/js/**", "/favicon.ico").permitAll() - // .requestMatchers("/v3/api-docs", "/v3/api-docs/", "/swagger-ui.html", "/swagger-ui/", "/swagger/**").permitAll() - // .requestMatchers("/register").permitAll() - .anyRequest().permitAll() + .cors(cors -> cors.configurationSource(corsConfigurationSource())) // ✅ CORS 설정 추가 + .headers(headersConfigurer -> headersConfigurer.frameOptions( + HeadersConfigurer.FrameOptionsConfig::sameOrigin)) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // ✅ 세션을 사용하지 않음 (JWT 기반) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers("/login/oauth2/code/google").permitAll() + .requestMatchers("/api/auth/**").permitAll() // ✅ OAuth 로그인 엔드포인트는 인증 필요 없음 + .requestMatchers("/ws/chat/**").permitAll() // ✅ WebSocket 연결 허용 + .anyRequest().authenticated() // ✅ 모든 요청은 인증 필요 ) .exceptionHandling(exceptionHandling -> exceptionHandling @@ -62,11 +58,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access denied"); }) ) - .addFilterBefore(jwtAuthenticationProcessingFilter, UsernamePasswordAuthenticationFilter.class) // 필터 순서 확인 - //== 소셜 로그인 설정 ==// - .oauth2Login(oauth2 -> oauth2.successHandler(oAuthLoginSuccessHandler) - .failureHandler(oAuthLoginFailureHandler)); // 소셜 로그인 실패 시 핸들러 설정 - //.userInfoEndpoint().userService(customOAuth2UserService)); // customUserService 설정 + .addFilterBefore(jwtAuthenticationProcessingFilter, + UsernamePasswordAuthenticationFilter.class) // ✅ JWT 필터 추가 + .oauth2Login(oauth2 -> oauth2 + .successHandler(oAuthLoginSuccessHandler) // ✅ OAuth2 로그인 성공 시 JWT 발급 + .failureHandler(oAuthLoginFailureHandler) + ); return http.build(); } @@ -76,15 +73,17 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList( - "http://localhost:3000/", - "https://develop.d4u0qurydeei4.amplifyapp.com" + "http://localhost:3000", + "https://develop.d4u0qurydeei4.amplifyapp.com", + "https://www.eatmate.site", + "https://eatmate.site" )); - configuration.addAllowedOriginPattern("*"); + configuration.addAllowedOriginPattern("*"); // 모든 도메인 허용 (필요하면 제거) + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE", "OPTIONS", "PUT")); - configuration.setAllowedHeaders(Arrays.asList("Content-Type", "Authorization", "Cookie")); - configuration.setExposedHeaders( - Arrays.asList("Role", "accept")); //헤더에 노출할 정보 Role 포함 - configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Arrays.asList("Content-Type", "Authorization")); // "Cookie" 제거 + + configuration.setExposedHeaders(Arrays.asList("Authorization")); // JWT가 담긴 Authorization 헤더를 노출 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration);