Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b70335a
:sparkles: feat : make HealthCheck api
jeeheaG May 17, 2025
29f06c5
:sparkles: feat : make refresh token delete logic in TokenRefreshService
jeeheaG May 18, 2025
2690848
:sparkles: feat : add refresh token delete logic at withdraw api
jeeheaG May 18, 2025
8a7d8da
:rocket: feat : make log out api
jeeheaG May 18, 2025
f227816
:white_check_mark: test : add test for log out api
jeeheaG May 18, 2025
e1637d4
:white_check_mark: test : refactor healthCheck and logOut test code
jeeheaG May 19, 2025
6d9a58f
:recycle: refactor : change log out api to endpoint /auth and move pa…
jeeheaG May 19, 2025
7884c95
:recycle: refactor : change get token logic of log out api
jeeheaG May 19, 2025
f899625
:recycle: refactor : add exception logic in JwtResolver.getUserIdFrom…
jeeheaG May 20, 2025
798ae75
:recycle: refactor : change token resolve logic at Logout and TokenRe…
jeeheaG May 20, 2025
cb16509
:bulb: chore : tidy up useless imports
jeeheaG May 24, 2025
6c924a8
:white_check_mark: test : make log out test data sql
jeeheaG May 24, 2025
bf76853
:white_check_mark: test : make login-logout success test case
jeeheaG May 24, 2025
0c65ab3
:bulb: chore : tidy up code
jeeheaG May 24, 2025
e9179ea
:recycle: refactor : extract method of login-logout test case code
jeeheaG May 25, 2025
53ccea2
:white_check_mark: test : make apple login-logout test case
jeeheaG May 25, 2025
77b68b2
:sparkles: add : add 'already log out' response at log out api
jeeheaG May 25, 2025
a31d44a
:white_check_mark: test : modify test for already log out case
jeeheaG May 25, 2025
314018a
:bug: fix : add UserException handler
jeeheaG May 25, 2025
35464c3
:recycle: refactor : rename log out acceptance test class
jeeheaG May 25, 2025
2bb7704
:white_check_mark: test : make fail test cases for log out api
jeeheaG May 25, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

