WEB-SER-032: 인증 오류 횟수 제한 | 위험도: 5 (매우 높음) | 전자금융: 5회 이내 제한 필수
자동화 도구로 무차별 대입 공격
시나리오를 선택하세요
# Python Flask
@app.route('/login', methods=['POST'])
def login():
username = request.form['username']
password = request.form['password']
user = User.query.filter_by(
username=username
).first()
# ❌ 문제점 1: 비밀번호 무제한 시도!
# ❌ 문제점 2: 계정 잠금 없음!
# ❌ 문제점 3: 딜레이 없음!
if user and user.password == password:
return {"status": "success"}
else:
return {"status": "fail"}
# Java Spring
@PostMapping("/login")
public ResponseEntity login(
@RequestParam String username,
@RequestParam String password) {
User user = userRepository
.findByUsername(username);
// ❌ 실패 횟수 체크 없음!
if (user != null &&
user.getPassword().equals(password)) {
return ResponseEntity.ok()
.body("success");
}
return ResponseEntity.status(401)
.body("fail");
}
XX은행 인증 서버 처리 과정
| username | attempts | locked | last_try |
|---|---|---|---|
| 데이터 없음 | |||
# Python Flask - 안전한 로그인
from datetime import datetime, timedelta
from flask_limiter import Limiter
# ✅ Rate Limiter 설정
limiter = Limiter(
app,
default_limits=["5 per minute"]
)
@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute") # 1분에 5회 제한
def login():
username = request.form['username']
password = request.form['password']
# ✅ 1단계: 계정 잠금 확인
attempt = LoginAttempt.query.filter_by(
username=username
).first()
if attempt:
# 5회 실패 시 계정 잠금
if attempt.failed_count >= 5:
if attempt.locked_until > datetime.now():
return {
"status": "locked",
"message": "계정이 잠겼습니다. " +
"30분 후 시도하세요."
}, 403
else:
# 잠금 시간 경과 - 리셋
attempt.failed_count = 0
attempt.locked_until = None
else:
attempt = LoginAttempt(username=username)
db.session.add(attempt)
# ✅ 2단계: 사용자 인증
user = User.query.filter_by(
username=username
).first()
if user and check_password_hash(
user.password, password):
# 성공 - 시도 횟수 리셋
attempt.failed_count = 0
attempt.last_success = datetime.now()
db.session.commit()
session['user_id'] = user.id
return {"status": "success"}
# ✅ 3단계: 실패 처리
attempt.failed_count += 1
attempt.last_attempt = datetime.now()
# 5회 실패 시 30분 잠금
if attempt.failed_count >= 5:
attempt.locked_until = (
datetime.now() + timedelta(minutes=30)
)
db.session.commit()
# ✅ 4단계: 로깅
logger.warning(
f"Failed login: {username}, "
f"attempts: {attempt.failed_count}, "
f"ip: {request.remote_addr}"
)
# ✅ 5단계: 딜레이 추가 (타이밍 공격 방어)
time.sleep(2) # 2초 딜레이
return {
"status": "fail",
"remaining": max(0, 5 - attempt.failed_count)
}, 401
# Java Spring Security - 안전한 로그인
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(
@RequestParam String username,
@RequestParam String password,
HttpServletRequest request) {
// ✅ 1단계: 계정 잠금 확인
LoginAttempt attempt = attemptRepository
.findByUsername(username)
.orElseGet(() -> {
LoginAttempt newAttempt =
new LoginAttempt();
newAttempt.setUsername(username);
return attemptRepository
.save(newAttempt);
});
// 5회 실패 시 잠금
if (attempt.getFailedCount() >= 5) {
if (attempt.getLockedUntil() != null &&
attempt.getLockedUntil()
.isAfter(LocalDateTime.now())) {
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(new LoginResponse(
"locked",
"계정이 잠겼습니다"
));
}
// 잠금 시간 경과
attempt.setFailedCount(0);
attempt.setLockedUntil(null);
}
// ✅ 2단계: 사용자 인증
User user = userRepository
.findByUsername(username)
.orElse(null);
if (user != null && passwordEncoder
.matches(password, user.getPassword())) {
// 성공 - 리셋
attempt.setFailedCount(0);
attempt.setLastSuccess(
LocalDateTime.now()
);
attemptRepository.save(attempt);
// 세션 생성
String token = jwtUtil.generateToken(user);
return ResponseEntity.ok(
new LoginResponse("success", token)
);
}
// ✅ 3단계: 실패 처리
attempt.setFailedCount(
attempt.getFailedCount() + 1
);
attempt.setLastAttempt(LocalDateTime.now());
if (attempt.getFailedCount() >= 5) {
attempt.setLockedUntil(
LocalDateTime.now().plusMinutes(30)
);
}
attemptRepository.save(attempt);
// ✅ 4단계: 로깅
auditLogger.logFailedLogin(
username,
request.getRemoteAddr(),
attempt.getFailedCount()
);
// ✅ 5단계: 딜레이
Thread.sleep(2000);
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new LoginResponse(
"fail",
"로그인 실패 (" +
(5 - attempt.getFailedCount()) +
"회 남음)"
));
}
// DB 스키마
CREATE TABLE login_attempts (
id BIGINT PRIMARY KEY,
username VARCHAR(255) NOT NULL,
failed_count INT DEFAULT 0,
locked_until TIMESTAMP,
last_attempt TIMESTAMP,
last_success TIMESTAMP,
UNIQUE(username)
);