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
26 changes: 23 additions & 3 deletions .github/workflows/cicd-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,29 @@ on:
pull_request:
branches: [ "develop" ]


jobs:
deploy:
ci:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Grant gradlew permission
run: chmod +x gradlew

- name: Run format check & tests
run: ./gradlew clean check

cd:
needs: ci
if: github.event_name == 'push'
runs-on: ubuntu-latest

steps:
Expand All @@ -25,7 +45,7 @@ jobs:
run: chmod +x gradlew

- name: Build JAR
run: ./gradlew clean build -x test
run: ./gradlew clean bootJar

# buildx 세팅 (amd64)
- name: Set up Docker Buildx
Expand Down
25 changes: 25 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
id 'java'
id 'org.springframework.boot' version '4.0.1'
id 'io.spring.dependency-management' version '1.1.7'
id 'com.diffplug.spotless' version '8.2.0'
Comment thread
seoyeon2001 marked this conversation as resolved.
}

group = 'com.payper'
Expand Down Expand Up @@ -50,8 +51,32 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testImplementation 'com.h2database:h2'
Comment thread
seoyeon2001 marked this conversation as resolved.
}

spotless {
java {
target("**/*.java")
palantirJavaFormat().formatJavadoc(true) // 포맷 스타일
removeUnusedImports() // 불필요한 import 제거
trimTrailingWhitespace() // 명시: 불필요한 공백 제거
endWithNewline() // 명시: 파일 끝에 새로운 줄 추가
}
}

tasks.named('test') {
useJUnitPlatform()
}

tasks.register('updateGitHooks', Copy) {
from("${rootDir}/scripts/pre-push")
into("${rootDir}/.git/hooks")

doLast {
file("${rootDir}/.git/hooks/pre-push").setExecutable(true)
}
}

tasks.named('check') {
dependsOn("spotlessCheck")
Comment thread
seoyeon2001 marked this conversation as resolved.
}
13 changes: 13 additions & 0 deletions scripts/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash

PROJECT_ROOT=$(git rev-parse --show-toplevel)

echo "Running spotlessCheck before push..."
cd $PROJECT_ROOT && ./gradlew spotlessCheck
Comment thread
seoyeon2001 marked this conversation as resolved.

if [ $? -ne 0 ]; then
echo "Spotless check failed. Please run ./gradlew spotlessApply"
exit 1
fi

echo "Spotless check passed."
7 changes: 3 additions & 4 deletions src/main/java/com/payper/server/PayperServerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
@SpringBootApplication
public class PayperServerApplication {

public static void main(String[] args) {
SpringApplication.run(PayperServerApplication.class, args);
}

public static void main(String[] args) {
SpringApplication.run(PayperServerApplication.class, args);
}
}
26 changes: 15 additions & 11 deletions src/main/java/com/payper/server/auth/AuthApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,25 @@
@Tag(name = "인증", description = "로그인, 토큰 재발급, 로그아웃 API")
public interface AuthApi {

@Operation(summary = "로그인", description = "OAuth 토큰으로 로그인 (미가입 시 자동 회원가입). Access Token은 응답 바디, Refresh Token은 HttpOnly 쿠키로 발급됩니다.", security = {})
ResponseEntity<ApiResponse<LoginSuccessResponse>> enroll(
LoginRequest loginRequest,
HttpServletResponse response
);
@Operation(
summary = "로그인",
description = "OAuth 토큰으로 로그인 (미가입 시 자동 회원가입). Access Token은 응답 바디, Refresh Token은 HttpOnly 쿠키로 발급됩니다.",
security = {})
ResponseEntity<ApiResponse<LoginSuccessResponse>> enroll(LoginRequest loginRequest, HttpServletResponse response);

@Operation(summary = "토큰 재발급", description = "Refresh Token(쿠키)으로 새로운 Access Token을 발급합니다.", security = {})
@Operation(
summary = "토큰 재발급",
description = "Refresh Token(쿠키)으로 새로운 Access Token을 발급합니다.",
security = {})
ResponseEntity<ApiResponse<ReissueSuccessResponse>> reissue(
@Parameter(description = "Refresh Token (HttpOnly 쿠키로 자동 전송)") String refreshToken,
HttpServletResponse response
);
HttpServletResponse response);