@Profile({"prod", "dev"})
@Repository
Expand All @@ -20,9 +21,8 @@ public class DatabaseTokenRepository implements JwtTokenRepository {


/**
* @param userId 사용자 ID
* 만료되지않은 refreshToken을 조회합니다.
* */
* @param userId 사용자 ID 만료되지않은 refreshToken을 조회합니다.
*/
@Override
public Optional<String> findRefreshTokenByUserId(final Long userId) {
LocalDateTime REPLACE_CUTOFF_TIME = LocalDateTime.now()
Expand All @@ -32,10 +32,9 @@ public Optional<String> findRefreshTokenByUserId(final Long userId) {
}

/**
* @param userId 사용자 ID
* @param refreshToken refreshToken
* refreshToken 엔티티를 UPSERT합니다.
* */
* @param userId 사용자 ID
* @param refreshToken refreshToken refreshToken 엔티티를 UPSERT합니다.
*/
@Override
public void saveRefreshTokenWithUserId(final Long userId, final String refreshToken) {

Expand All @@ -52,5 +51,13 @@ public void saveRefreshTokenWithUserId(final Long userId, final String refreshTo
refreshTokenJpaRepository.save(updatedRefreshToken);
}

/**
* @param userId 사용자 ID 사용자의 refreshToken을 DELETE 합니다.
*/
@Override
@Transactional
public void deleteRefreshTokenByUserId(Long userId) {
refreshTokenJpaRepository.deleteByUserId(userId);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import java.time.Duration;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.runimo.runimo.common.cache.InMemoryCache;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;

@Slf4j
@Profile({"test", "local"})
@Repository
@RequiredArgsConstructor
Expand All @@ -27,4 +29,9 @@ public Optional<String> findRefreshTokenByUserId(Long userId) {
public void saveRefreshTokenWithUserId(Long userId, String refreshToken) {
refreshTokenCache.put(userId, refreshToken, Duration.ofMillis(refreshTokenExpiry));
}

@Override
public void deleteRefreshTokenByUserId(Long userId) {
refreshTokenCache.remove(userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ public interface JwtTokenRepository {
Optional<String> findRefreshTokenByUserId(Long userId);

void saveRefreshTokenWithUserId(Long userId, String refreshToken);

void deleteRefreshTokenByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ public interface RefreshTokenJpaRepository extends JpaRepository<RefreshToken, S
@Query("select distinct r from RefreshToken r " +
"where r.userId = :userId and r.updatedAt > :cutOffDateTime")
Optional<RefreshToken> findByUserIdAfterCutoffTime(Long userId, LocalDateTime cutOffDateTime);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,13 @@ public TokenPair refreshAccessToken(String refreshToken) {
String newAccessToken = jwtTokenFactory.generateAccessToken(user);
return new TokenPair(newAccessToken, refreshToken);
}

/**
* 해당 사용자의 refresh token 삭제
*
* @param userId 사용자 식별자
*/
public void removeRefreshToken(Long userId) {
jwtTokenRepository.deleteRefreshTokenByUserId(userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.runimo.runimo.checker.controller;

import org.runimo.runimo.common.log.ServiceLog;
import org.runimo.runimo.common.response.SuccessResponse;
import org.runimo.runimo.exceptions.code.ExampleErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/checker")
public class HealthCheckController {

/**
* health check api
*/
@ServiceLog
@GetMapping("/health-check")
public ResponseEntity<SuccessResponse<String>> healthCheck() {
return ResponseEntity.ok(
SuccessResponse.of(ExampleErrorCode.SUCCESS, "Health check success !"));
}

}
3 changes: 2 additions & 1 deletion src/main/java/org/runimo/runimo/common/GlobalConsts.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ public final class GlobalConsts {
"/api/v1/auth",
"/swagger-ui",
"/v3/api-docs",
"/actuator"
"/actuator",
"/checker"
);

public static final String EMPTYFIELD = "EMPTY";
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/org/runimo/runimo/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/checker/**").permitAll()
.requestMatchers(("/error")).permitAll()
.anyRequest().authenticated()
)
Expand All @@ -52,6 +53,7 @@ public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exce
.permitAll()
.requestMatchers(("/error")).permitAll()
.requestMatchers("/api/v1/users/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/checker/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.runimo.runimo.user.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.common.response.SuccessResponse;
import org.runimo.runimo.user.enums.UserHttpResponseCode;
import org.runimo.runimo.user.service.usecases.logout.LogOutUsecase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "USER-LOG-OUT", description = "로그아웃 API")
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class LogOutController {

private final LogOutUsecase logOutUsecase;

@Operation(summary = "로그아웃", description = "사용자를 로그아웃 처리합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그아웃 성공"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음")
})
@PostMapping("/log-out")
public ResponseEntity<SuccessResponse<Void>> logOut(
@UserId Long userId
) {
logOutUsecase.execute(userId);
return ResponseEntity.ok()
.body(SuccessResponse.of(UserHttpResponseCode.LOG_OUT_SUCCESS, null));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ public enum UserHttpResponseCode implements CustomResponseCode {
JWT_TOKEN_BROKEN(HttpStatus.BAD_REQUEST, "JWT 토큰이 손상되었습니다", "JWT 토큰이 손상되었습니다"),
TOKEN_REFRESH_FAIL(HttpStatus.FORBIDDEN, "토큰 재발급 실패", "Refresh 토큰이 유효하지 않습니다."),
TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "인증 실패", "JWT 토큰 인증 실패"),
REFRESH_EXPIRED(HttpStatus.FORBIDDEN, "리프레시 토큰 만료", "리프레시 토큰 만료");
REFRESH_EXPIRED(HttpStatus.FORBIDDEN, "리프레시 토큰 만료", "리프레시 토큰 만료"),
TOKEN_DELETE_REFRESH_FAIL(HttpStatus.FORBIDDEN, "토큰 삭제 실패",
"사용자가 유효하지 않습니다. Refresh 토큰 삭제에 실패했습니다"),
LOG_OUT_SUCCESS(HttpStatus.OK, "로그아웃 성공", "로그아웃 성공"),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없음", "사용자를 찾을 수 없음");

private final HttpStatus code;
private final String clientMessage;
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/org/runimo/runimo/user/exception/UserException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.runimo.runimo.user.exception;

import org.runimo.runimo.exceptions.BusinessException;
import org.runimo.runimo.exceptions.code.CustomResponseCode;

public class UserException extends BusinessException {

protected UserException(CustomResponseCode errorCode) {
super(errorCode);
}

public UserException(CustomResponseCode errorCode, String logMessage) {
super(errorCode, logMessage);
}

public static UserException of(CustomResponseCode errorCode) {
return new UserException(errorCode, errorCode.getLogMessage());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.NoSuchElementException;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.auth.service.EncryptUtil;
import org.runimo.runimo.auth.service.TokenRefreshService;
import org.runimo.runimo.auth.service.login.apple.AppleTokenVerifier;
import org.runimo.runimo.user.domain.AppleUserToken;
import org.runimo.runimo.user.domain.OAuthInfo;
Expand All @@ -23,6 +24,7 @@ public class WithdrawService {
private final AppleTokenVerifier appleTokenVerifier;
private final AppleUserTokenRepository appleUserTokenRepository;
private final EncryptUtil encryptUtil;
private final TokenRefreshService tokenRefreshService;

@Transactional
public void withdraw(Long userId) {
Expand All @@ -34,6 +36,7 @@ public void withdraw(Long userId) {
}
oAuthInfoRepository.delete(oAuthInfo);
userRepository.delete(user);
tokenRefreshService.removeRefreshToken(user.getId());
}

private void withdrawAppleUser(User user) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.runimo.runimo.user.service.usecases.logout;

public interface LogOutUsecase {

void execute(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.runimo.runimo.user.service.usecases.logout;

import lombok.RequiredArgsConstructor;
import org.runimo.runimo.auth.service.TokenRefreshService;
import org.runimo.runimo.user.domain.User;
import org.runimo.runimo.user.enums.UserHttpResponseCode;
import org.runimo.runimo.user.exception.UserException;
import org.runimo.runimo.user.service.UserFinder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class LogOutUsecaseImpl implements LogOutUsecase {

private final UserFinder userFinder;
private final TokenRefreshService tokenRefreshService;

@Override
public void execute(Long userId) {
User user = userFinder.findUserById(userId).orElseThrow(() -> UserException.of(
UserHttpResponseCode.USER_NOT_FOUND));
tokenRefreshService.removeRefreshToken(user.getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package org.runimo.runimo.checker.controller;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.jupiter.api.Assertions.*;

import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.runimo.runimo.CleanUpUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class HealthCheckControllerTest {

@LocalServerPort
int port;

@Autowired
private CleanUpUtil cleanUpUtil;

@BeforeEach
void setUp() {
RestAssured.port = port;
}


@Test
void healthCheck() {

// when & then
given()
.contentType(ContentType.JSON)
.when()
.get("/checker/health-check")
.then()
.log().all()
.statusCode(200)
.body("payload", equalTo("Health check success !"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.runimo.runimo.user.controller;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.*;
import static org.runimo.runimo.TestConsts.TEST_USER_UUID;

import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.runimo.runimo.CleanUpUtil;
import org.runimo.runimo.TokenUtils;
import org.runimo.runimo.exceptions.code.CustomResponseCode;
import org.runimo.runimo.user.enums.UserHttpResponseCode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;


@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class LogOutControllerTest {

@LocalServerPort
int port;

@Autowired
private CleanUpUtil cleanUpUtil;

@Autowired
private TokenUtils tokenUtils;

private String token;

@BeforeEach
void setUp() {
RestAssured.port = port;
token = tokenUtils.createTokenByUserPublicId(TEST_USER_UUID);
}

@AfterEach
void tearDown() {
cleanUpUtil.cleanUpUserInfos();
}

@Test
@Sql(scripts = "/sql/log_out_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
void logOut() {
CustomResponseCode responseCode = UserHttpResponseCode.LOG_OUT_SUCCESS;

given()
.header("Authorization", token)
.contentType(ContentType.JSON)

.when()
.post("/api/v1/users/log-out")

.then()
.log().all()
.statusCode(responseCode.getHttpStatusCode().value())

.body("code", equalTo(responseCode.getCode()))
.body("message", equalTo(responseCode.getClientMessage()));
}
}
Loading