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
19 changes: 7 additions & 12 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.projectlombok:lombok'
testImplementation 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testImplementation 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

// 인증사 관련 의존성
implementation 'javax.servlet:jstl:1.2'
implementation "org.apache.tomcat.embed:tomcat-embed-jasper"
Expand Down Expand Up @@ -69,7 +70,6 @@ dependencies {
testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
testImplementation 'org.testcontainers:mysql:1.20.0'


// security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
Expand Down Expand Up @@ -103,11 +103,6 @@ dependencies {

runtimeOnly 'com.h2database:h2'

testImplementation 'org.springframework.boot:spring-boot-testcontainers:3.3.5'
testImplementation 'org.testcontainers:testcontainers:1.19.3'
testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
testImplementation 'org.testcoscntainers:mysql:1.20.0'

annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"

implementation 'org.apache.commons:commons-pool2:2.12.1'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package life.mosu.mosuserver.application.oauth;

import life.mosu.mosuserver.domain.user.entity.AuthProvider;
import life.mosu.mosuserver.domain.user.entity.UserJpaEntity;
import life.mosu.mosuserver.domain.user.entity.UserRole;
import life.mosu.mosuserver.domain.user.repository.UserJpaRepository;
import life.mosu.mosuserver.global.processor.StepProcessor;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@RequiredArgsConstructor
public class OAuthUserPersistenceProcessor implements StepProcessor<OAuthUserInfo, UserJpaEntity> {

private final UserJpaRepository userRepository;

@Override
@Transactional
public UserJpaEntity process(final OAuthUserInfo info) {
return userRepository.findByLoginId(info.email())
.map(existingUser -> {
existingUser.updateOAuthUser(
info.gender(),
info.name(),
info.phoneNumber(),
info.birthDay(),
info.marketingAgreed());
return existingUser;
})
.orElseGet(() -> {
final UserJpaEntity newUser = UserJpaEntity.builder()
.loginId(info.email())
.gender(info.gender())
.name(info.name())
.birth(info.birthDay())
.phoneNumber(info.phoneNumber())
.userRole(UserRole.ROLE_PENDING)
.provider(AuthProvider.KAKAO)
.agreedToTermsOfService(true)
.agreedToPrivacyPolicy(true)
.agreedToMarketing(info.marketingAgreed())
.build();
return userRepository.save(newUser);
});
}
Comment on lines +20 to +46

Choose a reason for hiding this comment

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

critical

The process method doesn't handle the case where info.email() is null. If the OAuth provider doesn't return an email, userRepository.findByLoginId(null) will be called, which will likely cause a NullPointerException or an IllegalArgumentException, breaking the login/signup flow for that user.

The previous implementation had a fallback for a null email, but it was flawed as it used a non-unique value ("NA"). This refactoring seems to have lost the null-safety check entirely.

If an email is required for identifying users, you should validate that it's not null and throw an exception if it is. This makes the requirement explicit and prevents runtime errors.

    public UserJpaEntity process(final OAuthUserInfo info) {
        if (info.email() == null) {
            // If email is not guaranteed to be provided, consider using another unique identifier from the provider.
            // Throwing an exception makes the requirement for an email explicit.
            throw new life.mosu.mosuserver.global.exception.CustomRuntimeException(life.mosu.mosuserver.global.exception.ErrorCode.INSUFFICIENT_KAKAO_USER_DATA, "Email is required from Kakao for login.");
        }
        return userRepository.findByLoginId(info.email())
                .map(existingUser -> {
                    existingUser.updateOAuthUser(
                            info.gender(),
                            info.name(),
                            info.phoneNumber(),
                            info.birthDay(),
                            info.marketingAgreed());
                    return existingUser;
                })
                .orElseGet(() -> {
                    final UserJpaEntity newUser = UserJpaEntity.builder()
                            .loginId(info.email())
                            .gender(info.gender())
                            .name(info.name())
                            .birth(info.birthDay())
                            .phoneNumber(info.phoneNumber())
                            .userRole(UserRole.ROLE_PENDING)
                            .provider(AuthProvider.KAKAO)
                            .agreedToTermsOfService(true)
                            .agreedToPrivacyPolicy(true)
                            .agreedToMarketing(info.marketingAgreed())
                            .build();
                    return userRepository.save(newUser);
                });
    }

}
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
package life.mosu.mosuserver.application.oauth;

import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import life.mosu.mosuserver.domain.profile.entity.Gender;
import life.mosu.mosuserver.domain.profile.repository.ProfileJpaRepository;
import life.mosu.mosuserver.domain.user.entity.AuthProvider;
import life.mosu.mosuserver.domain.user.entity.UserJpaEntity;
import life.mosu.mosuserver.domain.user.entity.UserRole;
import life.mosu.mosuserver.domain.user.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
Expand All @@ -25,7 +20,7 @@
@RequiredArgsConstructor
public class OAuthUserService extends DefaultOAuth2UserService {

private final UserJpaRepository userRepository;
private final OAuthUserPersistenceProcessor oAuthUserPersistenceProcessor;
private final ProfileJpaRepository profileRepository;
private final WebClient webClient;

Expand All @@ -44,12 +39,15 @@ public OAuth2User loadUser(final OAuth2UserRequest userRequest)
agreedToMarketing = termsList.stream()
.filter(term -> term instanceof Map)
.map(term -> (Map<String, Object>) term)
.filter(termMap -> "terms_03".equals(termMap.get("tag")))
.filter(termMap ->
"terms_03".equals(termMap.get("tag")))
.findFirst()
.map(termMap -> (Boolean) termMap.get("agreed"))
.orElse(false);
}

log.info("동의 여부{}", agreedToMarketing);

Choose a reason for hiding this comment

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

medium

This log statement uses log.info. For a deployment build, logging marketing consent for every OAuth login at the INFO level could create a lot of noise in the production logs. It would be more appropriate to use log.debug for this kind of information.

Additionally, the log message 동의 여부{} is missing a separator before the placeholder, which can make it harder to read. Consider changing it to 마케팅 동의 여부: {} for better clarity and descriptiveness.

Suggested change
log.info("동의 여부{}", agreedToMarketing);
log.debug("마케팅 동의 여부: {}", agreedToMarketing);


final String registrationId = userRequest.getClientRegistration().getRegistrationId();
final String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails()
Expand All @@ -59,43 +57,14 @@ public OAuth2User loadUser(final OAuth2UserRequest userRequest)
final OAuthUserInfo userInfo = OAuthUserInfo.of(OAuthProvider.from(registrationId),
oAuth2UserAttributes, agreedToMarketing);

final UserJpaEntity oAuthUser = updateOrWrite(userInfo);
final UserJpaEntity oAuthUser = oAuthUserPersistenceProcessor.process(userInfo);

Boolean isProfileRegistered = profileRepository.existsByUserId(oAuthUser.getId());

return new OAuthUser(oAuthUser, oAuth2UserAttributes, userNameAttributeName,
isProfileRegistered);
}

private UserJpaEntity updateOrWrite(final OAuthUserInfo info) {
return userRepository.findByLoginId(info.email())
.map(existingUser -> {
existingUser.updateOAuthUser(
info.gender(),
info.name(),
info.phoneNumber(),
info.birthDay() != null ? info.birthDay() : LocalDate.of(1900, 1, 1));
return existingUser;
})
.orElseGet(() -> {
final UserJpaEntity newUser = UserJpaEntity.builder()
.loginId(info.email() != null ? info.email() : "NA")
.gender(info.gender() != null ? info.gender() : Gender.PENDING)
.name(info.name() != null ? info.name() : "NA")
.birth(info.birthDay() != null ? info.birthDay()
: LocalDate.EPOCH)
.phoneNumber(info.phoneNumber() != null ? info.phoneNumber()
: "010-0000-0000")
.userRole(UserRole.ROLE_PENDING)
.provider(AuthProvider.KAKAO)
.agreedToTermsOfService(true)
.agreedToPrivacyPolicy(true)
.agreedToMarketing(info.marketingAgreed())
.build();
return userRepository.save(newUser);
});
}

private Map<String, Object> getServiceTerms(String accessToken) {

String url = "https://kapi.kakao.com/v2/user/service_terms";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@

@Getter
public class BlockedIp {

private final TimePenalty penaltyLevel;

public BlockedIp(TimePenalty penaltyLevel) {
this.penaltyLevel = penaltyLevel;
}

public Duration getTtl(){
return penaltyLevel.getDuration();
public static BlockedIp init() {
return new BlockedIp(TimePenalty.LEVEL_0);
}


public Duration getTtl() {
return penaltyLevel.getDuration();
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package life.mosu.mosuserver.domain.caffeine.dto;

import lombok.Getter;
import java.util.concurrent.atomic.AtomicInteger;

@Getter
public class RequestCounter {
private int count = 0;

public void increment() {
count++;
private final AtomicInteger count = new AtomicInteger();

public int incrementAndGet() {
return count.incrementAndGet();
}

public int getCount() {
return count.get();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,14 @@ public void updateOAuthUser(
Gender gender,
String name,
String phoneNumber,
LocalDate birth
LocalDate birth,
boolean agreedToMarketing
) {
this.gender = gender;
this.name = name;
this.phoneNumber = phoneNumber;
this.birth = birth;
this.agreedToMarketing = agreedToMarketing;
}

public void updateUserInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
import com.github.benmanes.caffeine.cache.Expiry;
import com.github.benmanes.caffeine.cache.LoadingCache;
import java.util.concurrent.TimeUnit;
import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory;
import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp;
import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory;
import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand Down Expand Up @@ -41,12 +41,14 @@ public long expireAfterCreate(String key, BlockedIp value, long currentTime) {
}

@Override
public long expireAfterUpdate(String key, BlockedIp value, long currentTime, long currentDuration) {
return currentDuration;
public long expireAfterUpdate(String key, BlockedIp value, long currentTime,
long currentDuration) {
return value.getTtl().toNanos();
}

@Override
public long expireAfterRead(String key, BlockedIp value, long currentTime, long currentDuration) {
public long expireAfterRead(String key, BlockedIp value, long currentTime,
long currentDuration) {
return currentDuration;
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp;
import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory;
import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter;
import life.mosu.mosuserver.global.config.IpRateLimitingProperties;
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
import life.mosu.mosuserver.global.exception.ErrorCode;
import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory;
import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp;

import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
Expand All @@ -31,59 +30,60 @@ public class IpRateLimitingFilter extends OncePerRequestFilter {
private final Cache<String, BlockedIpHistory> blockedHistoryCache;
private final LoadingCache<String, BlockedIp> blockedIpCache;


@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {

if (!ipRateLimitingProperties.isEnabled()) {
log.info("IpRateLimitingFilter disabled");
log.debug("IpRateLimitingFilter disabled");
filterChain.doFilter(request, response);
return;
}

String ip = getClientIp(request);

isAlreadyBlocked(ip);

RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter());
int after = counter.incrementAndGet();
int max = ipRateLimitingProperties.getMaxRequestsPerMinute();

synchronized (counter) {
counter.increment();

if (isOverPerMaxRequest(counter)) {
log.warn("차단된 IP: {}, 요청 횟수: {}", ip, counter.getCount());
handleBlockedIp(ip);
}
if (after > max) {
handleBlockedIp(ip);
}

log.debug("IP: {}, 요청 횟수 증가 후: {}", ip, counter.getCount());
log.debug("IP: {}, 요청 횟수 증가 후: {}", ip, after);
log.debug("Cache stats: {}", ipRequestCountsCache.stats());

filterChain.doFilter(request, response);
}

private boolean isOverPerMaxRequest(RequestCounter counter) {
return counter.getCount() >= ipRateLimitingProperties.getMaxRequestsPerMinute();
}

private void handleBlockedIp(String ip) {
BlockedIpHistory history = blockedHistoryCache.get(ip, k -> new BlockedIpHistory(ip));
TimePenalty nextPenaltyLevel = history.getPenaltyLevel().nextLevel();
history.updateHistory(nextPenaltyLevel);
BlockedIp existing = blockedIpCache.asMap().putIfAbsent(ip, BlockedIp.init());
if (existing != null) {
log.warn("이미 차단된 IP: {}, 차단 레벨: {}", ip, existing.getPenaltyLevel());
throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS);
}

TimePenalty level = blockedHistoryCache.asMap().compute(ip, (k, history) -> {
BlockedIpHistory h = (history == null) ? new BlockedIpHistory(ip) : history;
TimePenalty next = h.getPenaltyLevel().nextLevel();
h.updateHistory(next);
return h;
}).getPenaltyLevel();

blockedIpCache.invalidate(ip);
blockedIpCache.put(ip, new BlockedIp(nextPenaltyLevel));
log.warn("IP 차단: {}, 차단 레벨: {})", ip, nextPenaltyLevel);
blockedIpCache.asMap().computeIfPresent(ip, (k, v) -> new BlockedIp(level));

throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS);
}

private void isAlreadyBlocked(String requestedIp) {
if(blockedIpCache.getIfPresent(requestedIp) != null){
log.warn("이미 차단된 IP: {}", requestedIp);
private void isAlreadyBlocked(String ip) {
BlockedIp blockedIp = blockedIpCache.getIfPresent(ip);
if (blockedIp != null) {
log.warn("이미 차단된 IP: {}, 차단 레벨: {}", ip, blockedIp.getPenaltyLevel());
throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
@Getter
@RequiredArgsConstructor
public enum TimePenalty {
LEVEL_0(0, Duration.ZERO),
LEVEL_0(0, Duration.ofSeconds(10)),
LEVEL_1(1, Duration.ofMinutes(1)),
LEVEL_2(2, Duration.ofMinutes(5)),
LEVEL_3(3, Duration.ofMinutes(30)),
Expand Down
Loading