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 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 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 proc = appleJwtProcessor; if (proc == null) { synchronized (this) { if (appleJwtProcessor == null) { com.nimbusds.jose.jwk.source.JWKSource 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 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()); } }