feat: add OAuth2 auth via Gitea and User model
This commit is contained in:
parent
a1b966a22b
commit
108ab9807b
24
app/auth.py
Normal file
24
app/auth.py
Normal 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
|
||||||
|
|
@ -10,3 +10,14 @@ POSTGRES_HOST = os.getenv("POSTGRES_HOST", "127.0.0.1")
|
||||||
POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
|
POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
|
||||||
|
|
||||||
DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
|
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")
|
||||||
|
|
|
||||||
95
app/main.py
95
app/main.py
|
|
@ -1,18 +1,101 @@
|
||||||
from flask import Flask, render_template, jsonify
|
from flask import Flask, render_template, jsonify, redirect, url_for, request, session
|
||||||
from .db import init_db
|
from .db import init_db, engine
|
||||||
|
from . import config, auth
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from .models import User
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.secret_key = config.WEB_SECRET # Для подписи куки-сессий
|
||||||
|
|
||||||
# Инициализируем БД при старте (в будущем заменим на Alembic миграции)
|
# Инициализация БД
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
@app.route("/healthz")
|
@app.route("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
"""Проверка жизнеспособности сервиса."""
|
|
||||||
return jsonify({"status": "ok", "service": "morozovka-portal"})
|
return jsonify({"status": "ok", "service": "morozovka-portal"})
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
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"))
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,15 @@
|
||||||
from sqlmodel import SQLModel
|
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)
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,29 @@
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin-top: 20px;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Morozovka Portal</h1>
|
<h1>Morozovka Portal</h1>
|
||||||
|
|
||||||
|
{% if username %}
|
||||||
|
<p>Привет, <b>{{ username }}</b>!</p>
|
||||||
|
<a href="/profile" class="btn">Мой профиль</a>
|
||||||
|
{% else %}
|
||||||
<p>Скелет системы успешно запущен! (Flask Edition)</p>
|
<p>Скелет системы успешно запущен! (Flask Edition)</p>
|
||||||
<div class="badge">Stage 1.2: OK</div>
|
<a href="/login" class="btn">Войти через Gitea</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="badge">Stage 1.3: OAuth2 OK</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
45
app/templates/profile.html
Normal file
45
app/templates/profile.html
Normal 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>
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
annotated-types==0.8.0
|
annotated-types==0.8.0
|
||||||
blinker==1.9.0
|
blinker==1.9.0
|
||||||
|
certifi==2026.10.18
|
||||||
|
charset-normalizer==3.5.2
|
||||||
click==8.5.0
|
click==8.5.0
|
||||||
Flask==3.1.3
|
Flask==3.1.3
|
||||||
gunicorn==26.2.0
|
gunicorn==26.2.0
|
||||||
|
idna==3.20
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
MarkupSafe==3.0.4
|
MarkupSafe==3.0.4
|
||||||
psycopg2-binary==2.9.13
|
psycopg2-binary==2.9.13
|
||||||
pydantic==2.14.1
|
pydantic==2.14.1
|
||||||
pydantic_core==2.50.1
|
pydantic_core==2.50.1
|
||||||
|
requests==2.34.2
|
||||||
SQLAlchemy==2.1.5
|
SQLAlchemy==2.1.5
|
||||||
sqlmodel==0.0.48
|
sqlmodel==0.0.48
|
||||||
typing-inspection==0.4.4
|
typing-inspection==0.4.4
|
||||||
typing_extensions==4.16.0
|
typing_extensions==4.16.0
|
||||||
|
urllib3==2.8.0
|
||||||
Werkzeug==3.1.9
|
Werkzeug==3.1.9
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user