Skip to content

Commit 3679a53

Browse files
committed
feat: детализировать трейсинг auth, jwt и argon2
Что сделано: - добавлены спаны на Argon2 hash/verify для точного замера криптографии - добавлены спаны на создание и декодирование JWT - добавлены спаны на парсинг refresh и выдачу token pair в auth-роуте - в structlog добавлены trace_id и span_id для связи логов с Jaeger
1 parent 5e6ca2a commit 3679a53

4 files changed

Lines changed: 135 additions & 104 deletions

File tree

app/logging_config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
import logging
22

33
import structlog
4+
from opentelemetry import trace
5+
6+
7+
def _add_trace_context(
8+
_logger: logging.Logger,
9+
_method_name: str,
10+
event_dict: dict,
11+
) -> dict:
12+
span = trace.get_current_span()
13+
span_context = span.get_span_context()
14+
if span_context.is_valid:
15+
event_dict["trace_id"] = f"{span_context.trace_id:032x}"
16+
event_dict["span_id"] = f"{span_context.span_id:016x}"
17+
return event_dict
418

519

620
def setup_logging() -> None:
@@ -15,6 +29,7 @@ def setup_logging() -> None:
1529
processors=[
1630
structlog.stdlib.add_log_level,
1731
structlog.processors.TimeStamper(fmt="iso"),
32+
_add_trace_context,
1833
structlog.processors.JSONRenderer(),
1934
],
2035
wrapper_class=structlog.stdlib.BoundLogger,

app/routes/auth.py

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from fastapi import APIRouter, Depends, HTTPException, Response, status
44
from fastapi.security import OAuth2PasswordRequestForm
5+
from opentelemetry import trace
56
from redis.asyncio import Redis
67
from sqlalchemy.ext.asyncio import AsyncSession
78

@@ -28,6 +29,7 @@
2829
)
2930

3031
router = APIRouter(prefix="/auth", tags=["auth"])
32+
tracer = trace.get_tracer(__name__)
3133

3234

3335
def _refresh_ttl_seconds() -> int:
@@ -42,42 +44,44 @@ def _refresh_key(user_id: UUID, jti: str) -> str:
4244

4345
def _parse_refresh_token_or_401(refresh_token: str) -> tuple[UUID, str]:
4446
"""Декодирует refresh-токен и возвращает (user_id, jti). Поднимает 401 при ошибке."""
45-
try:
46-
token_data = decode_refresh_token(refresh_token)
47-
except (TokenExpired, TokenInvalid):
48-
raise HTTPException(
49-
status_code=status.HTTP_401_UNAUTHORIZED,
50-
detail="Недействительный или истёкший refresh токен",
51-
)
52-
53-
try:
54-
user_id = UUID(token_data.sub)
55-
except (ValueError, TypeError):
56-
raise HTTPException(
57-
status_code=status.HTTP_401_UNAUTHORIZED,
58-
detail="Некорректный идентификатор пользователя в refresh токене",
59-
)
60-
61-
jti = str(token_data.payload["jti"])
62-
return user_id, jti
47+
with tracer.start_as_current_span("auth.parse_refresh_token"):
48+
try:
49+
token_data = decode_refresh_token(refresh_token)
50+
except (TokenExpired, TokenInvalid):
51+
raise HTTPException(
52+
status_code=status.HTTP_401_UNAUTHORIZED,
53+
detail="Недействительный или истёкший refresh токен",
54+
)
55+
56+
try:
57+
user_id = UUID(token_data.sub)
58+
except (ValueError, TypeError):
59+
raise HTTPException(
60+
status_code=status.HTTP_401_UNAUTHORIZED,
61+
detail="Некорректный идентификатор пользователя в refresh токене",
62+
)
63+
64+
jti = str(token_data.payload["jti"])
65+
return user_id, jti
6366

6467

