a9ff2387c8
CI / build (push) Failing after 14m0s
- 로그인/비밀번호 재인증에 실패 기반 레이트리밋(Redis 슬라이딩 윈도우) · 로그인 IP당 10회/10분, 비밀번호 게이트 회원당 5회/10분, 초과 시 429 · 성공 시 실패 카운터 초기화, Redis 장애 시 통과(가용성 우선) · 회원가입 레이트리밋도 공통 enforceRateLimit 으로 일원화 - GlobalExceptionHandler: 미처리 500 로그에 메서드+경로 기록(추적성), 잘못된 JSON은 400 - 테스트: 레이트리밋 429 케이스 2개 추가 (백엔드 총 34) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
329 lines
13 KiB
Java
329 lines
13 KiB
Java
package com.sb.web.auth.service;
|
|
|
|
import com.sb.web.admin.service.AppSettingService;
|
|
import com.sb.web.auth.domain.AuthSession;
|
|
import com.sb.web.auth.domain.Member;
|
|
import com.sb.web.auth.dto.LoginRequest;
|
|
import com.sb.web.auth.dto.LoginResponse;
|
|
import com.sb.web.auth.dto.MemberResponse;
|
|
import com.sb.web.auth.dto.ProfileUpdateRequest;
|
|
import com.sb.web.auth.dto.SessionUser;
|
|
import com.sb.web.auth.dto.SignupRequest;
|
|
import com.sb.web.auth.mapper.AuthSessionMapper;
|
|
import com.sb.web.auth.mapper.MemberMapper;
|
|
import com.sb.web.common.exception.ApiException;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.DisplayName;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
import org.mockito.Mock;
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
import org.springframework.data.redis.core.RedisTemplate;
|
|
import org.springframework.data.redis.core.ValueOperations;
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.anyString;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.Mockito.never;
|
|
import static org.mockito.Mockito.verify;
|
|
import static org.mockito.Mockito.when;
|
|
|
|
/**
|
|
* AuthService 단위테스트 (Mockito).
|
|
* 인증 핵심: 비밀번호 재인증 / 가입정보 변경 / 회원가입 차단 / 로그인 / 세션 DB 백업 복원.
|
|
*/
|
|
@ExtendWith(MockitoExtension.class)
|
|
class AuthServiceTest {
|
|
|
|
@Mock MemberMapper memberMapper;
|
|
@Mock PasswordEncoder passwordEncoder;
|
|
@Mock RedisTemplate<String, Object> redisTemplate;
|
|
@Mock ValueOperations<String, Object> valueOps;
|
|
@Mock AppSettingService appSettingService;
|
|
@Mock AuthSessionMapper authSessionMapper;
|
|
|
|
AuthService service;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
service = new AuthService(memberMapper, passwordEncoder, redisTemplate, appSettingService, authSessionMapper);
|
|
}
|
|
|
|
private Member localMember() {
|
|
return Member.builder()
|
|
.id(1L).loginId("u1").password("hash").name("원래이름")
|
|
.email("old@b.com").provider("LOCAL").role("USER").status("ACTIVE")
|
|
.build();
|
|
}
|
|
|
|
// ===== verifyPassword =====
|
|
|
|
@Test
|
|
@DisplayName("verifyPassword: 일치하면 예외 없음")
|
|
void verifyPassword_ok() {
|
|
when(memberMapper.findById(1L)).thenReturn(localMember());
|
|
when(passwordEncoder.matches("pw", "hash")).thenReturn(true);
|
|
|
|
service.verifyPassword(1L, "pw");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("verifyPassword: 불일치면 401")
|
|
void verifyPassword_wrong() {
|
|
when(memberMapper.findById(1L)).thenReturn(localMember());
|
|
when(passwordEncoder.matches("bad", "hash")).thenReturn(false);
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps); // 실패 카운트(레이트리밋)
|
|
when(valueOps.increment(anyString())).thenReturn(1L);
|
|
|
|
assertThatThrownBy(() -> service.verifyPassword(1L, "bad"))
|
|
.isInstanceOf(ApiException.class)
|
|
.extracting("status").isEqualTo(HttpStatus.UNAUTHORIZED);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("verifyPassword: 소셜 계정은 400")
|
|
void verifyPassword_social() {
|
|
Member social = Member.builder().id(1L).provider("NAVER").password(null).build();
|
|
when(memberMapper.findById(1L)).thenReturn(social);
|
|
|
|
assertThatThrownBy(() -> service.verifyPassword(1L, "pw"))
|
|
.isInstanceOf(ApiException.class)
|
|
.extracting("status").isEqualTo(HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("verifyPassword: 회원 없으면 404")
|
|
void verifyPassword_notFound() {
|
|
when(memberMapper.findById(1L)).thenReturn(null);
|
|
|
|
assertThatThrownBy(() -> service.verifyPassword(1L, "pw"))
|
|
.isInstanceOf(ApiException.class)
|
|
.extracting("status").isEqualTo(HttpStatus.NOT_FOUND);
|
|
}
|
|
|
|
// ===== updateProfile =====
|
|
|
|
@Test
|
|
@DisplayName("updateProfile: 이름/이메일 변경 + 세션 이름 동기화")
|
|
void updateProfile_ok() {
|
|
when(memberMapper.findById(1L)).thenReturn(localMember());
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
when(valueOps.get("session:tok")).thenReturn(null); // Redis 세션 없음 → DB만 동기화
|
|
|
|
ProfileUpdateRequest req = new ProfileUpdateRequest();
|
|
req.setName(" 새이름 ");
|
|
req.setEmail("new@b.com");
|
|
|
|
MemberResponse res = service.updateProfile(1L, "tok", req);
|
|
|
|
assertThat(res.getName()).isEqualTo("새이름"); // trim
|
|
assertThat(res.getEmail()).isEqualTo("new@b.com");
|
|
verify(memberMapper).updateProfile(1L, "새이름", "new@b.com");
|
|
verify(authSessionMapper).updateName("tok", "새이름");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("updateProfile: 이름이 비면 400, 변경 안 함")
|
|
void updateProfile_blankName() {
|
|
when(memberMapper.findById(1L)).thenReturn(localMember());
|
|
|
|
ProfileUpdateRequest req = new ProfileUpdateRequest();
|
|
req.setName(" ");
|
|
|
|
assertThatThrownBy(() -> service.updateProfile(1L, "tok", req))
|
|
.isInstanceOf(ApiException.class)
|
|
.extracting("status").isEqualTo(HttpStatus.BAD_REQUEST);
|
|
verify(memberMapper, never()).updateProfile(any(), any(), any());
|
|
}
|
|
|
|
// ===== signup =====
|
|
|
|
@Test
|
|
@DisplayName("signup: 가입 제한 시 403")
|
|
void signup_disabled() {
|
|
when(appSettingService.isSignupEnabled()).thenReturn(false);
|
|
|
|
assertThatThrownBy(() -> service.signup(new SignupRequest(), "1.2.3.4"))
|
|
.isInstanceOf(ApiException.class)
|
|
.extracting("status").isEqualTo(HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("signup: 허니팟(website) 채워지면 400")
|
|
void signup_honeypot() {
|
|
when(appSettingService.isSignupEnabled()).thenReturn(true);
|
|
SignupRequest req = new SignupRequest();
|
|
req.setWebsite("http://bot");
|
|
|
|
assertThatThrownBy(() -> service.signup(req, "1.2.3.4"))
|
|
.isInstanceOf(ApiException.class)
|
|
.extracting("status").isEqualTo(HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("signup: IP 레이트리밋 초과 시 429")
|
|
void signup_rateLimited() {
|
|
when(appSettingService.isSignupEnabled()).thenReturn(true);
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
when(valueOps.increment(anyString())).thenReturn(6L); // SIGNUP_LIMIT(5) 초과
|
|
|
|
assertThatThrownBy(() -> service.signup(new SignupRequest(), "1.2.3.4"))
|
|
.isInstanceOf(ApiException.class)
|
|
.extracting("status").isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("signup: 정상 가입 시 회원 저장")
|
|
void signup_ok() {
|
|
when(appSettingService.isSignupEnabled()).thenReturn(true);
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
when(valueOps.increment(anyString())).thenReturn(1L);
|
|
when(memberMapper.countByLoginId("newbie")).thenReturn(0);
|
|
when(passwordEncoder.encode("password1")).thenReturn("hashed");
|
|
|
|
SignupRequest req = new SignupRequest();
|
|
req.setLoginId("newbie");
|
|
req.setPassword("password1");
|
|
req.setName("새회원");
|
|
req.setEmail("n@b.com");
|
|
|
|
MemberResponse res = service.signup(req, "1.2.3.4");
|
|
|
|
assertThat(res.getLoginId()).isEqualTo("newbie");
|
|
verify(memberMapper).insert(any(Member.class));
|
|
}
|
|
|
|
// ===== login =====
|
|
|
|
@Test
|
|
@DisplayName("login: 비밀번호 불일치 시 401")
|
|
void login_wrongPassword() {
|
|
when(memberMapper.findByLoginId("u1")).thenReturn(localMember());
|
|
when(passwordEncoder.matches("bad", "hash")).thenReturn(false);
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps); // 실패 카운트(레이트리밋)
|
|
when(valueOps.increment(anyString())).thenReturn(1L);
|
|
|
|
LoginRequest req = new LoginRequest();
|
|
req.setLoginId("u1");
|
|
req.setPassword("bad");
|
|
|
|
assertThatThrownBy(() -> service.login(req, "1.2.3.4"))
|
|
.isInstanceOf(ApiException.class)
|
|
.extracting("status").isEqualTo(HttpStatus.UNAUTHORIZED);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("login: 성공 시 토큰 발급 + Redis/DB 이중 저장")
|
|
void login_ok() {
|
|
when(memberMapper.findByLoginId("u1")).thenReturn(localMember());
|
|
when(passwordEncoder.matches("pw", "hash")).thenReturn(true);
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
|
|
LoginRequest req = new LoginRequest();
|
|
req.setLoginId("u1");
|
|
req.setPassword("pw");
|
|
req.setRememberMe(true);
|
|
|
|
LoginResponse res = service.login(req, "1.2.3.4");
|
|
|
|
assertThat(res.getToken()).isNotBlank();
|
|
verify(valueOps).set(anyString(), any(), any()); // Redis 저장
|
|
verify(authSessionMapper).insert(any(AuthSession.class)); // DB 백업
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("login: 실패 누적이 한도 초과면 429")
|
|
void login_rateLimited() {
|
|
when(memberMapper.findByLoginId("u1")).thenReturn(localMember());
|
|
when(passwordEncoder.matches("bad", "hash")).thenReturn(false);
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
when(valueOps.increment(anyString())).thenReturn(11L); // LOGIN_LIMIT(10) 초과
|
|
|
|
LoginRequest req = new LoginRequest();
|
|
req.setLoginId("u1");
|
|
req.setPassword("bad");
|
|
|
|
assertThatThrownBy(() -> service.login(req, "1.2.3.4"))
|
|
.isInstanceOf(ApiException.class)
|
|
.extracting("status").isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("verifyPassword: 실패 누적이 한도 초과면 429")
|
|
void verifyPassword_rateLimited() {
|
|
when(memberMapper.findById(1L)).thenReturn(localMember());
|
|
when(passwordEncoder.matches("bad", "hash")).thenReturn(false);
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
when(valueOps.increment(anyString())).thenReturn(6L); // VERIFY_PW_LIMIT(5) 초과
|
|
|
|
assertThatThrownBy(() -> service.verifyPassword(1L, "bad"))
|
|
.isInstanceOf(ApiException.class)
|
|
.extracting("status").isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
|
|
}
|
|
|
|
// ===== getSession (DB 백업 복원) =====
|
|
|
|
@Test
|
|
@DisplayName("getSession: Redis 적중 시 그대로 반환")
|
|
void getSession_redisHit() {
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
SessionUser cached = SessionUser.builder().id(1L).loginId("u1").name("N").role("USER").rememberMe(false).build();
|
|
when(valueOps.get("session:tok")).thenReturn(cached);
|
|
|
|
SessionUser out = service.getSession("tok");
|
|
|
|
assertThat(out).isNotNull();
|
|
assertThat(out.getLoginId()).isEqualTo("u1");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("getSession: Redis 미스 + DB 유효 → 복원 및 재수화")
|
|
void getSession_dbFallback() {
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
when(valueOps.get("session:tok")).thenReturn(null);
|
|
AuthSession db = AuthSession.builder()
|
|
.token("tok").memberId(1L).loginId("u1").name("N").role("USER")
|
|
.provider("LOCAL").rememberMe(true)
|
|
.expiresAt(LocalDateTime.now().plusDays(1))
|
|
.build();
|
|
when(authSessionMapper.findByToken("tok")).thenReturn(db);
|
|
|
|
SessionUser out = service.getSession("tok");
|
|
|
|
assertThat(out).isNotNull();
|
|
assertThat(out.getLoginId()).isEqualTo("u1");
|
|
verify(valueOps).set(eq("session:tok"), any(), any()); // Redis 재수화
|
|
verify(authSessionMapper).updateExpiry(eq("tok"), any()); // 슬라이딩 갱신
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("getSession: Redis 미스 + DB 없음 → null")
|
|
void getSession_miss() {
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
when(valueOps.get("session:tok")).thenReturn(null);
|
|
when(authSessionMapper.findByToken("tok")).thenReturn(null);
|
|
|
|
assertThat(service.getSession("tok")).isNull();
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("getSession: DB 백업이 만료면 정리하고 null")
|
|
void getSession_dbExpired() {
|
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
|
when(valueOps.get("session:tok")).thenReturn(null);
|
|
AuthSession expired = AuthSession.builder()
|
|
.token("tok").memberId(1L).expiresAt(LocalDateTime.now().minusMinutes(1))
|
|
.build();
|
|
when(authSessionMapper.findByToken("tok")).thenReturn(expired);
|
|
|
|
assertThat(service.getSession("tok")).isNull();
|
|
verify(authSessionMapper).delete("tok");
|
|
}
|
|
}
|