214 lines
9.4 KiB
Python
214 lines
9.4 KiB
Python
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import secrets
|
|
import time
|
|
from datetime import timedelta, timezone
|
|
from urllib.parse import urlsplit
|
|
|
|
from fastapi import Depends, HTTPException, Request
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import Settings, get_settings
|
|
from app.models import SystemUser, UserAction, utcnow
|
|
|
|
SESSION_COOKIE = "miem_admin_session"
|
|
LOGIN_CSRF_COOKIE = "miem_login_csrf"
|
|
SESSION_SECONDS = 12 * 60 * 60
|
|
PASSWORD_ITERATIONS = 600_000
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
salt = secrets.token_bytes(16)
|
|
digest = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, PASSWORD_ITERATIONS)
|
|
return f"pbkdf2_sha256${PASSWORD_ITERATIONS}${salt.hex()}${digest.hex()}"
|
|
|
|
|
|
def verify_password(password: str, encoded: str) -> bool:
|
|
try:
|
|
scheme, iterations, salt, expected = encoded.split('$')
|
|
if scheme != 'pbkdf2_sha256' or int(iterations) != PASSWORD_ITERATIONS:
|
|
return False
|
|
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(salt), PASSWORD_ITERATIONS)
|
|
return hmac.compare_digest(actual, bytes.fromhex(expected))
|
|
except (ValueError, TypeError, AttributeError):
|
|
return False
|
|
|
|
|
|
_DUMMY_HASH = hash_password('dummy password for unknown account timing')
|
|
|
|
|
|
def temporary_password() -> str:
|
|
groups = ('ABCDEFGHJKLMNPQRSTUVWXYZ', 'abcdefghijkmnpqrstuvwxyz', '23456789', '!@#$%+-_')
|
|
characters = [secrets.choice(group) for group in groups]
|
|
characters.extend(secrets.choice(''.join(groups)) for _ in range(4))
|
|
secrets.SystemRandom().shuffle(characters)
|
|
return ''.join(characters)
|
|
|
|
|
|
def ensure_bootstrap_admin(db: Session, settings: Settings) -> SystemUser:
|
|
username = settings.admin_username.strip().casefold()
|
|
user = db.scalar(select(SystemUser).where(SystemUser.username == username))
|
|
if user is not None:
|
|
return user
|
|
user = SystemUser(username=username, role='admin', password_hash=hash_password(settings.admin_password),
|
|
must_change_password=False)
|
|
db.add(user)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
user = db.scalar(select(SystemUser).where(SystemUser.username == username))
|
|
return user
|
|
|
|
|
|
def sign_session(username: str, settings: Settings, version: int = 0) -> str:
|
|
payload = base64.urlsafe_b64encode(json.dumps({'sub': username, 'iat': int(time.time()),
|
|
'v': version, 'csrf': secrets.token_urlsafe(24)}, separators=(',', ':')).encode()).decode().rstrip('=')
|
|
signature = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
|
return f'{payload}.{signature}'
|
|
|
|
|
|
def _session_data(token: str | None, settings: Settings) -> dict | None:
|
|
if not token or len(token) > 2048 or '.' not in token:
|
|
return None
|
|
payload, signature = token.rsplit('.', 1)
|
|
expected = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
|
if not hmac.compare_digest(signature.encode(), expected.encode()):
|
|
return None
|
|
try:
|
|
data = json.loads(base64.urlsafe_b64decode((payload + '=' * (-len(payload) % 4)).encode()))
|
|
now = int(time.time())
|
|
if (not isinstance(data, dict) or not isinstance(data.get('sub'), str)
|
|
or type(data.get('iat')) is not int or not now - SESSION_SECONDS <= data['iat'] <= now + 60
|
|
or type(data.get('v')) is not int or not isinstance(data.get('csrf'), str)):
|
|
return None
|
|
return data
|
|
except (ValueError, TypeError, UnicodeError):
|
|
return None
|
|
|
|
|
|
def read_session(token: str | None, settings: Settings) -> str | None:
|
|
data = _session_data(token, settings)
|
|
return data['sub'] if data else None
|
|
|
|
|
|
def session_csrf(request: Request, settings: Settings) -> str:
|
|
data = _session_data(request.cookies.get(SESSION_COOKIE), settings)
|
|
return data['csrf'] if data else ''
|
|
|
|
|
|
async def check_csrf(request: Request, settings: Settings = Depends(get_settings)):
|
|
if request.method != 'POST':
|
|
return
|
|
origin = request.headers.get('origin')
|
|
if origin:
|
|
try:
|
|
parsed = urlsplit(origin)
|
|
except ValueError:
|
|
raise HTTPException(403, 'Недопустимый источник запроса')
|
|
if parsed.scheme not in {'http', 'https'} or parsed.netloc.casefold() != request.url.netloc.casefold():
|
|
raise HTTPException(403, 'Запрос должен исходить со страницы этой системы')
|
|
# JSON with a custom header cannot be submitted by a cross-site HTML form; CORS stays disabled.
|
|
if request.headers.get('X-Council-Edit') == '1' and request.headers.get('content-type', '').startswith('application/json'):
|
|
return
|
|
expected = (request.cookies.get(LOGIN_CSRF_COOKIE, '') if request.url.path == '/admin/login'
|
|
else session_csrf(request, settings))
|
|
if request.url.path == '/admin/logout' and not expected:
|
|
return
|
|
supplied = request.headers.get('X-CSRF-Token', '')
|
|
if not supplied:
|
|
form = await request.form()
|
|
supplied = form.get('csrf_token', '')
|
|
if not expected or not isinstance(supplied, str) or not hmac.compare_digest(supplied.encode(), expected.encode()):
|
|
raise HTTPException(403, 'Обновите страницу и повторите действие')
|
|
|
|
|
|
def optional_user(request: Request, settings: Settings, db: Session, *, allow_password_change=False, lock=False) -> SystemUser | None:
|
|
data = _session_data(request.cookies.get(SESSION_COOKIE), settings)
|
|
if not data:
|
|
return None
|
|
query = select(SystemUser).where(SystemUser.username == data['sub'].casefold())
|
|
if lock:
|
|
query = query.with_for_update().execution_options(populate_existing=True)
|
|
try:
|
|
user = db.scalar(query)
|
|
if user is None and data['sub'].casefold() == settings.admin_username.strip().casefold():
|
|
user = ensure_bootstrap_admin(db, settings)
|
|
if (user and user.is_active and user.session_version == data['v'] and not user.must_change_password
|
|
and request.method == 'GET' and request.url.path.startswith('/admin')):
|
|
request.state.guide_pending = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id,
|
|
UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1)) == 'guide_start'
|
|
except SQLAlchemyError:
|
|
db.rollback()
|
|
raise HTTPException(503, 'Не удалось проверить учётную запись. Попробуйте ещё раз')
|
|
if user is None or not user.is_active or user.session_version != data['v']:
|
|
return None
|
|
request.state.user = user
|
|
request.state.csrf_token = data['csrf']
|
|
if user.must_change_password and not allow_password_change:
|
|
if request.url.path.startswith('/api/') or (request.method == 'POST' and request.url.path == '/admin/council/publications'):
|
|
raise HTTPException(403, 'Сначала смените временный пароль в личном кабинете')
|
|
raise HTTPException(303, headers={'Location': '/admin/account'})
|
|
return user
|
|
|
|
|
|
def require_user(request: Request, settings: Settings, db: Session, *, allow_password_change=False, lock=False) -> SystemUser:
|
|
user = optional_user(request, settings, db, allow_password_change=allow_password_change, lock=lock)
|
|
if user is None:
|
|
if request.url.path.startswith('/api/') or (request.method == 'POST' and request.url.path == '/admin/council/publications'):
|
|
raise HTTPException(401, 'Для этого действия войдите в систему')
|
|
raise HTTPException(303, headers={'Location': '/admin/login'})
|
|
return user
|
|
|
|
|
|
USER_ROLES = {
|
|
'user': 'Пользователь',
|
|
'council': 'Пользователь диссовета',
|
|
'admin': 'Администратор',
|
|
}
|
|
|
|
|
|
def can_use_council(user: SystemUser | None) -> bool:
|
|
return user is not None and user.role in {'admin', 'council'}
|
|
|
|
|
|
def require_admin(request: Request, settings: Settings, db: Session) -> SystemUser:
|
|
user = require_user(request, settings, db)
|
|
if user.role != 'admin':
|
|
raise HTTPException(403, 'Это действие доступно только администратору')
|
|
return user
|
|
|
|
|
|
def authenticate(db: Session, username: str, password: str, settings: Settings) -> SystemUser | None:
|
|
ensure_bootstrap_admin(db, settings)
|
|
user = db.scalar(select(SystemUser).where(SystemUser.username == username.strip().casefold()).with_for_update())
|
|
valid = verify_password(password, user.password_hash if user else _DUMMY_HASH)
|
|
if user is None:
|
|
return None
|
|
now = utcnow()
|
|
locked_until = user.locked_until
|
|
if locked_until and locked_until.tzinfo is None:
|
|
locked_until = locked_until.replace(tzinfo=timezone.utc)
|
|
if not user.is_active or (locked_until and locked_until > now):
|
|
return None
|
|
if not valid:
|
|
if locked_until:
|
|
user.locked_until = None
|
|
user.failed_login_attempts = 0
|
|
user.failed_login_attempts += 1
|
|
if user.failed_login_attempts >= 5:
|
|
user.locked_until = now + timedelta(minutes=15)
|
|
db.commit()
|
|
return None
|
|
user.failed_login_attempts = 0
|
|
user.locked_until = None
|
|
if user.first_login_at is None:
|
|
user.first_login_at = now
|
|
user.must_change_password = True
|
|
user.last_login_at = now
|
|
return user
|