diff --git a/src/main/java/com/back/domain/user/controller/UserAuthController.java b/src/main/java/com/back/domain/user/controller/UserAuthController.java new file mode 100644 index 00000000..6b587e7f --- /dev/null +++ b/src/main/java/com/back/domain/user/controller/UserAuthController.java @@ -0,0 +1,52 @@ +package com.back.domain.user.controller; + +import com.back.domain.user.service.UserAuthService; +import com.back.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "UserAuth", description = "사용자 인증 API") +@Slf4j +@RestController +@RequestMapping("/api/user/auth") +@RequiredArgsConstructor +public class UserAuthController { + + private final UserAuthService userAuthService; + + //400 Bad Request: 클라이언트가 잘못된 요청을 보냄 (형식 오류) + //401 Unauthorized: 인증 실패 (토큰 없음/만료/유효하지 않음) + //404 Not Found: 리소스를 찾을 수 없음 + @Operation(summary = "토큰 갱신", description = "리프레시 토큰으로 새로운 액세스 토큰을 발급") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토큰 갱신 성공"), + @ApiResponse(responseCode = "401", description = "토큰이 유효하지 않거나 만료됨") + }) + @PostMapping("/refresh") + public RsData refreshToken(HttpServletRequest request, HttpServletResponse response) { + boolean success = userAuthService.refreshTokens(request, response); + + if (success) { + return RsData.of(200, "토큰이 성공적으로 갱신되었습니다."); + } else { + return RsData.of(401, "토큰 갱신에 실패했습니다. 다시 로그인해주세요."); + } + } + + @Operation(summary = "로그아웃", description = "현재 세션을 종료하고 토큰을 무효화") + @ApiResponse(responseCode = "200", description = "로그아웃 성공") + @PostMapping("/logout") + public RsData logout(HttpServletRequest request, HttpServletResponse response) { + userAuthService.logout(request, response); + return RsData.of(200, "로그아웃되었습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/service/UserAuthService.java b/src/main/java/com/back/domain/user/service/UserAuthService.java new file mode 100644 index 00000000..0440b1b3 --- /dev/null +++ b/src/main/java/com/back/domain/user/service/UserAuthService.java @@ -0,0 +1,139 @@ +package com.back.domain.user.service; + +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.ServiceException; +import com.back.global.jwt.JwtUtil; +import com.back.global.jwt.refreshToken.entity.RefreshToken; +import com.back.global.jwt.refreshToken.repository.RefreshTokenRepository; +import com.back.global.jwt.refreshToken.service.RefreshTokenService; +import com.back.global.rsData.RsData; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserAuthService { + + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final RefreshTokenService refreshTokenService; + private final RefreshTokenRepository refreshTokenRepository; + + //OAuth 관련 + + public User joinSocial(String oauthId, String email, String nickname){ + userRepository.findByOauthId(oauthId) + .ifPresent(user -> { + throw new ServiceException(409, "이미 존재하는 계정입니다."); + }); + + // 고유한 닉네임 생성 + String uniqueNickname = generateUniqueNickname(nickname); + + User user = User.builder() + .email(email) + .nickname(uniqueNickname) + .abvDegree(0.0) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .role("USER") + .oauthId(oauthId) + .build(); + + return userRepository.save(user); + } + + @Transactional + public RsData findOrCreateOAuthUser(String oauthId, String email, String nickname) { + Optional existingUser = userRepository.findByOauthId(oauthId); + + if (existingUser.isPresent()) { + // 기존 사용자 업데이트 (이메일만 업데이트) + User user = existingUser.get(); + user.setEmail(email); + return RsData.of(200, "회원 정보가 업데이트 되었습니다", user); //더티체킹 + } else { + User newUser = joinSocial(oauthId, email, nickname); + return RsData.of(201, "사용자가 생성되었습니다", newUser); + } + } + + public String generateUniqueNickname(String baseNickname) { + // null이거나 빈 문자열인 경우 기본값 설정 + if (baseNickname == null || baseNickname.trim().isEmpty()) { + baseNickname = "User"; + } + + String nickname = baseNickname; + int counter = 1; + + // 중복 체크 및 고유한 닉네임 생성 + while (userRepository.findByNickname(nickname).isPresent()) { + nickname = baseNickname + counter; + counter++; + } + + return nickname; + } + + // 리프레시 토큰 관련 + + public void issueTokens(HttpServletResponse response, Long userId, String email) { + String accessToken = jwtUtil.generateAccessToken(userId, email); + String refreshToken = refreshTokenService.generateRefreshToken(userId, email); + + jwtUtil.addAccessTokenToCookie(response, accessToken); + jwtUtil.addRefreshTokenToCookie(response, refreshToken); + } + + public boolean refreshTokens(HttpServletRequest request, HttpServletResponse response) { + try { + String oldRefreshToken = jwtUtil.getRefreshTokenFromCookie(request); + + if (oldRefreshToken == null || !refreshTokenService.validateToken(oldRefreshToken)) { + return false; + } + + Optional tokenData = refreshTokenRepository.findByToken(oldRefreshToken); + if (tokenData.isEmpty()) { + return false; + } + + RefreshToken refreshTokenEntity = tokenData.get(); + Long userId = refreshTokenEntity.getUserId(); + String email = refreshTokenEntity.getEmail(); + + String newRefreshToken = refreshTokenService.rotateToken(oldRefreshToken); + String newAccessToken = jwtUtil.generateAccessToken(userId, email); + + jwtUtil.addAccessTokenToCookie(response, newAccessToken); + jwtUtil.addRefreshTokenToCookie(response, newRefreshToken); + + return true; + } catch (Exception e) { + log.error("토큰 갱신 중 오류 발생: {}", e.getMessage()); + return false; + } + } + + //토큰 끊기면서 OAuth 자동 로그아웃 + public void logout(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = jwtUtil.getRefreshTokenFromCookie(request); + + if (refreshToken != null) { + refreshTokenService.revokeToken(refreshToken); + } + + jwtUtil.removeAccessTokenCookie(response); + jwtUtil.removeRefreshTokenCookie(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 3555a62e..91b3ecbf 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -2,15 +2,10 @@ import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; -import com.back.global.exception.ServiceException; -import com.back.global.rsData.RsData; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.Optional; - @Service @RequiredArgsConstructor public class UserService { @@ -24,59 +19,6 @@ public User findById(Long id) { } - public User joinSocial(String oauthId, String email, String nickname){ - userRepository.findByOauthId(oauthId) - .ifPresent(user -> { - throw new ServiceException(409, "이미 존재하는 계정입니다."); - }); - - // 고유한 닉네임 생성 - String uniqueNickname = generateUniqueNickname(nickname); - - User user = User.builder() - .email(email) - .nickname(uniqueNickname) - .abvDegree(0.0) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) - .role("USER") - .oauthId(oauthId) - .build(); - - return userRepository.save(user); - } - - @Transactional - public RsData findOrCreateOAuthUser(String oauthId, String email, String nickname) { - Optional existingUser = userRepository.findByOauthId(oauthId); - - if (existingUser.isPresent()) { - // 기존 사용자 업데이트 (이메일만 업데이트) - User user = existingUser.get(); - user.setEmail(email); - return RsData.of(200, "회원 정보가 업데이트 되었습니다", user); //더티체킹 - } else { - User newUser = joinSocial(oauthId, email, nickname); - return RsData.of(201, "사용자가 생성되었습니다", newUser); - } - } - - public String generateUniqueNickname(String baseNickname) { - // null이거나 빈 문자열인 경우 기본값 설정 - if (baseNickname == null || baseNickname.trim().isEmpty()) { - baseNickname = "User"; - } - - String nickname = baseNickname; - int counter = 1; - - // 중복 체크 및 고유한 닉네임 생성 - while (userRepository.findByNickname(nickname).isPresent()) { - nickname = baseNickname + counter; - counter++; - } - return nickname; - } } \ No newline at end of file diff --git a/src/main/java/com/back/global/jwt/JwtUtil.java b/src/main/java/com/back/global/jwt/JwtUtil.java index e869b58c..011142e6 100644 --- a/src/main/java/com/back/global/jwt/JwtUtil.java +++ b/src/main/java/com/back/global/jwt/JwtUtil.java @@ -71,7 +71,7 @@ public void removeAccessTokenCookie(HttpServletResponse response) { response.addCookie(cookie); } - public boolean validateToken(String token) { + public boolean validateAccessToken(String token) { try { Jwts.parser() .verifyWith(secretKey) @@ -110,4 +110,35 @@ private Claims parseToken(String token) { .getPayload(); } + public void addRefreshTokenToCookie(HttpServletResponse response, String refreshToken) { + Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken); + cookie.setHttpOnly(true); + cookie.setSecure(false); + cookie.setPath("/"); + cookie.setMaxAge(60 * 60 * 24 * 30); + response.addCookie(cookie); + } + + public String getRefreshTokenFromCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (REFRESH_TOKEN_COOKIE_NAME.equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + + public void removeRefreshTokenCookie(HttpServletResponse response) { + Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, null); + cookie.setHttpOnly(true); + cookie.setSecure(false); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + + } \ No newline at end of file diff --git a/src/main/java/com/back/global/security/CustomAuthenticationFilter.java b/src/main/java/com/back/global/security/CustomAuthenticationFilter.java index a79a7907..58bfed5a 100644 --- a/src/main/java/com/back/global/security/CustomAuthenticationFilter.java +++ b/src/main/java/com/back/global/security/CustomAuthenticationFilter.java @@ -11,6 +11,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -21,16 +23,18 @@ import java.io.IOException; import java.util.Map; +@Slf4j @Component @RequiredArgsConstructor public class CustomAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final Rq rq; + @Value("${custom.accessToken.expirationSeconds}") + private int accessTokenExpiration; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - logger.debug("Processing request for " + request.getRequestURI()); - try { work(request, response, filterChain); } catch (ServiceException e) { @@ -90,7 +94,7 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt // accessToken 검증 if (isAccessTokenExists) { - if (jwtUtil.validateToken(accessToken)) { + if (jwtUtil.validateAccessToken(accessToken)) { Long userId = jwtUtil.getUserIdFromToken(accessToken); String email = jwtUtil.getEmailFromToken(accessToken); String nickname = jwtUtil.getNicknameFromToken(accessToken); @@ -113,7 +117,7 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt // accessToken이 만료됐으면 새로 발급 if (isAccessTokenExists && !isAccessTokenValid) { String newAccessToken = jwtUtil.generateAccessToken(user.getId(), user.getEmail()); - rq.setCrossDomainCookie("accessToken", newAccessToken, 60 * 20); + rq.setCrossDomainCookie("accessToken", newAccessToken, accessTokenExpiration); } // SecurityContext에 인증 정보 저장 diff --git a/src/main/java/com/back/global/security/CustomOAuth2UserService.java b/src/main/java/com/back/global/security/CustomOAuth2UserService.java index 3d35f4ce..39498558 100644 --- a/src/main/java/com/back/global/security/CustomOAuth2UserService.java +++ b/src/main/java/com/back/global/security/CustomOAuth2UserService.java @@ -1,7 +1,7 @@ package com.back.global.security; import com.back.domain.user.entity.User; -import com.back.domain.user.service.UserService; +import com.back.domain.user.service.UserAuthService; import com.back.global.rsData.RsData; import lombok.RequiredArgsConstructor; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; @@ -16,7 +16,7 @@ @Service @RequiredArgsConstructor public class CustomOAuth2UserService extends DefaultOAuth2UserService { - private final UserService userService; + private final UserAuthService userAuthService; // OAuth2 로그인 성공 시 자동 호출 @Override @@ -55,7 +55,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic // OAuth ID를 제공자와 함께 저장 (예: kakao_123456789) String uniqueOauthId = providerTypeCode.toLowerCase() + "_" + oauthUserId; - RsData rsData = userService.findOrCreateOAuthUser(uniqueOauthId, email, nickname); + RsData rsData = userAuthService.findOrCreateOAuthUser(uniqueOauthId, email, nickname); if (rsData.code()<200 || rsData.code()>299) { throw new OAuth2AuthenticationException("사용자 생성/조회 실패: " + rsData.message()); @@ -66,7 +66,6 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic String userEmail = user.getEmail() != null && !user.getEmail().trim().isEmpty() ? user.getEmail() : "unknown"; - // securityContext return new SecurityUser( user.getId(), userEmail,