feat: add OAuth2 auth via Gitea and User model

This commit is contained in:
Ivan Budakov 2026-10-19 17:43:06 +03:00
parent a1b966a22b
commit 108ab9807b
7 changed files with 207 additions and 9 deletions

24
app/auth.py Normal file
View File

@ -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

View File

@ -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")

View File

@ -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"))

View File

@ -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)

View File

@ -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; }
</style>
</head>
<body>
<h1>Morozovka Portal</h1>
<p>Скелет системы успешно запущен! (Flask Edition)</p>
<div class="badge">Stage 1.2: OK</div>
{% if username %}
<p>Привет, <b>{{ username }}</b>!</p>
<a href="/profile" class="btn">Мой профиль</a>
{% else %}
<p>Скелет системы успешно запущен! (Flask Edition)</p>
<a href="/login" class="btn">Войти через Gitea</a>
{% endif %}
<div class="badge">Stage 1.3: OAuth2 OK</div>
</body>
</html>

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Профиль — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 20px; max-width: 500px; margin: 0 auto; }
h2 { color: #58a6ff; margin-top: 0; }
.field { margin-bottom: 15px; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
.label { color: #8b949e; font-size: 0.8rem; text-transform: uppercase; }
.value { font-size: 1.1rem; font-family: monospace; }
a { color: #58a6ff; }
</style>
</head>
<body>
<div class="card">
<h2>Личный кабинет</h2>
<div class="field">
<div class="label">Логин в Gitea</div>
<div class="value">{{ user.username }}</div>
</div>
<div class="field">
<div class="label">ФИО</div>
<div class="value">{{ user.full_name or 'Не указано' }}</div>
</div>
<div class="field">
<div class="label">Email</div>
<div class="value">{{ user.email or 'Не указано' }}</div>
</div>
<div class="field">
<div class="label">Роль в системе</div>
<div class="value">{{ user.role }}</div>
</div>
<div style="margin-top: 20px;">
<a href="/">← На главную</a> | <a href="/logout" style="color: #f85149;">Выйти</a>
</div>
</div>
</body>
</html>

View File

@ -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