mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Улучшение документации и KDF с шифрованием totp
This commit is contained in:
@@ -16,6 +16,10 @@ from .core import (
|
||||
RECOVERY_CODE_SEGMENT_BYTES,
|
||||
RECOVERY_MIN_REMAINING_WARNING,
|
||||
RECOVERY_MAX_AGE_DAYS,
|
||||
KeyDeriver,
|
||||
deriver,
|
||||
AES256Cipher,
|
||||
cipher,
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
@@ -75,6 +79,10 @@ __all__ = [
|
||||
"RECOVERY_CODE_SEGMENT_BYTES",
|
||||
"RECOVERY_MIN_REMAINING_WARNING",
|
||||
"RECOVERY_MAX_AGE_DAYS",
|
||||
"KeyDeriver",
|
||||
"deriver",
|
||||
"AES256Cipher",
|
||||
"cipher",
|
||||
"verify_password",
|
||||
"get_password_hash",
|
||||
"create_access_token",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Модуль основного функционала авторизации и аутентификации"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from uuid import uuid4
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from argon2.low_level import hash_secret_raw, Type
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError, ExpiredSignatureError
|
||||
@@ -17,7 +20,6 @@ from library_service.settings import get_session, get_logger
|
||||
|
||||
|
||||
# Конфигурация JWT из переменных окружения
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
||||
PARTIAL_TOKEN_EXPIRE_MINUTES = int(os.getenv("PARTIAL_TOKEN_EXPIRE_MINUTES", "5"))
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
|
||||
@@ -38,16 +40,76 @@ RECOVERY_CODE_SEGMENT_BYTES = int(os.getenv("RECOVERY_CODE_SEGMENT_BYTES", "2"))
|
||||
RECOVERY_MIN_REMAINING_WARNING = int(os.getenv("RECOVERY_MIN_REMAINING_WARNING", "3"))
|
||||
RECOVERY_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365"))
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
|
||||
# Получение логгера
|
||||
logger = get_logger()
|
||||
|
||||
# OAuth2 схема
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
|
||||
class KeyDeriver:
|
||||
def __init__(self, master_key: bytes):
|
||||
self.master_key = master_key
|
||||
|
||||
def derive(
|
||||
self,
|
||||
context: str,
|
||||
key_len: int = 32,
|
||||
time_cost: int = 12,
|
||||
memory_cost: int = 512 * 1024,
|
||||
parallelism: int = 4,
|
||||
) -> bytes:
|
||||
"""
|
||||
Формирование разных ключей из одного.
|
||||
context: любая строка, например "aes", "hmac", "totp"
|
||||
"""
|
||||
salt = hashlib.sha256(context.encode("utf-8")).digest()
|
||||
key = hash_secret_raw(
|
||||
secret=self.master_key,
|
||||
salt=salt,
|
||||
time_cost=time_cost,
|
||||
memory_cost=memory_cost,
|
||||
parallelism=parallelism,
|
||||
hash_len=key_len,
|
||||
type=Type.ID,
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
class AES256Cipher:
|
||||
def __init__(self, key: bytes):
|
||||
if len(key) != 32:
|
||||
raise ValueError("AES-256 требует ключ длиной 32 байта")
|
||||
self.key = key
|
||||
self.aesgcm = AESGCM(key)
|
||||
|
||||
def encrypt(self, plaintext: bytes, nonce_len: int = 12) -> bytes:
|
||||
"""Зашифровывает данные с помощью AES256-GCM"""
|
||||
nonce = os.urandom(nonce_len)
|
||||
ct = self.aesgcm.encrypt(nonce, plaintext, associated_data=None)
|
||||
return nonce + ct
|
||||
|
||||
def decrypt(self, data: bytes, nonce_len: int = 12) -> bytes:
|
||||
"""Расшифровывает данные с помощью AES256-GCM"""
|
||||
nonce = data[:nonce_len]
|
||||
ct = data[nonce_len:]
|
||||
return self.aesgcm.decrypt(nonce, ct, associated_data=None)
|
||||
|
||||
|
||||
# Проверка секретного ключа
|
||||
if not SECRET_KEY:
|
||||
raise RuntimeError("SECRET_KEY environment variable is required")
|
||||
|
||||
deriver = KeyDeriver(SECRET_KEY.encode())
|
||||
|
||||
jwt_key = deriver.derive("jwt", key_len=32)
|
||||
|
||||
aes_key = deriver.derive("totp", key_len=32)
|
||||
cipher = AES256Cipher(aes_key)
|
||||
|
||||
|
||||
# Хэширование паролей
|
||||
pwd_context = CryptContext(
|
||||
schemes=["argon2"],
|
||||
@@ -88,7 +150,7 @@ def _create_token(
|
||||
}
|
||||
if token_type == "refresh":
|
||||
to_encode.update({"jti": str(uuid4())})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return jwt.encode(to_encode, jwt_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_partial_token(data: dict) -> str:
|
||||
@@ -119,7 +181,7 @@ def decode_token(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
payload = jwt.decode(token, jwt_key, algorithms=[ALGORITHM])
|
||||
username: str | None = payload.get("sub")
|
||||
user_id: int | None = payload.get("user_id")
|
||||
token_type: str | None = payload.get("type")
|
||||
|
||||
@@ -20,7 +20,7 @@ class User(UserBase, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
hashed_password: str = Field(nullable=False)
|
||||
is_2fa_enabled: bool = Field(default=False)
|
||||
totp_secret: str | None = Field(default=None, max_length=64)
|
||||
totp_secret: str | None = Field(default=None, max_length=80)
|
||||
recovery_code_hashes: str | None = Field(default=None, max_length=1500)
|
||||
recovery_codes_generated_at: datetime | None = Field(default=None)
|
||||
is_active: bool = Field(default=True)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Модуль работы с авторизацией и аутентификацией пользователей"""
|
||||
|
||||
import base64
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
@@ -51,6 +53,7 @@ from library_service.auth import (
|
||||
create_partial_token,
|
||||
RequirePartialAuth,
|
||||
verify_and_use_code,
|
||||
cipher,
|
||||
)
|
||||
|
||||
|
||||
@@ -250,9 +253,19 @@ def update_user_me(
|
||||
summary="Создание QR-кода TOTP 2FA",
|
||||
description="Генерирует секрет и QR-код для настройки TOTP",
|
||||
)
|
||||
def get_totp_qr_bitmap(auth: RequireAuth):
|
||||
def get_totp_qr_bitmap(
|
||||
current_user: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Возвращает данные для настройки TOTP"""
|
||||
return TOTPSetupResponse(**generate_totp_setup(auth.username))
|
||||
totp_data = generate_totp_setup(current_user.username)
|
||||
encrypted = cipher.encrypt(totp_data["secret"].encode())
|
||||
|
||||
current_user.totp_secret = base64.b64encode(encrypted).decode()
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
|
||||
return TOTPSetupResponse(**totp_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -273,13 +286,23 @@ def enable_2fa(
|
||||
detail="2FA already enabled",
|
||||
)
|
||||
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Secret key not generated"
|
||||
)
|
||||
|
||||
if not verify_totp_code(secret, data.code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid TOTP code",
|
||||
)
|
||||
|
||||
current_user.totp_secret = secret
|
||||
decrypted = cipher.decrypt(base64.b64decode(current_user.totp_secret.encode()))
|
||||
if secret != decrypted.decode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Incorret secret"
|
||||
)
|
||||
|
||||
current_user.is_2fa_enabled = True
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
@@ -339,7 +362,8 @@ def verify_2fa(
|
||||
verified = False
|
||||
|
||||
if data.code and user.totp_secret:
|
||||
if verify_totp_code(user.totp_secret, data.code):
|
||||
decrypted = cipher.decrypt(base64.b64decode(user.totp_secret.encode()))
|
||||
if verify_totp_code(decrypted.decode(), data.code):
|
||||
verified = True
|
||||
|
||||
if not verified:
|
||||
|
||||
@@ -21,9 +21,10 @@ from library_service.services.captcha import (
|
||||
router = APIRouter(prefix="/cap", tags=["captcha"])
|
||||
|
||||
|
||||
@router.post("/challenge")
|
||||
@router.post("/challenge", summary="Задача capjs")
|
||||
@limiter.limit("15/minute")
|
||||
async def challenge(request: Request, ip: str = Depends(get_ip)):
|
||||
"""Возвращает задачу capjs"""
|
||||
if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges"
|
||||
@@ -50,9 +51,10 @@ async def challenge(request: Request, ip: str = Depends(get_ip)):
|
||||
return {"challenge": {"c": 50, "s": 32, "d": 4}, "token": token, "expires": expires}
|
||||
|
||||
|
||||
@router.post("/redeem")
|
||||
@router.post("/redeem", summary="Проверка задачи")
|
||||
@limiter.limit("30/minute")
|
||||
async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
|
||||
"""Возвращает capjs_token"""
|
||||
token = payload.get("token")
|
||||
solutions = payload.get("solutions", [])
|
||||
|
||||
|
||||
@@ -441,7 +441,7 @@ $(document).ready(() => {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await Api.get("/api/auth/users?skip=0&limit=500");
|
||||
const data = await Api.get("/api/users?skip=0&limit=500");
|
||||
cachedUsers = data.users;
|
||||
renderUsersList(cachedUsers);
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: 20px;
|
||||
}
|
||||
li {
|
||||
margin: 15px 0;
|
||||
@@ -40,6 +44,46 @@
|
||||
p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
#erDiagram {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-top: 30px;
|
||||
background: #fafafa;
|
||||
overflow: hidden;
|
||||
}
|
||||
.er-table {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
background: #fff;
|
||||
border: 1px solid #3498db;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
font-size: 13px;
|
||||
}
|
||||
.er-table-header {
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.er-table-body {
|
||||
padding: 6px 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.er-field {
|
||||
padding: 2px 0;
|
||||
position: relative;
|
||||
}
|
||||
.relation-label {
|
||||
font-size: 11px;
|
||||
background: #fff;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -51,11 +95,234 @@
|
||||
<p>Status: {{ status }}</p>
|
||||
<ul>
|
||||
<li><a href="/">Home page</a></li>
|
||||
<li><a href="/docs">Swagger UI</a></li>
|
||||
<li><a href="/redoc">ReDoc</a></li>
|
||||
<li>
|
||||
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
|
||||
</li>
|
||||
<li><a href="/docs">Swagger UI</a></li>
|
||||
<li><a href="/redoc">ReDoc</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>ER Diagram</h2>
|
||||
<div id="erDiagram"></div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
|
||||
<script>
|
||||
const diagramData = {
|
||||
entities: [
|
||||
{
|
||||
id: "User",
|
||||
title: "users",
|
||||
fields: [
|
||||
{ id: "id", label: "id (PK)" },
|
||||
{ id: "username", label: "username" },
|
||||
{ id: "email", label: "email" },
|
||||
{ id: "full_name", label: "full_name" },
|
||||
{ id: "is_active", label: "is_active" },
|
||||
{ id: "is_verified", label: "is_verified" },
|
||||
{ id: "is_2fa_enabled", label: "is_2fa_enabled" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "Role",
|
||||
title: "roles",
|
||||
fields: [
|
||||
{ id: "id", label: "id (PK)" },
|
||||
{ id: "name", label: "name" },
|
||||
{ id: "description", label: "description" },
|
||||
{ id: "payroll", label: "payroll" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "UserRole",
|
||||
title: "user_roles",
|
||||
fields: [
|
||||
{ id: "user_id", label: "user_id (FK)" },
|
||||
{ id: "role_id", label: "role_id (FK)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "Author",
|
||||
title: "authors",
|
||||
fields: [
|
||||
{ id: "id", label: "id (PK)" },
|
||||
{ id: "name", label: "name" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "Book",
|
||||
title: "books",
|
||||
fields: [
|
||||
{ id: "id", label: "id (PK)" },
|
||||
{ id: "title", label: "title" },
|
||||
{ id: "description", label: "description" },
|
||||
{ id: "page_count", label: "page_count" },
|
||||
{ id: "status", label: "status" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "Genre",
|
||||
title: "genres",
|
||||
fields: [
|
||||
{ id: "id", label: "id (PK)" },
|
||||
{ id: "name", label: "name" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "Loan",
|
||||
title: "loans",
|
||||
fields: [
|
||||
{ id: "id", label: "id (PK)" },
|
||||
{ id: "book_id", label: "book_id (FK)" },
|
||||
{ id: "user_id", label: "user_id (FK)" },
|
||||
{ id: "borrowed_at", label: "borrowed_at" },
|
||||
{ id: "due_date", label: "due_date" },
|
||||
{ id: "returned_at", label: "returned_at" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "AuthorBook",
|
||||
title: "authors_books",
|
||||
fields: [
|
||||
{ id: "author_id", label: "author_id (FK)" },
|
||||
{ id: "book_id", label: "book_id (FK)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "GenreBook",
|
||||
title: "genres_books",
|
||||
fields: [
|
||||
{ id: "genre_id", label: "genre_id (FK)" },
|
||||
{ id: "book_id", label: "book_id (FK)" }
|
||||
]
|
||||
}
|
||||
],
|
||||
relations: [
|
||||
{
|
||||
fromEntity: "Loan",
|
||||
fromField: "book_id",
|
||||
toEntity: "Book",
|
||||
toField: "id",
|
||||
fromMultiplicity: "N",
|
||||
toMultiplicity: "1"
|
||||
},
|
||||
{
|
||||
fromEntity: "Loan",
|
||||
fromField: "user_id",
|
||||
toEntity: "User",
|
||||
toField: "id",
|
||||
fromMultiplicity: "N",
|
||||
toMultiplicity: "1"
|
||||
},
|
||||
{
|
||||
fromEntity: "AuthorBook",
|
||||
fromField: "author_id",
|
||||
toEntity: "Author",
|
||||
toField: "id",
|
||||
fromMultiplicity: "N",
|
||||
toMultiplicity: "1"
|
||||
},
|
||||
{
|
||||
fromEntity: "AuthorBook",
|
||||
fromField: "book_id",
|
||||
toEntity: "Book",
|
||||
toField: "id",
|
||||
fromMultiplicity: "N",
|
||||
toMultiplicity: "1"
|
||||
},
|
||||
{
|
||||
fromEntity: "GenreBook",
|
||||
fromField: "genre_id",
|
||||
toEntity: "Genre",
|
||||
toField: "id",
|
||||
fromMultiplicity: "N",
|
||||
toMultiplicity: "1"
|
||||
},
|
||||
{
|
||||
fromEntity: "GenreBook",
|
||||
fromField: "book_id",
|
||||
toEntity: "Book",
|
||||
toField: "id",
|
||||
fromMultiplicity: "N",
|
||||
toMultiplicity: "1"
|
||||
},
|
||||
{
|
||||
fromEntity: "UserRole",
|
||||
fromField: "user_id",
|
||||
toEntity: "User",
|
||||
toField: "id",
|
||||
fromMultiplicity: "N",
|
||||
toMultiplicity: "1"
|
||||
},
|
||||
{
|
||||
fromEntity: "UserRole",
|
||||
fromField: "role_id",
|
||||
toEntity: "Role",
|
||||
toField: "id",
|
||||
fromMultiplicity: "N",
|
||||
toMultiplicity: "1"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
jsPlumb.ready(function () {
|
||||
jsPlumb.setContainer("erDiagram");
|
||||
|
||||
const container = document.getElementById("erDiagram");
|
||||
const baseLeft = 40;
|
||||
const baseTop = 80;
|
||||
const spacingX = 240;
|
||||
|
||||
diagramData.entities.forEach((entity, index) => {
|
||||
const table = document.createElement("div");
|
||||
table.className = "er-table";
|
||||
table.id = "table-" + entity.id;
|
||||
table.style.top = baseTop + "px";
|
||||
table.style.left = baseLeft + index * spacingX + "px";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "er-table-header";
|
||||
header.textContent = entity.title || entity.id;
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "er-table-body";
|
||||
|
||||
entity.fields.forEach(field => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "er-field";
|
||||
row.id = "field-" + entity.id + "-" + field.id;
|
||||
row.textContent = field.label || field.id;
|
||||
body.appendChild(row);
|
||||
});
|
||||
|
||||
table.appendChild(header);
|
||||
table.appendChild(body);
|
||||
container.appendChild(table);
|
||||
});
|
||||
|
||||
const common = {
|
||||
endpoint: "Dot",
|
||||
endpointStyle: { radius: 4, fill: "#3498db" },
|
||||
connector: ["Flowchart", { cornerRadius: 5 }],
|
||||
paintStyle: { stroke: "#3498db", strokeWidth: 2 },
|
||||
hoverPaintStyle: { stroke: "#2980b9", strokeWidth: 2 },
|
||||
anchor: ["Continuous", { faces: ["left", "right"] }]
|
||||
};
|
||||
|
||||
const tableIds = diagramData.entities.map(e => "table-" + e.id);
|
||||
jsPlumb.draggable(tableIds, { containment: "parent" });
|
||||
|
||||
diagramData.relations.forEach(rel => {
|
||||
jsPlumb.connect({
|
||||
source: "field-" + rel.fromEntity + "-" + rel.fromField,
|
||||
target: "field-" + rel.toEntity + "-" + rel.toField,
|
||||
overlays: [
|
||||
["Label", { label: rel.fromMultiplicity || "", location: 0.2, cssClass: "relation-label" }],
|
||||
["Label", { label: rel.toMultiplicity || "", location: 0.8, cssClass: "relation-label" }]
|
||||
],
|
||||
...common
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user