From 108ab9807b81af75f1e66d7af79e5adde4afa180 Mon Sep 17 00:00:00 2001 From: Ivan Budakov Date: Mon, 19 Oct 2026 17:43:06 +0300 Subject: [PATCH] feat: add OAuth2 auth via Gitea and User model --- app/auth.py | 24 ++++++++++ app/config.py | 11 +++++ app/main.py | 95 +++++++++++++++++++++++++++++++++++--- app/models.py | 14 +++++- app/templates/index.html | 22 ++++++++- app/templates/profile.html | 45 ++++++++++++++++++ requirements.txt | 5 ++ 7 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 app/auth.py create mode 100644 app/templates/profile.html diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..3ea27cc --- /dev/null +++ b/app/auth.py @@ -0,0 +1,24 @@ +import requests +from . import config + +def get_gitea_token(code: str): + """Меняет временный код на access token.""" + url = f"{config.GITEA_URL}/login/oauth/access_token" + data = { + "client_id": config.GITEA_CLIENT_ID, + "client_secret": config.GITEA_CLIENT_SECRET, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": "https://portal.morozovka.space/auth/callback" + } + response = requests.post(url, data=data, headers={"Accept": "application/json"}) + return response.json().get("access_token") + +def get_gitea_user(token: str): + """Получает данные пользователя из Gitea по токену.""" + url = f"{config.GITEA_URL}/api/v1/user" + headers = {"Authorization": f"token {token}"} + response = requests.get(url, headers=headers) + if response.status_code == 200: + return response.json() + return None diff --git a/app/config.py b/app/config.py index 145aae3..588b934 100644 --- a/app/config.py +++ b/app/config.py @@ -10,3 +10,14 @@ POSTGRES_HOST = os.getenv("POSTGRES_HOST", "127.0.0.1") POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" + +# Настройки Gitea +GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000") # Внутренний адрес для API +GITEA_PUBLIC_URL = os.getenv("GITEA_PUBLIC_URL", "https://git.morozovka.space") # Для редиректа браузера + +# Вставь сюда свои Client ID и Client Secret из Gitea +GITEA_CLIENT_ID = "66697efe-d88b-41fe-b3d5-6498d978e1fa" +GITEA_CLIENT_SECRET = "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a" + +# Секретный ключ для куки-сессий Flask (можешь оставить этот) +WEB_SECRET = os.getenv("WEB_SECRET", "super-secret-morozovka-key-2026") diff --git a/app/main.py b/app/main.py index c86ba8b..e66e28b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,18 +1,101 @@ -from flask import Flask, render_template, jsonify -from .db import init_db +from flask import Flask, render_template, jsonify, redirect, url_for, request, session +from .db import init_db, engine +from . import config, auth +from sqlmodel import Session, select +from .models import User app = Flask(__name__) +app.secret_key = config.WEB_SECRET # Для подписи куки-сессий -# Инициализируем БД при старте (в будущем заменим на Alembic миграции) +# Инициализация БД with app.app_context(): init_db() @app.route("/healthz") def healthz(): - """Проверка жизнеспособности сервиса.""" return jsonify({"status": "ok", "service": "morozovka-portal"}) @app.route("/") def index(): - """Главная страница портала.""" - return render_template("index.html") + # Если юзер уже в сессии, показываем имя + current_user = session.get("username") + return render_template("index.html", username=current_user) + +# 1. Редирект на Gitea для авторизации +@app.route("/login") +def login(): + gitea_auth_url = ( + f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize" + f"?client_id={config.GITEA_CLIENT_ID}&response_type=code" + f"&redirect_uri=https://portal.morozovka.space/auth/callback" + f"&scope=read:user" + ) + return redirect(gitea_auth_url) + +# 2. Колбэк от Gitea +@app.route("/auth/callback") +def auth_callback(): + code = request.args.get("code") + if not code: + return "Ошибка: Gitea не передала код авторизации.", 400 + + # Меняем код на токен + token = auth.get_gitea_token(code) + if not token: + return "Ошибка: не удалось получить токен от Gitea.", 400 + + # Получаем юзера из Gitea + gitea_user = auth.get_gitea_user(token) + if not gitea_user: + return "Ошибка: не удалось получить профиль пользователя.", 400 + + # Сохраняем или обновляем в БД + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first() + + if not user: + # Создаем нового + new_user = User( + gitea_id=gitea_user["id"], + username=gitea_user["login"], + full_name=gitea_user.get("full_name"), + email=gitea_user.get("email"), + role="admin" if gitea_user["login"] == "git_admin" else "student" # Первому дадим админа + ) + db_session.add(new_user) + db_session.commit() + user = new_user + else: + # Обновляем данные, если изменились в Gitea + user.username = gitea_user["login"] + user.full_name = gitea_user.get("full_name") + user.email = gitea_user.get("email") + db_session.add(user) + db_session.commit() + + # Пишем в сессию Flask + session["user_id"] = user.id + session["username"] = user.username + + return redirect(url_for("profile")) + +# 3. Страница профиля (только для авторизованных) +@app.route("/profile") +def profile(): + user_id = session.get("user_id") + if not user_id: + return redirect(url_for("login")) + + with Session(engine) as db_session: + user = db_session.get(User, user_id) + if not user: + session.clear() + return redirect(url_for("login")) + + return render_template("profile.html", user=user) + +# 4. Выход +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("index")) diff --git a/app/models.py b/app/models.py index 103f019..fa3f68a 100644 --- a/app/models.py +++ b/app/models.py @@ -1,3 +1,15 @@ from sqlmodel import SQLModel -# Здесь будут модели User, Project и т.д. +from sqlmodel import SQLModel, Field +from typing import Optional +from datetime import datetime + +class User(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + gitea_id: int = Field(unique=True, index=True) + username: str = Field(index=True) + full_name: Optional[str] = None + email: Optional[str] = None + role: str = Field(default="student") # admin, professor, lead, member, student + is_active: bool = Field(default=True) + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/app/templates/index.html b/app/templates/index.html index a401eaa..2867fff 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -33,11 +33,29 @@ font-size: 0.8rem; margin-top: 20px; } + .btn { + background: #238636; + color: white; + text-decoration: none; + padding: 10px 20px; + border-radius: 5px; + margin-top: 20px; + font-weight: bold; + } + .btn:hover { background: #2ea043; }

Morozovka Portal

-

Скелет системы успешно запущен! (Flask Edition)

-
Stage 1.2: OK
+ + {% if username %} +

Привет, {{ username }}!

+ Мой профиль + {% else %} +

Скелет системы успешно запущен! (Flask Edition)

+ Войти через Gitea + {% endif %} + +
Stage 1.3: OAuth2 OK
diff --git a/app/templates/profile.html b/app/templates/profile.html new file mode 100644 index 0000000..8b5d72b --- /dev/null +++ b/app/templates/profile.html @@ -0,0 +1,45 @@ + + + + + Профиль — Morozovka Portal + + + +
+

Личный кабинет

+ +
+
Логин в Gitea
+
{{ user.username }}
+
+ +
+
ФИО
+
{{ user.full_name or 'Не указано' }}
+
+ +
+
Email
+
{{ user.email or 'Не указано' }}
+
+ +
+
Роль в системе
+
{{ user.role }}
+
+ +
+ ← На главную | Выйти +
+
+ + diff --git a/requirements.txt b/requirements.txt index ebcf532..ea2d765 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,21 @@ annotated-types==0.8.0 blinker==1.9.0 +certifi==2026.10.18 +charset-normalizer==3.5.2 click==8.5.0 Flask==3.1.3 gunicorn==26.2.0 +idna==3.20 itsdangerous==2.2.0 Jinja2==3.1.6 MarkupSafe==3.0.4 psycopg2-binary==2.9.13 pydantic==2.14.1 pydantic_core==2.50.1 +requests==2.34.2 SQLAlchemy==2.1.5 sqlmodel==0.0.48 typing-inspection==0.4.4 typing_extensions==4.16.0 +urllib3==2.8.0 Werkzeug==3.1.9