feat: 회원가입 제한·봇차단(허니팟/레이트리밋)·카드 자동인식 보정
- 관리자 회원가입 허용 토글(app_setting), 공개 GET /auth/signup-enabled - 회원가입 봇차단: 허니팟(website) + IP 레이트리밋(Redis, 1h 5회) - 카드 알림: 현금 오선택 보정(카드 양방향 매칭+단일카드 자동), 광고 푸시 차단(승인신호 없는 광고성 표현 무시) - @MapperScan 에 admin.mapper 추가 - account.sql: 매 기동 wallet MODIFY 제거(라이브 락 위험) — CREATE 정의에 255 반영됨 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<Wallet> 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);
|
||||
}
|
||||
|
||||
/* ===================== 상환/납부 ===================== */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String, Boolean> getSignupSetting() {
|
||||
return Map.of("enabled", appSettingService.isSignupEnabled());
|
||||
}
|
||||
|
||||
@PutMapping("/signup-setting")
|
||||
public Map<String, Boolean> updateSignupSetting(@RequestBody SignupSettingRequest req) {
|
||||
return Map.of("enabled", appSettingService.setSignupEnabled(req.isEnabled()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.sb.web.admin.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 회원가입 허용 설정 변경 요청.
|
||||
*/
|
||||
@Data
|
||||
public class SignupSettingRequest {
|
||||
|
||||
private boolean enabled;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Boolean> signupEnabled() {
|
||||
return java.util.Map.of("enabled", appSettingService.isSignupEnabled());
|
||||
}
|
||||
|
||||
@PostMapping("/signup")
|
||||
public ResponseEntity<MemberResponse> signup(@Valid @RequestBody SignupRequest req) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(req));
|
||||
public ResponseEntity<MemberResponse> 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")
|
||||
|
||||
@@ -24,4 +24,7 @@ public class SignupRequest {
|
||||
|
||||
@Email(message = "이메일 형식이 올바르지 않습니다.")
|
||||
private String email;
|
||||
|
||||
/** 허니팟 — 정상 사용자에겐 숨겨진 필드. 값이 있으면 봇으로 간주 */
|
||||
private String website;
|
||||
}
|
||||
|
||||
@@ -34,13 +34,28 @@ 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 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.sb.web.admin.mapper.AppSettingMapper">
|
||||
|
||||
<select id="isSignupEnabled" resultType="java.lang.Boolean">
|
||||
SELECT signup_enabled FROM app_setting WHERE id = 1
|
||||
</select>
|
||||
|
||||
<update id="updateSignupEnabled">
|
||||
UPDATE app_setting SET signup_enabled = #{enabled} WHERE id = 1
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
Reference in New Issue
Block a user