diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 1f432bd..a0cd2ed 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -23,6 +23,7 @@ public enum ErrorCode { USER_NOT_FOUND_BY_EMAIL(HttpStatus.NOT_FOUND, "ACCOUNT-002", "해당 이메일의 회원을 찾을 수 없습니다."), ACCOUNT_ALREADY_LINKED(HttpStatus.CONFLICT, "ACCOUNT-003", "이미 다른 사용자와 연동된 소셜 계정입니다."), SOCIAL_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "ACCOUNT-004", "연동된 소셜 계정 정보를 찾을 수 없습니다."), + USER_SETTING_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-005", "해당 아이디의 설정을 찾을 수 없습니다."), // 소셜 로그인 관련 OAUTH_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-001", "소셜 로그인 처리 중 오류가 발생했습니다."), @@ -31,7 +32,7 @@ public enum ErrorCode { OAUTH_COMMUNICATION_FAILED(HttpStatus.BAD_GATEWAY, "OAUTH-004", "소셜 플랫폼과의 통신에 실패했습니다."), SOCIAL_TOKEN_REFRESH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-005", "소셜 플랫폼의 토큰 갱신에 실패했습니다."), SOCIAL_ACCOUNT_CONNECT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-006", "소셜 계정 연동에 실패했습니다."), - GITHUB_TOKEN_REFRESH_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "OAUTH-007", "GitHub 토큰 갱신은 지원하지 않습니다. 재인증이 필요합니다."), + SOCIAL_TOKEN_REFRESH_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "OAUTH-007", "리프레시 토큰 갱신은 지원하지 않습니다. 재인증이 필요합니다."), // S3 파일 관련 // Client Errors (4xx) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/CustomOAuth2User.java similarity index 95% rename from src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java rename to src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/CustomOAuth2User.java index a79f1e1..ea70964 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/CustomOAuth2User.java @@ -1,4 +1,4 @@ -package com.teamEWSN.gitdeun.common.oauth.entity; +package com.teamEWSN.gitdeun.common.oauth.dto; import com.teamEWSN.gitdeun.user.entity.Role; import lombok.Getter; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GitHubEmailDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GitHubEmailDto.java new file mode 100644 index 0000000..05176c5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GitHubEmailDto.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.common.oauth.dto; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GitHubEmailDto { + + private String email; + private boolean primary; + private boolean verified; + private String visibility; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java deleted file mode 100644 index f0cf9ad..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.teamEWSN.gitdeun.common.oauth.dto; - -import lombok.Builder; -import lombok.Getter; -import lombok.ToString; - -@ToString -@Getter -@Builder -public class OAuth2UserDto { - private String nickname; - private String name; - private String email; - private String role; - private String profileImage; -} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java index 9b1ce2f..ccbb3da 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java @@ -1,7 +1,7 @@ package com.teamEWSN.gitdeun.common.oauth.entity; import com.teamEWSN.gitdeun.common.converter.CryptoConverter; -import com.teamEWSN.gitdeun.common.util.BaseEntity; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; import com.teamEWSN.gitdeun.user.entity.User; import jakarta.persistence.*; import lombok.*; @@ -10,7 +10,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "social_connection") -public class SocialConnection extends BaseEntity { +public class SocialConnection extends AuditedEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java index 03dc0aa..d96969f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java @@ -3,16 +3,18 @@ import com.teamEWSN.gitdeun.common.cookie.CookieUtil; import com.teamEWSN.gitdeun.common.jwt.JwtToken; import com.teamEWSN.gitdeun.common.jwt.JwtTokenProvider; -import com.teamEWSN.gitdeun.common.oauth.entity.CustomOAuth2User; +import com.teamEWSN.gitdeun.common.oauth.service.OAuthStateService; +import com.teamEWSN.gitdeun.user.service.AuthService; 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.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; @@ -22,6 +24,8 @@ public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final JwtTokenProvider jwtTokenProvider; + private final OAuthStateService oAuthStateService; + private final AuthService authService; private final CookieUtil cookieUtil; @Value("${app.front-url}") @@ -29,19 +33,22 @@ public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHa @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - - // 우리 서비스의 JWT 생성 + OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication; + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String state = request.getParameter("state"); + + String purpose = oAuthStateService.consumeState(state); // "connect:42" 또는 null + if (purpose != null && purpose.startsWith("connect:")) { + Long userId = Long.parseLong(purpose.split(":")[1]); + authService.connectGithubAccount(oAuth2User, userId); + response.sendRedirect(frontUrl + "/oauth/callback#connected=true"); + return; + } + + // 일반 로그인 흐름 JwtToken jwtToken = jwtTokenProvider.generateToken(authentication); - log.info("JWT가 발급되었습니다. Access Token: {}", jwtToken.getAccessToken()); - - // Refresh Token은 HttpOnly 쿠키에 저장 cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), jwtTokenProvider.getRefreshTokenExpired()); - - // Access Token은 쿼리 파라미터로 프론트엔드에 전달 - String targetUrl = UriComponentsBuilder.fromUriString(frontUrl + "/oauth/callback") - .queryParam("accessToken", jwtToken.getAccessToken()) - .build().toUriString(); - + String targetUrl = frontUrl + "/oauth/callback#accessToken=" + jwtToken.getAccessToken(); getRedirectStrategy().sendRedirect(request, response, targetUrl); } } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/record/GoogleTokenResponse.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/record/GoogleTokenResponse.java new file mode 100644 index 0000000..dceba76 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/record/GoogleTokenResponse.java @@ -0,0 +1,12 @@ +package com.teamEWSN.gitdeun.common.oauth.record; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleTokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("expires_in") Long expiresIn, + @JsonProperty("scope") String scope, + @JsonProperty("id_token") String idToken +) {} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java index cf9c64e..d9d2310 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -2,6 +2,7 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.oauth.dto.GitHubEmailDto; import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; import com.teamEWSN.gitdeun.common.oauth.dto.provider.GoogleResponseDto; import com.teamEWSN.gitdeun.common.oauth.dto.provider.OAuth2ResponseDto; @@ -11,7 +12,7 @@ import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; import com.teamEWSN.gitdeun.user.repository.UserRepository; -import com.teamEWSN.gitdeun.common.oauth.entity.CustomOAuth2User; +import com.teamEWSN.gitdeun.common.oauth.dto.CustomOAuth2User; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,6 +22,8 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; import java.util.UUID; @@ -29,6 +32,8 @@ @RequiredArgsConstructor public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final GitHubApiHelper gitHubApiHelper; + private final SocialTokenRefreshService socialTokenRefreshService; private final UserRepository userRepository; private final SocialConnectionRepository socialConnectionRepository; @@ -44,61 +49,68 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); } - User user = processUserInTransaction(oAuth2User, userRequest); + User user = processUser(oAuth2User, userRequest); return new CustomOAuth2User(user.getId(), user.getRole()); } // @Transactional - public User processUserInTransaction(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { - OAuth2ResponseDto oAuth2ResponseDto = getOAuth2ResponseDto(oAuth2User, userRequest); - - // 이메일 정보가 없는 경우 예외 처리 (GitHub 등) - if (oAuth2ResponseDto.getEmail() == null) { - throw new GlobalException(ErrorCode.EMAIL_NOT_PROVIDED); - } - - OauthProvider provider = OauthProvider.valueOf(oAuth2ResponseDto.getProvider().toUpperCase()); - String providerId = oAuth2ResponseDto.getProviderId(); - String accessToken = userRequest.getAccessToken().getTokenValue(); + public User processUser(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { + OAuth2ResponseDto dto = getOAuth2ResponseDto(oAuth2User, userRequest); + OauthProvider provider = OauthProvider.valueOf(dto.getProvider().toUpperCase()); + String providerId = dto.getProviderId(); + String accessToken = userRequest.getAccessToken().getTokenValue(); String refreshToken = (String) userRequest.getAdditionalParameters().get("refresh_token"); + /* ② 이미 연결된 계정 → 토큰 갱신 로직 추상화 */ return socialConnectionRepository.findByProviderAndProviderId(provider, providerId) - .map(connection -> { - log.info("기존 소셜 계정 정보를 업데이트합니다: {}", provider); - connection.updateTokens(accessToken, refreshToken); - return connection.getUser(); + .map(conn -> { + // provider 별 refresh 정책 + socialTokenRefreshService.refreshSocialToken(conn, accessToken, refreshToken); + return conn.getUser(); }) - .orElseGet(() -> { - // 다른 사용자가 이미 해당 이메일을 사용 중인지 확인 - userRepository.findByEmailAndDeletedAtIsNull(oAuth2ResponseDto.getEmail()) - .ifPresent(existingUser -> { - // 이메일은 같지만, 소셜 연동 정보가 없는 경우 -> 계정 연동 - log.info("기존 회원 계정에 소셜 계정을 연동합니다: {}", provider); - connectSocialAccount(existingUser, provider, providerId, accessToken, refreshToken); - }); - // 위에서 연동했거나, 완전 신규 유저인 경우를 처리 - // 다시 이메일로 조회하여 최종 유저를 반환하거나 새로 생성 - return userRepository.findByEmailAndDeletedAtIsNull(oAuth2ResponseDto.getEmail()) - .orElseGet(() -> { - log.info("신규 회원 및 소셜 계정을 생성합니다: {}", provider); - return createNewUser(oAuth2ResponseDto, provider, providerId, accessToken, refreshToken); - }); - }); + .orElseGet(() -> createOrConnect(dto, provider, providerId, accessToken, refreshToken)); } - private static OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { + // OAuth2 공급자로부터 받은 사용자 정보를 기반으로 OAuth2ResponseDto를 생성(인스턴스 메서드) + private OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { String registrationId = userRequest.getClientRegistration().getRegistrationId(); + Map attr = oAuth2User.getAttributes(); - OAuth2ResponseDto oAuth2ResponseDto; if (registrationId.equalsIgnoreCase("google")) { - oAuth2ResponseDto = new GoogleResponseDto(oAuth2User.getAttributes()); - } else if (registrationId.equalsIgnoreCase("github")) { - oAuth2ResponseDto = new GitHubResponseDto(oAuth2User.getAttributes()); + return new GoogleResponseDto(attr); + } + if (registrationId.equalsIgnoreCase("github")) { + /* ① 기본 프로필에 e-mail 없으면 /user/emails 호출 */ + if (attr.get("email") == null) { + // accessToken 으로 GitHub 보조 API 호출 + List emails = + gitHubApiHelper.getPrimaryEmails(userRequest.getAccessToken().getTokenValue()); + attr.put("email", + emails.stream().filter(GitHubEmailDto::isPrimary) + .findFirst().map(GitHubEmailDto::getEmail).orElse(null)); + } + return new GitHubResponseDto(attr); } else { // 지원하지 않는 소셜 로그인 제공자 throw new GlobalException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER); } - return oAuth2ResponseDto; + } + + /** + * 사용자가 존재하면 계정을 연결하고, 존재하지 않으면 새로 생성합니다. + */ + private User createOrConnect(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + // 이메일로 기존 사용자를 찾습니다. + return userRepository.findByEmailAndDeletedAtIsNull(response.getEmail()) + .map(user -> { + // 사용자가 존재하면, 새 소셜 계정을 연결 + connectSocialAccount(user, provider, providerId, accessToken, refreshToken); + return user; + }) + .orElseGet(() -> { + // 사용자가 존재하지 않으면, 새 사용자를 생성 + return createNewUser(response, provider, providerId, accessToken, refreshToken); + }); } private User createNewUser(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) { diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java index 13f5ce0..c87661b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java @@ -2,7 +2,7 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; -import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; +import com.teamEWSN.gitdeun.common.oauth.dto.GitHubEmailDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -10,13 +10,12 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.List; import java.util.Map; @Slf4j @@ -32,60 +31,28 @@ public class GitHubApiHelper { @Value("${spring.security.oauth2.client.registration.github.client-secret}") private String clientSecret; - @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") - private String redirectUri; /** - * 인가 코드로 GitHub Access Token을 요청합니다. - * @param code GitHub에서 받은 인가 코드 - * @return Access Token 문자열 - */ - public String getAccessToken(String code) { - String tokenUri = "https://github.com/login/oauth/access_token"; - - MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("client_id", clientId); - formData.add("client_secret", clientSecret); - formData.add("code", code); - formData.add("redirect_uri", redirectUri); - - Map response = webClient.post() - .uri(tokenUri) - .accept(MediaType.APPLICATION_JSON) - .bodyValue(formData) - .retrieve() - // 단순 map이 아닌 정확한 타입 정보를 런타임에도 잃어버리지 않도록 ParameterizedTypeReference를 사용 - .bodyToMono(new ParameterizedTypeReference>() {}) - .block(); - - if (response == null || response.get("access_token") == null) { - log.error("GitHub Access Token 발급 실패: {}", response); - throw new GlobalException(ErrorCode.OAUTH_PROCESSING_ERROR); - } - - return (String) response.get("access_token"); - } - - /** - * Access Token으로 GitHub 사용자 정보를 조회합니다. + * Access Token으로 사용자의 이메일 목록을 조회합니다. + * GitHub 기본 사용자 정보에 이메일이 포함되지 않은 경우 사용됩니다. * @param accessToken GitHub Access Token - * @return GitHub 사용자 정보를 담은 DTO + * @return 이메일 정보 DTO 리스트 */ - public GitHubResponseDto getUserInfo(String accessToken) { - String userInfoUri = "https://api.github.com/user"; + public List getPrimaryEmails(String accessToken) { + String emailsUri = "https://api.github.com/user/emails"; - Map attributes = webClient.get() - .uri(userInfoUri) - .header(HttpHeaders.AUTHORIZATION, "token " + accessToken) + List emails = webClient.get() + .uri(emailsUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .retrieve() - .bodyToMono(new ParameterizedTypeReference>() {}) + .bodyToMono(new ParameterizedTypeReference>() {}) .block(); - if (attributes == null) { + if (emails == null || emails.isEmpty()) { + log.error("GitHub 이메일 정보를 가져올 수 없습니다."); throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); } - - return new GitHubResponseDto(attributes); + return emails; } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java index cdfdb1d..7a0d3c8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java @@ -1,16 +1,99 @@ package com.teamEWSN.gitdeun.common.oauth.service; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.oauth.record.GoogleTokenResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; + +@Slf4j @Component @RequiredArgsConstructor public class GoogleApiHelper { private final WebClient webClient; + @Value("${spring.security.oauth2.client.registration.google.client-id}") + private String googleClientId; + + @Value("${spring.security.oauth2.client.registration.google.client-secret}") + private String googleClientSecret; + + + /** + * 토큰이 만료되었는지 확인 + * @param accessToken 확인할 액세스 토큰 + * @return 만료 여부 (true: 만료됨, false: 유효함) + */ + protected boolean isExpired(String accessToken) { + // String validateUrl = "https://www.googleapis.com/oauth2/v1/tokeninfo"; + try { + // 토큰 정보 요청 + webClient.get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .host("www.googleapis.com") + .path("/oauth2/v1/tokeninfo") + .queryParam("access_token", accessToken) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + + // 응답이 있으면 토큰이 유효함 + return false; // 만료되지 않음 + } catch (WebClientResponseException e) { + // 401, 400 등의 에러는 토큰이 만료되었거나 유효하지 않음 + log.debug("토큰 검증 오류: {}", e.getMessage()); + return true; // 만료됨 + } catch (Exception e) { + // 기타 예외 + log.error("토큰 검증 중 예상치 못한 오류: {}", e.getMessage()); + return true; // 안전하게 만료로 취급 + } + } + + /** + * 리프레시 토큰으로 새 액세스 토큰 요청 + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 응답 객체 + */ + protected GoogleTokenResponse refreshToken(String refreshToken) { + String tokenUrl = "https://oauth2.googleapis.com/token"; + + // 요청 바디 구성 + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", googleClientId); + formData.add("client_secret", googleClientSecret); + formData.add("refresh_token", refreshToken); + formData.add("grant_type", "refresh_token"); + + try { + // 토큰 갱신 요청 + return webClient.post() + .uri(tokenUrl) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)) + .retrieve() + .bodyToMono(GoogleTokenResponse.class) + .block(); + } catch (Exception e) { + log.error("Google 토큰 갱신 실패: {}", e.getMessage()); + throw new GlobalException(ErrorCode.SOCIAL_TOKEN_REFRESH_FAILED); + } + } + + public Mono revokeToken(String accessToken) { String revokeUrl = "https://accounts.google.com/o/oauth2/revoke"; return webClient.post() diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/OAuthStateService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/OAuthStateService.java new file mode 100644 index 0000000..cf776bb --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/OAuthStateService.java @@ -0,0 +1,29 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OAuthStateService { + + private final RedisTemplate redisTemplate; + private static final Duration EXPIRATION = Duration.ofMinutes(3); + + public String createState(String purpose) { + String state = UUID.randomUUID().toString(); + redisTemplate.opsForValue().set("oauth:state:" + state, purpose, EXPIRATION); + return state; + } + + public String consumeState(String state) { + String key = "oauth:state:" + state; + String purpose = redisTemplate.opsForValue().get(key); + redisTemplate.delete(key); + return purpose; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java index d026f47..2d2a0f5 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java @@ -1,34 +1,34 @@ package com.teamEWSN.gitdeun.common.oauth.service; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.oauth.record.GoogleTokenResponse; import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.util.Optional; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.reactive.function.BodyInserters; - // 레포 및 마인드맵 호출 시 소셜로그인 토큰 갱신 호출 @Slf4j @Service +@Transactional @RequiredArgsConstructor public class SocialTokenRefreshService { private final SocialConnectionRepository socialConnectionRepository; private final WebClient webClient; - private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱을 위한 ObjectMapper + private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱을 위한 ObjectMapper' + private final GitHubApiHelper gitHubApiHelper; + private final GoogleApiHelper googleApiHelper; @Value("${spring.security.oauth2.client.registration.google.client-id}") private String googleClientId; @@ -37,110 +37,57 @@ public class SocialTokenRefreshService { private String googleClientSecret; - // oauth 토큰 갱신 + // 기존 refreshToken 기반 갱신(주기적/자동 갱신) public void refreshSocialToken(Long userId, OauthProvider provider) { SocialConnection connection = socialConnectionRepository.findByUserIdAndProvider(userId, provider) .orElseThrow(() -> new GlobalException(ErrorCode.SOCIAL_CONNECTION_NOT_FOUND)); switch (provider) { - case GOOGLE -> refreshGoogleToken(connection); + case GOOGLE -> refreshGoogle(connection, Optional.empty(), Optional.empty()); case GITHUB -> { log.warn("GitHub는 토큰 갱신을 지원하지 않습니다. 재인증이 필요합니다."); - throw new GlobalException(ErrorCode.GITHUB_TOKEN_REFRESH_NOT_SUPPORTED); + throw new GlobalException(ErrorCode.SOCIAL_TOKEN_REFRESH_NOT_SUPPORTED); } } + + // 갱신 후 저장 명시 + socialConnectionRepository.save(connection); } - private void refreshGoogleToken(SocialConnection connection) { - if (connection.getRefreshToken() == null) { - throw new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN); + // oauth 새로운 토큰 제공 시 갱신(로그인 콜백) + public void refreshSocialToken(SocialConnection conn, + String latestAccess, String latestRefresh) { + switch (conn.getProvider()) { + case GOOGLE -> refreshGoogle(conn, Optional.ofNullable(latestAccess), + Optional.ofNullable(latestRefresh)); + // GitHub은 refresh 불가 + case GITHUB -> conn.updateTokens(latestAccess, null); // accessToken만 교체 } - try { - // Google Token 갱신 API 호출 - String tokenUrl = "https://oauth2.googleapis.com/token"; - - - MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("client_id", googleClientId); - formData.add("client_secret", googleClientSecret); - formData.add("refresh_token", connection.getRefreshToken()); - formData.add("grant_type", "refresh_token"); - - - String response = webClient.post() - .uri(tokenUrl) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .bodyValue(BodyInserters.fromFormData(formData)) - .retrieve() - .bodyToMono(String.class) - .block(); // 결과를 동기적으로 기다림 - - JsonNode tokenNode = objectMapper.readTree(response); - - String newAccessToken = tokenNode.get("access_token").asText(); - // 구글은 리프레시 토큰을 갱신하면 기존 리프레시 토큰을 다시 주지 않는 경우가 대부분 - String newRefreshToken = tokenNode.has("refresh_token") ? - tokenNode.get("refresh_token").asText() : connection.getRefreshToken(); - - connection.updateTokens(newAccessToken, newRefreshToken); - socialConnectionRepository.save(connection); + socialConnectionRepository.save(conn); + } - log.info("Google 토큰 갱신 완료: userId={}", connection.getUser().getId()); - } catch (Exception e) { - log.error("Google 토큰 갱신 실패: userId={}, error={}", - connection.getUser().getId(), e.getMessage()); - throw new GlobalException(ErrorCode.SOCIAL_TOKEN_REFRESH_FAILED); + private void refreshGoogle(SocialConnection conn, + Optional latestAccessOpt, + Optional latestRefreshOpt) { + // 1. latestAccess가 주어지고 유효하면 교체 + if (latestAccessOpt.isPresent() && !googleApiHelper.isExpired(latestAccessOpt.get())) { + String newRefresh = latestRefreshOpt.orElse(conn.getRefreshToken()); + conn.updateTokens(latestAccessOpt.get(), newRefresh); + return; } - } - /** - * 토큰 유효성 검증 - */ - public boolean isTokenValid(String accessToken, OauthProvider provider) { - try { - return switch (provider) { - case GOOGLE -> validateGoogleToken(accessToken); - case GITHUB -> validateGitHubToken(accessToken); - }; - } catch (Exception e) { - log.error("토큰 유효성 검증 실패: provider={}, error={}", provider, e.getMessage()); - return false; + // 2. refreshToken 기반 재발급 (latestRefresh가 있으면 그것 사용, 없으면 기존) + String refreshToUse = latestRefreshOpt.orElse(conn.getRefreshToken()); + if (refreshToUse == null) { + throw new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN); } - } - private boolean validateGoogleToken(String accessToken) { - String validateUrl = "https://www.googleapis.com/oauth2/v1/tokeninfo"; - - try { - webClient.get() - .uri(uriBuilder -> uriBuilder - .path(validateUrl) - .queryParam("access_token", accessToken) - .build()) - .retrieve() - .bodyToMono(String.class) - .block(); - return true; - } catch (Exception e) { - return false; - } - } + GoogleTokenResponse res = googleApiHelper.refreshToken(refreshToUse); - private boolean validateGitHubToken(String accessToken) { - String validateUrl = "https://api.github.com/user"; - - try { - webClient.get() - .uri(validateUrl) - .header(HttpHeaders.AUTHORIZATION, "token " + accessToken) - .retrieve() - .bodyToMono(String.class) - .block(); - return true; - } catch (Exception e) { - return false; - } + String newRefresh = (res.refreshToken() != null) ? res.refreshToken() : conn.getRefreshToken(); + conn.updateTokens(res.accessToken(), newRefresh); } + } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/AuditedEntity.java b/src/main/java/com/teamEWSN/gitdeun/common/util/AuditedEntity.java new file mode 100644 index 0000000..3b04b77 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/AuditedEntity.java @@ -0,0 +1,24 @@ +package com.teamEWSN.gitdeun.common.util; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@MappedSuperclass +public class AuditedEntity extends CreatedEntity { + + @LastModifiedDate + @Column(name = "updated_at", nullable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime updatedAt; + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java b/src/main/java/com/teamEWSN/gitdeun/common/util/CreatedEntity.java similarity index 80% rename from src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java rename to src/main/java/com/teamEWSN/gitdeun/common/util/CreatedEntity.java index e2dcb5b..dc5a3f7 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/CreatedEntity.java @@ -15,14 +15,9 @@ @Getter @Setter @MappedSuperclass -public class BaseEntity { +public class CreatedEntity { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(0)") private LocalDateTime createdAt; - - @LastModifiedDate - @Column(name = "updated_at", nullable = false, columnDefinition = "DATETIME(0)") - private LocalDateTime updatedAt; - } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java new file mode 100644 index 0000000..b903ef3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.repo.controller; + +public class RepoController { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java new file mode 100644 index 0000000..9c8714f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoDto.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.repo.dto; + +public class RepoDto { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java new file mode 100644 index 0000000..c527d3f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.repo.entity; + +public class Repo { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java b/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java new file mode 100644 index 0000000..7d3b49b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.repo.repository; + +public class RepoRepository { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java new file mode 100644 index 0000000..9eaf262 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.repo.service; + +public class RepoService { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/controller/RepositoryController.java b/src/main/java/com/teamEWSN/gitdeun/repository/controller/RepositoryController.java deleted file mode 100644 index ced6177..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/controller/RepositoryController.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.controller; - -public class RepositoryController { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java b/src/main/java/com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java deleted file mode 100644 index 603d314..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.dto; - -public class RepositoryDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/entity/Repository.java b/src/main/java/com/teamEWSN/gitdeun/repository/entity/Repository.java deleted file mode 100644 index 2d51bbe..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/entity/Repository.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.entity; - -public class Repository { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/repository/RepositoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/repository/repository/RepositoryRepository.java deleted file mode 100644 index 6abc5b4..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/repository/RepositoryRepository.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.repository; - -public class RepositoryRepository { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/service/RepositoryService.java b/src/main/java/com/teamEWSN/gitdeun/repository/service/RepositoryService.java deleted file mode 100644 index 28097f8..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/service/RepositoryService.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.service; - -public class RepositoryService { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java index 5d06c4b..acc86dd 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -3,29 +3,41 @@ import com.teamEWSN.gitdeun.common.cookie.CookieUtil; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.common.jwt.JwtToken; +import com.teamEWSN.gitdeun.common.oauth.service.OAuthStateService; import com.teamEWSN.gitdeun.user.dto.UserTokenResponseDto; import com.teamEWSN.gitdeun.user.service.AuthService; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.Map; + @Slf4j @RestController -@RequestMapping("/api/oauth") +@RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { @Value("${jwt.refresh-expired}") private Long refreshTokenExpired; + private final OAuthStateService oAuthStateService; private final AuthService authService; private final CookieUtil cookieUtil; + @Value("${app.front-url}") + private String frontUrl; + + // 깃허브 연동 흐름 구분 조회 + @GetMapping("/connect/github/state") + public ResponseEntity> generateStateForGithubConnect(@AuthenticationPrincipal CustomUserDetails user) { + String state = oAuthStateService.createState("connect:" + user.getId()); + return ResponseEntity.ok(Map.of("state", state)); + } // 로그아웃 API @PostMapping("/logout") @@ -54,14 +66,30 @@ public ResponseEntity refreshAccessToken( return ResponseEntity.ok(new UserTokenResponseDto(newJwtToken.getAccessToken())); } - @GetMapping("/connect/github/callback") - public ResponseEntity connectGithubAccountCallback( - @RequestParam("code") String code, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - authService.connectGithubAccount(userDetails.getId(), code); - - return ResponseEntity.ok().build(); - } +// /** +// * GitHub의 모든 OAuth 콜백을 처리하는 단일 엔드포인트 +// * @param code GitHub에서 제공하는 Authorization Code +// * @param userDetails 현재 로그인된 사용자 정보. 비로그인 상태면 null. +// * @return 로그인 또는 계정 연동 흐름에 따라 적절한 경로로 포워딩 또는 리디렉션 +// */ +// @GetMapping("/github/callback") +// public ResponseEntity githubCallback( +// @RequestParam("code") String code, +// @AuthenticationPrincipal CustomUserDetails userDetails, +// HttpServletResponse response // 쿠키 설정을 위해 필요 +// ) { +// +// if (userDetails != null) { +// // "계정 연동" 흐름 +// authService.connectGithubAccount(userDetails.getId(), code); +// // 성공했다는 응답 전달 +// return ResponseEntity.ok().body(Map.of("status", "success", "message", "계정 연동 성공!")); +// +// } else { +// // "최초 로그인" 흐름 +// GithubLoginResponseDto loginResponse = authService.loginWithGithub(code, response); +// return ResponseEntity.ok(loginResponse); +// } +// } } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java new file mode 100644 index 0000000..8c26416 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java @@ -0,0 +1,48 @@ +package com.teamEWSN.gitdeun.user.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.user.dto.UserSettingResponseDto; +import com.teamEWSN.gitdeun.user.dto.UserSettingUpdateRequestDto; +import com.teamEWSN.gitdeun.user.service.UserSettingService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/settings") +@RequiredArgsConstructor +public class UserSettingController { + private final UserSettingService userSettingService; + + /** + * 현재 로그인된 사용자의 설정을 조회합니다. + * @param userDetails 인증된 사용자 정보 + * @return 현재 설정 정보를 담은 응답 + */ + @GetMapping + public ResponseEntity getUserSettings( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UserSettingResponseDto responseDto = userSettingService.getSettings(userDetails.getId()); + return ResponseEntity.ok(responseDto); + } + + /** + * 현재 로그인된 사용자의 설정을 변경합니다. + * @param userDetails 인증된 사용자 정보 + * @param requestDto 변경할 설정 정보를 담은 요청 DTO + * @return 변경된 설정 정보를 담은 응답 + */ + @PatchMapping // 리소스의 일부만 변경하므로 PATCH가 더 적합합니다. + public ResponseEntity updateUserSettings( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody UserSettingUpdateRequestDto requestDto + ) { + UserSettingResponseDto responseDto = userSettingService.updateSettings(userDetails.getId(), requestDto); + return ResponseEntity.ok(responseDto); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java index c05807e..cdf4d13 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java @@ -4,7 +4,6 @@ import lombok.Builder; import lombok.Getter; -import java.time.LocalDate; @Getter @Builder diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java new file mode 100644 index 0000000..4f54f79 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.user.dto; + +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserSettingResponseDto { + private UserSetting.DisplayTheme theme; + private UserSetting.UserMode mode; + private boolean emailNotification; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java new file mode 100644 index 0000000..d7683d3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.user.dto; + +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserSettingUpdateRequestDto { + @NotNull(message = "테마를 선택해주세요.") + private UserSetting.DisplayTheme theme; + + @NotNull(message = "모드를 선택해주세요.") + private UserSetting.UserMode mode; + + @NotNull(message = "이메일 수신 여부를 선택해주세요.") + private Boolean emailNotification; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index 5e33ea3..0d0c532 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -1,7 +1,7 @@ package com.teamEWSN.gitdeun.user.entity; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; -import com.teamEWSN.gitdeun.common.util.BaseEntity; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; import jakarta.persistence.*; import lombok.*; @@ -13,7 +13,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "users") -public class User extends BaseEntity { +public class User extends AuditedEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java new file mode 100644 index 0000000..773133e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java @@ -0,0 +1,72 @@ +package com.teamEWSN.gitdeun.user.entity; + +import com.teamEWSN.gitdeun.user.dto.UserSettingUpdateRequestDto; +import jakarta.persistence.Entity; +import lombok.*; +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_settings") +public class UserSetting { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + // 화면 테마 (LIGHT, DARK) + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'LIGHT'") + private DisplayTheme theme = DisplayTheme.LIGHT; + + // 사용자 모드 (GENERAL, DEVELOPER) + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'GENERAL'") + private UserMode mode = UserMode.GENERAL; + + @Builder.Default + @Column(name = "email_notification", nullable = false) + @ColumnDefault("true") + private boolean emailNotification = true; + + + public enum DisplayTheme { + LIGHT, DARK + } + + public enum UserMode { + GENERAL, DEVELOPER + } + + @Builder + private UserSetting(User user, DisplayTheme theme, UserMode mode, boolean emailNotification) { + this.user = user; + this.theme = theme; + this.mode = mode; + this.emailNotification = emailNotification; + } + + public static UserSetting createDefault(User user) { + return UserSetting.builder() + .user(user) + .build(); + } + + public void update(UserSettingUpdateRequestDto dto) { + this.theme = dto.getTheme(); + this.mode = dto.getMode(); + this.emailNotification = dto.getEmailNotification(); + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserSettingMapper.java b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserSettingMapper.java new file mode 100644 index 0000000..916e774 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserSettingMapper.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.user.mapper; + +import com.teamEWSN.gitdeun.user.dto.UserSettingResponseDto; +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface UserSettingMapper { + + UserSettingResponseDto toResponseDto(UserSetting userSetting); + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserSettingRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserSettingRepository.java new file mode 100644 index 0000000..fb0228a --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserSettingRepository.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.user.repository; + +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserSettingRepository extends JpaRepository { + + // 사용자 ID로 설정 조회 + Optional findByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java index d17f8f6..4543ca7 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -6,11 +6,9 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.cookie.CookieUtil; import com.teamEWSN.gitdeun.common.jwt.*; -import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; -import com.teamEWSN.gitdeun.common.oauth.service.GitHubApiHelper; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; import jakarta.servlet.http.HttpServletResponse; @@ -19,9 +17,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Objects; + @Slf4j @Service @@ -33,7 +34,6 @@ public class AuthService { private final BlacklistService blacklistService; private final UserService userService; private final CookieUtil cookieUtil; - private final GitHubApiHelper gitHubApiHelper; private final UserRepository userRepository; private final SocialConnectionRepository socialConnectionRepository; @@ -49,7 +49,6 @@ public class AuthService { @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") private String githubRedirectUri; - // 로그 아웃 @Transactional public void logout(String accessToken, String refreshToken, HttpServletResponse response) { @@ -89,47 +88,36 @@ private Authentication createAuthentication(User user) { // 구글 -> Github 계정 연동 - @Transactional - public void connectGithubAccount(Long userId, String code) { + public void connectGithubAccount(OAuth2User githubUser, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - // GitHubApiHelper를 통해 Access Token 요청 - String accessToken = gitHubApiHelper.getAccessToken(code); - - // GitHubApiHelper를 통해 사용자 정보 요청 - GitHubResponseDto githubResponse = gitHubApiHelper.getUserInfo(accessToken); + String providerId = Objects.requireNonNull(githubUser.getAttribute("id")).toString(); + String accessToken = githubUser.getAttribute("access_token"); + String refreshToken = githubUser.getAttribute("refresh_token"); - String providerId = githubResponse.getProviderId(); - OauthProvider provider = OauthProvider.GITHUB; - - // 이미 다른 계정에 연동되어 있는지 확인 - socialConnectionRepository.findByProviderAndProviderId(provider, providerId) + // 이미 다른 계정에 연동되어 있는지, 동일 사용자에 의해 이미 연동되는지 확인 + socialConnectionRepository.findByProviderAndProviderId(OauthProvider.GITHUB, providerId) .ifPresent(connection -> { if (!connection.getUser().getId().equals(userId)) { throw new GlobalException(ErrorCode.ACCOUNT_ALREADY_LINKED); + } else { + log.info("이미 현재 사용자와 연동된 GitHub 계정입니다: {}", providerId); + return; } }); - // 현재 사용자에 대한 중복 연동 확인 (이미 연동되어 있으면 추가 로직 없음) - boolean alreadyLinked = user.getSocialConnections().stream() - .anyMatch(conn -> conn.getProvider() == provider && conn.getProviderId().equals(providerId)); - - if (alreadyLinked) { - log.info("이미 현재 사용자와 연동된 GitHub 계정입니다: {}", providerId); - return; // 이미 연동되었으므로 여기서 종료 - } // 신규 소셜 연동 정보 생성 및 저장 - SocialConnection newConnection = SocialConnection.builder() + SocialConnection connection = SocialConnection.builder() .user(user) - .provider(provider) + .provider(OauthProvider.GITHUB) .providerId(providerId) .accessToken(accessToken) - .refreshToken(null) + .refreshToken(refreshToken) .build(); - socialConnectionRepository.save(newConnection); - log.info("사용자(ID:{})에게 GitHub 계정(ProviderId:{}) 연동이 완료되었습니다.", userId, providerId); + socialConnectionRepository.save(connection); } + } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java new file mode 100644 index 0000000..a3a00ad --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java @@ -0,0 +1,61 @@ +package com.teamEWSN.gitdeun.user.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.user.dto.UserSettingResponseDto; +import com.teamEWSN.gitdeun.user.dto.UserSettingUpdateRequestDto; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import com.teamEWSN.gitdeun.user.mapper.UserSettingMapper; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.user.repository.UserSettingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserSettingService { + + private final UserSettingRepository userSettingRepository; + private final UserRepository userRepository; + private final UserSettingMapper userSettingMapper; + + /** + * 사용자 ID로 설정 정보 조회 + * 설정이 없는 경우 기본값을 생성하고 저장한 뒤 반환합니다. + * @param userId 조회할 사용자의 ID + * @return 사용자의 설정 정보 DTO + */ + @Transactional + public UserSettingResponseDto getSettings(Long userId) { + UserSetting userSetting = userSettingRepository.findByUserId(userId) + .orElseGet(() -> { + // 설정이 없는 경우, 기본 설정을 생성 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + UserSetting defaultSetting = UserSetting.createDefault(user); + return userSettingRepository.save(defaultSetting); + }); + + return userSettingMapper.toResponseDto(userSetting); + } + + /** + * 사용자 설정 업데이트 + * @param userId 업데이트할 사용자의 ID + * @param requestDto 업데이트할 설정 내용 + * @return 업데이트된 사용자의 설정 정보 DTO + */ + @Transactional // 쓰기 작업을 위한 @Transactional + public UserSettingResponseDto updateSettings(Long userId, UserSettingUpdateRequestDto requestDto) { + UserSetting userSetting = userSettingRepository.findByUserId(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_SETTING_NOT_FOUND_BY_ID)); + + // 엔티티의 update 메서드를 사용하여 상태 변경 + userSetting.update(requestDto); + + return userSettingMapper.toResponseDto(userSetting); + } +} + diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 69e048c..5f65fb5 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -9,11 +9,11 @@ spring: client: registration: google: - redirect-uri: http://localhost:8080/login/oauth2/code/google + redirect-uri: http://localhost:8080/login/oauth2/code/google # /oauth2/authorization/google 로그인 시작 URL github: client-id: ${GITHUB_DEV_CLIENT_ID} client-secret: ${GITHUB_DEV_CLIENT_SECRET} - redirect-uri: http://localhost:8080/login/oauth2/code/github + redirect-uri: http://localhost:8080/api/auth/github/callback # provider: # google: # authorization-uri: https://accounts.google.com/o/oauth2/v2/auth diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a8081ce..fbd1c5f 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -17,7 +17,7 @@ spring: github: client-id: ${GITHUB_PROD_CLIENT_ID} client-secret: ${GITHUB_PROD_CLIENT_SECRET} - redirect-uri: https://api.gitdeun.site/login/oauth2/code/github + redirect-uri: https://api.gitdeun.site/api/auth/github/callback jwt: