이미지 인증 코드 생성 시스템 설계
인증 코드 생성 모듈 통합
utils 폴더 내에 captcha.py 파일과 fonts 디렉터리를 추가하여 이미지 인증 코드 생성 로직을 구현합니다. 이 모듈은 파이썬의 PIL 라이브러리를 활용해 난이도 있는 인증 이미지를 생성하며, 글꼴, 색상, 곡선 효과 등을 조합하여 인간이 인식하기 어려운 형태로 만듭니다.
from io import BytesIO
from PIL import Image, ImageDraw, ImageFilter, ImageFont
import random
import string
import os
class BezierCurve:
def __init__(self):
self.tsequence = [t / 20.0 for t in range(21)]
self.cache = {}
def pascal_triangle(self, n):
result = [1]
x, numerator = 1, n
for denominator in range(1, n // 2 + 1):
x *= numerator
x //= denominator
result.append(x)
numerator -= 1
if n % 2 == 0:
result.extend(reversed(result[:-1]))
else:
result.extend(reversed(result))
return result
def generate_bezier_points(self, n):
try:
return self.cache[n]
except KeyError:
coeffs = self.pascal_triangle(n - 1)
points = []
for t in self.tsequence:
tpowers = [t ** i for i in range(n)]
upowers = [(1 - t) ** i for i in range(n - 1, -1, -1)]
coefs = [c * a * b for c, a, b in zip(coeffs, tpowers, upowers)]
points.append(coefs)
self.cache[n] = points
return points
class ImageCaptcha:
def __init__(self):
self.bcurve = BezierCurve()
self.font_dir = os.path.dirname(__file__)
self.fonts = [
os.path.join(self.font_dir, 'fonts', f)
for f in ['Arial.ttf', 'Georgia.ttf', 'actionj.ttf']
]
@staticmethod
def get_instance():
if not hasattr(ImageCaptcha, '_instance'):
ImageCaptcha._instance = ImageCaptcha()
return ImageCaptcha._instance
def create_captcha(self, width=200, height=75, text=None):
self.text = text or ''.join(random.choices(
string.ascii_uppercase + '3456789', k=4))
self.size = (width, height)
self.color = self.random_color(0, 200, random.randint(220, 255))
image = Image.new('RGB', self.size, (255, 255, 255))
draw = ImageDraw.Draw(image)
# 배경 처리
draw.rectangle([(0, 0), self.size], fill=self.random_color(238, 255))
# 텍스트 및 왜곡 적용
font_sizes = (65, 70, 75)
fonts = [ImageFont.truetype(f, s) for f in self.fonts for s in font_sizes]
char_images = []
for ch in self.text:
font = random.choice(fonts)
w, h = draw.textsize(ch, font=font)
char_img = Image.new('RGB', (w, h), (0, 0, 0))
char_draw = ImageDraw.Draw(char_img)
char_draw.text((0, 0), ch, font=font, fill=self.color)
char_img = char_img.crop(char_img.getbbox())
char_images.append(char_img)
# 텍스트 배치 및 왜곡
total_width = sum(int(i.size[0] * 0.75) for i in char_images[:-1]) + char_images[-1].size[0]
offset = (width - total_width) // 2
for img in char_images:
w, h = img.size
mask = img.convert('L').point(lambda i: i * 1.97)
image.paste(img, (offset, (height - h) // 2), mask)
offset += int(w * 0.75)
# 곡선, 노이즈, 블러 효과 추가
image = self.apply_curve(image)
image = self.add_noise(image)
image = image.filter(ImageFilter.SMOOTH)
# 결과 반환
code_id = ''.join(random.choices(string.ascii_letters + '3456789', k=24))
buffer = BytesIO()
image.save(buffer, format='JPEG')
return code_id, self.text, buffer.getvalue()
@staticmethod
def random_color(start, end, opacity=None):
r = random.randint(start, end)
g = random.randint(start, end)
b = random.randint(start, end)
return (r, g, b, opacity) if opacity else (r, g, b)
def apply_curve(self, image, width=4, count=6):
dx, dy = image.size
dx /= count
path = [(dx * i, random.randint(0, dy)) for i in range(1, count)]
beziers = self.bcurve.generate_bezier_points(count - 1)
points = [tuple(sum(c * p for c, p in zip(b, ps)) for ps in zip(*path)) for b in beziers]
ImageDraw.Draw(image).line(points, fill=self.color, width=width)
return image
def add_noise(self, image, count=50, level=2):
w, h = image.size
dw, dh = w // 10, h // 10
draw = ImageDraw.Draw(image)
for _ in range(count):
x = random.randint(dw, w - dw)
y = random.randint(dh, h - dh)
draw.line([(x, y), (x + level, y)], fill=self.color, width=level)
return image
def generate(self):
self.create_captcha()
return self.create_captcha()
이미지 인증 코드 API 엔드포인트 구현
ihome/api_1_0/verify_codes.py에 다음과 같은 엔드포인트를 정의하여 클라이언트에서 이미지 인증 코드를 요청할 수 있도록 합니다.
from flask import make_response, jsonify
from ihome.utils.captcha import ImageCaptcha.get_instance() as captcha
from ihome.utils.constants import IMAGE_CODE_REDIS_EXPIRES
from ihome.utils.response_codes import RET
from . import api
import redis
@api.route('/image_codes/<code_key>')
def get_image_code(code_key):
code_id, real_text, image_data = captcha.generate()
# Redis에 인증코드 저장 (180초 유효기간)
try:
redis_client.setex(code_key, IMAGE_CODE_REDIS_EXPIRES, real_text)
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='인증코드 저장 실패')
response = make_response(image_data)
response.headers['Content-Type'] = 'image/jpeg'
return response
프론트엔드 이미지 인증 코드 로직
register.js에서 이미지 인증 코드를 자동으로 로드하고, 사용자 입력값과 비교하는 기능을 구현합니다.
function generateImageCode() {
const key = generateUUID();
const url = `/api/v1.0/image_codes/${key}`;
$('.image-code img').attr('src', url);
window.imageCodeKey = key;
}
function generateUUID() {
let d = Date.now();
if (window.performance && typeof window.performance.now === 'function') {
d += performance.now();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
문자 메시지 인증 코드 전송 기능
제휴 서비스 연동 설정
容联云 통신 플랫폼을 이용하여 문자 인증 코드를 발송합니다. 설치 후 필요한 인증 정보를 환경 변수나 설정 파일에 저장합니다.
pip install ronglian_sms_sdk
SMS 전송 엔드포인트 개선
from ronglian_sms_sdk import SmsSDK
from . import api
import json
import random
@api.route('/sms_codes/<phone>')
def send_sms_code(phone):
# 전달된 파라미터 검증
image_code = request.args.get('image_code')
image_key = request.args.get('image_code_key')
if not all([image_code, image_key]):
return jsonify(errno=RET.PARAMERR, errmsg='필수 파라미터 누락')
# 중복 요청 방지 및 이미지 인증 확인
try:
stored_code = redis_client.get(image_key)
if not stored_code or stored_code.decode().lower() != image_code.lower():
return jsonify(errno=RET.DATAERR, errmsg='이미지 인증 실패')
redis_client.delete(image_key)
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='인증코드 처리 오류')
# 동일 번호의 1분 이내 재전송 차단
last_sent_key = f'last_sent_{phone}'
if redis_client.get(last_sent_key):
return jsonify(errno=RET.DATAEXIST, errmsg='1분 이내 재전송 불가')
# 6자리 랜덤 코드 생성
sms_code = f'{random.randint(0, 999999):06d}'
# SDK 초기화 및 전송
sdk = SmsSDK(constants.ACCID, constants.ACCTOKEN, constants.APPID)
template_id = '1'
data = (sms_code, constants.SMS_CODE_REDIS_EXPIRES // 60)
try:
resp_json = sdk.sendMessage(template_id, phone, data)
resp_dict = json.loads(resp_json)
if resp_dict.get('statusCode') != '000000':
return jsonify(errno=RET.THIRDERR, errmsg=resp_dict.get('statusMsg'))
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.THIRDERR, errmsg='SMS 전송 실패')
# 인증 코드 저장 및 재전송 차단 기록
try:
redis_client.setex(f'sms_code_{phone}', constants.SMS_CODE_REDIS_EXPIRES, sms_code)
redis_client.setex(last_sent_key, constants.SEND_SMS_CODE_INTERVAL, '1')
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='Redis 저장 실패')
return jsonify(errno=RET.OK)
프론트엔드 문자 인증 코드 요청
function sendSMSCode() {
const mobile = $('#mobile').val();
const imageCode = $('#imagecode').val();
if (!mobile || !imageCode) {
alert('모든 필드를 입력하세요');
return;
}
$.get(`/api/v1.0/sms_codes/${mobile}`, {
image_code: imageCode,
image_code_key: window.imageCodeKey
}, function(res) {
if (res.errno !== '0') {
alert(res.errmsg);
generateImageCode();
} else {
let seconds = 60;
const $btn = $('.phonecode-a');
const timer = setInterval(() => {
$btn.html(`${seconds--}초`);
if (seconds <= 0) {
clearInterval(timer);
$btn.html('재전송');
}
}, 1000);
}
}, 'json');
}
회원 가입 처리 로직
ihome/api_1_0/users.py에서 회원가입 엔드포인트를 구현합니다.
from flask import request, jsonify
from sqlalchemy.exc import IntegrityError
from . import api
from ihome import db, models, redis_client
from ihome.utils.response_codes import RET
import re
@api.route('/users', methods=['POST'])
def register():
data = request.get_json()
phone = data.get('mobile')
sms_code = data.get('phoneCode')
password = data.get('password')
confirm_password = data.get('password2')
if not all([phone, sms_code, password, confirm_password]):
return jsonify(errno=RET.PARAMERR, errmsg='필수 항목 누락')
if password != confirm_password:
return jsonify(errno=RET.PARAMERR, errmsg='비밀번호 일치하지 않음')
if not re.match(r'^1[^120]\d{9}$', phone):
return jsonify(errno=RET.PARAMERR, errmsg='유효한 전화번호 형식 아님')
# 문자 인증 코드 검증
redis_key = f'sms_code_{phone}'
stored_code = redis_client.get(redis_key)
if not stored_code or stored_code.decode() != sms_code:
return jsonify(errno=RET.PARAMERR, errmsg='인증 코드 오류')
# 사용자 생성 및 저장
user = models.Users(phone=phone, password=password, name=phone)
try:
db.session.add(user)
db.session.commit()
except IntegrityError:
return jsonify(errno=RET.DATAEXIST, errmsg='이미 등록된 전화번호')
except Exception as e:
db.session.rollback()
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='사용자 생성 실패')
# 세션 설정 (자동 로그인)
session['user_id'] = user.id
session['phone'] = phone
session['name'] = phone
return jsonify(errno=RET.OK)
프론트엔드 가입 처리 로직
$('.form-register').submit(function(e) {
e.preventDefault();
const mobile = $('#mobile').val();
const phoneCode = $('#phonecode').val();
const passwd = $('#password').val();
const passwd2 = $('#password2').val();
if (!mobile || !phoneCode || !passwd || passwd !== passwd2) {
alert('모든 필드를 올바르게 입력하세요.');
return;
}
const postData = {
mobile: mobile,
phoneCode: phoneCode,
password: passwd,
password2: passwd2
};
$.ajax({
url: '/api/v1.0/users',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(postData),
dataType: 'json',
headers: { 'X-CSRFToken': getCookie('csrf_token') },
success: function(res) {
if (res.errno === '0') {
location.href = '/index.html';
} else {
alert(res.errmsg);
}
}
});
});