This commit is contained in:
Vladislav 2026-08-02 04:31:32 -04:00
parent d6512dac5a
commit c1efb9d36f
4 changed files with 243 additions and 38 deletions

View File

@ -8,12 +8,14 @@
{% block content %}
{{ header.section(user) }}
<form on class="c_sbrxk">
<form class="c_sbrxk">
<img class="login-illustration" src="/static/login-art.png" alt="" aria-hidden="true">
<div class="c_dtjoh">
<div class="c_aihm6 floating-group">
<input type="email" id="email" name="email" class="c_xhd5k" required>
<label for="email" class="c_z3ybu floating-label">Email</label>
<input type="text" id="email" name="email" class="c_xhd5k" autocomplete="username" required>
<label for="email" class="c_z3ybu floating-label">Логин</label>
</div>
<span class="login-hint">Можно указать телефон или email от аккаунта</span>
<div class="c_aihm6 floating-group file-group poster-group password-group" style="margin-bottom: 0;">
<input type="password" id="password" name="password" class="c_xhd5k" required>
<label for="password" class="c_z3ybu floating-label">Пароль</label>
@ -34,7 +36,8 @@
</div>
</div>
<a href="/restore{% if next %}?next={{next}}{% endif %}" onclick="sendToNextPage(event)" style="margin-bottom: 1rem;">Забыли пароль ?</a>
<span class="login-hint">Минимум 8 символов</span>
<a href="/restore{% if next %}?next={{next}}{% endif %}" onclick="sendToNextPage(event)" class="login-forgot" style="margin-bottom: 1rem;">Забыли пароль ?</a>
<button class="c_aq9et" id="createBtn" type="button">
<span class="c_som9s">Войти</span>
@ -42,6 +45,18 @@
<div style="display: flex; gap:15px">
<span>Нет аккаунта?</span><a href="/registration{% if next %}?next={{next}}{% endif %}">Регистрация</a>
</div>
<div class="login-social">
<div class="login-social__or"><span>или войдите через</span></div>
<div class="login-social__row">
<a class="login-social__btn login-social__btn--vk" href="/api/v1/social/vk/register{% if next %}?next={{next}}{% endif %}">
<svg class="login-social__logo" width="22" height="22" viewBox="0 0 24 24" fill="#0077FF" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M13.162 18.994c-6.13 0-9.625-4.203-9.77-11.196h3.07c.101 5.132 2.366 7.307 4.16 7.755V7.798h2.89v4.427c1.77-.19 3.63-2.208 4.256-4.427h2.89c-.48 2.734-2.498 4.752-3.933 5.581 1.435.673 3.733 2.432 4.61 5.614h-3.182c-.682-2.125-2.384-3.767-4.64-3.99v3.99h-.34z"/>
</svg>
<span>VK</span>
</a>
</div>
</div>
</div>
</form>
@ -120,13 +135,18 @@ function sendToNextPage(e) {
window.location.href = next;
} else {
// non-2xx — mark all input groups as error
const allFields = document.querySelectorAll('.floating-group');
let msg;
if (res.status === 403) {
// Аккаунт заблокирован — поля ввода ни при чём, показываем причину
msg = 'Ваш аккаунт заблокирован администратором.';
try { const d = await res.json(); if (d && typeof d.detail === 'string') msg = d.detail; } catch (e) {}
allFields.forEach(g => g.classList.remove('error'));
} else {
msg = 'Неверный логин или пароль';
allFields.forEach(g => g.classList.add('error'));
// Optional: you can show server message if present
// const errorText = await res.text();
// console.error('Server error:', errorText);
}
if (typeof showToast === 'function') showToast({ message: msg, status: 'error', duration: 9000 });
}
} catch (err) {
// Network or other unexpected error — mark fields as error
@ -179,6 +199,17 @@ function sendToNextPage(e) {
pwd.focus();
});
// Вход через Макс — бэкенд ещё не готов: уведомление, без перехода
var maxLoginBtn = document.querySelector('.login-social__btn--max');
if (maxLoginBtn) {
maxLoginBtn.addEventListener('click', function (e) {
e.preventDefault();
if (typeof showToast === 'function') {
showToast({ message: 'Вход через Макс — в процессе реализации', status: 'info', duration: 2800 });
}
});
}
</script>
{{ footer.section() }}
<script>
@ -441,6 +472,43 @@ input[type="number"]:focus::-webkit-inner-spin-button {display: none;}
}
.c_sbrxk{z-index: 1000;}
#preview_poster{width: 335px;}
/* «Забыли пароль?» — второстепенная ссылка */
.login-forgot { font-size: 13px; color: #888; align-self: flex-start; }
.login-forgot:hover { color: #5F1798; text-decoration: underline; }
/* Подписи-подсказки под полями */
.login-hint { font-size: 12px; color: #888; align-self: flex-start; line-height: 1.3; margin-top: -6px; }
/* убираем большой нижний отступ у группы, за которой идёт подсказка */
.floating-group:has(+ .login-hint) { margin-bottom: 0; }
/* Микроанимация кнопки «Войти» */
.c_aq9et { cursor: pointer; box-shadow: 0 4px 14px -4px rgba(95,23,152,.5); transition: transform .18s, box-shadow .18s, background .18s; }
.c_aq9et:hover { background: #6d1ba9; transform: translateY(-2px); box-shadow: 0 10px 24px -6px rgba(95,23,152,.6); }
.c_aq9et:active { transform: translateY(0); box-shadow: 0 4px 12px -4px rgba(95,23,152,.5); }
@media (prefers-reduced-motion: reduce) { .c_aq9et:hover, .c_aq9et:active { transform: none; } }
/* Картинка слева от формы (только десктоп) */
.login-illustration { display: none; }
@media (min-width: 901px) {
.login-illustration { display: block; width: 340px; max-width: 38vw; height: auto; align-self: center; }
.c_sbrxk { gap: 80px; }
}
/* Вход через VK / Макс */
.login-social { display: flex; flex-direction: column; gap: 10px; align-self: stretch; margin-top: 4px; }
.login-social__or { display: flex; align-items: center; gap: 10px; color: #999; font-size: 13px; }
.login-social__or::before, .login-social__or::after { content: ''; flex: 1; height: 1px; background: #e5e5e5; }
.login-social__row { display: flex; gap: 10px; }
.login-social__btn { flex: 1 1 0; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; border-radius: 8px; background: #fff; border: 2px solid #ddd; color: #111; text-decoration: none; font-size: 15px; font-weight: 600; transition: border-color .18s, box-shadow .18s, transform .18s; }
.login-social__btn:hover { transform: translateY(-1px); box-shadow: 0 6px 16px -6px rgba(0,0,0,.28); }
.login-social__btn:active { transform: translateY(0); }
.login-social__btn--vk:hover { border-color: #0077FF; }
.login-social__btn--max:hover { border-color: #7a3ff2; }
.login-social__logo { flex: 0 0 auto; display: block; width: 22px; height: 22px; }
@media (prefers-reduced-motion: reduce) {
.login-social__btn:hover, .login-social__btn:active { transform: none; }
}
</style>
{{ footer.style() }}
{% endblock %}

View File

@ -1,5 +1,6 @@
import os
import json
import re # регистронезависимый поиск email при VK-регистрации
import hashlib
import base64
from datetime import datetime, timedelta, UTC
@ -9,7 +10,8 @@ from bson import ObjectId
from config import *
from db import mongo_client, redis_client, gridfs_map
import asyncio
from utils import fetch_bytes
import secrets
from utils import fetch_bytes, get_password_hash
import io
import uuid
from utils import encrypt, decrypt
@ -17,7 +19,7 @@ from utils import encrypt, decrypt
# ---------------------- Константы OAuth ----------------------
OAUTH_URL = 'https://id.vk.com/oauth2/auth'
AUTHORIZE_URL = 'https://id.vk.ru/authorize'
SCOPES = 'offline,wall,photos,groups'
SCOPES = 'offline,wall,photos,groups,email' # email — для регистрации через VK
REDIRECT_URL = f'{FULL_DOMAIN}/api/v1/social/vk/token'
@ -29,14 +31,21 @@ class TokenManager:
def __init__(self):
self.tokens_coll = mongo_client["vk"]["tokens"]
async def get_auth_url(self, user_id: str) -> str:
async def get_auth_url(self, user_id: str = None, purpose: str = "link", next: str = None) -> str:
"""purpose: "link" (привязка соцсети) или "register" (вход/регистрация через VK);
next — куда вернуть пользователя после успешного входа."""
state = os.urandom(32).hex()
code_verifier = os.urandom(50).hex()
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode('utf-8')).digest()
).decode('utf-8').rstrip('=')
oauth_data = {"user_id": user_id, "code_verifier": code_verifier}
oauth_data = {
"user_id": user_id,
"code_verifier": code_verifier,
"purpose": purpose,
"next": next,
}
await redis_client.setex(f"oauth_state:{state}", 60 * 15, json.dumps(oauth_data))
url = (
@ -73,7 +82,10 @@ class TokenManager:
return None
oauth_data = json.loads(oauth_data_raw)
user_id = oauth_data["user_id"]
user_id = oauth_data.get("user_id")
purpose = oauth_data.get("purpose", "link")
next_url = oauth_data.get("next")
created = False
code_verifier = oauth_data["code_verifier"]
# Выполняем запрос к VK для обмена кода на токены
@ -103,6 +115,13 @@ class TokenManager:
})
vk_profile = reqp.json()['response'][0]
# Режим регистрации: находим или создаём пользователя ДО сохранения соцсетей,
# чтобы дальше socials/tokens сохранились уже с реальным user_id.
if purpose == "register" and not user_id:
user_id, created = await self._resolve_user_for_register(
vk_profile, token_data.get("email")
)
reqg = await client.post(f"{VK_API_URI}groups.get", data={
'access_token':access_token,
'extended':1,
@ -111,8 +130,11 @@ class TokenManager:
})
vk_groups = reqg.json()['response']['items']
urls = [vk_profile['photo_max_orig']] + [i['photo_200'] for i in vk_groups]
results = await asyncio.gather(*(fetch_bytes(client, u) for u in urls))
results = [await self.save_vk(i) for i in results]
image_bytes = await asyncio.gather(*(fetch_bytes(client, u) for u in urls))
# В режиме регистрации ставим аватар VK в профиль, если фото не задано вручную
if purpose == "register" and user_id and image_bytes:
await self._maybe_set_vk_avatar(user_id, created, image_bytes[0])
results = [await self.save_vk(i) for i in image_bytes]
socials = [{
'name': f"{vk_profile['last_name']} {vk_profile['first_name']}",
'screen_name': vk_profile['screen_name'],
@ -142,7 +164,12 @@ class TokenManager:
# Удаляем использованный state, чтобы его нельзя было переиспользовать.
await redis_client.delete(key)
return token_data
return {
"user_id": user_id,
"registered": created,
"purpose": purpose,
"next": next_url,
}
# Обработка ошибок HTTP (напр., неверный код, истёк скоп)
except httpx.HTTPStatusError as e:
@ -157,6 +184,73 @@ class TokenManager:
return None
async def _resolve_user_for_register(self, vk_profile: dict, email: str | None):
"""Находит пользователя по vk_id или email, иначе создаёт нового.
Возвращает кортеж (user_id: str, created: bool).
При совпадении по email — привязывает vk_id к найденному аккаунту.
"""
users = mongo_client["auth"]["users"]
vk_id = vk_profile.get("id")
# 1) Уже привязанный VK-аккаунт
existing = await users.find_one({"vk_id": vk_id}, {"_id": 1})
if existing:
return str(existing["_id"]), False
# 2) Существующий аккаунт с таким email — логиним и привязываем VK
if email:
existing = await users.find_one(
{"email": {"$regex": f"^{re.escape(email)}$", "$options": "i"}},
{"_id": 1},
)
if existing:
await users.update_one({"_id": existing["_id"]}, {"$set": {"vk_id": vk_id}})
return str(existing["_id"]), False
# 3) Создаём нового пользователя (роль по умолчанию — художник)
first = vk_profile.get("first_name", "") or ""
last = vk_profile.get("last_name", "") or ""
doc = {
"username": f"{first} {last}".strip(),
"female_name_lastname": f"{last} {first}".strip(),
"email": email,
"phone": None,
"img": "/static/painter.svg",
"roles": ["user"],
"extra_scopes": [],
"vk_id": vk_id,
"client_id": "vk",
"living_place": None,
"offer_accepted": False,
# Неиспользуемый случайный пароль: вход по email/паролю невозможен,
# но login-эндпоинт не падает на None.startswith(...).
"hashed_password": get_password_hash(secrets.token_urlsafe(32)),
}
res = await users.insert_one(doc)
return str(res.inserted_id), True
async def _maybe_set_vk_avatar(self, user_id: str, created: bool, avatar_bytes: bytes):
"""Ставит аватар из VK в профиль, если пользователь не задал своё фото.
«Не задан вручную» = img пустой или дефолтная заглушка (/static/...).
Для только что созданного пользователя ставим всегда.
"""
if not avatar_bytes:
return
users = mongo_client["auth"]["users"]
if not created:
u = await users.find_one({"_id": ObjectId(user_id)}, {"img": 1})
img = (u or {}).get("img")
if img and not str(img).startswith("/static/"):
return # у пользователя уже своё фото — не трогаем
try:
grid = gridfs_map["users"]
file_id = await grid.upload_from_stream(f"vk_{user_id}.jpg", io.BytesIO(avatar_bytes))
await users.update_one({"_id": ObjectId(user_id)}, {"$set": {"img": str(file_id)}})
except Exception as e:
logging.error(f"[TokenManager] VK avatar save failed: {e}")
# ====================== ШИФРОВАНИЕ ======================
async def _encrypt_token_fields(self, token_data: dict) -> dict:
"""Шифрует access_token и refresh_token."""

View File

@ -46,8 +46,13 @@
"\n",
"try:\n",
" load_env('env')\n",
"except:\n",
" load_env('env_example')"
"except Exception:\n",
" # env_example тоже может отсутствовать (в образ эти файлы не копируются) —\n",
" # тогда переменные берутся из окружения контейнера/k8s-секрета\n",
" try:\n",
" load_env('env_example')\n",
" except Exception:\n",
" pass"
],
"display_name": ".venv"
},
@ -78,7 +83,7 @@
"from celery import Celery\n",
"from bson import ObjectId\n",
"from fastapi import status\n",
"from fastapi import APIRouter,Depends\n",
"from fastapi import APIRouter, Depends, Request, Response\n",
"from fastapi.responses import RedirectResponse\n",
"from images import insert_logo\n"
],
@ -130,10 +135,29 @@
" return RedirectResponse(url=url, status_code=status.HTTP_303_SEE_OTHER)\n",
"\n",
"\n",
"@router.get(\"/register\")\n",
"async def vk_register_start(next: str = '/profile'):\n",
" \"\"\"Старт регистрации/входа через VK (без предварительной авторизации).\"\"\"\n",
" url = await token_manager.get_auth_url(purpose='register', next=next)\n",
" return RedirectResponse(url=url, status_code=status.HTTP_303_SEE_OTHER)\n",
"\n",
"\n",
"@router.get(\"/token\")\n",
"async def oauth_callback(code: str, state: str, device_id: str = None):\n",
" token_data = await token_manager.get_token(code=code, state=state, device_id=device_id)\n",
" return RedirectResponse(url='/profile/social', status_code=status.HTTP_303_SEE_OTHER)\n"
"async def oauth_callback(request: Request, response: Response, code: str, state: str, device_id: str = None):\n",
" result = await token_manager.get_token(code=code, state=state, device_id=device_id)\n",
" if not result or not result.get('user_id'):\n",
" return RedirectResponse(url='/login', status_code=status.HTTP_303_SEE_OTHER)\n",
"\n",
" # Регистрация/вход через VK — выдаём нашу сессию и редиректим на next\n",
" if result.get('purpose') == 'register':\n",
" await issue_session(result['user_id'], request, response, client_id='vk')\n",
" next_url = result.get('next') or '/profile'\n",
" if not is_safe_redirect(next_url):\n",
" next_url = '/profile'\n",
" return RedirectResponse(url=next_url, status_code=status.HTTP_303_SEE_OTHER, headers=response.headers)\n",
"\n",
" # Привязка соцсети к уже залогиненному пользователю\n",
" return RedirectResponse(url='/profile/social', status_code=status.HTTP_303_SEE_OTHER)"
],
"display_name": ".venv"
},
@ -175,7 +199,6 @@
"metadata": {},
"outputs": [],
"source": [
"# async def send_vk(item: dict, text: str, file_ids: list, doc: dict, sign_avatar: bool = True): TODO\n",
"@router.post(\"/send\")\n",
"async def send_vk(data:dict, request: Request):\n",
" auth = request.headers.get(\"Authorization\")\n",
@ -250,7 +273,6 @@
"metadata": {},
"outputs": [],
"source": [
"# async def edit_vk(item: dict, text: str, file_ids: list, doc: dict, sign_avatar: bool = True): TODO\n",
"@router.post(\"/edit\")\n",
"async def edit_vk(data:dict, request: Request):\n",
" auth = request.headers.get(\"Authorization\")\n",
@ -265,6 +287,7 @@
" doc = data['doc']\n",
" post_id = data['post_id']\n",
" sign_avatar = data['sign_avatar']\n",
"# async def edit_vk(item: dict, text: str, file_ids: list, doc: dict, sign_avatar: bool = True):\n",
" db_name = item.get('db')\n",
" if db_name == 'competitions':\n",
" social = await social_collection.find_one({'service': True, 'type':'vk'})\n",
@ -327,7 +350,6 @@
"metadata": {},
"outputs": [],
"source": [
"# TODO сделать вызов функции из @celery_app_vk.task( а не по url в router\n",
"@router.post(\"/delete\")\n",
"async def wall_delete(data: dict, request: Request):\n",
" # ---- auth ----\n",
@ -1614,7 +1636,7 @@
],
"metadata": {
"kernelspec": {
"display_name": ".venv (3.14.6.final.0)",
"display_name": ".venv",
"language": "python",
"name": "python3"
},

39
vk.py
View File

@ -31,8 +31,13 @@ def load_env(file_path):
try:
load_env('env')
except:
except Exception:
# env_example тоже может отсутствовать (в образ эти файлы не копируются) —
# тогда переменные берутся из окружения контейнера/k8s-секрета
try:
load_env('env_example')
except Exception:
pass
## Class
@ -48,7 +53,7 @@ from io import BytesIO
from celery import Celery
from bson import ObjectId
from fastapi import status
from fastapi import APIRouter,Depends
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import RedirectResponse
from images import insert_logo
@ -70,12 +75,30 @@ async def auth_redirect(
return RedirectResponse(url=url, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/token")
async def oauth_callback(code: str, state: str, device_id: str = None):
token_data = await token_manager.get_token(code=code, state=state, device_id=device_id)
return RedirectResponse(url='/profile/social', status_code=status.HTTP_303_SEE_OTHER)
@router.get("/register")
async def vk_register_start(next: str = '/profile'):
"""Старт регистрации/входа через VK (без предварительной авторизации)."""
url = await token_manager.get_auth_url(purpose='register', next=next)
return RedirectResponse(url=url, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/token")
async def oauth_callback(request: Request, response: Response, code: str, state: str, device_id: str = None):
result = await token_manager.get_token(code=code, state=state, device_id=device_id)
if not result or not result.get('user_id'):
return RedirectResponse(url='/login', status_code=status.HTTP_303_SEE_OTHER)
# Регистрация/вход через VK — выдаём нашу сессию и редиректим на next
# if result.get('purpose') == 'register':
# await issue_session(result['user_id'], request, response, client_id='vk')
# next_url = result.get('next') or '/profile'
# if not is_safe_redirect(next_url):
# next_url = '/profile'
# return RedirectResponse(url=next_url, status_code=status.HTTP_303_SEE_OTHER, headers=response.headers)
# Привязка соцсети к уже залогиненному пользователю
return RedirectResponse(url='/profile/social', status_code=status.HTTP_303_SEE_OTHER)
async def get_images(db_name: str, file_ids: list, sign_avatar: bool = True):
images = {}
photo_bytes_list= []
@ -99,7 +122,6 @@ async def get_images(db_name: str, file_ids: list, sign_avatar: bool = True):
return images
# async def send_vk(item: dict, text: str, file_ids: list, doc: dict, sign_avatar: bool = True): TODO
@router.post("/send")
async def send_vk(data:dict, request: Request):
auth = request.headers.get("Authorization")
@ -166,7 +188,6 @@ async def send_vk(data:dict, request: Request):
# async def edit_vk(item: dict, text: str, file_ids: list, doc: dict, sign_avatar: bool = True): TODO
@router.post("/edit")
async def edit_vk(data:dict, request: Request):
auth = request.headers.get("Authorization")
@ -181,6 +202,7 @@ async def edit_vk(data:dict, request: Request):
doc = data['doc']
post_id = data['post_id']
sign_avatar = data['sign_avatar']
# async def edit_vk(item: dict, text: str, file_ids: list, doc: dict, sign_avatar: bool = True):
db_name = item.get('db')
if db_name == 'competitions':
social = await social_collection.find_one({'service': True, 'type':'vk'})
@ -235,7 +257,6 @@ async def edit_vk(data:dict, request: Request):
# TODO сделать вызов функции из @celery_app_vk.task( а не по url в router
@router.post("/delete")
async def wall_delete(data: dict, request: Request):
# ---- auth ----