366 lines
13 KiB
Python
366 lines
13 KiB
Python
from fastapi import HTTPException, Request, Response, Security
|
|
from fastapi.security import SecurityScopes
|
|
import jwt
|
|
from typing import List, Any, Dict, Optional
|
|
from datetime import timedelta
|
|
from urllib.parse import urlencode
|
|
from bson import ObjectId
|
|
import httpx
|
|
from db import *
|
|
from utils import *
|
|
from config import *
|
|
import logging
|
|
import secrets
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
|
|
|
|
bearer_scheme = HTTPBearer(auto_error=False)
|
|
|
|
|
|
def get_token(
|
|
creds: HTTPAuthorizationCredentials = Security(bearer_scheme),
|
|
):
|
|
if not creds or creds.scheme.lower() != "bearer":
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
if creds.credentials != ATOKEN:
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
return creds.credentials
|
|
|
|
|
|
def is_browser_request(request: Request) -> bool:
|
|
accept = request.headers.get("accept", "")
|
|
return "text/html" in accept
|
|
|
|
|
|
async def token_create(user_id: str, url: str = '/profile'):
|
|
raw_token = secrets.token_urlsafe(48)
|
|
token_hash = hash_token(raw_token)
|
|
created_at = datetime.now(timezone.utc)
|
|
await users_token_collection.insert_one({
|
|
'token_hash': token_hash,
|
|
'url': url,
|
|
'user_id': user_id,
|
|
'created_at': created_at
|
|
})
|
|
return raw_token
|
|
|
|
|
|
def get_bearer_token(request: Request) -> Optional[str]:
|
|
auth = request.headers.get("Authorization")
|
|
if auth and auth.lower().startswith("bearer "):
|
|
parts = auth.split(" ", 1)
|
|
if len(parts) == 2:
|
|
return parts[1].strip()
|
|
return None
|
|
|
|
|
|
async def get_access_token(request: Request, response: Response) -> Optional[str]:
|
|
|
|
token = request.cookies.get(ACCESS_COOKIE)
|
|
if token:
|
|
t = token.strip()
|
|
if t:
|
|
return t
|
|
bearer = get_bearer_token(request)
|
|
if bearer:
|
|
return bearer
|
|
return None
|
|
|
|
|
|
def get_refresh_token(request: Request) -> Optional[str]:
|
|
token = request.cookies.get(REFRESH_COOKIE)
|
|
if token:
|
|
t = token.strip()
|
|
if t:
|
|
return t
|
|
return None
|
|
|
|
|
|
async def validate_refresh_and_rotate(refresh_token: str, response: Response, request: Request) -> Optional[Dict[str, str]]:
|
|
try:
|
|
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
|
|
if payload.get("type") != "refresh":
|
|
return None
|
|
|
|
user_id_str = payload.get("sub")
|
|
if not user_id_str:
|
|
return None
|
|
refresh_hash = hash_token(refresh_token)
|
|
stored = await redis_client.get(f"refresh:{refresh_hash}")
|
|
if not stored or stored != user_id_str:
|
|
return None
|
|
|
|
await redis_client.delete(f"refresh:{refresh_hash}")
|
|
|
|
user = await users_collection.find_one({"_id": ObjectId(user_id_str)},
|
|
{"hashed_password": 0})
|
|
if not user:
|
|
return None
|
|
|
|
scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", []))
|
|
client_id = payload.get("client_id")
|
|
client_ip = payload.get("ip")
|
|
|
|
access_exp = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
refresh_exp = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
|
|
|
new_access = create_jwt_token(
|
|
{"sub": user_id_str, "scopes": scopes, "client_id": client_id, "ip": client_ip},
|
|
access_exp
|
|
)
|
|
new_refresh = create_jwt_token(
|
|
{"sub": user_id_str, "type": "refresh", "client_id": client_id, "ip": client_ip},
|
|
refresh_exp
|
|
)
|
|
|
|
new_refresh_hash = hash_token(new_refresh)
|
|
await redis_client.setex(f"access:{new_access}", int(access_exp.total_seconds()), user_id_str)
|
|
await redis_client.setex(f"refresh:{new_refresh_hash}", int(refresh_exp.total_seconds()), user_id_str)
|
|
|
|
session_key = f"user_sessions:{user_id_str}"
|
|
await redis_client.sadd(session_key, f"access:{new_access}")
|
|
await redis_client.sadd(session_key, f"refresh:{new_refresh_hash}")
|
|
|
|
response.set_cookie(
|
|
key="access_token",
|
|
value=new_access,
|
|
httponly=True,
|
|
secure=PROD,
|
|
samesite="Strict",
|
|
max_age=int(access_exp.total_seconds())
|
|
)
|
|
response.set_cookie(
|
|
key="refresh_token",
|
|
value=new_refresh_hash,
|
|
httponly=True,
|
|
secure=PROD,
|
|
samesite="Strict",
|
|
max_age=int(refresh_exp.total_seconds())
|
|
)
|
|
user['scopes'] = scopes
|
|
user['id'] = user_id_str
|
|
user = bson_clear(user)
|
|
return await _build_user(user, request)
|
|
|
|
|
|
async def get_current_user(
|
|
request: Request,
|
|
response: Response,
|
|
security_scopes: SecurityScopes
|
|
) -> Dict[str, Any]:
|
|
token_old = request.cookies.get('token') or request.cookies.get('v2token')
|
|
access = await get_access_token(request, response)
|
|
if token_old and not access:
|
|
try:
|
|
headers = {'Authorization': f"Bearer {token_old}"}
|
|
async with httpx.AsyncClient(http2=True, verify=ctx) as client:
|
|
res = await client.get(f'{FULL_PATH_DOMAIN}/api/v1/auth/me', headers=headers)
|
|
email = res.json()['data']['email']
|
|
user = await users_collection.find_one({
|
|
"$or": [
|
|
{"email": email},
|
|
{"email": email.lower()}
|
|
]
|
|
})
|
|
except Exception as e:
|
|
logging.error(e)
|
|
raise HTTPException(status_code=307, headers={"Location": '/login'}, detail="User not found")
|
|
|
|
user_id_str = str(user["_id"])
|
|
scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", []))
|
|
for scope in security_scopes.scopes:
|
|
if scope not in scopes:
|
|
raise HTTPException(status_code=403, detail="Not enough permissions")
|
|
client_ip = request.client.host
|
|
|
|
access_exp_delta = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
refresh_exp_delta = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
|
|
|
access_token = create_jwt_token(
|
|
{"sub": user_id_str, "scopes": scopes, "client_id": 'old_token', "ip": client_ip},
|
|
access_exp_delta
|
|
)
|
|
refresh_token = create_jwt_token(
|
|
{"sub": user_id_str, "type": "refresh", "client_id": 'old_token', "ip": client_ip},
|
|
refresh_exp_delta
|
|
)
|
|
|
|
await redis_client.setex(f"access:{access_token}", int(access_exp_delta.total_seconds()), user_id_str)
|
|
await redis_client.setex(f"refresh:{hash_token(refresh_token)}", int(refresh_exp_delta.total_seconds()), user_id_str)
|
|
|
|
session_key = f"user_sessions:{user_id_str}"
|
|
await redis_client.sadd(session_key, f"access:{access_token}")
|
|
await redis_client.sadd(session_key, f"refresh:{hash_token(refresh_token)}")
|
|
|
|
response.set_cookie(
|
|
key="access_token",
|
|
value=access_token,
|
|
httponly=True,
|
|
secure=PROD,
|
|
samesite="Strict",
|
|
max_age=int(access_exp_delta.total_seconds())
|
|
)
|
|
response.set_cookie(
|
|
key="refresh_token",
|
|
value=refresh_token,
|
|
httponly=True,
|
|
secure=PROD,
|
|
samesite="Strict",
|
|
max_age=int(refresh_exp_delta.total_seconds())
|
|
)
|
|
if user:
|
|
user['scopes'] = scopes
|
|
user['id'] = user_id_str
|
|
user = bson_clear(user)
|
|
return await _build_user(user, request)
|
|
|
|
check, scopes = await validate_access_token(access, request, security_scopes)
|
|
if access and check:
|
|
return await get_user_details(access, scopes, response, request)
|
|
|
|
refresh = get_refresh_token(request)
|
|
|
|
if refresh:
|
|
rotated = await validate_refresh_and_rotate(refresh, response, request)
|
|
if rotated:
|
|
return rotated
|
|
|
|
if is_browser_request(request):
|
|
next_url = str(request.url)
|
|
login_url = "/login?" + urlencode({"next": next_url})
|
|
raise HTTPException(status_code=307, headers={"Location": login_url}, detail="Redirecting to login")
|
|
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
|
|
async def validate_access_token(access: str, request: Request, security_scopes: SecurityScopes) -> bool:
|
|
key = f"access:{access}"
|
|
user_id_str = await redis_client.get(key)
|
|
if user_id_str:
|
|
try:
|
|
payload = jwt.decode(access, SECRET_KEY, algorithms=[ALGORITHM])
|
|
if payload.get("sub") != user_id_str:
|
|
raise HTTPException(status_code=401, detail="Token mismatch")
|
|
# Validate scopes
|
|
token_scopes: List[str] = payload.get("scopes", [])
|
|
if request.cookies.get("admin_access_token"):
|
|
token_scopes.append('back')
|
|
for scope in security_scopes.scopes:
|
|
if scope not in token_scopes:
|
|
raise HTTPException(status_code=403, detail="Not enough permissions")
|
|
return True, token_scopes
|
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
|
return False, []
|
|
return False, []
|
|
|
|
|
|
async def get_user_details(access: str, scopes, response: Response, request: Request,) -> Dict[str, Any]:
|
|
key = f"access:{access}"
|
|
user_id_str = await redis_client.get(key)
|
|
if user_id_str:
|
|
user = await users_collection.find_one(
|
|
{"_id": ObjectId(user_id_str)},
|
|
{"hashed_password": 0}
|
|
)
|
|
if user:
|
|
if user.get("blocked"):
|
|
raise HTTPException(status_code=403, detail="Аккаунт заблокирован администратором")
|
|
user['scopes'] = scopes
|
|
user['id'] = user_id_str
|
|
user = bson_clear(user)
|
|
return await _build_user(user, request)
|
|
raise HTTPException(status_code=307, headers={"Location": '/login'}, detail="User not found")
|
|
|
|
|
|
async def _build_user(user, request: Request):
|
|
await notify_collection.update_one({'link':str(request.url).replace(str(request.base_url),'/')},{'$set':{'read':False}})
|
|
if (read_id:=request.cookies.get('read')):
|
|
print(read_id)
|
|
user['notify'] = await notify_collection.find({'user_id':user['id']}).limit(128).to_list()
|
|
return user
|
|
|
|
|
|
async def get_user(
|
|
request: Request,
|
|
response: Response,
|
|
) -> Optional[Dict[str, Any]]:
|
|
token = await get_access_token(request, response)
|
|
if not token:
|
|
return None
|
|
|
|
key = f"access:{token}"
|
|
user_id_str = await redis_client.get(key)
|
|
if not user_id_str:
|
|
return None
|
|
|
|
try:
|
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
if payload.get("sub") != user_id_str:
|
|
return None
|
|
|
|
user = await users_collection.find_one({"_id": ObjectId(user_id_str)}, {"hashed_password": 0})
|
|
if not user:
|
|
return None
|
|
if user.get("blocked"):
|
|
return None
|
|
|
|
scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", []))
|
|
if request.cookies.get("admin_access_token"):
|
|
scopes.append('back')
|
|
user['scopes'] = scopes
|
|
user['id'] = user_id_str
|
|
user = bson_clear(user)
|
|
return await _build_user(user, request)
|
|
|
|
except jwt.ExpiredSignatureError:
|
|
return None
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
|
|
|
|
async def issue_session(user_id: str, request: Request, response: Response,
|
|
client_id: str = 'web', scopes=None):
|
|
"""Выдаёт новую сессию: JWT access/refresh, запись в Redis и cookie.
|
|
|
|
Переиспользуемый хелпер (например, для входа/регистрации через VK).
|
|
Если scopes не переданы — вычисляются из ролей пользователя.
|
|
"""
|
|
if scopes is None:
|
|
user = await users_collection.find_one({"_id": ObjectId(user_id)})
|
|
scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", []))
|
|
|
|
client_ip = request.headers.get('X-Real-IP') or (request.client.host if request.client else None)
|
|
|
|
access_exp = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
refresh_exp = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
|
|
|
access_token = create_jwt_token(
|
|
{"sub": user_id, "scopes": scopes, "client_id": client_id, "ip": client_ip},
|
|
access_exp,
|
|
)
|
|
refresh_token = create_jwt_token(
|
|
{"sub": user_id, "type": "refresh", "client_id": client_id, "ip": client_ip},
|
|
refresh_exp,
|
|
)
|
|
|
|
await redis_client.setex(f"access:{access_token}", int(access_exp.total_seconds()), user_id)
|
|
await redis_client.setex(f"refresh:{hash_token(refresh_token)}", int(refresh_exp.total_seconds()), user_id)
|
|
|
|
session_key = f"user_sessions:{user_id}"
|
|
await redis_client.sadd(session_key, f"access:{access_token}")
|
|
await redis_client.sadd(session_key, f"refresh:{hash_token(refresh_token)}")
|
|
|
|
response.set_cookie(
|
|
key="access_token", value=access_token, httponly=True, secure=PROD,
|
|
samesite="Strict", max_age=int(access_exp.total_seconds()),
|
|
)
|
|
response.set_cookie(
|
|
key="refresh_token", value=refresh_token, httponly=True, secure=PROD,
|
|
samesite="Strict", max_age=int(refresh_exp.total_seconds()),
|
|
)
|
|
|
|
return access_token, refresh_token
|