feat: 카카오 로그인으로 마일리지 적립 기능 추가

- 카카오 OAuth 2.0 클라이언트 모듈 추가 (services/kakao_client.py)
- 적립 페이지에 "카카오로 적립하기" 버튼 추가
- OAuth 콜백 처리: 전화번호 자동 적립 / 미제공 시 폰 입력 폴백
- state 파라미터로 claim 컨텍스트 보존 + CSRF 보호
- customer_identities 테이블 활용한 카카오 계정 연결
- 마이페이지 헤더 sticky 고정
- 카카오 OAuth 설정 가이드 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
thug0bin
2026-02-25 02:27:05 +09:00
parent 82220a4a44
commit 31cf6e3816
8 changed files with 1188 additions and 15 deletions

View File

@@ -3,8 +3,10 @@ Flask 웹 서버 - QR 마일리지 적립
간편 적립: 전화번호 + 이름만 입력
"""
from flask import Flask, request, render_template, jsonify, redirect, url_for
from flask import Flask, request, render_template, jsonify, redirect, url_for, session
import hashlib
import base64
import secrets
from datetime import datetime, timezone, timedelta
import sys
import os
@@ -452,6 +454,67 @@ def claim_mileage(user_id, token_info):
return (False, f"적립 처리 실패: {str(e)}", 0)
def normalize_kakao_phone(kakao_phone):
"""
카카오 전화번호 형식 변환
"+82 10-1234-5678""01012345678"
"""
if not kakao_phone:
return None
digits = kakao_phone.replace('+', '').replace('-', '').replace(' ', '')
if digits.startswith('82'):
digits = '0' + digits[2:]
if len(digits) >= 10:
return digits
return None
def link_kakao_identity(user_id, kakao_id, kakao_data):
"""카카오 계정을 customer_identities에 연결"""
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id FROM customer_identities
WHERE provider = 'kakao' AND provider_user_id = ?
""", (kakao_id,))
if not cursor.fetchone():
# raw_data 제외 (세션 크기 절약)
store_data = {k: v for k, v in kakao_data.items() if k != 'raw_data'}
cursor.execute("""
INSERT INTO customer_identities (user_id, provider, provider_user_id, provider_data)
VALUES (?, 'kakao', ?, ?)
""", (user_id, kakao_id, json.dumps(store_data, ensure_ascii=False)))
# 프로필 이미지, 이메일 업데이트
updates = []
params = []
if kakao_data.get('profile_image'):
updates.append("profile_image_url = ?")
params.append(kakao_data['profile_image'])
if kakao_data.get('email'):
updates.append("email = ?")
params.append(kakao_data['email'])
if updates:
params.append(user_id)
cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params)
conn.commit()
def find_user_by_kakao_id(kakao_id):
"""카카오 ID로 기존 연결된 사용자 조회"""
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT user_id FROM customer_identities
WHERE provider = 'kakao' AND provider_user_id = ?
""", (kakao_id,))
row = cursor.fetchone()
return row['user_id'] if row else None
# ============================================================================
# 라우트
# ============================================================================
@@ -640,6 +703,197 @@ def api_claim():
}), 500
# ============================================================================
# 카카오 적립 라우트
# ============================================================================
@app.route('/claim/kakao/start')
def claim_kakao_start():
"""카카오 OAuth 시작 - claim 컨텍스트를 state에 담아 카카오로 리다이렉트"""
from services.kakao_client import get_kakao_client
token_param = request.args.get('t', '')
if ':' not in token_param:
return render_template('error.html', message="잘못된 QR 코드입니다.")
parts = token_param.split(':')
if len(parts) != 2:
return render_template('error.html', message="잘못된 QR 코드입니다.")
transaction_id, nonce = parts
# 토큰 사전 검증
success, message, token_info = verify_claim_token(transaction_id, nonce)
if not success:
return render_template('error.html', message=message)
# state: claim 컨텍스트 + CSRF 토큰
csrf_token = secrets.token_hex(16)
state_data = {
't': token_param,
'csrf': csrf_token
}
state_encoded = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
session['kakao_csrf'] = csrf_token
kakao_client = get_kakao_client()
auth_url = kakao_client.get_authorization_url(state=state_encoded)
return redirect(auth_url)
@app.route('/claim/kakao/callback')
def claim_kakao_callback():
"""카카오 OAuth 콜백 - 토큰 교환 → 사용자 정보 → 적립 처리"""
from services.kakao_client import get_kakao_client
code = request.args.get('code')
state_encoded = request.args.get('state')
error = request.args.get('error')
if error:
return render_template('error.html', message="카카오 로그인이 취소되었습니다.")
if not code or not state_encoded:
return render_template('error.html', message="카카오 인증 정보가 올바르지 않습니다.")
# 1. state 디코딩
try:
state_json = base64.urlsafe_b64decode(state_encoded).decode()
state_data = json.loads(state_json)
except Exception:
return render_template('error.html', message="인증 상태 정보가 올바르지 않습니다.")
# 2. CSRF 검증
csrf_token = state_data.get('csrf')
if csrf_token != session.pop('kakao_csrf', None):
return render_template('error.html', message="보안 검증에 실패했습니다. 다시 시도해주세요.")
# 3. claim 컨텍스트 복원
token_param = state_data.get('t', '')
parts = token_param.split(':')
if len(parts) != 2:
return render_template('error.html', message="적립 정보가 올바르지 않습니다.")
transaction_id, nonce = parts
# 4. 토큰 재검증 (OAuth 중 다른 사람이 적립했을 수 있음)
success, message, token_info = verify_claim_token(transaction_id, nonce)
if not success:
return render_template('error.html', message=message)
# 5. 카카오 access_token 교환
kakao_client = get_kakao_client()
success, token_data = kakao_client.get_access_token(code)
if not success:
logging.error(f"카카오 토큰 교환 실패: {token_data}")
return render_template('error.html', message="카카오 인증에 실패했습니다. 다시 시도해주세요.")
# 6. 사용자 정보 조회
access_token = token_data.get('access_token')
success, user_info = kakao_client.get_user_info(access_token)
if not success:
logging.error(f"카카오 사용자 정보 조회 실패: {user_info}")
return render_template('error.html', message="카카오 사용자 정보를 가져올 수 없습니다.")
kakao_id = user_info.get('kakao_id')
kakao_name = user_info.get('name') or user_info.get('nickname', '')
kakao_phone_raw = user_info.get('phone_number')
kakao_phone = normalize_kakao_phone(kakao_phone_raw)
# 7. 분기: 전화번호가 있으면 자동 적립, 없으면 폰 입력 폼
if kakao_phone:
# 자동 적립
existing_user_id = find_user_by_kakao_id(kakao_id)
if existing_user_id:
user_id = existing_user_id
is_new = False
else:
user_id, is_new = get_or_create_user(kakao_phone, kakao_name)
link_kakao_identity(user_id, kakao_id, user_info)
success, msg, new_balance = claim_mileage(user_id, token_info)
if not success:
return render_template('error.html', message=msg)
return render_template('claim_kakao_success.html',
points=token_info['claimable_points'],
balance=new_balance,
phone=kakao_phone,
name=kakao_name)
else:
# 전화번호 없음 → 세션에 카카오 정보 저장 + 폰 입력 폼
session['kakao_claim'] = {
'kakao_id': kakao_id,
'name': kakao_name,
'profile_image': user_info.get('profile_image'),
'email': user_info.get('email'),
'token_param': token_param
}
return render_template('claim_kakao_phone.html',
token_info=token_info,
kakao_name=kakao_name,
kakao_profile_image=user_info.get('profile_image'))
@app.route('/api/claim/kakao', methods=['POST'])
def api_claim_kakao():
"""카카오 적립 (전화번호 폴백) - 세션의 카카오 정보 + 폼의 전화번호로 적립"""
kakao_data = session.pop('kakao_claim', None)
if not kakao_data:
return jsonify({
'success': False,
'message': '카카오 인증 정보가 만료되었습니다. 다시 시도해주세요.'
}), 400
data = request.get_json()
phone = data.get('phone', '').strip().replace('-', '').replace(' ', '')
if len(phone) < 10:
session['kakao_claim'] = kakao_data
return jsonify({
'success': False,
'message': '올바른 전화번호를 입력해주세요.'
}), 400
token_param = kakao_data['token_param']
parts = token_param.split(':')
transaction_id, nonce = parts
success, message, token_info = verify_claim_token(transaction_id, nonce)
if not success:
return jsonify({'success': False, 'message': message}), 400
kakao_id = kakao_data['kakao_id']
name = kakao_data['name']
existing_user_id = find_user_by_kakao_id(kakao_id)
if existing_user_id:
user_id = existing_user_id
is_new = False
else:
user_id, is_new = get_or_create_user(phone, name)
link_kakao_identity(user_id, kakao_id, kakao_data)
success, message, new_balance = claim_mileage(user_id, token_info)
if not success:
return jsonify({'success': False, 'message': message}), 500
return jsonify({
'success': True,
'message': message,
'points': token_info['claimable_points'],
'balance': new_balance,
'is_new_user': is_new
})
@app.route('/my-page')
def my_page():
"""마이페이지 (전화번호로 조회)"""