Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
@@ -0,0 +1,44 @@
package org.runimo.runimo.auth.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.auth.service.logout.LogOutUsecase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

private final LogOutUsecase logOutUsecase;

@Operation(summary = "로그아웃", description = "사용자를 로그아웃 처리합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그아웃 성공"),
@ApiResponse(responseCode = "200", description = "로그아웃 성공 (이미 로그아웃된 사용자)"),
@ApiResponse(responseCode = "401", description = "토큰 검증 실패"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음")
})
@PostMapping("/log-out")
public ResponseEntity<SuccessResponse<Void>> logOut(
@RequestHeader("Authorization") String accessTokenHeader
) {
String token = accessTokenHeader.replace("Bearer ", "");
boolean logoutProcessed = logOutUsecase.execute(token);

UserHttpResponseCode responseCode = logoutProcessed ? UserHttpResponseCode.LOG_OUT_SUCCESS
: UserHttpResponseCode.ALREADY_LOG_OUT_SUCCESS;
return ResponseEntity.ok()
.body(SuccessResponse.of(responseCode, null));
}
}
10 changes: 8 additions & 2 deletions src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,14 @@ public UserDetail getUserDetailFromJwtToken(String token) throws JWTVerification
}

public String getUserIdFromJwtToken(String token) throws JWTVerificationException {
DecodedJWT jwt = verifyJwtToken(token);
return jwt.getSubject();
String userPublicId;
try {
DecodedJWT jwt = verifyJwtToken(token);
userPublicId = jwt.getSubject();
} catch (Exception e) {
throw UserJwtException.of(UserHttpResponseCode.TOKEN_INVALID);
}
return userPublicId;
}

public SignupTokenPayload getSignupTokenPayload(String token)
Expand Down
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 @@ -27,25 +27,38 @@ public void putRefreshToken(String userPublicId, String refreshToken) {
}

public TokenPair refreshAccessToken(String refreshToken) {
String userPublicId;
try {
jwtResolver.verifyJwtToken(refreshToken);
userPublicId = jwtResolver.getUserIdFromJwtToken(refreshToken);
} catch (Exception e) {
throw UserJwtException.of(UserHttpResponseCode.TOKEN_REFRESH_FAIL);
}
String userPublicId = jwtResolver.getUserIdFromJwtToken(refreshToken);

User user = userFinder.findUserByPublicId(userPublicId)
.orElseThrow(() -> UserJwtException.of(UserHttpResponseCode.TOKEN_REFRESH_FAIL));

// Check if the refresh token is expired
String storedToken = jwtTokenRepository.findRefreshTokenByUserId(user.getId())
.orElseThrow(() -> UserJwtException.of(UserHttpResponseCode.REFRESH_EXPIRED));
String storedToken = getStoredRefreshToken(user.getId());
if (!storedToken.equals(refreshToken)) {
throw UserJwtException.of(UserHttpResponseCode.TOKEN_INVALID);
}

String newAccessToken = jwtTokenFactory.generateAccessToken(user);
return new TokenPair(newAccessToken, refreshToken);
}

/**
* 해당 사용자의 refresh token 조회
*
* @param userId 사용자 식별자
* @return refresh 토큰
*/
public String getStoredRefreshToken(Long userId) {
return jwtTokenRepository.findRefreshTokenByUserId(userId)
.orElseThrow(() -> UserJwtException.of(UserHttpResponseCode.REFRESH_EXPIRED));
}

/**
* 해당 사용자의 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,6 @@
package org.runimo.runimo.auth.service.logout;

public interface LogOutUsecase {

boolean execute(String refreshToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.runimo.runimo.auth.service.logout;

import lombok.RequiredArgsConstructor;
import org.runimo.runimo.auth.exceptions.UserJwtException;
import org.runimo.runimo.auth.jwt.JwtResolver;
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;
private final JwtResolver jwtResolver;

@Override
public boolean execute(String accessToken) {
String userPublicId = jwtResolver.getUserIdFromJwtToken(accessToken);
User user = userFinder.findUserByPublicId(userPublicId).orElseThrow(() -> UserException.of(
UserHttpResponseCode.USER_NOT_FOUND));

try {
tokenRefreshService.getStoredRefreshToken(user.getId());
} catch (UserJwtException ue) {
if (ue.getErrorCode() == UserHttpResponseCode.REFRESH_EXPIRED) {
return false;
}
}

tokenRefreshService.removeRefreshToken(user.getId());
return true;
}
}
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
Expand Up @@ -10,6 +10,7 @@
import org.runimo.runimo.external.ExternalServiceException;
import org.runimo.runimo.hatch.exception.HatchException;
import org.runimo.runimo.runimo.exception.RunimoException;
import org.runimo.runimo.user.exception.UserException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
Expand Down Expand Up @@ -48,6 +49,13 @@ public ResponseEntity<ErrorResponse> handleUserJwtException(UserJwtException e)
.body(ErrorResponse.of(e.getErrorCode()));
}

@ExceptionHandler(UserException.class)
public ResponseEntity<ErrorResponse> handleUserJwtException(UserException e) {
log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
return ResponseEntity.status(e.getHttpStatusCode())
.body(ErrorResponse.of(e.getErrorCode()));
}

@ExceptionHandler(SignUpException.class)
public ResponseEntity<ErrorResponse> handleSignUpException(SignUpException e) {
log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ 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, "로그아웃 성공", "로그아웃 성공"),
ALREADY_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
Loading