diff --git a/result_server/app.py b/result_server/app.py index 36bd37c..b6e77fd 100644 --- a/result_server/app.py +++ b/result_server/app.py @@ -12,6 +12,7 @@ # Import Flask and route blueprints from flask import Flask, render_template, current_app +from flask_session import Session from routes.receive import receive_bp from routes.results import results_bp from routes.upload_tgz import upload_bp @@ -43,11 +44,38 @@ def create_app(prefix="", base_dir=None): # --- セッションCookieのセキュリティ設定 --- app.config.update( + SESSION_TYPE='filesystem', + SESSION_FILE_DIR=os.path.join(base_dir, "flask_session"), + SESSION_PERMANENT=True, + SESSION_USE_SIGNER=True, SESSION_COOKIE_SECURE=True, # HTTPS必須 SESSION_COOKIE_HTTPONLY=True, # JSからのアクセス禁止 SESSION_COOKIE_SAMESITE="Strict", # もしくは "Lax" PERMANENT_SESSION_LIFETIME=timedelta(minutes=30), # セッション寿命を短めに ) + Session(app) + + + # Redis 接続 + import redis + redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0") + r_conn = redis.from_url(redis_url, decode_responses=True) + + # 環境ごとの prefix + if prefix == "/dev": + app.config["SESSION_COOKIE_NAME"] = "session_dev" + key_prefix = "dev:" + else: + app.config["SESSION_COOKIE_NAME"] = "session_main" + key_prefix = "main:" + + # OTP Manager 初期化 + import utils.otp_redis_manager as otp_redis_manager + otp_redis_manager.init_redis(r_conn, key_prefix) + + # 他でもredisを使う場合、 + #app.redis = r_conn + #app.redis_prefix = key_prefix diff --git a/result_server/routes/results.py b/result_server/routes/results.py index 6a88ef4..fa36ee5 100644 --- a/result_server/routes/results.py +++ b/result_server/routes/results.py @@ -4,7 +4,8 @@ redirect, url_for, flash, abort, send_from_directory ) from utils.results_loader import load_results_table, load_estimated_results_table -from utils.otp_manager import send_otp, verify_otp, get_affiliations +#from utils.otp_manager import get_affiliations +from utils.otp_redis_manager import send_otp, verify_otp, invalidate_otp, is_allowed, get_affiliations from utils.result_file import load_result_file, get_file_confidential_tags results_bp = Blueprint("results", __name__) @@ -51,30 +52,29 @@ def handle_otp_post(session_key_authenticated, session_key_email, route_name): if email and not otp: success, msg = send_otp(email) - if success: - flash("OTPをメールに送信しました") + flash(msg) + if success and is_allowed(email): + session.clear() session[session_key_email] = email session["otp_stage"] = "otp" else: - flash(msg) - session.pop(session_key_email, None) + session.clear() session["otp_stage"] = "email" return redirect(url_for(route_name)) elif otp: otp_email = session.get(session_key_email) if otp_email and verify_otp(otp_email, otp): + session.clear() session[session_key_authenticated] = True flash("認証成功") - session.pop("otp_stage", None) else: - flash("OTP認証失敗") - session.pop(session_key_email, None) - session.pop(session_key_authenticated, None) - session["otp_stage"] = "email" + session.clear() + flash("認証失敗") return redirect(url_for(route_name)) + def render_confidential_table(template_name, public_only, session_key_authenticated, session_key_email, loader_func=None): authenticated = session.get(session_key_authenticated, False) otp_email = session.get(session_key_email) diff --git a/result_server/utils/otp_redis_manager.py b/result_server/utils/otp_redis_manager.py new file mode 100644 index 0000000..312cd78 --- /dev/null +++ b/result_server/utils/otp_redis_manager.py @@ -0,0 +1,124 @@ +import redis +import random +import string +import time +from typing import List, Tuple +from datetime import datetime, timedelta +from email.mime.text import MIMEText +import smtplib +import json +import os + +# ------------------------------- +# Redis 接続 (初期化は後で) +# ------------------------------- +r = None +prefix = "" # app から渡す + +def init_redis(redis_conn, key_prefix: str = ""): + global r, prefix + r = redis_conn + prefix = key_prefix + +# ------------------------------- +# メール許可・所属 +# ------------------------------- +with open("config/allowed_emails.json", encoding="utf-8") as f: + _ALLOWED = json.load(f) + +def is_allowed(email: str) -> bool: + return email in _ALLOWED + +def get_affiliations(email: str) -> List[str]: + if not is_allowed(email): + return [] + aff = _ALLOWED[email] + if isinstance(aff, list): + return [str(a).strip() for a in aff if str(a).strip()] + if isinstance(aff, str): + return [a.strip() for a in aff.split(",") if a.strip()] + return [] + +# ------------------------------- +# Gmail SMTP 設定 +# ------------------------------- +SMTP_SERVER = os.environ.get("SMTP_SERVER", "smtp.gmail.com") +SMTP_PORT = int(os.environ.get("SMTP_PORT", 587)) +SMTP_USER = os.environ.get("SMTP_USER") +SMTP_PASS = os.environ.get("SMTP_PASS") + +# ------------------------------- +# OTP 設定 +# ------------------------------- +OTP_TTL_SECONDS = 5 * 60 +MAX_OTP_ATTEMPTS = 5 + +def generate_otp_code(length=6) -> str: + return ''.join(random.choices(string.digits, k=length)) + +def send_otp(email: str) -> Tuple[bool, str]: + """OTP を生成して Redis に保存してメール送信""" + public_message = "メールを確認してください。" + if not is_allowed(email): + return True, public_message + + code = generate_otp_code() + now = int(time.time()) + key = f"{prefix}:otp:{email}" + + r.hmset(key, { + "code": code, + "expires_at": now + OTP_TTL_SECONDS, + "attempts_left": MAX_OTP_ATTEMPTS, + }) + r.expire(key, OTP_TTL_SECONDS) + + # メール送信 + msg = MIMEText( + f"あなたの認証コード (OTP) は {code} です。\n" + f"有効期限は {datetime.fromtimestamp(now + OTP_TTL_SECONDS).strftime('%H:%M:%S')} までです。" + ) + msg["Subject"] = "OTP 認証コード" + msg["From"] = SMTP_USER + msg["To"] = email + + try: + with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server: + server.starttls() + server.login(SMTP_USER, SMTP_PASS) + server.sendmail(SMTP_USER, [email], msg.as_string()) + except Exception as e: + return False, f"メール送信失敗: {e}" + + return True, public_message + +def verify_otp(email: str, code: str) -> bool: + key = f"{prefix}:otp:{email}" + data = r.hgetall(key) + now = int(time.time()) + if not data: + return False + + if now > int(data.get("expires_at", 0)): + r.delete(key) + return False + + attempts_left = int(data.get("attempts_left", MAX_OTP_ATTEMPTS)) + if attempts_left <= 0: + r.delete(key) + return False + + if code == data.get("code"): + r.delete(key) + return True + else: + attempts_left -= 1 + if attempts_left <= 0: + r.delete(key) + else: + r.hset(key, "attempts_left", attempts_left) + return False + +def invalidate_otp(email: str): + r.delete(f"{prefix}:otp:{email}") + diff --git a/scripts/matrix_generate.sh b/scripts/matrix_generate.sh index b2c5aeb..3a2742e 100644 --- a/scripts/matrix_generate.sh +++ b/scripts/matrix_generate.sh @@ -7,6 +7,19 @@ OUTPUT_FILE=".gitlab-ci.generated.yml" source ./scripts/job_functions.sh +CODE_FILTER="" +SYSTEM_FILTER="" + +while [[ $# -gt 0 ]]; do + case $1 in + code=*) CODE_FILTER="${1#code=}" ;; + system=*) SYSTEM_FILTER="${1#system=}" ;; + *) echo "Unknown argument: $1"; exit 1 ;; + esac + shift +done + + echo "# Auto-generated GitLab CI configuration" > "$OUTPUT_FILE" echo " stages: @@ -20,11 +33,15 @@ stages: for listfile in programs/*/list.csv; do program_dir=$(dirname "$listfile") program=$(basename "$program_dir") - + + [[ -n "$CODE_FILTER" && "$program" != "$CODE_FILTER" ]] && continue + while IFS=, read -r system mode queue_group nodes numproc_node nthreads elapse; do [[ "$system" == "system" ]] && continue # skip header [[ "$system" == *"#"* ]] && continue # skip # + [[ -n "$SYSTEM_FILTER" && "$system" != "$SYSTEM_FILTER" ]] && continue + job_prefix="${program}_${system}_N${nodes}_P${numproc_node}_T${nthreads}" program_path="$program_dir"