diff --git a/.github/workflows/develop-ci.yml b/.github/workflows/develop-ci.yml new file mode 100644 index 0000000..4529545 --- /dev/null +++ b/.github/workflows/develop-ci.yml @@ -0,0 +1,78 @@ +name: Build & Push Docker Image to GHCR (develop) + +on: + push: + branches: + - develop + +jobs: + build: + # runs-on: self-hosted + runs-on: ubuntu-latest + + env: + IMAGE_NAME: ghcr.io/kteventee/eventee-auth-service + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set image tag + run: | + echo "IMAGE_TAG=develop-${GITHUB_SHA::7}" >> $GITHUB_ENV + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "temurin" + + - name: Configure Gradle for GitHub Packages + run: | + mkdir -p ~/.gradle + cat < ~/.gradle/gradle.properties + gpr.user=${{ github.actor }} + gpr.token=${{ secrets.COMMON_TOKEN_GITHUB }} + EOF + + - name: Grant execute permission for Gradlew + run: chmod +x ./gradlew + + - name: Build JAR + run: ./gradlew clean bootJar --no-daemon + + + - name: Login to GHCR + run: | + echo "${{ secrets.GHCR_GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Build & Push Docker image + run: | + docker build -t $IMAGE_NAME:${IMAGE_TAG} . + docker push $IMAGE_NAME:${IMAGE_TAG} + + # update GitOps + - name: Install kustomize + run: | + curl -s https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh | bash + sudo mv kustomize /usr/local/bin + + - name: Checkout GitOps repo + uses: actions/checkout@v4 + with: + repository: KTEventee/eventee-gitops + token: ${{ secrets.GITOPS_TOKEN }} + path: gitops + + - name: Update auth image tag in GitOps + run: | + cd gitops/dev/auth + kustomize edit set image REPLACE_IMAGE_AUTH=$IMAGE_NAME:${IMAGE_TAG} + + - name: Commit & push GitOps change + run: | + cd gitops + git config user.name "eventee-develop-ci" + git config user.email "ci@eventee.cloud" + git commit -am "(chore/dev): deploy auth ${IMAGE_TAG}" + git push origin main \ No newline at end of file diff --git a/.github/workflows/prod-ci.yml b/.github/workflows/prod-ci.yml new file mode 100644 index 0000000..233c6af --- /dev/null +++ b/.github/workflows/prod-ci.yml @@ -0,0 +1,85 @@ +name: Build & Push Docker Image to ECR (build and push prod) + +on: + pull_request: + branches: + - prod + types: + - closed + +permissions: + contents: read + +jobs: + build: + environment: prod + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + env: + AWS_REGION: ${{ secrets.AWS_REGION }} + ECR_REPOSITORY: ${{ secrets.AWS_ECR_REPOSITORY }} + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "temurin" + + - name: Configure Gradle for GitHub Packages + run: | + mkdir -p ~/.gradle + cat < ~/.gradle/gradle.properties + gpr.user=${{ github.actor }} + gpr.token=${{ secrets.COMMON_TOKEN_GITHUB }} + EOF + + - name: Grant execute permission for Gradlew + run: chmod +x ./gradlew + + - name: Build JAR + run: ./gradlew clean bootJar --no-daemon + + - name: Login to ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Generate image tag + id: image + run: | + SHORT_SHA=$(echo $GITHUB_SHA | cut -c1-7) + echo "tag=sha-${SHORT_SHA}" >> $GITHUB_OUTPUT + + - name: Build and Push Image + run: | + docker build -t ${{ steps.login-ecr.outputs.registry }}/$ECR_REPOSITORY:${{ steps.image.outputs.tag }} . + docker push ${{ steps.login-ecr.outputs.registry }}/$ECR_REPOSITORY:${{ steps.image.outputs.tag }} + + - name: Notify GitOps repo (deploy prod) + if: success() + run: | + curl -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.GITOPS_TOKEN }}" \ + https://api.github.com/repos/KTEventee/eventee-gitops/dispatches \ + -d '{ + "event_type": "deploy-prod", + "client_payload": { + "service" : "auth", + "image": "'"${{ steps.login-ecr.outputs.registry }}/$ECR_REPOSITORY"'", + "tag": "'"${{ steps.image.outputs.tag }}"'" + } + }' + + diff --git a/.gitignore b/.gitignore index c2065bc..faf0fc4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ out/ ### VS Code ### .vscode/ +.DS_Store +gradle.properties + +src/main/resources/application-local.properties +docker-compose.yml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9557fa5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM amazoncorretto:17-alpine3.22 + +RUN addgroup -S app && adduser -S app -G app +WORKDIR /home/app +COPY build/libs/*.jar app.jar + +RUN chown -R app:app /home/app +USER app + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index cbde530..843ea89 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '4.0.0' + id 'org.springframework.boot' version '3.2.6' id 'io.spring.dependency-management' version '1.1.7' } @@ -19,11 +19,58 @@ repositories { } dependencies { + // 공통 모듈 + implementation "eventee.server:common:v0.5.0" + + // 스프링 스타터 implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // 롬복 + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + //jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.5' + + //oauth +// implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + //swagger + implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0" + + //postgres + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.postgresql:postgresql:42.7.3' + runtimeOnly 'org.postgresql:postgresql' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + + // WebClientt + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + } tasks.named('test') { useJUnitPlatform() } + +repositories { + maven { + name = 'GitHubPackages' + url = uri("https://maven.pkg.github.com/KTEventee/eventee-common") // GitHub 리포지토리 URL + credentials { + username = project.findProperty("gpr.user") ?: System.getenv("COMMON_USERNAME_GITHUB") + password = project.findProperty("gpr.token") ?: System.getenv("COMMON_TOKEN_GITHUB") + } + } +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2..b82aa23 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/eventee/server/auth/client/MemberApiClient.java b/src/main/java/eventee/server/auth/client/MemberApiClient.java new file mode 100644 index 0000000..620a253 --- /dev/null +++ b/src/main/java/eventee/server/auth/client/MemberApiClient.java @@ -0,0 +1,28 @@ +package eventee.server.auth.client; + +import eventee.server.auth.client.dto.InternalGoogleLoginRequest; +import eventee.server.auth.client.dto.InternalMemberResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + + +@Component +@RequiredArgsConstructor +public class MemberApiClient { + + private final WebClient memberWebClient; + + public InternalMemberResponse findOrCreateByGoogle( + InternalGoogleLoginRequest request + ) { + return memberWebClient.post() + .uri("/internal/members/google") + .bodyValue(request) + .retrieve() + .bodyToMono(InternalMemberResponse.class) + .block(); + } +} + + diff --git a/src/main/java/eventee/server/auth/client/dto/InternalGoogleLoginRequest.java b/src/main/java/eventee/server/auth/client/dto/InternalGoogleLoginRequest.java new file mode 100644 index 0000000..f848f26 --- /dev/null +++ b/src/main/java/eventee/server/auth/client/dto/InternalGoogleLoginRequest.java @@ -0,0 +1,7 @@ +package eventee.server.auth.client.dto; + +public record InternalGoogleLoginRequest( + String socialId, + String email, + String nickname +) {} diff --git a/src/main/java/eventee/server/auth/client/dto/InternalMemberResponse.java b/src/main/java/eventee/server/auth/client/dto/InternalMemberResponse.java new file mode 100644 index 0000000..7a461b1 --- /dev/null +++ b/src/main/java/eventee/server/auth/client/dto/InternalMemberResponse.java @@ -0,0 +1,6 @@ +package eventee.server.auth.client.dto; + +public record InternalMemberResponse( + Long memberId, + boolean isNew +) {} diff --git a/src/main/java/eventee/server/auth/config/WebClientConfig.java b/src/main/java/eventee/server/auth/config/WebClientConfig.java new file mode 100644 index 0000000..426944c --- /dev/null +++ b/src/main/java/eventee/server/auth/config/WebClientConfig.java @@ -0,0 +1,19 @@ +package eventee.server.auth.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient memberWebClient( + @Value("${member.service.base-url}") String memberBaseUrl + ) { + return WebClient.builder() + .baseUrl(memberBaseUrl) + .build(); + } +} diff --git a/src/main/java/eventee/server/auth/controller/AuthController.java b/src/main/java/eventee/server/auth/controller/AuthController.java new file mode 100644 index 0000000..93e4a9b --- /dev/null +++ b/src/main/java/eventee/server/auth/controller/AuthController.java @@ -0,0 +1,85 @@ +package eventee.server.auth.controller; + +import eventee.server.auth.dto.LoginResponse; +import eventee.server.auth.service.CookieHelper; +import eventee.server.auth.service.GoogleTokenService; +import eventee.server.common.exception.BaseResponse; +import eventee.server.common.exception.codes.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Auth", description = "소셜 로그인 API") +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final GoogleTokenService googleTokenService; + private final CookieHelper cookieHelper; + + @Operation(summary = "구글 로그인", description = "구글 OAuth 인증 코드로 로그인 처리 및 JWT 토큰 발급") + @GetMapping("/google") + public void processGoogleLogin( + @RequestParam("code") String code, + @RequestParam("state") String state, + HttpServletResponse response + ) throws IOException { + + LoginResponse loginResponse = googleTokenService.handleLogin(code); + + ResponseCookie refreshCookie = cookieHelper.createHttpOnlyCookie( + "refreshToken", + loginResponse.refreshToken() + ); + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + + // 프론트엔드로 리다이렉트 + String redirectUrl = "https://www.eventee.cloud/oauth/callback/google/success" +// String redirectUrl = "http://localhost:3000/oauth/callback/google/success" + + "?accessToken=" + loginResponse.accessToken() + + "&email=" + loginResponse.email() + + "&socialId=" + loginResponse.socialId() + + "&state=" + state; + + response.sendRedirect(redirectUrl); + } + + + // 로그아웃 + @Operation( + summary = "로그아웃", + description = """ + 로그인된 사용자의 Refresh Token을 무효화하고, + HttpOnly 쿠키에 저장된 refresh token을 삭제합니다. + """ + ) + @PostMapping("/logout") + public BaseResponse logout( + @RequestParam Long memberId, + @CookieValue(value = "refreshToken", required = false) String refreshToken, + HttpServletResponse response + ) { + + googleTokenService.logout(memberId, refreshToken); + + Cookie cookie = new Cookie("refreshToken", null); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + + return BaseResponse.of(SuccessCode.SUCCESS, "로그아웃 완료"); + } + +} diff --git a/src/main/java/eventee/server/auth/dto/GoogleTokenResponse.java b/src/main/java/eventee/server/auth/dto/GoogleTokenResponse.java new file mode 100644 index 0000000..87ab2d9 --- /dev/null +++ b/src/main/java/eventee/server/auth/dto/GoogleTokenResponse.java @@ -0,0 +1,12 @@ +package eventee.server.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + + +public record GoogleTokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") Long expiresIn, + @JsonProperty("scope") String scope, + @JsonProperty("token_type") String tokenType, + @JsonProperty("id_token") String idToken +) {} diff --git a/src/main/java/eventee/server/auth/dto/LoginResponse.java b/src/main/java/eventee/server/auth/dto/LoginResponse.java new file mode 100644 index 0000000..f58f193 --- /dev/null +++ b/src/main/java/eventee/server/auth/dto/LoginResponse.java @@ -0,0 +1,15 @@ +package eventee.server.auth.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public record LoginResponse( + String email, + String socialId, + Long memberId, + String accessToken, + @JsonIgnore String refreshToken +) { + public LoginResponse(String email, String socialId, Long memberId, String accessToken) { + this(email, socialId, memberId, accessToken, null); + } +} diff --git a/src/main/java/eventee/server/auth/dto/OAuthAttributes.java b/src/main/java/eventee/server/auth/dto/OAuthAttributes.java new file mode 100644 index 0000000..f72aab0 --- /dev/null +++ b/src/main/java/eventee/server/auth/dto/OAuthAttributes.java @@ -0,0 +1,24 @@ +package eventee.server.auth.dto; + +import java.util.Map; +import lombok.Builder; + +@Builder +public record OAuthAttributes( + Map attributes, + String sub, + String email, + String name +) { + + + public static OAuthAttributes of(Map attributes) { + return OAuthAttributes.builder() + .attributes(attributes) + .sub((String) attributes.get("sub")) + .email((String) attributes.get("email")) + .name((String) attributes.get("name")) + .build(); + } + +} diff --git a/src/main/java/eventee/server/auth/exception/AuthHandler.java b/src/main/java/eventee/server/auth/exception/AuthHandler.java new file mode 100644 index 0000000..7dbf3b9 --- /dev/null +++ b/src/main/java/eventee/server/auth/exception/AuthHandler.java @@ -0,0 +1,11 @@ +package eventee.server.auth.exception; + +import eventee.server.common.exception.BaseException; +import eventee.server.common.exception.codes.BaseCode; + +public class AuthHandler extends BaseException { + + public AuthHandler(BaseCode code) { + super(code); + } +} \ No newline at end of file diff --git a/src/main/java/eventee/server/auth/exception/status/AuthErrorStatus.java b/src/main/java/eventee/server/auth/exception/status/AuthErrorStatus.java new file mode 100644 index 0000000..a9f191c --- /dev/null +++ b/src/main/java/eventee/server/auth/exception/status/AuthErrorStatus.java @@ -0,0 +1,38 @@ +package eventee.server.auth.exception.status; + + +import eventee.server.common.exception.codes.BaseCode; +import eventee.server.common.exception.codes.reason.Reason; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AuthErrorStatus implements BaseCode { + + // 로그아웃 관련 + AUTH_LOGOUT_REFRESH_TOKEN_MISSING(HttpStatus.BAD_REQUEST, "AUTH-0200", "쿠키에 Refresh Token이 존재하지 않습니다."), + AUTH_LOGOUT_TOKEN_NOT_FOUND_IN_REDIS(HttpStatus.BAD_REQUEST, "AUTH-0201", "Redis에 저장된 Refresh Token이 존재하지 않습니다."), + AUTH_LOGOUT_TOKEN_MISMATCH(HttpStatus.UNAUTHORIZED, "AUTH-0202", "요청한 Refresh Token이 서버에 저장된 값과 일치하지 않습니다."), + AUTH_LOGOUT_REDIS_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH-0203", "Redis에서 Refresh Token 삭제 중 오류가 발생했습니다."), + AUTH_LOGOUT_UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH-0204", "로그아웃 처리 중 알 수 없는 오류가 발생했습니다."), + AUTH_IMAGE_PRESIGNED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH-0105", "Presigned URL 생성 중 오류가 발생했습니다."), + AUTH_IMAGE_INVALID_REQUEST(HttpStatus.BAD_REQUEST, "AUTH-0106", "이미지 업로드 요청 데이터가 유효하지 않습니다."); + + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public Reason.ReasonDto getReasonHttpStatus() { + return Reason.ReasonDto.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/eventee/server/auth/service/CookieHelper.java b/src/main/java/eventee/server/auth/service/CookieHelper.java new file mode 100644 index 0000000..a4add11 --- /dev/null +++ b/src/main/java/eventee/server/auth/service/CookieHelper.java @@ -0,0 +1,40 @@ +package eventee.server.auth.service; + +import eventee.server.common.exception.BaseException; +import eventee.server.common.exception.codes.ErrorCode; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieHelper { + + // Refresh Token 또는 Access Token 등을 HttpOnly 쿠키로 생성 + public ResponseCookie createHttpOnlyCookie(String name, String value) { + return ResponseCookie.from(name, value) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(7 * 24 * 60 * 60) // 7일 + .sameSite("Strict") + .build(); + } + + /** + * Cookie 헤더에서 refreshToken 값을 추출 + * @param cookieHeader HTTP 요청 헤더의 Cookie 문자열 + * @return refreshToken 값 + */ + public String extractRefreshToken(String cookieHeader) { + if (cookieHeader == null || !cookieHeader.contains("refreshToken")) { + throw new BaseException(ErrorCode.REFRESH_TOKEN_NOT_VALID); + } + + for (String cookie : cookieHeader.split(";")) { + if (cookie.trim().startsWith("refreshToken=")) { + return cookie.split("=")[1].trim(); + } + } + + throw new BaseException(ErrorCode.REFRESH_TOKEN_NOT_VALID); + } +} diff --git a/src/main/java/eventee/server/auth/service/GoogleTokenService.java b/src/main/java/eventee/server/auth/service/GoogleTokenService.java new file mode 100644 index 0000000..89c4867 --- /dev/null +++ b/src/main/java/eventee/server/auth/service/GoogleTokenService.java @@ -0,0 +1,186 @@ +package eventee.server.auth.service; + +import eventee.server.auth.client.MemberApiClient; +import eventee.server.auth.client.dto.InternalGoogleLoginRequest; +import eventee.server.auth.client.dto.InternalMemberResponse; +import eventee.server.auth.dto.GoogleTokenResponse; +import eventee.server.auth.dto.LoginResponse; +import eventee.server.auth.dto.OAuthAttributes; +import eventee.server.auth.exception.AuthHandler; +import eventee.server.auth.exception.status.AuthErrorStatus; +import eventee.server.auth.token.TokenProvider; +import eventee.server.auth.token.vo.AccessToken; +import eventee.server.auth.token.vo.RefreshToken; +import eventee.server.common.exception.BaseException; +import eventee.server.common.exception.codes.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleTokenService implements OAuth2TokenService { + + private final RestTemplate restTemplate; + private final TokenProvider tokenProvider; + private final RedisTemplate redisTemplate; + private final MemberApiClient memberClient; + + private static final String REFRESH_TOKEN_PREFIX = "REFRESH:"; + private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000L * 60 * 60 * 24 * 2; // 2일 + + @Value("${spring.security.oauth2.client.registration.google.client-id}") + private String clientId; + @Value("${spring.security.oauth2.client.registration.google.client-secret}") + private String clientSecret; + @Value("${spring.security.oauth2.client.provider.google.token-uri}") + private String tokenUri; + @Value("${spring.security.oauth2.client.provider.google.user-info-uri}") + private String userInfoUri; + @Value("${spring.security.oauth2.client.registration.google.authorization-grant-type}") + private String grantType; + @Value("${spring.security.oauth2.client.registration.google.redirect-uri}") + private String redirectUri; + + @Transactional + @Override + public LoginResponse handleLogin(String code) { + try { + String decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8); + + // 1) Google Access Token 얻기 + GoogleTokenResponse tokenResponse = getAccessToken(decodedCode); + String googleAccessToken = tokenResponse.accessToken(); + + // 2) Google 사용자 정보 조회 + OAuthAttributes attributes = getUserInfo(googleAccessToken); + + // 3) Member Service에 회원 생성/조회 요청 + InternalMemberResponse member = + memberClient.findOrCreateByGoogle( + new InternalGoogleLoginRequest( + attributes.sub(), + attributes.email(), + attributes.name() + ) + ); + + // 4) JWT 생성 + AccessToken accessToken = + tokenProvider.generateAccessToken(member.memberId()); + RefreshToken refreshToken = + tokenProvider.generateRefreshToken(member.memberId()); + + // 5) Redis에 Refresh Token 저장 + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + member.memberId(), + refreshToken.token(), + REFRESH_TOKEN_EXPIRE_TIME, + TimeUnit.MILLISECONDS + ); + + + return new LoginResponse( + attributes.email(), + attributes.sub(), + member.memberId(), + accessToken.token(), + refreshToken.token() + ); + + } catch (Exception e) { + log.error("[구글 로그인 실패] error={}", e.getMessage(), e); + throw new BaseException(ErrorCode._INTERNAL_SERVER_ERROR); + } + } + + + @Override + @Transactional + public void logout(Long memberId, String refreshToken) { + + if (memberId == null || refreshToken == null || refreshToken.isBlank()) { + throw new AuthHandler(AuthErrorStatus.AUTH_LOGOUT_REFRESH_TOKEN_MISSING); + } + + String redisKey = REFRESH_TOKEN_PREFIX + memberId; + String storedToken = redisTemplate.opsForValue().get(redisKey); + + if (storedToken == null) { + throw new AuthHandler(AuthErrorStatus.AUTH_LOGOUT_TOKEN_NOT_FOUND_IN_REDIS); + } + + if (!storedToken.equals(refreshToken)) { + throw new AuthHandler(AuthErrorStatus.AUTH_LOGOUT_TOKEN_MISMATCH); + } + + Boolean deleted = redisTemplate.delete(redisKey); + if (Boolean.FALSE.equals(deleted)) { + throw new AuthHandler(AuthErrorStatus.AUTH_LOGOUT_REDIS_DELETE_FAILED); + } + + log.info("[로그아웃 성공] memberId={}", memberId); + } + + + + @Override + @Transactional + public GoogleTokenResponse getAccessToken(String code) { + String url = UriComponentsBuilder.fromHttpUrl(tokenUri) + .queryParam("grant_type", grantType) + .queryParam("client_id", clientId) + .queryParam("client_secret", clientSecret) + .queryParam("redirect_uri", redirectUri) + .queryParam("code", code) + .toUriString(); + + try { + ResponseEntity response = + restTemplate.exchange(url, HttpMethod.POST, null, GoogleTokenResponse.class); + + return response.getBody(); + } catch (Exception e) { + throw new BaseException(ErrorCode.INVALID_TOKEN); + } + } + + @Override + @Transactional + public OAuthAttributes getUserInfo(String accessToken) { + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + HttpEntity request = new HttpEntity<>(headers); + + try { + ResponseEntity> response = restTemplate.exchange( + userInfoUri, HttpMethod.GET, request, new ParameterizedTypeReference<>() {}); + + return OAuthAttributes.of(response.getBody()); + + } catch (Exception e) { + throw new BaseException(ErrorCode.MEMBER_NOT_FOUND); + } + } + + private String mask(String raw) { + if (raw == null) return "null"; + int visible = Math.min(6, raw.length()); + return raw.substring(0, visible) + "...(" + raw.length() + ")"; + } +} diff --git a/src/main/java/eventee/server/auth/service/OAuth2TokenService.java b/src/main/java/eventee/server/auth/service/OAuth2TokenService.java new file mode 100644 index 0000000..3683161 --- /dev/null +++ b/src/main/java/eventee/server/auth/service/OAuth2TokenService.java @@ -0,0 +1,16 @@ +package eventee.server.auth.service; + +import eventee.server.auth.dto.GoogleTokenResponse; +import eventee.server.auth.dto.LoginResponse; +import eventee.server.auth.dto.OAuthAttributes; + +public interface OAuth2TokenService { + GoogleTokenResponse getAccessToken(String code); + + OAuthAttributes getUserInfo(String accessToken); + + LoginResponse handleLogin(String code); + void logout(Long memberId, String refreshToken); + + +} diff --git a/src/main/java/eventee/server/auth/token/JwtProperties.java b/src/main/java/eventee/server/auth/token/JwtProperties.java new file mode 100644 index 0000000..52d90bb --- /dev/null +++ b/src/main/java/eventee/server/auth/token/JwtProperties.java @@ -0,0 +1,11 @@ +package eventee.server.auth.token; + +public class JwtProperties { + public static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분 + public static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 1주일 + public static final long LOGIN_EXPIRE_TIME = 1000 * 60 * 60*2; // 2시간 + public static final String JWT_ACCESS_TOKEN_HEADER_NAME = "Authorization"; + public static final String JWT_ACCESS_TOKEN_TYPE = "Bearer "; + public static final String JWT_REFRESH_TOKEN_COOKIE_NAME = "X-REFRESH-TOKEN"; + public static final String JWT_ACCESS_TOKEN_COOKIE_NAME = "ACCESS_TOKEN"; +} \ No newline at end of file diff --git a/src/main/java/eventee/server/auth/token/JwtProvider.java b/src/main/java/eventee/server/auth/token/JwtProvider.java new file mode 100644 index 0000000..3e365ff --- /dev/null +++ b/src/main/java/eventee/server/auth/token/JwtProvider.java @@ -0,0 +1,72 @@ +package eventee.server.auth.token; + +import static eventee.server.auth.token.JwtProperties.ACCESS_TOKEN_EXPIRE_TIME; +import static eventee.server.auth.token.JwtProperties.REFRESH_TOKEN_EXPIRE_TIME; + +import eventee.server.auth.token.vo.AccessToken; +import eventee.server.auth.token.vo.RefreshToken; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; +import javax.crypto.SecretKey; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Getter +@Slf4j +@Component +public class JwtProvider implements TokenProvider { + + private static final String ISSUER = "eventee-auth"; + + private final SecretKey secretKey; + private final JwtParser jwtParser; + + public JwtProvider(@Value("${jwt.secret}") String secret) { + byte[] decodedKey = + Base64.getDecoder().decode(secret.getBytes(StandardCharsets.UTF_8)); + this.secretKey = Keys.hmacShaKeyFor(decodedKey); + this.jwtParser = Jwts.parser().verifyWith(this.secretKey).build(); + } + + @Override + public AccessToken generateAccessToken(Long memberId) { + + Date now = new Date(); + Date expiry = new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME); + + String token = Jwts.builder() + .claim("type", "access") + .claim("memberId", memberId) + .issuer(ISSUER) + .issuedAt(now) + .expiration(expiry) + .signWith(secretKey) + .compact(); + + return AccessToken.of(token); + } + + @Override + public RefreshToken generateRefreshToken(Long memberId) { + + Date now = new Date(); + Date expiry = new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME); + + String token = Jwts.builder() + .claim("type", "refresh") + .claim("memberId", memberId) + .issuer(ISSUER) + .issuedAt(now) + .expiration(expiry) + .signWith(secretKey) + .compact(); + + return RefreshToken.of(token); + } +} diff --git a/src/main/java/eventee/server/auth/token/TokenProvider.java b/src/main/java/eventee/server/auth/token/TokenProvider.java new file mode 100644 index 0000000..77ac415 --- /dev/null +++ b/src/main/java/eventee/server/auth/token/TokenProvider.java @@ -0,0 +1,12 @@ +package eventee.server.auth.token; + +import eventee.server.auth.token.vo.AccessToken; +import eventee.server.auth.token.vo.RefreshToken; + +public interface TokenProvider { + + AccessToken generateAccessToken(Long memberId); + RefreshToken generateRefreshToken(Long memberId); + + +} diff --git a/src/main/java/eventee/server/auth/token/vo/AccessToken.java b/src/main/java/eventee/server/auth/token/vo/AccessToken.java new file mode 100644 index 0000000..7516448 --- /dev/null +++ b/src/main/java/eventee/server/auth/token/vo/AccessToken.java @@ -0,0 +1,9 @@ +package eventee.server.auth.token.vo; + +public record AccessToken( + String token +) { + public static AccessToken of(String token) { + return new AccessToken(token); + } +} \ No newline at end of file diff --git a/src/main/java/eventee/server/auth/token/vo/RefreshToken.java b/src/main/java/eventee/server/auth/token/vo/RefreshToken.java new file mode 100644 index 0000000..da00c1c --- /dev/null +++ b/src/main/java/eventee/server/auth/token/vo/RefreshToken.java @@ -0,0 +1,11 @@ +package eventee.server.auth.token.vo; + +public record RefreshToken( + String token +) { + public static RefreshToken of(String token) { + return new RefreshToken(token); + } +} + + diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..7494bba --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,46 @@ +spring.application.name=auth +server.port=8080 +server.address=0.0.0.0 +server.servlet.context-path=/api/v1/auth +server.forward-headers-strategy=framework + +spring.datasource.url=jdbc:postgresql://${DB_HOST}:5432/eventee +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +spring.data.redis.host=${SPRING_DATA_REDIS_HOST} +spring.data.redis.port=${SPRING_DATA_REDIS_PORT} +spring.data.redis.ssl.enabled=false + +aws.s3.bucket=${S3_BUCKET} +aws.s3.region=${AWS_REGION} +aws.s3.allowed-content-types[0]=image/jpeg +aws.s3.allowed-content-types[1]=image/png +aws.s3.allowed-content-types[2]=image/webp +aws.s3.max-upload-size-bytes=5242880 + +logging.level.root=INFO +logging.level.org.springframework.web=INFO +logging.level.org.hibernate.SQL=ERROR + +management.endpoints.web.exposure.include=health,prometheus +management.endpoint.health.probes.enabled=true +management.health.redis.enabled=false + +cors.allowed-origins=${CORS_ALLOWED_ORIGINS_AUTH} +swagger.server-url=${SWAGGER_SERVER_URL_AUTH} +member.service.base-url=${MEMBER_SERVICE_BASE_URL} + +jwt.secret=${JWT_SECRET} +spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} +spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} +spring.security.oauth2.client.registration.google.redirect-uri=${GOOGLE_REDIRECT_URI} +spring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code +spring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token +spring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v3/userinfo \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..bf7ed74 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,46 @@ +spring.application.name=auth +server.port=8080 +server.address=0.0.0.0 +server.servlet.context-path=/api/v1/auth +server.forward-headers-strategy=framework + +spring.datasource.url=jdbc:postgresql://${DB_HOST}:5432/eventee +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +spring.data.redis.host=${SPRING_DATA_REDIS_HOST} +spring.data.redis.port=${SPRING_DATA_REDIS_PORT} +spring.data.redis.ssl.enabled=true + +aws.s3.bucket=${S3_BUCKET} +aws.s3.region=${AWS_REGION} +aws.s3.allowed-content-types[0]=image/jpeg +aws.s3.allowed-content-types[1]=image/png +aws.s3.allowed-content-types[2]=image/webp +aws.s3.max-upload-size-bytes=5242880 + +logging.level.root=INFO +logging.level.org.springframework.web=INFO +logging.level.org.hibernate.SQL=ERROR + +management.endpoints.web.exposure.include=health,prometheus +management.endpoint.health.probes.enabled=true +management.health.redis.enabled=false + +cors.allowed-origins=${CORS_ALLOWED_ORIGINS_AUTH} +swagger.server-url=${SWAGGER_SERVER_URL_AUTH} +member.service.base-url=${MEMBER_SERVICE_BASE_URL} + +jwt.secret=${JWT_SECRET} +spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} +spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} +spring.security.oauth2.client.registration.google.redirect-uri=${GOOGLE_REDIRECT_URI} +spring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code +spring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token +spring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v3/userinfo \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 95952ce..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=auth