@Operation(summary = "로그아웃", description = "Refresh Token을 무효화하고 쿠키를 삭제합니다.", security = {})
@Operation(
summary = "로그아웃",
description = "Refresh Token을 무효화하고 쿠키를 삭제합니다.",
security = {})
ResponseEntity<ApiResponse<String>> logout(
@Parameter(description = "Refresh Token (HttpOnly 쿠키로 자동 전송)") String refreshToken,
HttpServletResponse response
);
HttpServletResponse response);
}
30 changes: 9 additions & 21 deletions src/main/java/com/payper/server/auth/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,43 +18,31 @@
public class AuthController implements AuthApi {
private final AuthService authService;

@PostMapping("/login") //로그인 시도 -> 필요하면 가입 -> 로그인
@PostMapping("/login") // 로그인 시도 -> 필요하면 가입 -> 로그인
public ResponseEntity<ApiResponse<LoginSuccessResponse>> enroll(
@RequestBody LoginRequest loginRequest,
HttpServletResponse response
) {
//OAuth 리소스 서버와 통신 문제 생기면 예외
OAuthUserInfo oauthUserInfo = authService.findOAuthUserInfo(
loginRequest.getOauthToken(),
AuthType.KAKAO
);

//유저가 inactive(밴, 정지) 되어있으면 예외
@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
// OAuth 리소스 서버와 통신 문제 생기면 예외
OAuthUserInfo oauthUserInfo = authService.findOAuthUserInfo(loginRequest.getOauthToken(), AuthType.KAKAO);

// 유저가 inactive(밴, 정지) 되어있으면 예외
User user = authService.findOrEnrollOAuthUser(oauthUserInfo);

String accessToken = authService.enrollNewAuthTokens(user, response);

return ResponseEntity.ok(ApiResponse.ok(new LoginSuccessResponse(accessToken)));
}


@PostMapping("/reissue")
public ResponseEntity<ApiResponse<ReissueSuccessResponse>> reissue(
@CookieValue(required = false) String refreshToken,
HttpServletResponse response
) {
@CookieValue(required = false) String refreshToken, HttpServletResponse response) {
String accessToken = authService.reissueAccessToken(refreshToken, response);

return ResponseEntity.ok(
ApiResponse.ok(new ReissueSuccessResponse(accessToken))
);
return ResponseEntity.ok(ApiResponse.ok(new ReissueSuccessResponse(accessToken)));
}

@PostMapping("/logout")
public ResponseEntity<ApiResponse<String>> logout(
@CookieValue(required = false) String refreshToken,
HttpServletResponse response
) {
@CookieValue(required = false) String refreshToken, HttpServletResponse response) {
authService.clearRefreshTokenAndEntity(refreshToken, response);

return ResponseEntity.ok(ApiResponse.ok("logout success"));
Expand Down
91 changes: 40 additions & 51 deletions src/main/java/com/payper/server/auth/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@
import com.payper.server.user.entity.User;
import com.payper.server.user.entity.UserRole;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import java.util.Date;
import java.util.Optional;

@Service
@Transactional
@RequiredArgsConstructor
Expand All @@ -36,33 +35,33 @@ public class AuthService {
private final JwtParseUtil jwtParseUtil;

private User findOrEnrollUser(OAuthUserInfo oauthUserInfo, UserRole userRole) {
//먼저 검증
// 먼저 검증
Optional<User> user = userService.getActiveOAuthUser(oauthUserInfo);

user.ifPresent(
u->{
log.info("가입 유저 확인 - userId: {}, userName: {}, userRole: {}", u.getId(),u.getName(),u.getUserRole().name());
}
);

return user.orElseGet(
() -> {
User savedUser = userService.save(
User.create(
AuthType.KAKAO,
oauthUserInfo.getName(),
oauthUserInfo.getOauthId(),
userRole, // 매개변수 사용
true
)
);

log.info("유저 가입 & 저장 - userId: {}, userName: {}, userRole: {}",
savedUser.getId(),savedUser.getName(),savedUser.getUserRole().name());

return savedUser;
}
);
user.ifPresent(u -> {
log.info(
"가입 유저 확인 - userId: {}, userName: {}, userRole: {}",
u.getId(),
u.getName(),
u.getUserRole().name());
});

return user.orElseGet(() -> {
User savedUser = userService.save(User.create(
AuthType.KAKAO,
oauthUserInfo.getName(),
oauthUserInfo.getOauthId(),
userRole, // 매개변수 사용
true));

log.info(
"유저 가입 & 저장 - userId: {}, userName: {}, userRole: {}",
savedUser.getId(),
savedUser.getName(),
savedUser.getUserRole().name());

return savedUser;
});
}

public User findOrEnrollOAuthUser(OAuthUserInfo oauthUserInfo) {
Expand All @@ -86,7 +85,7 @@ public String enrollNewAuthTokens(User user, HttpServletResponse response) {
upsertRefreshTokenAndEntity(user.getUserIdentifier(), response, issuedAt);
String accessToken = upsertAccessToken(user.getUserIdentifier(), issuedAt);

log.info("업서트 토큰 - userId: {}, issuedAt: {}",user.getId(),issuedAt);
log.info("업서트 토큰 - userId: {}, issuedAt: {}", user.getId(), issuedAt);

return accessToken;
}
Expand All @@ -113,30 +112,25 @@ public String reissueAccessToken(String refreshToken, HttpServletResponse respon
}
userIdentifier = jwtParseUtil.getUserIdentifier(refreshToken);
} catch (AuthException e) {
throw
switch (e.getErrorCode()) {
case JWT_ERROR -> new ApiException(ErrorCode.JWT_REISSUE_ERROR);
case JWT_EXPIRED -> new ApiException(ErrorCode.JWT_REISSUE_EXPIRED);
default -> new ApiException(ErrorCode.REISSUE_ERROR);
};
throw switch (e.getErrorCode()) {
case JWT_ERROR -> new ApiException(ErrorCode.JWT_REISSUE_ERROR);
case JWT_EXPIRED -> new ApiException(ErrorCode.JWT_REISSUE_EXPIRED);
default -> new ApiException(ErrorCode.REISSUE_ERROR);
};
}

// 2) DB에 없으면 리플레이 공격 의심 -> 해당 유저 토큰 전부 폐기
Optional<RefreshTokenEntity> refreshTokenEntity = jwtRefreshTokenUtil.getRefreshTokenEntity(refreshToken);
refreshTokenEntity.ifPresentOrElse(
(r) -> {
},
() -> {
jwtRefreshTokenUtil.deleteAllRefreshTokenEntity(userIdentifier);
throw new ApiException(ErrorCode.JWT_REISSUE_OLD);
}
);
refreshTokenEntity.ifPresentOrElse((r) -> {}, () -> {
jwtRefreshTokenUtil.deleteAllRefreshTokenEntity(userIdentifier);
throw new ApiException(ErrorCode.JWT_REISSUE_OLD);
});

Date reissuedAt = jwtParseUtil.getIssuedAt(refreshToken);
upsertRefreshTokenAndEntity(userIdentifier, response, reissuedAt);
String accessToken = upsertAccessToken(userIdentifier, new Date());

log.info("토큰 재발급 완료 reissuedAt: {}",reissuedAt);
log.info("토큰 재발급 완료 reissuedAt: {}", reissuedAt);

return accessToken;
}
Expand All @@ -151,9 +145,7 @@ private void upsertRefreshTokenAndEntity(String userIdentifier, HttpServletRespo
RefreshTokenEntity refreshTokenEntity =
jwtRefreshTokenUtil.generateRefreshTokenEntity(userIdentifier, refreshToken);


if(response!=null)
jwtRefreshTokenUtil.generateCookieRefreshToken(refreshToken, response);
if (response != null) jwtRefreshTokenUtil.generateCookieRefreshToken(refreshToken, response);

jwtRefreshTokenUtil.upsertRefreshTokenEntity(refreshTokenEntity);
}
Expand All @@ -166,10 +158,7 @@ public void clearRefreshTokenAndEntity(String refreshToken, HttpServletResponse
}

Optional<RefreshTokenEntity> refreshTokenEntity = jwtRefreshTokenUtil.getRefreshTokenEntity(refreshToken);
refreshTokenEntity.ifPresent(
r ->
jwtRefreshTokenUtil.deleteAllRefreshTokenEntity(r.getUserIdentifier())
);
refreshTokenEntity.ifPresent(r -> jwtRefreshTokenUtil.deleteAllRefreshTokenEntity(r.getUserIdentifier()));

log.info("리프레시 토큰 만료 완료");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.payper.server.auth.dto.request;


import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
Expand All @@ -11,6 +10,6 @@
@Schema(description = "로그인 요청")
public class LoginRequest {
@Schema(description = "OAuth Provider가 제공한 access token", example = "kakao_oauth_token_example")
@NotBlank(message="OAuth Provider가 제공한 oauth resource access token이 필요합니다.")
@NotBlank(message = "OAuth Provider가 제공한 oauth resource access token이 필요합니다.")
private String oauthToken;
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package com.payper.server.auth.jwt;

import com.payper.server.auth.jwt.entity.RefreshTokenEntity;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {

@Modifying(clearAutomatically = true, flushAutomatically = true)
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/payper/server/auth/jwt/entity/JwtType.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.payper.server.auth.jwt.entity;

public enum JwtType {
ACCESS,REFRESH
ACCESS,
REFRESH
}
Loading