# backend/routes/auth.py from __future__ import annotations import logging import threading from typing import Optional from urllib.parse import urlparse, urljoin from flask import ( Blueprint, render_template, redirect, url_for, flash, request, session, current_app, ) from flask_login import login_user, logout_user, current_user, login_required from backend.forms.auth_forms import RegistrationForm, LoginForm from backend.models.user import User, db # ── (선택) Telegram: 미설정이면 조용히 패스 try: from telegram import Bot from telegram.constants import ParseMode except Exception: # 라이브러리 미설치/미사용 환경 Bot = None ParseMode = None auth_bp = Blueprint("auth", __name__) # ───────────────────────────────────────────────────────────── # 유틸 # ───────────────────────────────────────────────────────────── def _is_safe_url(target: str) -> bool: """로그인 후 next 파라미터의 안전성 확인(동일 호스트만 허용).""" ref = urlparse(request.host_url) test = urlparse(urljoin(request.host_url, target)) return (test.scheme in ("http", "https")) and (ref.netloc == test.netloc) def _notify(text: str) -> None: """텔레그램 알림 (설정 없으면 바로 return).""" token = (current_app.config.get("TELEGRAM_BOT_TOKEN") or "").strip() chat_id = (current_app.config.get("TELEGRAM_CHAT_ID") or "").strip() if not (token and chat_id and Bot and ParseMode): return def _send(): try: bot = Bot(token=token) bot.send_message(chat_id=chat_id, text=text, parse_mode=ParseMode.HTML) except Exception as e: current_app.logger.warning("Telegram send failed: %s", e) threading.Thread(target=_send, daemon=True).start() # ───────────────────────────────────────────────────────────── # Blueprint 등록 훅 # ───────────────────────────────────────────────────────────── def register_auth_routes(app): """app.py에서 register_routes(app, socketio) 호출 시 사용.""" app.register_blueprint(auth_bp) @app.before_request def _touch_session(): # 요청마다 세션 갱신(만료 슬라이딩) + 로그아웃 플래그 정리 session.modified = True if current_user.is_authenticated and session.get("just_logged_out"): session.pop("just_logged_out", None) flash("세션이 만료되어 자동 로그아웃 되었습니다.", "info") # ───────────────────────────────────────────────────────────── # 회원가입 # ───────────────────────────────────────────────────────────── @auth_bp.route("/register", methods=["GET", "POST"]) def register(): if current_user.is_authenticated: current_app.logger.info("REGISTER: already auth → /index") return redirect(url_for("main.index")) form = RegistrationForm() if form.validate_on_submit(): # 모델 내부에서 email/username 정규화됨(find_by_*) if User.find_by_email(form.email.data): flash("이미 등록된 이메일입니다.", "warning") current_app.logger.info("REGISTER: dup email %s", form.email.data) return render_template("register.html", form=form) if User.find_by_username(form.username.data): flash("이미 사용 중인 사용자명입니다.", "warning") current_app.logger.info("REGISTER: dup username %s", form.username.data) return render_template("register.html", form=form) user = User(username=form.username.data, email=form.email.data, is_active=False) user.set_password(form.password.data) # passlib: 기본 Argon2id db.session.add(user) db.session.commit() _notify( f"🆕 신규 가입 요청\n" f"📛 사용자: {user.username}\n" f"📧 이메일: {user.email}" ) current_app.logger.info("REGISTER: created id=%s email=%s", user.id, user.email) flash("회원가입이 완료되었습니다. 관리자의 승인을 기다려주세요.", "success") return redirect(url_for("auth.login")) else: if request.method == "POST": current_app.logger.info("REGISTER: form errors=%s", form.errors) return render_template("register.html", form=form) # ───────────────────────────────────────────────────────────── # 로그인 # ───────────────────────────────────────────────────────────── @auth_bp.route("/login", methods=["GET", "POST"]) def login(): if current_user.is_authenticated: current_app.logger.info("LOGIN: already auth → /index") return redirect(url_for("main.index")) form = LoginForm() if form.validate_on_submit(): current_app.logger.info("LOGIN: form ok email=%s", form.email.data) user: Optional[User] = User.find_by_email(form.email.data) if not user: flash("이메일 또는 비밀번호가 올바르지 않습니다.", "danger") current_app.logger.info("LOGIN: user not found") return render_template("login.html", form=form) pass_ok = user.check_password(form.password.data) # passlib verify(+자동 재해시) current_app.logger.info( "LOGIN: found id=%s active=%s pass_ok=%s", user.id, user.is_active, pass_ok ) if not pass_ok: flash("이메일 또는 비밀번호가 올바르지 않습니다.", "danger") return render_template("login.html", form=form) if not user.is_active: flash("계정이 아직 승인되지 않았습니다.", "warning") return render_template("login.html", form=form) # 성공 login_user(user, remember=form.remember.data) session.permanent = True _notify(f"🔐 로그인 성공\n👤 {user.username}") current_app.logger.info("LOGIN: SUCCESS → redirect") nxt = request.args.get("next") if nxt and _is_safe_url(nxt): return redirect(nxt) return redirect(url_for("main.index")) else: if request.method == "POST": current_app.logger.info("LOGIN: form errors=%s", form.errors) return render_template("login.html", form=form) # ───────────────────────────────────────────────────────────── # 로그아웃 # ───────────────────────────────────────────────────────────── @auth_bp.route("/logout", methods=["GET"]) @login_required def logout(): if current_user.is_authenticated: current_app.logger.info("LOGOUT: user=%s", current_user.username) logout_user() session["just_logged_out"] = True return redirect(url_for("auth.login"))