611 lines
30 KiB
Java
611 lines
30 KiB
Java
package com.sb.web.auth.service;
|
|
|
|
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.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.Map;
|
|
import java.util.UUID;
|
|
|
|
/**
|
|
* 인증 서비스.
|
|
* - 회원가입: BCrypt 해시 저장
|
|
* - 로그인: 비밀번호 검증 후 세션 토큰 발급 → Redis 저장
|
|
* - 세션: Redis 에서 토큰으로 조회(슬라이딩 만료), 로그아웃 시 삭제
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class AuthService {
|
|
|
|
private final MemberMapper memberMapper;
|
|
private final PasswordEncoder passwordEncoder;
|
|
private final RedisTemplate<String, Object> redisTemplate;
|
|
private final com.sb.web.admin.service.AppSettingService appSettingService;
|
|
private final com.sb.web.auth.mapper.AuthSessionMapper authSessionMapper;
|
|
private final com.sb.web.auth.mapper.WithdrawMapper withdrawMapper;
|
|
private final com.sb.web.account.mapper.BackupMapper backupMapper;
|
|
|
|
/** 구글 OAuth 클라이언트 ID (ID 토큰 aud 검증용). 미설정 시 구글 로그인 비활성. */
|
|
@org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
|
|
private String googleClientId;
|
|
|
|
/** 애플 로그인 aud(=iOS 앱 번들 ID). iOS 번들ID는 kr.sblog.slimbudget.app (안드로이드 패키지와 별개). */
|
|
@org.springframework.beans.factory.annotation.Value("${app.apple-client-id:kr.sblog.slimbudget.app}")
|
|
private String appleClientId;
|
|
|
|
/** 애플 ID 토큰 검증기(JWKS 캐시 포함). 최초 사용 시 lazy 초기화. */
|
|
private volatile com.nimbusds.jwt.proc.ConfigurableJWTProcessor<com.nimbusds.jose.proc.SecurityContext> appleJwtProcessor;
|
|
|
|
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);
|
|
|
|
// 무차별 대입 방지 — 실패한 시도만 카운트 (정상 로그인은 영향 없음)
|
|
private static final int LOGIN_LIMIT = 10;
|
|
private static final Duration LOGIN_WINDOW = Duration.ofMinutes(10);
|
|
private static final int VERIFY_PW_LIMIT = 5;
|
|
private static final Duration VERIFY_PW_WINDOW = Duration.ofMinutes(10);
|
|
|
|
@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 레이트리밋
|
|
if (clientIp != null && !clientIp.isBlank()) {
|
|
enforceRateLimit("signup:rl:" + clientIp, SIGNUP_LIMIT, SIGNUP_WINDOW,
|
|
"회원가입 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.");
|
|
}
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Redis 슬라이딩 윈도우 레이트리밋. 윈도우 내 호출이 limit 초과면 429.
|
|
* Redis 장애 시에는 통과(가용성 우선) — 차단보다 서비스 가용성을 우선한다.
|
|
*/
|
|
private void enforceRateLimit(String key, int limit, Duration window, String message) {
|
|
try {
|
|
Long count = redisTemplate.opsForValue().increment(key);
|
|
if (count != null && count == 1L) {
|
|
redisTemplate.expire(key, window);
|
|
}
|
|
if (count != null && count > limit) {
|
|
log.warn("[ratelimit] 차단 key={} count={}", key, count);
|
|
throw new ApiException(HttpStatus.TOO_MANY_REQUESTS, message);
|
|
}
|
|
} catch (ApiException e) {
|
|
throw e;
|
|
} catch (Exception e) {
|
|
log.warn("[ratelimit] Redis 오류로 레이트리밋 생략: {}", e.toString());
|
|
}
|
|
}
|
|
|
|
public LoginResponse login(LoginRequest req, String clientIp) {
|
|
Member member = memberMapper.findByLoginId(req.getLoginId());
|
|
boolean passwordOk = member != null
|
|
&& member.getPassword() != null
|
|
&& passwordEncoder.matches(req.getPassword(), member.getPassword());
|
|
if (!passwordOk) {
|
|
// 실패한 시도만 IP 단위로 카운트 — 무차별 대입 방지(정상 로그인엔 영향 없음)
|
|
if (clientIp != null && !clientIp.isBlank()) {
|
|
enforceRateLimit("login:rl:" + clientIp, LOGIN_LIMIT, LOGIN_WINDOW,
|
|
"로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.");
|
|
}
|
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다.");
|
|
}
|
|
if (!"ACTIVE".equals(member.getStatus())) {
|
|
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
|
}
|
|
// 로그인 성공 — 실패 카운터 초기화
|
|
if (clientIp != null && !clientIp.isBlank()) {
|
|
try { redisTemplate.delete("login:rl:" + clientIp); } catch (Exception ignore) { /* 무시 */ }
|
|
}
|
|
|
|
return issueSession(member, req.isRememberMe());
|
|
}
|
|
|
|
/** 세션 토큰 발급 → Redis + DB 백업. login/소셜로그인 공용. */
|
|
private LoginResponse issueSession(Member member, boolean rememberMe) {
|
|
Duration ttl = rememberMe ? REMEMBER_TTL : SESSION_TTL;
|
|
SessionUser session = SessionUser.from(member);
|
|
session.setRememberMe(rememberMe);
|
|
|
|
String token = UUID.randomUUID().toString().replace("-", "");
|
|
java.time.LocalDateTime expiresAt = java.time.LocalDateTime.now().plus(ttl);
|
|
try {
|
|
redisTemplate.opsForValue().set(SESSION_PREFIX + token, session, ttl);
|
|
} catch (Exception e) {
|
|
log.warn("[login] Redis 세션 저장 실패(무시, DB 백업 사용): {}", e.toString());
|
|
}
|
|
authSessionMapper.insert(com.sb.web.auth.domain.AuthSession.of(token, session, expiresAt));
|
|
log.info("[login] member={} provider={} (token issued, rememberMe={})",
|
|
member.getId(), member.getProvider(), rememberMe);
|
|
|
|
return LoginResponse.builder()
|
|
.token(token)
|
|
.expiresInSeconds(ttl.getSeconds())
|
|
.member(MemberResponse.from(member))
|
|
.build();
|
|
}
|
|
|
|
/** 구글 로그인/가입 — ID 토큰 검증 후 provider=GOOGLE 회원 조회/생성하고 세션 발급. */
|
|
@Transactional
|
|
public LoginResponse googleLogin(String idToken, boolean rememberMe) {
|
|
if (googleClientId == null || googleClientId.isBlank()) {
|
|
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "구글 로그인이 설정되지 않았습니다.");
|
|
}
|
|
if (idToken == null || idToken.isBlank()) {
|
|
throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다.");
|
|
}
|
|
// 구글 ID 토큰 검증(서명·만료는 구글이 검증) → 클레임 반환
|
|
Map<?, ?> info;
|
|
try {
|
|
info = org.springframework.web.client.RestClient.create()
|
|
.get()
|
|
.uri("https://oauth2.googleapis.com/tokeninfo?id_token={t}", idToken)
|
|
.retrieve()
|
|
.body(Map.class);
|
|
} catch (Exception e) {
|
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "구글 인증에 실패했습니다.");
|
|
}
|
|
if (info == null || !googleClientId.equals(String.valueOf(info.get("aud")))) {
|
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 구글 토큰입니다.");
|
|
}
|
|
String sub = String.valueOf(info.get("sub"));
|
|
String email = info.get("email") == null ? null : String.valueOf(info.get("email"));
|
|
boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified")));
|
|
String name = info.get("name") != null ? String.valueOf(info.get("name"))
|
|
: (email != null ? email.split("@")[0] : "사용자");
|
|
String picture = info.get("picture") != null ? String.valueOf(info.get("picture")) : null;
|
|
|
|
// 1) 구글 sub 로 이미 연결/가입된 계정 조회
|
|
Member member = memberMapper.findByGoogleId(sub);
|
|
|
|
// 2) 없으면 같은 (검증된)이메일의 기존 계정에 구글 연결 — 중복 계정/데이터 분리 방지
|
|
if (member == null && email != null && !email.isBlank() && emailVerified) {
|
|
Member existing = memberMapper.findByEmailForLink(email);
|
|
if (existing != null) {
|
|
if (!"ACTIVE".equals(existing.getStatus())) {
|
|
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
|
}
|
|
memberMapper.linkGoogle(existing.getId(), sub);
|
|
existing.setGoogleId(sub);
|
|
member = existing;
|
|
log.info("[google] linked to existing member id={} provider={} email={}",
|
|
member.getId(), member.getProvider(), email);
|
|
}
|
|
}
|
|
|
|
// 3) 그래도 없으면 신규 구글 계정 생성(최초 로그인 = 가입). 가입 제한 시 차단.
|
|
if (member == null) {
|
|
if (!appSettingService.isSignupEnabled()) {
|
|
throw new ApiException(HttpStatus.FORBIDDEN, "현재 회원가입이 제한되어 있습니다.");
|
|
}
|
|
member = Member.builder()
|
|
.name(name)
|
|
.email(email)
|
|
.provider("GOOGLE")
|
|
.providerId(sub)
|
|
.googleId(sub)
|
|
.googlePicture(picture)
|
|
.role("USER")
|
|
.status("ACTIVE")
|
|
.build();
|
|
memberMapper.insert(member);
|
|
log.info("[google] new member id={} email={}", member.getId(), email);
|
|
}
|
|
if (!"ACTIVE".equals(member.getStatus())) {
|
|
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
|
}
|
|
// 구글 아바타가 바뀌었으면 동기화(신규 가입은 이미 반영됨 → 변경 없을 때 불필요한 UPDATE 생략)
|
|
if (picture != null && !picture.equals(member.getGooglePicture())) {
|
|
memberMapper.updateGooglePicture(member.getId(), picture);
|
|
member.setGooglePicture(picture);
|
|
}
|
|
return issueSession(member, rememberMe);
|
|
}
|
|
|
|
public String googleClientId() {
|
|
return googleClientId == null ? "" : googleClientId;
|
|
}
|
|
|
|
/**
|
|
* 애플 로그인. Sign in with Apple identity token(JWT)을 애플 공개키(JWKS)로 검증한 뒤,
|
|
* 구글과 동일한 규칙으로 계정을 조회/연결/생성한다.
|
|
*/
|
|
public LoginResponse appleLogin(String identityToken, String providedName, boolean rememberMe) {
|
|
if (identityToken == null || identityToken.isBlank()) {
|
|
throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다.");
|
|
}
|
|
com.nimbusds.jwt.JWTClaimsSet claims;
|
|
try {
|
|
claims = verifyAppleToken(identityToken);
|
|
} catch (Exception e) {
|
|
log.warn("[apple] token verify failed: {}", e.toString());
|
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "애플 인증에 실패했습니다.");
|
|
}
|
|
// 발급자·대상(aud=번들 ID)·만료 검증
|
|
java.util.Date now = new java.util.Date();
|
|
if (claims.getExpirationTime() == null || claims.getExpirationTime().before(now)
|
|
|| !"https://appleid.apple.com".equals(claims.getIssuer())
|
|
|| claims.getAudience() == null || !claims.getAudience().contains(appleClientId)) {
|
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 애플 토큰입니다.");
|
|
}
|
|
String sub = claims.getSubject();
|
|
if (sub == null || sub.isBlank()) {
|
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 애플 토큰입니다.");
|
|
}
|
|
String email;
|
|
boolean emailVerified;
|
|
try {
|
|
email = claims.getStringClaim("email");
|
|
Object ev = claims.getClaim("email_verified"); // 문자열("true")/불리언 모두 대응
|
|
emailVerified = ev != null && "true".equals(String.valueOf(ev));
|
|
} catch (Exception e) {
|
|
email = null;
|
|
emailVerified = false;
|
|
}
|
|
String name = (providedName != null && !providedName.isBlank()) ? providedName
|
|
: (email != null ? email.split("@")[0] : "사용자");
|
|
|
|
// 1) 애플 sub 로 이미 연결/가입된 계정 조회
|
|
Member member = memberMapper.findByAppleId(sub);
|
|
|
|
// 2) 없으면 같은 (검증된)이메일의 기존 계정에 애플 연결 — 중복 계정/데이터 분리 방지
|
|
if (member == null && email != null && !email.isBlank() && emailVerified) {
|
|
Member existing = memberMapper.findByEmailForAppleLink(email);
|
|
if (existing != null) {
|
|
if (!"ACTIVE".equals(existing.getStatus())) {
|
|
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
|
}
|
|
memberMapper.linkApple(existing.getId(), sub);
|
|
existing.setAppleId(sub);
|
|
member = existing;
|
|
log.info("[apple] linked to existing member id={} provider={} email={}",
|
|
member.getId(), member.getProvider(), email);
|
|
}
|
|
}
|
|
|
|
// 3) 그래도 없으면 신규 애플 계정 생성(최초 로그인 = 가입). 가입 제한 시 차단.
|
|
if (member == null) {
|
|
if (!appSettingService.isSignupEnabled()) {
|
|
throw new ApiException(HttpStatus.FORBIDDEN, "현재 회원가입이 제한되어 있습니다.");
|
|
}
|
|
member = Member.builder()
|
|
.name(name)
|
|
.email(email)
|
|
.provider("APPLE")
|
|
.providerId(sub)
|
|
.appleId(sub)
|
|
.role("USER")
|
|
.status("ACTIVE")
|
|
.build();
|
|
memberMapper.insert(member);
|
|
log.info("[apple] new member id={} email={}", member.getId(), email);
|
|
}
|
|
if (!"ACTIVE".equals(member.getStatus())) {
|
|
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
|
}
|
|
return issueSession(member, rememberMe);
|
|
}
|
|
|
|
/** 애플 identity token 을 애플 JWKS(공개키)로 서명 검증하고 클레임을 반환. */
|
|
private com.nimbusds.jwt.JWTClaimsSet verifyAppleToken(String idToken) throws Exception {
|
|
com.nimbusds.jwt.proc.ConfigurableJWTProcessor<com.nimbusds.jose.proc.SecurityContext> proc = appleJwtProcessor;
|
|
if (proc == null) {
|
|
synchronized (this) {
|
|
if (appleJwtProcessor == null) {
|
|
com.nimbusds.jose.jwk.source.JWKSource<com.nimbusds.jose.proc.SecurityContext> keySource =
|
|
com.nimbusds.jose.jwk.source.JWKSourceBuilder
|
|
.create(new java.net.URL("https://appleid.apple.com/auth/keys"))
|
|
.retrying(true)
|
|
.build();
|
|
com.nimbusds.jwt.proc.DefaultJWTProcessor<com.nimbusds.jose.proc.SecurityContext> p =
|
|
new com.nimbusds.jwt.proc.DefaultJWTProcessor<>();
|
|
p.setJWSKeySelector(new com.nimbusds.jose.proc.JWSVerificationKeySelector<>(
|
|
com.nimbusds.jose.JWSAlgorithm.RS256, keySource));
|
|
appleJwtProcessor = p;
|
|
}
|
|
proc = appleJwtProcessor;
|
|
}
|
|
}
|
|
// 서명 검증(iss/aud/exp 는 호출부에서 확인)
|
|
return proc.process(idToken, null);
|
|
}
|
|
|
|
/**
|
|
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
|
|
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
|
|
*/
|
|
public SessionUser getSession(String token) {
|
|
if (token == null || token.isBlank()) {
|
|
return null;
|
|
}
|
|
String key = SESSION_PREFIX + token;
|
|
// 1) Redis 우선 (장애 시 예외는 무시하고 DB 백업으로)
|
|
try {
|
|
Object cached = redisTemplate.opsForValue().get(key);
|
|
if (cached instanceof SessionUser user) {
|
|
redisTemplate.expire(key, user.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
|
|
return user;
|
|
}
|
|
} catch (Exception e) {
|
|
log.warn("[session] Redis 조회 실패 → DB 백업 사용: {}", e.toString());
|
|
}
|
|
// 2) DB 백업에서 복원
|
|
AuthSession db = authSessionMapper.findByToken(token);
|
|
if (db == null) {
|
|
return null;
|
|
}
|
|
if (db.getExpiresAt() == null || db.getExpiresAt().isBefore(java.time.LocalDateTime.now())) {
|
|
authSessionMapper.delete(token); // 만료분 정리
|
|
return null;
|
|
}
|
|
SessionUser user = db.toSessionUser();
|
|
Duration ttl = user.isRememberMe() ? REMEMBER_TTL : SESSION_TTL;
|
|
try {
|
|
redisTemplate.opsForValue().set(key, user, ttl); // Redis 재수화
|
|
} catch (Exception ignore) {
|
|
// Redis 가 아직 불가해도 DB 로 동작
|
|
}
|
|
authSessionMapper.updateExpiry(token, java.time.LocalDateTime.now().plus(ttl)); // DB 슬라이딩
|
|
return user;
|
|
}
|
|
|
|
public void logout(String token) {
|
|
if (token != null && !token.isBlank()) {
|
|
try {
|
|
redisTemplate.delete(SESSION_PREFIX + token);
|
|
} catch (Exception ignore) {
|
|
// Redis 장애여도 DB 삭제는 진행
|
|
}
|
|
authSessionMapper.delete(token);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 회원 탈퇴 — 사용자가 소유한 모든 데이터를 삭제하고 회원을 제거한다.
|
|
* (게시판 글/댓글/추천 · 가계부 전체 · 포인트 · 결제기록 · 세션 → 회원)
|
|
*/
|
|
@Transactional
|
|
public void withdraw(Long memberId, String token) {
|
|
Member member = memberMapper.findById(memberId);
|
|
if (member == null) {
|
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
|
}
|
|
// 1) 게시판 — 표/댓글/글/이미지 (참조 순서 고려)
|
|
withdrawMapper.deletePostVotesByMember(memberId);
|
|
withdrawMapper.deleteCommentVotesByMember(memberId);
|
|
withdrawMapper.deleteCommentVotesOnMyPosts(memberId);
|
|
withdrawMapper.deleteCommentVotesOnMyComments(memberId);
|
|
withdrawMapper.deleteCommentsOnMyPosts(memberId);
|
|
withdrawMapper.deleteCommentsByAuthor(memberId);
|
|
withdrawMapper.deletePostVotesOnMyPosts(memberId);
|
|
withdrawMapper.deletePostTagsOnMyPosts(memberId);
|
|
withdrawMapper.deletePostsByAuthor(memberId);
|
|
withdrawMapper.deleteBoardImagesByMember(memberId);
|
|
// 2) 가계부(계정) 데이터 (복구 삭제와 동일 순서)
|
|
backupMapper.deleteEntryTags(memberId);
|
|
backupMapper.deleteEntries(memberId);
|
|
backupMapper.deleteRecurrings(memberId);
|
|
backupMapper.deleteBudgets(memberId);
|
|
backupMapper.deleteBudgetIncome(memberId);
|
|
backupMapper.deleteQuickEntries(memberId);
|
|
backupMapper.deleteInvestTrades(memberId);
|
|
backupMapper.deleteInvestHoldings(memberId);
|
|
backupMapper.deleteCategories(memberId);
|
|
backupMapper.deleteTags(memberId);
|
|
backupMapper.deleteWallets(memberId);
|
|
// 3) 신고 / 포인트 / 결제 / 세션
|
|
withdrawMapper.deleteReportsByMember(memberId);
|
|
withdrawMapper.deletePointHistory(memberId);
|
|
withdrawMapper.deleteIapPurchases(memberId);
|
|
withdrawMapper.deleteAuthSessions(memberId);
|
|
// 4) 회원 삭제
|
|
memberMapper.deleteById(memberId);
|
|
// 5) 현재 세션(Redis) 정리
|
|
logout(token);
|
|
log.info("[withdraw] member {} ({}) 및 데이터 전체 삭제", memberId, member.getLoginId());
|
|
}
|
|
|
|
/** 만료된 백업 세션 정리 (매일 새벽 4시) */
|
|
@org.springframework.scheduling.annotation.Scheduled(cron = "0 0 4 * * *")
|
|
public void cleanupExpiredSessions() {
|
|
try {
|
|
int n = authSessionMapper.deleteExpired(java.time.LocalDateTime.now());
|
|
if (n > 0) log.info("[session] 만료 백업 세션 {}건 정리", n);
|
|
} catch (Exception e) {
|
|
log.warn("[session] 만료 세션 정리 실패: {}", e.toString());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 비밀번호 재인증 (가입정보 변경 진입 전 본인 확인). 일치하지 않으면 401.
|
|
*/
|
|
public void verifyPassword(Long memberId, String password) {
|
|
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 (password == null || !passwordEncoder.matches(password, member.getPassword())) {
|
|
// 실패한 시도만 회원 단위로 카운트 — 비밀번호 게이트 무차별 대입 방지
|
|
enforceRateLimit("verifypw:rl:" + memberId, VERIFY_PW_LIMIT, VERIFY_PW_WINDOW,
|
|
"비밀번호 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.");
|
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "비밀번호가 올바르지 않습니다.");
|
|
}
|
|
}
|
|
|
|
/** 현재 회원 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화용 */
|
|
public MemberResponse getProfile(Long memberId) {
|
|
Member member = memberMapper.findById(memberId);
|
|
if (member == null) {
|
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
|
}
|
|
return MemberResponse.from(member);
|
|
}
|
|
|
|
/**
|
|
* 가입정보(이름/이메일) 변경. 비밀번호 인증을 통과한 화면에서 호출된다.
|
|
* 변경 후 세션(Redis + DB 백업)의 표시 이름도 동기화한다.
|
|
*/
|
|
@Transactional
|
|
public MemberResponse updateProfile(Long memberId, String token, com.sb.web.auth.dto.ProfileUpdateRequest req) {
|
|
Member member = memberMapper.findById(memberId);
|
|
if (member == null) {
|
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
|
}
|
|
String name = req.getName() == null ? null : req.getName().trim();
|
|
if (name == null || name.isBlank()) {
|
|
throw new ApiException(HttpStatus.BAD_REQUEST, "이름을 입력하세요.");
|
|
}
|
|
String email = (req.getEmail() == null || req.getEmail().isBlank()) ? null : req.getEmail().trim();
|
|
memberMapper.updateProfile(memberId, name, email);
|
|
member.setName(name);
|
|
member.setEmail(email);
|
|
syncSessionName(token, name);
|
|
log.info("[profile] updated for {} (name={})", member.getLoginId(), name);
|
|
return MemberResponse.from(member);
|
|
}
|
|
|
|
/**
|
|
* 프로필 사진(사용자 지정) 설정/해제. null/빈 값이면 해제 → 구글 사진으로 폴백.
|
|
* data URL(base64 이미지)만 허용하고 과도한 크기는 거절한다.
|
|
*/
|
|
@Transactional
|
|
public MemberResponse updateProfileImage(Long memberId, String image) {
|
|
Member member = memberMapper.findById(memberId);
|
|
if (member == null) {
|
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
|
}
|
|
String value = (image == null || image.isBlank()) ? null : image.trim();
|
|
if (value != null) {
|
|
if (!value.startsWith("data:image/")) {
|
|
throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 형식이 올바르지 않습니다.");
|
|
}
|
|
if (value.length() > 700_000) { // 약 500KB 이미지 상한 (base64 약 33% 팽창 고려)
|
|
throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 용량이 너무 큽니다. 더 작은 사진을 사용하세요.");
|
|
}
|
|
}
|
|
memberMapper.updateProfileImage(memberId, value);
|
|
member.setProfileImage(value);
|
|
log.info("[profile] image {} for {}", value == null ? "cleared" : "updated", member.getLoginId());
|
|
return MemberResponse.from(member);
|
|
}
|
|
|
|
/** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */
|
|
private void syncSessionName(String token, String name) {
|
|
if (token == null || token.isBlank()) {
|
|
return;
|
|
}
|
|
String key = SESSION_PREFIX + token;
|
|
try {
|
|
Object cached = redisTemplate.opsForValue().get(key);
|
|
if (cached instanceof SessionUser u) {
|
|
u.setName(name);
|
|
Long ttl = redisTemplate.getExpire(key, java.util.concurrent.TimeUnit.SECONDS);
|
|
Duration remain = (ttl != null && ttl > 0)
|
|
? Duration.ofSeconds(ttl)
|
|
: (u.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
|
|
redisTemplate.opsForValue().set(key, u, remain);
|
|
}
|
|
} catch (Exception e) {
|
|
log.warn("[profile] Redis 세션 이름 동기화 실패(무시): {}", e.toString());
|
|
}
|
|
try {
|
|
authSessionMapper.updateName(token, name);
|
|
} catch (Exception ignore) {
|
|
// DB 백업 동기화 실패해도 다음 로그인 시 갱신됨
|
|
}
|
|
}
|
|
|
|
/** 결제/멤버십 변경 시 활성 세션(Redis + DB 백업)의 plan 을 즉시 맞춘다 (재로그인 없이 유료 기능 개방). */
|
|
public void syncSessionPlan(String token, String plan) {
|
|
if (token == null || token.isBlank()) {
|
|
return;
|
|
}
|
|
String key = SESSION_PREFIX + token;
|
|
try {
|
|
Object cached = redisTemplate.opsForValue().get(key);
|
|
if (cached instanceof SessionUser u) {
|
|
u.setPlan(plan);
|
|
Long ttl = redisTemplate.getExpire(key, java.util.concurrent.TimeUnit.SECONDS);
|
|
Duration remain = (ttl != null && ttl > 0)
|
|
? Duration.ofSeconds(ttl)
|
|
: (u.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
|
|
redisTemplate.opsForValue().set(key, u, remain);
|
|
}
|
|
} catch (Exception e) {
|
|
log.warn("[billing] Redis 세션 plan 동기화 실패(무시): {}", e.toString());
|
|
}
|
|
try {
|
|
authSessionMapper.updatePlan(token, plan);
|
|
} catch (Exception ignore) {
|
|
// DB 백업 동기화 실패해도 다음 로그인 시 갱신됨
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체.
|
|
*/
|
|
@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());
|
|
}
|
|
}
|