Flask-아이홈 임대주택 프로젝트: 회원가입 기능 구현

이미지 인증 코드 생성 시스템 설계

인증 코드 생성 모듈 통합

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);
            }
        }
    });
});

태그: flask python Redis REST API jwt

6월 27일 05:00에 게시됨