package com.sb.web.auth.service; 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.PasswordChangeRequest; import com.sb.web.auth.dto.SessionUser; import com.sb.web.auth.dto.SignupRequest; import com.sb.web.common.exception.ApiException; import com.sb.web.auth.mapper.MemberMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.util.UUID; /** * 인증 서비스. * - 회원가입: BCrypt 해시 저장 * - 로그인: 비밀번호 검증 후 세션 토큰 발급 → Redis 저장 * - 세션: Redis 에서 토큰으로 조회(슬라이딩 만료), 로그아웃 시 삭제 */ @Slf4j @Service @RequiredArgsConstructor public class AuthService { private final MemberMapper memberMapper; private final PasswordEncoder passwordEncoder; private final RedisTemplate redisTemplate; private final com.sb.web.admin.service.AppSettingService appSettingService; private static final String SESSION_PREFIX = "session:"; private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션 private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지) // 회원가입 봇 방지 — IP당 가입 시도 제한(슬라이딩 윈도우) private static final int SIGNUP_LIMIT = 5; private static final Duration SIGNUP_WINDOW = Duration.ofHours(1); @Transactional public MemberResponse signup(SignupRequest req, String clientIp) { if (!appSettingService.isSignupEnabled()) { throw new ApiException(HttpStatus.FORBIDDEN, "현재 회원가입이 제한되어 있습니다."); } // 1) 허니팟: 숨김 필드에 값이 차 있으면 봇 → 조용히 차단 if (req.getWebsite() != null && !req.getWebsite().isBlank()) { log.warn("[signup] honeypot 차단 ip={}", clientIp); throw new ApiException(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."); } // 2) IP 레이트리밋 rateLimitSignup(clientIp); if (memberMapper.countByLoginId(req.getLoginId()) > 0) { throw new ApiException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다."); } Member member = Member.builder() .loginId(req.getLoginId()) .password(passwordEncoder.encode(req.getPassword())) .name(req.getName()) .email(req.getEmail()) .provider("LOCAL") .role("USER") .status("ACTIVE") .build(); memberMapper.insert(member); log.info("[signup] new member: {}", member.getLoginId()); return MemberResponse.from(member); } /** IP당 가입 시도 횟수 제한 (Redis 슬라이딩 윈도우). 초과 시 429 */ private void rateLimitSignup(String ip) { if (ip == null || ip.isBlank()) { return; } String key = "signup:rl:" + ip; Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1L) { redisTemplate.expire(key, SIGNUP_WINDOW); } if (count != null && count > SIGNUP_LIMIT) { log.warn("[signup] 레이트리밋 차단 ip={} count={}", ip, count); throw new ApiException(HttpStatus.TOO_MANY_REQUESTS, "회원가입 시도가 너무 많습니다. 잠시 후 다시 시도해주세요."); } } public LoginResponse login(LoginRequest req) { Member member = memberMapper.findByLoginId(req.getLoginId()); if (member == null || member.getPassword() == null || !passwordEncoder.matches(req.getPassword(), member.getPassword())) { throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다."); } if (!"ACTIVE".equals(member.getStatus())) { throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다."); } Duration ttl = req.isRememberMe() ? REMEMBER_TTL : SESSION_TTL; SessionUser session = SessionUser.from(member); session.setRememberMe(req.isRememberMe()); String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set(SESSION_PREFIX + token, session, ttl); log.info("[login] {} (token issued, rememberMe={})", member.getLoginId(), req.isRememberMe()); return LoginResponse.builder() .token(token) .expiresInSeconds(ttl.getSeconds()) .member(MemberResponse.from(member)) .build(); } /** * 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다. */ public SessionUser getSession(String token) { if (token == null || token.isBlank()) { return null; } String key = SESSION_PREFIX + token; Object cached = redisTemplate.opsForValue().get(key); if (cached instanceof SessionUser user) { // 슬라이딩 만료: 자동 로그인 세션이면 길게(30일), 아니면 60분으로 갱신 redisTemplate.expire(key, user.isRememberMe() ? REMEMBER_TTL : SESSION_TTL); return user; } return null; } public void logout(String token) { if (token != null && !token.isBlank()) { redisTemplate.delete(SESSION_PREFIX + token); } } /** * 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체. */ @Transactional public void changePassword(Long memberId, PasswordChangeRequest req) { Member member = memberMapper.findById(memberId); if (member == null) { throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."); } if (!"LOCAL".equals(member.getProvider()) || member.getPassword() == null) { throw new ApiException(HttpStatus.BAD_REQUEST, "소셜 로그인 계정은 비밀번호를 변경할 수 없습니다."); } if (!passwordEncoder.matches(req.getCurrentPassword(), member.getPassword())) { throw new ApiException(HttpStatus.BAD_REQUEST, "현재 비밀번호가 올바르지 않습니다."); } if (passwordEncoder.matches(req.getNewPassword(), member.getPassword())) { throw new ApiException(HttpStatus.BAD_REQUEST, "새 비밀번호가 기존 비밀번호와 동일합니다."); } memberMapper.updatePassword(memberId, passwordEncoder.encode(req.getNewPassword())); log.info("[password] changed for {}", member.getLoginId()); } }