6568
async def _issue_token_pair(user_id: UUID, redis: Redis) -> TokenPair:
6669
"""Создаёт новую пару токенов и сохраняет jti refresh-токена в Redis."""
67-
access_token = create_access_token(subject=str(user_id))
68-
refresh_token, jti = create_refresh_token(subject=str(user_id))
69-
70-
await redis.set(
71-
_refresh_key(user_id, jti),
72-
str(user_id),
73-
ex=_refresh_ttl_seconds(),
74-
)
75-
76-
return TokenPair(
77-
access_token=access_token,
78-
refresh_token=refresh_token,
79-
token_type="bearer",
80-
)
70+
with tracer.start_as_current_span("auth.issue_token_pair"):
71+
access_token = create_access_token(subject=str(user_id))
72+
refresh_token, jti = create_refresh_token(subject=str(user_id))
73+
74+
await redis.set(
75+
_refresh_key(user_id, jti),
76+
str(user_id),
77+
ex=_refresh_ttl_seconds(),
78+
)
79+
80+
return TokenPair(
81+
access_token=access_token,
82+
refresh_token=refresh_token,
83+
token_type="bearer",
84+
)
8185

8286

8387
@router.post(

app/security/jwt.py

Lines changed: 65 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
from uuid import uuid4
99

1010
import jwt
11+
from opentelemetry import trace
1112
from jwt import ExpiredSignatureError, InvalidTokenError
1213

1314
from app.config import settings
1415

16+
tracer = trace.get_tracer(__name__)
17+
1518

1619
def create_access_token(
1720
*,
@@ -20,24 +23,25 @@ def create_access_token(
2023
expires_delta: timedelta | None = None,
2124
) -> str:
2225
"""Создаёт подписанный access-токен с TTL из настроек (или expires_delta)."""
23-
now = datetime.now(timezone.utc)
24-
expire = now + (
25-
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
26-
)
27-
payload: dict[str, Any] = {
28-
"sub": subject,
29-
"iat": int(now.timestamp()),
30-
"exp": int(expire.timestamp()),
31-
"type": "access",
32-
}
33-
if extra_claims:
34-
payload.update(extra_claims)
35-
36-
return jwt.encode(
37-
payload=payload,
38-
key=settings.SECRET_KEY,
39-
algorithm=settings.ALGORITHM,
40-
)
26+
with tracer.start_as_current_span("security.jwt.create_access_token"):
27+
now = datetime.now(timezone.utc)
28+
expire = now + (
29+
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
30+
)
31+
payload: dict[str, Any] = {
32+
"sub": subject,
33+
"iat": int(now.timestamp()),
34+
"exp": int(expire.timestamp()),
35+
"type": "access",
36+
}
37+
if extra_claims:
38+
payload.update(extra_claims)
39+
40+
return jwt.encode(
41+
payload=payload,
42+
key=settings.SECRET_KEY,
43+
algorithm=settings.ALGORITHM,
44+
)
4145

4246

4347
def create_refresh_token(
@@ -46,24 +50,27 @@ def create_refresh_token(
4650
expires_delta: timedelta | None = None,
4751
) -> tuple[str, str]:
4852
"""Создаёт refresh-токен с уникальным jti. Возвращает (token, jti)."""
49-
now = datetime.now(timezone.utc)
50-
expire = now + (expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS))
51-
jti = uuid4().hex
52-
53-
payload: dict[str, Any] = {
54-
"sub": subject,
55-
"iat": int(now.timestamp()),
56-
"exp": int(expire.timestamp()),
57-
"type": "refresh",
58-
"jti": jti,
59-
}
60-
61-
token = jwt.encode(
62-
payload=payload,
63-
key=settings.SECRET_KEY,
64-
algorithm=settings.ALGORITHM,
65-
)
66-
return token, jti
53+
with tracer.start_as_current_span("security.jwt.create_refresh_token"):
54+
now = datetime.now(timezone.utc)
55+
expire = now + (
56+
expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
57+
)
58+
jti = uuid4().hex
59+
60+
payload: dict[str, Any] = {
61+
"sub": subject,
62+
"iat": int(now.timestamp()),
63+
"exp": int(expire.timestamp()),
64+
"type": "refresh",
65+
"jti": jti,
66+
}
67+
68+
token = jwt.encode(
69+
payload=payload,
70+
key=settings.SECRET_KEY,
71+
algorithm=settings.ALGORITHM,
72+
)
73+
return token, jti
6774

6875

6976
class TokenExpired(Exception):
@@ -88,27 +95,28 @@ class TokenData:
8895

8996
def _decode_token(token: str) -> dict[str, Any]:
9097
"""Общая декодировка и базовая валидация JWT."""
91-
try:
92-
payload = jwt.decode(
93-
jwt=token,
94-
key=settings.SECRET_KEY,
95-
algorithms=[settings.ALGORITHM],
96-
options={"require": ["sub", "exp", "type"]},
97-
)
98-
except ExpiredSignatureError as e:
99-
raise TokenExpired("Токен истёк") from e
100-
except InvalidTokenError as e:
101-
raise TokenInvalid("Неверный токен/Ошибка валидации токена") from e
102-
103-
sub = payload.get("sub")
104-
if not isinstance(sub, str) or not sub:
105-
raise TokenInvalid("Ошибка субъекта в токене")
106-
107-
token_type = payload.get("type")
108-
if token_type not in {"access", "refresh"}:
109-
raise TokenInvalid("Некорректный тип токена")
110-
111-
return payload
98+
with tracer.start_as_current_span("security.jwt.decode_token"):
99+
try:
100+
payload = jwt.decode(
101+
jwt=token,
102+
key=settings.SECRET_KEY,
103+
algorithms=[settings.ALGORITHM],
104+
options={"require": ["sub", "exp", "type"]},
105+
)
106+
except ExpiredSignatureError as e:
107+
raise TokenExpired("Токен истёк") from e
108+
except InvalidTokenError as e:
109+
raise TokenInvalid("Неверный токен/Ошибка валидации токена") from e
110+
111+
sub = payload.get("sub")
112+
if not isinstance(sub, str) or not sub:
113+
raise TokenInvalid("Ошибка субъекта в токене")
114+
115+
token_type = payload.get("type")
116+
if token_type not in {"access", "refresh"}:
117+
raise TokenInvalid("Некорректный тип токена")
118+
119+
return payload
112120

113121

114122
def decode_access_token(token: str) -> TokenData:

app/security/password.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import logging
1010

11+
from opentelemetry import trace
1112
from argon2 import PasswordHasher
1213
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
1314
from argon2.low_level import Type
@@ -16,6 +17,7 @@
1617
from app.config import settings
1718

1819
log = logging.getLogger(__name__)
20+
tracer = trace.get_tracer(__name__)
1921

2022
pwd_hasher = PasswordHasher(
2123
time_cost=settings.ARGON_TIME_COST,
@@ -38,21 +40,23 @@ def get_dummy_hash() -> str:
3840

3941

4042
async def hash_password(*, password: str) -> str:
41-
if len(password) > settings.ARGON_MAX_PASSWORD_LEN:
42-
raise ValueError("Пароль слишком длинный")
43-
return await run_in_threadpool(pwd_hasher.hash, password)
43+
with tracer.start_as_current_span("security.password.hash_argon2"):
44+
if len(password) > settings.ARGON_MAX_PASSWORD_LEN:
45+
raise ValueError("Пароль слишком длинный")
46+
return await run_in_threadpool(pwd_hasher.hash, password)
4447

4548

4649
async def verify_password(*, password: str, hashed_password: str) -> bool:
47-
def _verify() -> bool:
48-
if len(password) > settings.ARGON_MAX_PASSWORD_LEN:
49-
return False
50-
try:
51-
return pwd_hasher.verify(hashed_password, password)
52-
except VerifyMismatchError:
53-
return False
54-
except (VerificationError, InvalidHash) as exc:
55-
log.warning("Argon2 verify failed (%s)", exc.__class__.__name__)
56-
return False
57-
58-
return await run_in_threadpool(_verify)
50+
with tracer.start_as_current_span("security.password.verify_argon2"):
51+
def _verify() -> bool:
52+
if len(password) > settings.ARGON_MAX_PASSWORD_LEN:
53+
return False
54+
try:
55+
return pwd_hasher.verify(hashed_password, password)
56+
except VerifyMismatchError:
57+
return False
58+
except (VerificationError, InvalidHash) as exc:
59+
log.warning("Argon2 verify failed (%s)", exc.__class__.__name__)
60+
return False
61+
62+
return await run_in_threadpool(_verify)

0 commit comments

Comments
 (0)