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: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ plugins {
id 'io.spring.dependency-management' version '1.1.7'
id "org.sonarqube" version "6.2.0.5505"
id 'jacoco'
id 'org.flywaydb.flyway' version '11.10.0'

}

group = 'org.Runimo'
Expand Down Expand Up @@ -44,6 +46,8 @@ dependencies {

runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'


testImplementation 'io.rest-assured:rest-assured:5.4.0'
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/org/runimo/runimo/auth/domain/SignupToken.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.runimo.runimo.auth.exceptions.SignUpException;
import org.runimo.runimo.common.CreatedAuditEntity;
import org.runimo.runimo.user.domain.SocialProvider;
import org.runimo.runimo.user.enums.UserHttpResponseCode;

@Table(name = "signup_token")
@Entity
Expand All @@ -32,6 +35,13 @@ public class SignupToken extends CreatedAuditEntity {
@Enumerated(EnumType.STRING)
private SocialProvider socialProvider;

@Version
@Column(name = "version")
private Long version;

@Column(name = "used", nullable = false)
private Boolean used = false;

@Builder
public SignupToken(String token, String providerId, String refreshToken,
SocialProvider socialProvider) {
Expand All @@ -40,4 +50,11 @@ public SignupToken(String token, String providerId, String refreshToken,
this.refreshToken = refreshToken;
this.socialProvider = socialProvider;
}

public void markAsUsed() {
if (this.used) {
throw new SignUpException(UserHttpResponseCode.SIGNIN_FAIL_ALREADY_EXIST);
}
this.used = true;
}
Comment on lines +54 to +59
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fix the error code for token reuse scenario.

The markAsUsed() method throws SIGNIN_FAIL_ALREADY_EXIST which seems inappropriate for a token reuse scenario. Consider using a more specific error code like TOKEN_ALREADY_USED or TOKEN_INVALID.

 public void markAsUsed() {
     if (this.used) {
-        throw new SignUpException(UserHttpResponseCode.SIGNIN_FAIL_ALREADY_EXIST);
+        throw new SignUpException(UserHttpResponseCode.TOKEN_INVALID);
     }
     this.used = true;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void markAsUsed() {
if (this.used) {
throw new SignUpException(UserHttpResponseCode.SIGNIN_FAIL_ALREADY_EXIST);
}
this.used = true;
}
public void markAsUsed() {
if (this.used) {
throw new SignUpException(UserHttpResponseCode.TOKEN_INVALID);
}
this.used = true;
}
🤖 Prompt for AI Agents
In src/main/java/org/runimo/runimo/auth/domain/SignupToken.java lines 54 to 59,
the markAsUsed() method throws an incorrect error code SIGNIN_FAIL_ALREADY_EXIST
when the token is already used. Replace this error code with a more appropriate
one such as TOKEN_ALREADY_USED or TOKEN_INVALID to accurately reflect the token
reuse scenario.

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
public interface SignupTokenRepository extends JpaRepository<SignupToken, String> {

@Query(
"SELECT st FROM SignupToken st WHERE st.token = :token AND st.createdAt > :createdAtAfter"
"SELECT st FROM SignupToken st WHERE st.token = :token "
+ "AND st.createdAt > :createdAtAfter "
+ "AND st.used = false"
)
Optional<SignupToken> findByIdAndCreatedAtAfter(String token, LocalDateTime createdAtAfter);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
package org.runimo.runimo.auth.service;

import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.auth.domain.SignupToken;
import org.runimo.runimo.auth.exceptions.SignUpException;
import org.runimo.runimo.auth.jwt.JwtResolver;
import org.runimo.runimo.auth.jwt.JwtTokenFactory;
import org.runimo.runimo.auth.jwt.SignupTokenPayload;
import org.runimo.runimo.auth.repository.SignupTokenRepository;
import org.runimo.runimo.auth.service.dto.SignupUserResponse;
import org.runimo.runimo.auth.service.dto.UserSignupCommand;
import org.runimo.runimo.external.FileStorageService;
Expand All @@ -17,7 +13,6 @@
import org.runimo.runimo.user.domain.AppleUserToken;
import org.runimo.runimo.user.domain.SocialProvider;
import org.runimo.runimo.user.domain.User;
import org.runimo.runimo.user.enums.UserHttpResponseCode;
import org.runimo.runimo.user.repository.AppleUserTokenRepository;
import org.runimo.runimo.user.service.UserRegisterService;
import org.runimo.runimo.user.service.dto.command.DeviceTokenDto;
Expand All @@ -29,21 +24,19 @@
@RequiredArgsConstructor
public class SignUpUsecaseImpl implements SignUpUsecase {

private static final int REGISTER_CUTOFF_MIN = 10;
private final UserRegisterService userRegisterService;
private final FileStorageService fileStorageService;
private final EggGrantService eggGrantService;
private final JwtTokenFactory jwtTokenFactory;
private final SignupTokenRepository signupTokenRepository;
private final AppleUserTokenRepository appleUserTokenRepository;
private final JwtResolver jwtResolver;
private final SignupTokenService signupTokenService;

@Override
@Transactional
public SignupUserResponse register(UserSignupCommand command) {
// 1. 토큰 검증
SignupTokenPayload payload = jwtResolver.getSignupTokenPayload(command.registerToken());
SignupToken signupToken = findUnExpiredSignupToken(payload.token());
SignupTokenPayload payload = signupTokenService.extractPayload(command.registerToken());
SignupToken signupToken = signupTokenService.findUnExpiredToken(payload.token());
// 2. 유저생성
userRegisterService.validateExistingUser(payload.providerId(), payload.socialProvider());
String imgUrl = fileStorageService.storeFile(command.profileImage());
Expand All @@ -57,22 +50,11 @@ public SignupUserResponse register(UserSignupCommand command) {
Egg grantedEgg = eggGrantService.grantGreetingEggToUser(savedUser);
EggType eggType = grantedEgg.getEggType();

removeSignupToken(payload.token());
signupTokenService.invalidateSignupToken(signupToken);
return new SignupUserResponse(savedUser, jwtTokenFactory.generateTokenPair(savedUser),
grantedEgg, eggType.getCode());
}

private void removeSignupToken(String token) {
signupTokenRepository.deleteByToken(token);
}

private SignupToken findUnExpiredSignupToken(String token) {
LocalDateTime cutOffTime = LocalDateTime.now().minusMinutes(REGISTER_CUTOFF_MIN);
return signupTokenRepository.
findByIdAndCreatedAtAfter(token, cutOffTime)
.orElseThrow(() -> new SignUpException(UserHttpResponseCode.TOKEN_INVALID));
}

private void createAppleUserToken(Long userId, SignupToken signupToken) {
AppleUserToken appleUserToken = new AppleUserToken(
userId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.runimo.runimo.auth.service;


import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.auth.domain.SignupToken;
import org.runimo.runimo.auth.exceptions.SignUpException;
import org.runimo.runimo.auth.jwt.JwtResolver;
import org.runimo.runimo.auth.jwt.SignupTokenPayload;
import org.runimo.runimo.auth.repository.SignupTokenRepository;
import org.runimo.runimo.user.enums.UserHttpResponseCode;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class SignupTokenService {

private static final int REGISTER_CUTOFF_MIN = 10;
private final JwtResolver jwtResolver;
private final SignupTokenRepository signupTokenRepository;

public SignupTokenPayload extractPayload(String token) {
return jwtResolver.getSignupTokenPayload(token);
}

public SignupToken findUnExpiredToken(String token) {
LocalDateTime cutOffTime = LocalDateTime.now().minusMinutes(REGISTER_CUTOFF_MIN);
return signupTokenRepository.
findByIdAndCreatedAtAfter(token, cutOffTime)
.orElseThrow(() -> new SignUpException(UserHttpResponseCode.TOKEN_INVALID));
}

public void invalidateSignupToken(SignupToken signupToken) {
try {
signupToken.markAsUsed();
signupTokenRepository.save(signupToken);
} catch (OptimisticLockingFailureException e) {
throw new SignUpException(UserHttpResponseCode.SIGNIN_FAIL_ALREADY_EXIST);
}
}
}
27 changes: 27 additions & 0 deletions src/main/resources/db/migration/V1__Create_baseline.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- V1__Create_baseline.sql
-- Production baseline - existing schema as of 2025-06-24
-- This migration represents the current state of the production database
-- All existing tables are already present, this is just a baseline marker

-- Flyway baseline marker for existing production database
-- No actual schema changes in this file as tables already exist

-- Existing tables:
-- - users
-- - user_token
-- - user_love_point
-- - oauth_account
-- - apple_user_token
-- - signup_token
-- - running_record
-- - item
-- - egg_type
-- - item_activity
-- - user_item
-- - incubating_egg
-- - runimo_definition
-- - runimo
-- - user_refresh_token

-- This baseline allows us to start versioned migrations from V2 onwards
SELECT 'Baseline migration - existing schema marked as V1' as message;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
ALTER TABLE `signup_token`
ADD COLUMN `version` BIGINT NOT NULL DEFAULT 0;

ALTER TABLE `signup_token`
ADD COLUMN `used` BOOLEAN NOT NULL DEFAULT FALSE;

-- 기존 토큰들은 사용되지 않은 것으로 처리
UPDATE `signup_token`
SET `used` = FALSE,
`version` = 0;
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ void tearDown() {
}

@Test
void 중복_유저_회원가입_409응답() throws JsonProcessingException {
@DisplayName("중복된 유저를 회원가입 시도하면 401응답 - 동일한 토큰")
void 중복_유저_회원가입_401응답() throws JsonProcessingException {
AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN);

given()
Expand All @@ -176,6 +177,44 @@ void tearDown() {
);
token = jwtTokenFactory.generateSignupTemporalToken("provider-id", SocialProvider.KAKAO,
"valid-token");
request = new AuthSignupRequest(token, "username", Gender.UNKNOWN);

given()
.contentType(ContentType.MULTIPART)
.multiPart("request", objectMapper.writeValueAsString(request))
.when()
.post("/api/v1/auth/signup")
.then()
.statusCode(HttpStatus.UNAUTHORIZED.value());

signupTokenRepository.delete(signupToken);
}

@Test
@DisplayName("중복된 유저를 회원가입 시도하면 409응답 - 동일한 토큰")
void 중복_유저_회원가입_409응답_다른토큰() throws JsonProcessingException {
AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN);

given()
.contentType(ContentType.MULTIPART)
.multiPart("request", objectMapper.writeValueAsString(request))
.when()
.post("/api/v1/auth/signup")
.then()
.statusCode(HttpStatus.CREATED.value())
.log().all()
.body("payload.nickname", equalTo("username"))
.body("payload.token_pair.access_token", notNullValue())
.body("payload.token_pair.refresh_token", notNullValue());

SignupToken signupToken = new SignupToken(
"valid-token-2",
"provider-id",
"refresh-token",
SocialProvider.KAKAO
);
token = jwtTokenFactory.generateSignupTemporalToken("provider-id", SocialProvider.KAKAO,
"valid-token-2");
signupTokenRepository.save(signupToken);
request = new AuthSignupRequest(token, "username", Gender.UNKNOWN);

Expand Down
25 changes: 25 additions & 0 deletions src/test/java/org/runimo/runimo/auth/domain/SignupTokenTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.runimo.runimo.auth.domain;

import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;
import org.runimo.runimo.auth.exceptions.SignUpException;
import org.runimo.runimo.user.domain.SocialProvider;

class SignupTokenTest {

@Test
void should_fail_when_token_is_marked() {
// given
SignupToken token = SignupToken.builder()
.token("dummy.token.value")
.refreshToken("dummy.refresh.token.value")
.socialProvider(SocialProvider.KAKAO)
.build();
token.markAsUsed();

// when & then
assertThrows(SignUpException.class, token::markAsUsed);
}

}
26 changes: 10 additions & 16 deletions src/test/java/org/runimo/runimo/auth/service/SignUpUsecaseTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,14 @@
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;

import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.runimo.runimo.auth.domain.SignupToken;
import org.runimo.runimo.auth.exceptions.SignUpException;
import org.runimo.runimo.auth.jwt.JwtResolver;
import org.runimo.runimo.auth.jwt.JwtTokenFactory;
import org.runimo.runimo.auth.jwt.SignupTokenPayload;
import org.runimo.runimo.auth.repository.SignupTokenRepository;
import org.runimo.runimo.auth.service.dto.SignupUserResponse;
import org.runimo.runimo.auth.service.dto.UserSignupCommand;
import org.runimo.runimo.external.FileStorageService;
Expand All @@ -43,11 +40,9 @@ class SignUpUsecaseTest {
@Mock
private JwtTokenFactory jwtTokenFactory;
@Mock
private SignupTokenRepository signupTokenRepository;
@Mock
private AppleUserTokenRepository appleUserTokenRepository;
@Mock
private JwtResolver jwtResolver;
private SignupTokenService signupTokenService;

private SignUpUsecase sut;

Expand All @@ -59,9 +54,8 @@ void setUp() {
fileStorageService,
eggGrantService,
jwtTokenFactory,
signupTokenRepository,
appleUserTokenRepository,
jwtResolver
signupTokenService
);
}

Expand All @@ -72,10 +66,10 @@ void setUp() {
SignupTokenPayload payload = new SignupTokenPayload(registerToken, "socialId123",
SocialProvider.KAKAO);

when(jwtResolver.getSignupTokenPayload(registerToken)).thenReturn(payload);
when(signupTokenRepository.findByIdAndCreatedAtAfter(eq(registerToken), any()))
.thenReturn(Optional.of(new SignupToken(
registerToken, "refresh", "refresh", SocialProvider.KAKAO)));
when(signupTokenService.extractPayload(registerToken)).thenReturn(payload);
when(signupTokenService.findUnExpiredToken(eq(registerToken)))
.thenReturn(new SignupToken(
registerToken, "refresh", "refresh", SocialProvider.KAKAO));
when(userRegisterService.registerUser(any())).thenReturn(UserFixtures.getUserWithId(1L));
when(eggGrantService.grantGreetingEggToUser(any())).thenReturn(
EggFixtures.createDefaultEgg());
Expand All @@ -101,10 +95,10 @@ void setUp() {
SignupTokenPayload payload = new SignupTokenPayload(registerToken, "socialId123",
SocialProvider.KAKAO);

when(jwtResolver.getSignupTokenPayload(registerToken)).thenReturn(payload);
when(signupTokenRepository.findByIdAndCreatedAtAfter(eq(registerToken), any()))
.thenReturn(Optional.of(new SignupToken(
registerToken, "refresh", "refresh", SocialProvider.KAKAO)));
when(signupTokenService.extractPayload(registerToken)).thenReturn(payload);
when(signupTokenService.findUnExpiredToken(eq(registerToken)))
.thenReturn(new SignupToken(
registerToken, "refresh", "refresh", SocialProvider.KAKAO));

doThrow(new org.runimo.runimo.auth.exceptions.SignUpException(
UserHttpResponseCode.SIGNIN_FAIL_ALREADY_EXIST))
Expand Down
Loading