설명
취약한 사용자 인증(Broken User Authentication)은 애플리케이션이 사용자 인증을 적절하게 처리하지 못해, 공격자가 다른 사용자의 계정에 무단으로 접근할 수 있는 취약성입니다. 이는 약한 비밀번호 정책, 세션 관리의 부적절함, 다중 인증 미사용 등으로 인해 발생할 수 있습니다.
잠재적 영향
- 계정 탈취: 공격자가 사용자 계정을 탈취하여 민감한 정보에 접근할 수 있습니다.
- 권한 상승: 공격자가 관리자 권한을 획득하여 시스템을 조작하거나 손상시킬 수 있습니다.
- 데이터 유출: 민감한 데이터가 공격자에게 노출될 수 있습니다.
해결 방법
- 강력한 비밀번호 정책: 충분한 길이를 요구하고 유출되었거나 흔한 비밀번호를 차단하세요. 문자 조합 규칙만으로 강도를 판단하지 마세요.
- 로그인 시도 제한: 계정과 클라이언트 주소 기준의 로그인 시도 횟수를 공유 저장소에서 추적하고 임계치 초과 시 지연 또는 잠금을 적용합니다.
- 다중 인증(MFA): 추가적인 인증 수단을 사용하여 보안을 강화합니다.
- 세션 관리 강화: 세션 타임아웃, 세션 고정 공격 방지 등을 구현합니다.
- 보안 라이브러리 사용: Django, Flask, FastAPI 등과 같은 프레임워크에서 제공하는 인증 및 세션 관리 기능을 사용합니다.
예시
아래는 인증 처리의 일부입니다. 모델, 공유 저장소, 라우팅과 의존성은 별도로 구성해야 합니다. 브라우저의 쿠키 인증에는 CSRF 방어를 적용하고 HTTPS를 사용하세요. 세션·토큰 서명 비밀은 충분한 무작위성으로 생성해 외부에서 주입하세요.
Django
변경 전
python
# Unsafe Django code
from django.contrib.auth import authenticate
def login_view(request):
username = request.POST['username']
password = request.POST['password']
user = authenticate(request, username=username, password=password)
if user is not None:
# 로그인 성공
...
else:
# 로그인 실패
...
변경 후
python
# Safe Django code
import hashlib
from django.contrib.auth import authenticate, login
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.http import HttpResponse
from django.views.decorators.http import require_POST
MAX_USER_ATTEMPTS = 5
MAX_IP_ATTEMPTS = 20
ATTEMPT_WINDOW = 5 * 60
def login_attempt_keys(request, username):
remote_addr = request.META.get('REMOTE_ADDR', 'unknown')
user_key = hashlib.sha256(username.casefold().encode()).hexdigest()
ip_key = hashlib.sha256(remote_addr.encode()).hexdigest()
return [f'login:user:{user_key}', f'login:ip:{ip_key}']
def increment_login_attempt(key):
cache.add(key, 0, timeout=ATTEMPT_WINDOW)
try:
return cache.incr(key)
except ValueError:
cache.set(key, 1, timeout=ATTEMPT_WINDOW)
return 1
def consume_login_attempt(user_key, ip_key):
# IP 한도를 먼저 검사해 무작위 사용자명으로 캐시 키가 계속 생기는 것을 제한합니다.
if increment_login_attempt(ip_key) > MAX_IP_ATTEMPTS:
return False
return increment_login_attempt(user_key) <= MAX_USER_ATTEMPTS
@require_POST
def login_view(request):
username = request.POST.get('username', '')
password = request.POST.get('password', '')
user_attempt_key, ip_attempt_key = login_attempt_keys(request, username)
if not consume_login_attempt(user_attempt_key, ip_attempt_key):
return HttpResponse("Too many login attempts", status=429)
user = authenticate(request, username=username, password=password)
if user is None:
return HttpResponse("Invalid credentials", status=401)
# 성공한 계정의 카운터만 초기화합니다. IP 카운터는 공격자가 자신의
# 계정으로 로그인해 우회하지 못하도록 만료될 때까지 유지합니다.
cache.delete(user_attempt_key)
login(request, user)
return HttpResponse("Logged in")
@login_required
def sensitive_view(request):
# 민감한 데이터 처리 로직
return HttpResponse("Sensitive data")
설명:
- 변경 전: 비밀번호 확인은 수행하지만 로그인 시도 제한과 로그인 세션 설정은 보이지 않습니다. 외부 계층의 통제 여부를 포함해 실제 인증 흐름을 확인해야 합니다.
- 변경 후: 원자적 증가를 지원하는 공유 Django 캐시에서 계정과 클라이언트 주소별 시도를 제한합니다. 성공 시 해당 계정 카운터만 초기화하고 IP 카운터는 만료까지 유지해, 공격자가 자신의 계정으로 로그인하며 IP 제한을 우회하지 못하게 합니다.
login()으로 세션을 설정하고login_required로 민감한 뷰를 보호합니다. 리버스 프록시 환경에서는 신뢰할 수 있는 실제 클라이언트 주소가REMOTE_ADDR에 전달되도록 별도 구성해야 합니다.
Flask
변경 전
python
# Unsafe Flask code
from flask import Flask, request
from werkzeug.security import check_password_hash
app = Flask(__name__)
@app.route('/login', methods=['POST'])
def login():
username = request.form['username']
password = request.form['password']
user = User.query.filter_by(username=username).first()
if user and check_password_hash(user.password, password):
# 로그인 성공
...
else:
# 로그인 실패
...
변경 후
python
# Safe Flask code
import hashlib
import os
from flask import Flask, request, redirect, url_for
from werkzeug.security import check_password_hash, generate_password_hash
from flask_login import LoginManager, login_user, login_required
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
app = Flask(__name__)
flask_secret = os.environ['FLASK_SECRET_KEY']
if len(flask_secret) < 32:
raise RuntimeError('FLASK_SECRET_KEY must contain at least 32 characters')
app.config.update(
SECRET_KEY=flask_secret,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE='Lax',
)
login_manager = LoginManager()
login_manager.init_app(app)
dummy_password_hash = generate_password_hash(os.urandom(32).hex())
limiter = Limiter(
key_func=get_remote_address,
app=app,
storage_uri=os.environ['RATELIMIT_STORAGE_URI'],
)
@login_manager.user_loader
def load_user(user_id):
# User 모델은 UserMixin을 구현하고 안정적인 기본 키를 get_id()로 반환합니다.
return User.query.filter_by(id=user_id).first()
def username_rate_key():
username = request.form.get('username', '').casefold()[:128]
return hashlib.sha256(username.encode()).hexdigest()
@app.route('/login', methods=['POST'])
@limiter.limit('20 per 5 minutes', key_func=get_remote_address)
@limiter.limit('5 per 5 minutes', key_func=username_rate_key)
def login():
username = request.form.get('username', '')
password = request.form.get('password', '')
if not username or len(username) > 128 or not password or len(password) > 1024:
return "Invalid credentials", 400
user = User.query.filter_by(username=username).first()
stored_hash = user.password if user is not None else dummy_password_hash
password_is_valid = check_password_hash(stored_hash, password)
if user is not None and password_is_valid:
login_user(user)
return redirect(url_for('dashboard'))
return "Invalid credentials", 401
@app.route('/dashboard')
@login_required
def dashboard():
# 민감한 데이터 처리 로직
return "Sensitive data"
설명:
- 변경 전: 비밀번호 확인은 수행하지만 로그인 시도 제한과 로그인 세션 설정은 보이지 않습니다. 외부 계층의 통제 여부를 포함해 실제 인증 흐름을 확인해야 합니다.
- 변경 후: 외부 공유 저장소를 사용하는 Flask-Limiter로 계정과 클라이언트 주소 기준의 시도 횟수를 각각 제한하고, 존재하지 않는 계정도 더미 해시 검증을 수행합니다. 환경에서 세션 비밀을 주입하고 안전한 쿠키 속성을 설정하며,
user_loader로 후속 요청의 로그인 사용자를 복원합니다.@app.route다음에@login_required를 적용하여 Flask가 실제로 보호된 핸들러를 등록합니다.
FastAPI
변경 전
python
# Unsafe FastAPI code
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/login")
async def login(request: Request):
form_data = await request.form()
username = form_data['username']
password = form_data['password']
user = get_user_by_username(username)
if user and user.password == password:
# 로그인 성공
...
else:
# 로그인 실패
...
변경 후
python
# Safe FastAPI code
import hashlib
import os
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.security import OAuth2PasswordRequestForm
from fastapi_login import LoginManager
from pwdlib import PasswordHash
from redis.asyncio import Redis
app = FastAPI()
jwt_secret = os.environ['JWT_SECRET']
if len(jwt_secret) < 32:
raise RuntimeError('JWT_SECRET must contain at least 32 characters')
manager = LoginManager(jwt_secret, token_url="/auth/token")
password_hash = PasswordHash.recommended()
# 존재하지 않는 계정도 같은 비밀번호 해시 검증 경로를 거치게 합니다.
dummy_password_hash = password_hash.hash(os.urandom(32))
redis = Redis.from_url(os.environ['REDIS_URL'], decode_responses=True)
ATTEMPT_WINDOW = 5 * 60
MAX_USER_ATTEMPTS = 5
MAX_IP_ATTEMPTS = 20
CONSUME_ATTEMPT = """
local ip_attempts = redis.call('INCR', KEYS[1])
if ip_attempts == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if ip_attempts > tonumber(ARGV[2]) then
return 0
end
local user_attempts = redis.call('INCR', KEYS[2])
if user_attempts == 1 then
redis.call('EXPIRE', KEYS[2], ARGV[1])
end
if user_attempts > tonumber(ARGV[3]) then
return 0
end
return 1
"""
async def consume_login_attempt(username, client_address):
user_digest = hashlib.sha256(username.casefold().encode()).hexdigest()
ip_digest = hashlib.sha256(client_address.encode()).hexdigest()
user_key = f'login:user:{user_digest}'
ip_key = f'login:ip:{ip_digest}'
allowed = await redis.eval(
CONSUME_ATTEMPT,
2,
ip_key,
user_key,
ATTEMPT_WINDOW,
MAX_IP_ATTEMPTS,
MAX_USER_ATTEMPTS,
)
return allowed == 1, user_key
@manager.user_loader()
def load_user(username: str):
return get_user_by_username(username)
@app.post("/auth/token")
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
username = form_data.username
password = form_data.password
if not username or len(username) > 128 or not password or len(password) > 1024:
raise HTTPException(status_code=400, detail="Invalid credentials")
# 전달 헤더를 직접 신뢰하지 않고 ASGI 서버가 검증해 제공한 client 주소를 사용합니다.
client_address = request.client.host if request.client else 'unknown'
allowed, user_attempt_key = await consume_login_attempt(username, client_address)
if not allowed:
raise HTTPException(status_code=429, detail="Too many login attempts")
user = load_user(username)
stored_hash = user.password_hash if user is not None else dummy_password_hash
password_is_valid = password_hash.verify(password, stored_hash)
if user is None or not password_is_valid:
raise HTTPException(status_code=401, detail="Invalid credentials")
# IP 카운터는 자신의 계정으로 로그인해 우회하지 못하도록 유지합니다.
await redis.delete(user_attempt_key)
access_token = manager.create_access_token(data={"sub": username})
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/dashboard")
async def dashboard(current_user = Depends(manager)):
# 민감한 데이터 처리 로직
return {"detail": "Sensitive data"}
설명:
- 변경 전: 비밀번호를 평문으로 비교하며 로그인 시도 제한도 없습니다. 비밀번호 해시 검증과 인증된 요청의 접근 통제가 필요합니다.
- 변경 후: 하나의 원자적 Redis Lua 스크립트로 클라이언트 주소 한도를 먼저 확인한 뒤 계정별 시도를 제한합니다. IP 한도를 넘으면 새 사용자 키를 만들지 않아 계정 스프레이와 캐시 키 증가를 억제하고, 성공 시 계정 카운터만 초기화해 자신의 계정으로 IP 제한을 우회하지 못하게 합니다. 존재하지 않는 계정도 더미 해시로 같은 검증 경로를 거쳐 사용자명 열거를 어렵게 합니다. 환경에서 충분히 긴 토큰 서명 비밀을 주입하고 평문 비교 대신 권장 비밀번호 해시 검증을 사용하며, 보호된 엔드포인트에는 인증 의존성을 적용합니다. 프록시 환경에서는 ASGI 서버가 신뢰하는 프록시에서 온 전달 헤더만 반영하도록 구성해야 합니다.