Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ out/
### environment files ###
.env
.env.prod
load-env.ps1

### docker test ###
docker-compose.override.yml
docker-compose.override.yml

15 changes: 15 additions & 0 deletions load-env.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Get-Content ".env" | ForEach-Object {
$line = $_.Trim()

if ($line -eq "" -or $line.StartsWith("#")) {
return
}

$name, $value = $line.Split("=", 2)

if ($name -and $value) {
[Environment]::SetEnvironmentVariable($name.Trim(), $value.Trim(), "Process")
}
}

Write-Host "Loaded .env variables into current PowerShell process."
2 changes: 2 additions & 0 deletions src/main/java/ceos/ipx/IpxBeApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@EnableConfigurationProperties({
GoogleOAuthProperties.class,
FrontendOAuthProperties.class
})
@SpringBootApplication
@ConfigurationPropertiesScan
public class IpxBeApplication {

public static void main(String[] args) {
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import ceos.ipx.domain.auth.service.GoogleOAuthService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import ceos.ipx.domain.auth.dto.GoogleOAuthTokenRequest;
import ceos.ipx.domain.auth.dto.OAuthTokenResponse;

import java.net.URI;

Expand Down Expand Up @@ -165,4 +167,26 @@ public ResponseEntity<Void> handleGoogleOAuthCallback(
.location(URI.create(frontendCallbackUrl))
.build();
}

@Operation(
summary = "Google OAuth code 토큰 교환 및 로그인 처리",
description = "FE OAuth Callback 화면에서 받은 Google Authorization Code를 전달받아 Google 사용자 정보를 조회하고, 기존 Google 회원이면 로그인 성공 처리하며 신규 Google 사용자이면 추가 회원가입 필요 상태를 반환합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Google OAuth 로그인 성공 또는 추가 회원가입 필요"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "비활성화된 계정"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "일반 로그인 계정으로 가입된 이메일"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "502", description = "Google 토큰 교환 또는 사용자 정보 조회 실패"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/oauth/token")
public ResponseEntity<ApiResponse<OAuthTokenResponse>> exchangeGoogleOAuthToken(
@Valid @RequestBody GoogleOAuthTokenRequest request,
HttpServletResponse httpServletResponse
) {
OAuthTokenResponse response = authService.exchangeGoogleOAuthToken(request, httpServletResponse);

return ResponseEntity.ok(ApiResponse.ok(response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ceos.ipx.domain.auth.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "Google OAuth code 토큰 교환 요청")
public record GoogleOAuthTokenRequest(

@NotBlank(message = "Google Authorization Code는 필수입니다.")
@Schema(description = "Google Authorization Code", example = "4/0AbCdEf...")
String code
) {
}
24 changes: 24 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/dto/GoogleTokenResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package ceos.ipx.domain.auth.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

public record GoogleTokenResponse(

@JsonProperty("access_token")
String accessToken,

@JsonProperty("expires_in")
Long expiresIn,

@JsonProperty("refresh_token")
String refreshToken,

String scope,

@JsonProperty("token_type")
String tokenType,

@JsonProperty("id_token")
String idToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package ceos.ipx.domain.auth.dto;

public record GoogleUserInfoResponse(
String id,
String email,
String name,
String picture
) {
}
6 changes: 6 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/dto/OAuthLoginStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package ceos.ipx.domain.auth.dto;

public enum OAuthLoginStatus {
LOGIN_SUCCESS,
NEED_SIGNUP
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package ceos.ipx.domain.auth.dto;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "OAuth 회원가입 필요 응답")
public record OAuthSignupRequiredResponse(

@Schema(description = "OAuth 회원가입 토큰")
String oauthSignupToken,

@Schema(description = "Google 이메일", example = "test@gmail.com")
String email,

@Schema(description = "Google 이름", example = "홍길동")
String name,

@Schema(description = "가입 제공자", example = "GOOGLE")
String provider
) {
}
62 changes: 62 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/dto/OAuthTokenResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package ceos.ipx.domain.auth.dto;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "OAuth token 교환 결과 응답")
public record OAuthTokenResponse(

@Schema(description = "OAuth 로그인 처리 상태", example = "LOGIN_SUCCESS")
OAuthLoginStatus status,

@Schema(description = "JWT AccessToken")
String accessToken,

@Schema(description = "토큰 타입", example = "Bearer")
String tokenType,

@Schema(description = "AccessToken 만료 시간(초)", example = "3600")
Long expiresIn,

@Schema(description = "로그인한 사용자 정보")
LoginUserResponse user,

@Schema(description = "OAuth 회원가입 토큰")
String oauthSignupToken,

@Schema(description = "Google 이메일", example = "test@gmail.com")
String email,

@Schema(description = "Google 이름", example = "홍길동")
String name,

@Schema(description = "가입 제공자", example = "GOOGLE")
String provider
) {
public static OAuthTokenResponse loginSuccess(LoginResponse loginResponse) {
return new OAuthTokenResponse(
OAuthLoginStatus.LOGIN_SUCCESS,
loginResponse.accessToken(),
loginResponse.tokenType(),
loginResponse.expiresIn(),
loginResponse.user(),
null,
null,
null,
null
);
}

public static OAuthTokenResponse needSignup(OAuthSignupRequiredResponse signupResponse) {
return new OAuthTokenResponse(
OAuthLoginStatus.NEED_SIGNUP,
null,
null,
null,
null,
signupResponse.oauthSignupToken(),
signupResponse.email(),
signupResponse.name(),
signupResponse.provider()
);
}
}
75 changes: 74 additions & 1 deletion src/main/java/ceos/ipx/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
import ceos.ipx.domain.auth.dto.PasswordResetRequest;
import ceos.ipx.domain.user.entity.UserProvider;
import java.util.regex.Pattern;

import ceos.ipx.domain.auth.dto.GoogleOAuthTokenRequest;
import ceos.ipx.domain.auth.dto.GoogleTokenResponse;
import ceos.ipx.domain.auth.dto.GoogleUserInfoResponse;
import ceos.ipx.domain.auth.dto.OAuthSignupRequiredResponse;
import ceos.ipx.domain.auth.dto.OAuthTokenResponse;

@Service
@RequiredArgsConstructor
Expand All @@ -39,6 +43,8 @@ public class AuthService {
private final AccessTokenBlacklistService accessTokenBlacklistService;
private final CookieUtils cookieUtils;
private final EmailVerificationService emailVerificationService;
private final GoogleOAuthClient googleOAuthClient;
private final OAuthSignupTokenService oauthSignupTokenService;

@Transactional
public SignUpResponse signUp(SignUpRequest request) {
Expand Down Expand Up @@ -111,6 +117,19 @@ public LoginResponse login(LoginRequest request, HttpServletResponse httpServlet
);
}

@Transactional
public OAuthTokenResponse exchangeGoogleOAuthToken(
GoogleOAuthTokenRequest request,
HttpServletResponse httpServletResponse
) {
GoogleTokenResponse googleTokenResponse = googleOAuthClient.exchangeCodeForToken(request.code());
GoogleUserInfoResponse googleUserInfo = googleOAuthClient.getUserInfo(googleTokenResponse.accessToken());

return userRepository.findByEmail(googleUserInfo.email())
.map(user -> handleExistingGoogleOAuthUser(user, httpServletResponse))
.orElseGet(() -> handleNewGoogleOAuthUser(googleUserInfo));
}

public ReissueResponse reissue(String refreshToken, HttpServletResponse httpServletResponse) {
if (refreshToken == null || refreshToken.isBlank()) {
throw new BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND);
Expand Down Expand Up @@ -176,6 +195,47 @@ public void resetPassword(PasswordResetRequest request) {
emailVerificationService.deletePasswordResetVerification(request.getVerificationToken(), email);
}

private OAuthTokenResponse handleExistingGoogleOAuthUser(
User user,
HttpServletResponse httpServletResponse
) {
if (!user.isActive()) {
throw new BusinessException(ErrorCode.INACTIVE_USER);
}

if (user.getProvider() == UserProvider.LOCAL) {
throw new BusinessException(ErrorCode.SOCIAL_LOGIN_NOT_ALLOWED);
}

if (user.getProvider() != UserProvider.GOOGLE) {
throw new BusinessException(ErrorCode.LOGIN_FAILED);
}

String accessToken = jwtTokenProvider.createAccessToken(user);
String refreshToken = jwtTokenProvider.createRefreshToken(user);

refreshTokenService.saveRefreshToken(
user.getId(),
refreshToken,
jwtTokenProvider.getRefreshTokenExpirationSeconds()
);

cookieUtils.addRefreshTokenCookie(
httpServletResponse,
refreshToken,
jwtTokenProvider.getRefreshTokenExpirationSeconds()
);

LoginResponse loginResponse = new LoginResponse(
accessToken,
jwtTokenProvider.getTokenType(),
jwtTokenProvider.getAccessTokenExpirationSeconds(),
LoginUserResponse.from(user)
);

return OAuthTokenResponse.loginSuccess(loginResponse);
}

private void validatePasswordResetUser(User user) {
if (!user.isActive()) {
throw new BusinessException(ErrorCode.INACTIVE_USER);
Expand Down Expand Up @@ -204,6 +264,19 @@ private void validateNewPassword(String newPassword, String newPasswordConfirm,
}
}

private OAuthTokenResponse handleNewGoogleOAuthUser(GoogleUserInfoResponse googleUserInfo) {
String oauthSignupToken = oauthSignupTokenService.saveGoogleUserInfo(googleUserInfo);

OAuthSignupRequiredResponse signupResponse = new OAuthSignupRequiredResponse(
oauthSignupToken,
googleUserInfo.email(),
googleUserInfo.name(),
UserProvider.GOOGLE.name()
);

return OAuthTokenResponse.needSignup(signupResponse);
}

@Transactional
public void logout(
String authorizationHeader,
Expand Down
Loading
Loading