diff --git a/src/main/java/com/sb/web/SbBtApplication.java b/src/main/java/com/sb/web/SbBtApplication.java index 3237cd7..25967c7 100644 --- a/src/main/java/com/sb/web/SbBtApplication.java +++ b/src/main/java/com/sb/web/SbBtApplication.java @@ -5,7 +5,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper"}) +@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper"}) public class SbBtApplication { public static void main(String[] args) { diff --git a/src/main/java/com/sb/web/account/service/AccountService.java b/src/main/java/com/sb/web/account/service/AccountService.java index cee57cb..71a74a5 100644 --- a/src/main/java/com/sb/web/account/service/AccountService.java +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -260,16 +260,27 @@ public class AccountService { return toResponse(mapper.findByIdAndMember(id, memberId)); } - /** 카드사명으로 등록된 CARD 지갑 id 찾기 (issuer/name 부분일치) */ + /** 카드사명으로 등록된 CARD 지갑 id 찾기 (issuer/name 양방향 부분일치). 미매칭이면 카드가 1개뿐일 때 그 카드로. */ private Long matchCardWalletId(String issuer, Long memberId) { - if (issuer == null) return null; - return walletMapper.findByMember(memberId).stream() + List cards = walletMapper.findByMember(memberId).stream() .filter(w -> "CARD".equals(w.getType())) - .filter(w -> (w.getIssuer() != null && w.getIssuer().contains(issuer)) - || (w.getName() != null && w.getName().contains(issuer))) - .map(Wallet::getId) - .findFirst() - .orElse(null); + .toList(); + if (issuer != null) { + Long matched = cards.stream() + .filter(w -> overlaps(w.getIssuer(), issuer) || overlaps(w.getName(), issuer)) + .map(Wallet::getId) + .findFirst() + .orElse(null); + if (matched != null) return matched; + } + // 카드사 미감지/미매칭이라도 등록된 카드가 정확히 1개면 그 카드로 기본 매칭 + return cards.size() == 1 ? cards.get(0).getId() : null; + } + + /** 양방향 부분일치 (KB국민 ↔ 국민 등) */ + private boolean overlaps(String walletField, String issuer) { + if (walletField == null || issuer == null) return false; + return walletField.contains(issuer) || issuer.contains(walletField); } /* ===================== 상환/납부 ===================== */ diff --git a/src/main/java/com/sb/web/account/service/CardNotificationParser.java b/src/main/java/com/sb/web/account/service/CardNotificationParser.java index 51caa31..b3f7212 100644 --- a/src/main/java/com/sb/web/account/service/CardNotificationParser.java +++ b/src/main/java/com/sb/web/account/service/CardNotificationParser.java @@ -26,6 +26,10 @@ public class CardNotificationParser { private static final Pattern AMOUNT = Pattern.compile("([0-9]{1,3}(?:,[0-9]{3})+|[0-9]{4,})\\s*원"); private static final Pattern DATE_MD = Pattern.compile("(\\d{1,2})[./](\\d{1,2})"); + // 광고/이벤트 푸시에 흔한 표현 — 강한 승인신호(승인/출금)가 없으면 결제로 보지 않는다. + private static final Pattern AD_HINT = Pattern.compile( + "이벤트|광고|혜택|쿠폰|할인|적립|포인트|캐시백|마일리지|리워드|당첨|응모|추첨|프로모션|배너|무료|증정|모집"); + @Getter @Builder public static class Parsed { @@ -49,6 +53,11 @@ public class CardNotificationParser { if (!am.find() || (!approved && !canceled)) { return notCard(); } + // 광고/이벤트 차단: 강한 승인신호(승인/출금/취소/환불)가 없는데 광고성 표현이 있으면 결제 아님 + boolean strongSignal = raw.contains("승인") || raw.contains("출금") || canceled; + if (!strongSignal && AD_HINT.matcher(raw).find()) { + return notCard(); + } long amount = Long.parseLong(am.group(1).replace(",", "")); String issuer = detectIssuer(raw); diff --git a/src/main/java/com/sb/web/admin/controller/AdminSettingsController.java b/src/main/java/com/sb/web/admin/controller/AdminSettingsController.java new file mode 100644 index 0000000..67908f6 --- /dev/null +++ b/src/main/java/com/sb/web/admin/controller/AdminSettingsController.java @@ -0,0 +1,31 @@ +package com.sb.web.admin.controller; + +import com.sb.web.admin.dto.SignupSettingRequest; +import com.sb.web.admin.service.AppSettingService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 관리자용 앱 설정 API. (/api/admin — AdminInterceptor 로 관리자만 접근) + * GET /signup-setting 회원가입 허용 여부 조회 + * PUT /signup-setting 회원가입 허용 여부 변경 + */ +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminSettingsController { + + private final AppSettingService appSettingService; + + @GetMapping("/signup-setting") + public Map getSignupSetting() { + return Map.of("enabled", appSettingService.isSignupEnabled()); + } + + @PutMapping("/signup-setting") + public Map updateSignupSetting(@RequestBody SignupSettingRequest req) { + return Map.of("enabled", appSettingService.setSignupEnabled(req.isEnabled())); + } +} diff --git a/src/main/java/com/sb/web/admin/dto/SignupSettingRequest.java b/src/main/java/com/sb/web/admin/dto/SignupSettingRequest.java new file mode 100644 index 0000000..da2dbc1 --- /dev/null +++ b/src/main/java/com/sb/web/admin/dto/SignupSettingRequest.java @@ -0,0 +1,12 @@ +package com.sb.web.admin.dto; + +import lombok.Data; + +/** + * 회원가입 허용 설정 변경 요청. + */ +@Data +public class SignupSettingRequest { + + private boolean enabled; +} diff --git a/src/main/java/com/sb/web/admin/mapper/AppSettingMapper.java b/src/main/java/com/sb/web/admin/mapper/AppSettingMapper.java new file mode 100644 index 0000000..8a16a7b --- /dev/null +++ b/src/main/java/com/sb/web/admin/mapper/AppSettingMapper.java @@ -0,0 +1,16 @@ +package com.sb.web.admin.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 앱 전역 설정(app_setting, 단일 행 id=1) 접근. + */ +@Mapper +public interface AppSettingMapper { + + /** 회원가입 허용 여부 (true=허용) */ + Boolean isSignupEnabled(); + + int updateSignupEnabled(@Param("enabled") boolean enabled); +} diff --git a/src/main/java/com/sb/web/admin/service/AppSettingService.java b/src/main/java/com/sb/web/admin/service/AppSettingService.java new file mode 100644 index 0000000..adec57c --- /dev/null +++ b/src/main/java/com/sb/web/admin/service/AppSettingService.java @@ -0,0 +1,28 @@ +package com.sb.web.admin.service; + +import com.sb.web.admin.mapper.AppSettingMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 앱 전역 설정 서비스. 현재는 회원가입 허용 여부. + */ +@Service +@RequiredArgsConstructor +public class AppSettingService { + + private final AppSettingMapper appSettingMapper; + + /** 회원가입 허용 여부. 설정 행이 없으면 기본 허용(true). */ + public boolean isSignupEnabled() { + Boolean v = appSettingMapper.isSignupEnabled(); + return v == null || v; + } + + @Transactional + public boolean setSignupEnabled(boolean enabled) { + appSettingMapper.updateSignupEnabled(enabled); + return enabled; + } +} diff --git a/src/main/java/com/sb/web/auth/controller/AuthController.java b/src/main/java/com/sb/web/auth/controller/AuthController.java index eaeb748..f4ba045 100644 --- a/src/main/java/com/sb/web/auth/controller/AuthController.java +++ b/src/main/java/com/sb/web/auth/controller/AuthController.java @@ -28,10 +28,27 @@ import org.springframework.web.bind.annotation.*; public class AuthController { private final AuthService authService; + private final com.sb.web.admin.service.AppSettingService appSettingService; + + /** 회원가입 허용 여부 (비보호) — 프론트가 가입 진입 전에 차단 표시용 */ + @GetMapping("/signup-enabled") + public java.util.Map signupEnabled() { + return java.util.Map.of("enabled", appSettingService.isSignupEnabled()); + } @PostMapping("/signup") - public ResponseEntity signup(@Valid @RequestBody SignupRequest req) { - return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(req)); + public ResponseEntity signup(@Valid @RequestBody SignupRequest req, + HttpServletRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(req, clientIp(request))); + } + + /** 클라이언트 IP (nginx 프록시 뒤 → X-Forwarded-For 우선) */ + private String clientIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isBlank()) { + return xff.split(",")[0].trim(); + } + return request.getRemoteAddr(); } @PostMapping("/login") diff --git a/src/main/java/com/sb/web/auth/dto/SignupRequest.java b/src/main/java/com/sb/web/auth/dto/SignupRequest.java index 8e9b9d9..08b920d 100644 --- a/src/main/java/com/sb/web/auth/dto/SignupRequest.java +++ b/src/main/java/com/sb/web/auth/dto/SignupRequest.java @@ -24,4 +24,7 @@ public class SignupRequest { @Email(message = "이메일 형식이 올바르지 않습니다.") private String email; + + /** 허니팟 — 정상 사용자에겐 숨겨진 필드. 값이 있으면 봇으로 간주 */ + private String website; } diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index 347a222..d4abc5e 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -34,13 +34,28 @@ 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) { + 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, "이미 사용 중인 아이디입니다."); } @@ -58,6 +73,23 @@ public class AuthService { 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 diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql index 84604a8..23f86d9 100644 --- a/src/main/resources/db/account.sql +++ b/src/main/resources/db/account.sql @@ -28,8 +28,9 @@ ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_balance BIGINT NOT NULL DEFA ALTER TABLE wallet ADD COLUMN IF NOT EXISTS opening_date DATE NULL; ALTER TABLE wallet ADD COLUMN IF NOT EXISTS current_value BIGINT NULL; ALTER TABLE wallet ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0; --- 계좌번호 암호화 저장(AES-GCM, enc: 접두)으로 평문보다 길어져 컬럼 확장 (멱등) -ALTER TABLE wallet MODIFY account_number VARCHAR(255) NULL; +-- 계좌번호 암호화 저장(AES-GCM)으로 평문보다 길어 VARCHAR(255) 필요 — 운영 적용 완료(CREATE TABLE 정의에 반영). +-- 매 기동 MODIFY 는 라이브 테이블 락 위험이 있어 제거. 기존 환경 확장이 필요하면 1회 수동 적용: +-- ALTER TABLE wallet MODIFY account_number VARCHAR(255) NULL; CREATE TABLE IF NOT EXISTS account_entry ( id BIGINT NOT NULL AUTO_INCREMENT, diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql index a9fa8c1..a348423 100644 --- a/src/main/resources/db/member.sql +++ b/src/main/resources/db/member.sql @@ -19,3 +19,14 @@ CREATE TABLE IF NOT EXISTS member ( UNIQUE KEY uk_member_login_id (login_id), UNIQUE KEY uk_member_provider (provider, provider_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경. +-- ============================================================ +CREATE TABLE IF NOT EXISTS app_setting ( + id BIGINT NOT NULL, + signup_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '회원가입 허용 여부(0=제한)', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +INSERT IGNORE INTO app_setting (id, signup_enabled) VALUES (1, 1); diff --git a/src/main/resources/mapper/AppSettingMapper.xml b/src/main/resources/mapper/AppSettingMapper.xml new file mode 100644 index 0000000..4a3abd3 --- /dev/null +++ b/src/main/resources/mapper/AppSettingMapper.xml @@ -0,0 +1,14 @@ + + + + + + + + UPDATE app_setting SET signup_enabled = #{enabled} WHERE id = 1 + + +