s-authkit-server 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- authkit_server/__init__.py +68 -0
- authkit_server/exceptions.py +34 -0
- authkit_server/password/__init__.py +10 -0
- authkit_server/password/hasher.py +85 -0
- authkit_server/refresh/__init__.py +9 -0
- authkit_server/refresh/models.py +249 -0
- authkit_server/token/__init__.py +16 -0
- authkit_server/token/jose_issuer.py +317 -0
- authkit_server/token/keys.py +46 -0
- authkit_server/token/models.py +99 -0
- s_authkit_server-0.1.1.dist-info/METADATA +238 -0
- s_authkit_server-0.1.1.dist-info/RECORD +14 -0
- s_authkit_server-0.1.1.dist-info/WHEEL +4 -0
- s_authkit_server-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""s-authkit-server — СЕРВЕРНОЕ ядро авторизации для Skillery проектов.
|
|
2
|
+
|
|
3
|
+
Сторона сервера: наш бэкенд авторизует СВОИХ пользователей — выпускает и проверяет
|
|
4
|
+
собственные токены, хранит и ротирует refresh, хеширует пароли. Зеркальный кит
|
|
5
|
+
``s-authkit-client`` (импорт ``authkit_client``) решает обратную задачу — доступ
|
|
6
|
+
НАШЕГО кода к ЧУЖИМ сервисам (сессии, секреты, OAuth-обновление, живая проба).
|
|
7
|
+
Суффикс в имени и есть указатель стороны: ``_server`` против ``_client``.
|
|
8
|
+
|
|
9
|
+
Чистая инфраструктура без завязок на конкретное приложение: JWT RS256 issuing/verification,
|
|
10
|
+
refresh-token management, password hashing. Переиспользуется в будущих
|
|
11
|
+
микросервисах и интеграциях.
|
|
12
|
+
|
|
13
|
+
**Ядро (v0.1):**
|
|
14
|
+
- JWT RS256 issuer/verifier (JoseTokenIssuer)
|
|
15
|
+
- Persistent refresh-token store (IRefreshTokenStore + RefreshTokenRecord)
|
|
16
|
+
- Argon2 password hasher (Argon2PasswordHasher)
|
|
17
|
+
- RSA keypair generation (ensure_jwt_keypair)
|
|
18
|
+
|
|
19
|
+
**Отложено (v0.2+):** OAuth, PermissionResolver, ExchangeCode (app-specific).
|
|
20
|
+
|
|
21
|
+
Зависимости: python-jose[cryptography], passlib[argon2], cryptography.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from authkit_server.exceptions import (
|
|
26
|
+
AuthKitError,
|
|
27
|
+
PasswordHashError,
|
|
28
|
+
RefreshTokenError,
|
|
29
|
+
TokenError,
|
|
30
|
+
)
|
|
31
|
+
from authkit_server.password import (
|
|
32
|
+
Argon2PasswordHasher,
|
|
33
|
+
IPasswordHasher,
|
|
34
|
+
PasswordHasher,
|
|
35
|
+
)
|
|
36
|
+
from authkit_server.refresh import IRefreshTokenStore, RefreshTokenRecord
|
|
37
|
+
from authkit_server.token import (
|
|
38
|
+
ITokenIssuer,
|
|
39
|
+
JoseTokenIssuer,
|
|
40
|
+
TokenClaims,
|
|
41
|
+
TokenIssuer,
|
|
42
|
+
TokenIssueRequest,
|
|
43
|
+
TokenPair,
|
|
44
|
+
ensure_jwt_keypair,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# exceptions
|
|
49
|
+
"AuthKitError",
|
|
50
|
+
"TokenError",
|
|
51
|
+
"RefreshTokenError",
|
|
52
|
+
"PasswordHashError",
|
|
53
|
+
# token
|
|
54
|
+
"TokenPair",
|
|
55
|
+
"TokenClaims",
|
|
56
|
+
"TokenIssueRequest",
|
|
57
|
+
"ITokenIssuer",
|
|
58
|
+
"TokenIssuer",
|
|
59
|
+
"JoseTokenIssuer",
|
|
60
|
+
"ensure_jwt_keypair",
|
|
61
|
+
# refresh
|
|
62
|
+
"RefreshTokenRecord",
|
|
63
|
+
"IRefreshTokenStore",
|
|
64
|
+
# password
|
|
65
|
+
"IPasswordHasher",
|
|
66
|
+
"PasswordHasher",
|
|
67
|
+
"Argon2PasswordHasher",
|
|
68
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Исключения модуля authkit_server."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthKitError(Exception):
|
|
6
|
+
"""Базовое исключение модуля authkit_server."""
|
|
7
|
+
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TokenError(AuthKitError):
|
|
12
|
+
"""Ошибка в работе с JWT."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RefreshTokenError(AuthKitError):
|
|
18
|
+
"""Ошибка refresh-токена (истёк, отозван, неизвестен)."""
|
|
19
|
+
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PasswordHashError(AuthKitError):
|
|
24
|
+
"""Ошибка хеширования пароля."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AuthKitError",
|
|
31
|
+
"TokenError",
|
|
32
|
+
"RefreshTokenError",
|
|
33
|
+
"PasswordHashError",
|
|
34
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Password hasher через passlib (алгоритм по умолчанию — Argon2id)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from passlib.context import CryptContext
|
|
7
|
+
|
|
8
|
+
from authkit_server.exceptions import PasswordHashError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class IPasswordHasher(Protocol):
|
|
13
|
+
"""Ролевой контракт хешера паролей (structural typing).
|
|
14
|
+
|
|
15
|
+
Развязывает потребителя от конкретного алгоритма: код зависит от роли
|
|
16
|
+
(hash/verify), а не от Argon2/bcrypt/etc. Помечен ``@runtime_checkable`` —
|
|
17
|
+
доступна проверка ``isinstance(x, IPasswordHasher)``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def hash(self, raw: str) -> str:
|
|
21
|
+
"""Хеширует plaintext-пароль в self-contained хеш."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def verify(self, raw: str, hashed: str) -> bool:
|
|
25
|
+
"""Проверяет plaintext-пароль против хеша."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PasswordHasher:
|
|
30
|
+
"""Password hasher через passlib (Argon2id по умолчанию).
|
|
31
|
+
|
|
32
|
+
Публичное имя не завязано на алгоритм: класс реализует роль
|
|
33
|
+
``IPasswordHasher``, а конкретная схема (Argon2id) — деталь реализации.
|
|
34
|
+
Использует passlib с настройками для интерактивных flow'ов
|
|
35
|
+
(~50-100ms на современном CPU). Возвращает self-contained хеши с salt,
|
|
36
|
+
верификация не требует отдельного хранения salt.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
"""Инициализирует hasher с Argon2id (passlib defaults)."""
|
|
41
|
+
self._ctx = CryptContext(schemes=["argon2"], deprecated="auto")
|
|
42
|
+
|
|
43
|
+
def hash(self, raw: str) -> str:
|
|
44
|
+
"""Хеширует пароль.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
raw: Plaintext пароль.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Self-contained хеш (algorithm + salt + hash).
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
PasswordHashError: Если хеширование не удалось.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
return str(self._ctx.hash(raw))
|
|
57
|
+
except Exception as e:
|
|
58
|
+
raise PasswordHashError(f"Failed to hash password: {e}") from e
|
|
59
|
+
|
|
60
|
+
def verify(self, raw: str, hashed: str) -> bool:
|
|
61
|
+
"""Проверяет пароль против хеша.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
raw: Plaintext пароль для проверки.
|
|
65
|
+
hashed: Self-contained хеш для сравнения.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
True если пароль совпадает, False иначе.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
return bool(self._ctx.verify(raw, hashed))
|
|
72
|
+
except Exception:
|
|
73
|
+
# Любая ошибка (невалидный хеш, невалидный пароль) → False
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Deprecated-алиас: публичное имя развязано с алгоритмом (Argon2PasswordHasher →
|
|
78
|
+
# PasswordHasher). Старое имя остаётся импортируемым для обратной совместимости.
|
|
79
|
+
Argon2PasswordHasher = PasswordHasher
|
|
80
|
+
|
|
81
|
+
# Статическая проверка: PasswordHasher структурно удовлетворяет IPasswordHasher.
|
|
82
|
+
_conforms_to_protocol: type[IPasswordHasher] = PasswordHasher
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["IPasswordHasher", "PasswordHasher", "Argon2PasswordHasher"]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""RefreshTokenRecord и IRefreshTokenStore protocol."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class RefreshTokenRecord:
|
|
12
|
+
"""Запись о выданном refresh-токене.
|
|
13
|
+
|
|
14
|
+
В БД хранится только sha256(token_plaintext), сам токен остаётся у клиента.
|
|
15
|
+
revoked_at != None означает, что токен был отозван (обычно после rotation).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
user_id: str
|
|
19
|
+
"""Идентификатор пользователя."""
|
|
20
|
+
|
|
21
|
+
company_id: str | None
|
|
22
|
+
"""Идентификатор активной компании на момент выдачи."""
|
|
23
|
+
|
|
24
|
+
role_id: str | None
|
|
25
|
+
"""Идентификатор роли на момент выдачи."""
|
|
26
|
+
|
|
27
|
+
permissions: tuple[str, ...]
|
|
28
|
+
"""Снимок прав на момент выдачи (для snapshot-based авторизации)."""
|
|
29
|
+
|
|
30
|
+
token_hash: str
|
|
31
|
+
"""SHA256 хеш plaintext-токена."""
|
|
32
|
+
|
|
33
|
+
expires_at: datetime
|
|
34
|
+
"""Момент истечения токена (UTC)."""
|
|
35
|
+
|
|
36
|
+
id: str | None = None
|
|
37
|
+
"""Первичный ключ записи в store (назначает store при сохранении)."""
|
|
38
|
+
|
|
39
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
40
|
+
"""Момент создания записи (UTC)."""
|
|
41
|
+
|
|
42
|
+
revoked_at: datetime | None = None
|
|
43
|
+
"""Момент отзыва токена (None если активен)."""
|
|
44
|
+
|
|
45
|
+
rotated_from: str | None = None
|
|
46
|
+
"""ID предыдущего refresh-токена (для rotation chain и audit)."""
|
|
47
|
+
|
|
48
|
+
user_agent: str | None = None
|
|
49
|
+
"""User-Agent строка браузера (если есть)."""
|
|
50
|
+
|
|
51
|
+
ip_address: str | None = None
|
|
52
|
+
"""IP-адрес, с которого вышёл пользователь (если есть)."""
|
|
53
|
+
|
|
54
|
+
role_slug: str | None = None
|
|
55
|
+
"""Slug роли на момент выдачи (для resurrection при rotation)."""
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def create(
|
|
59
|
+
cls,
|
|
60
|
+
*,
|
|
61
|
+
user_id: str,
|
|
62
|
+
company_id: str | None,
|
|
63
|
+
role_id: str | None,
|
|
64
|
+
permissions: set[str],
|
|
65
|
+
token_hash: str,
|
|
66
|
+
expires_at: datetime,
|
|
67
|
+
rotated_from: str | None = None,
|
|
68
|
+
user_agent: str | None = None,
|
|
69
|
+
ip_address: str | None = None,
|
|
70
|
+
role_slug: str | None = None,
|
|
71
|
+
) -> RefreshTokenRecord:
|
|
72
|
+
"""Factory-метод для создания RefreshTokenRecord.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
user_id: Идентификатор пользователя.
|
|
76
|
+
company_id: Идентификатор компании (или None).
|
|
77
|
+
role_id: Идентификатор роли (или None).
|
|
78
|
+
permissions: Набор прав (конвертится в sorted tuple).
|
|
79
|
+
token_hash: SHA256(plaintext_token).
|
|
80
|
+
expires_at: Момент истечения.
|
|
81
|
+
rotated_from: ID предыдущего токена при rotation.
|
|
82
|
+
user_agent: User-Agent (опционально).
|
|
83
|
+
ip_address: IP-адрес (опционально).
|
|
84
|
+
role_slug: Slug роли (опционально).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
RefreshTokenRecord с id=None (назначит store).
|
|
88
|
+
"""
|
|
89
|
+
return cls(
|
|
90
|
+
user_id=user_id,
|
|
91
|
+
company_id=company_id,
|
|
92
|
+
role_id=role_id,
|
|
93
|
+
permissions=tuple(sorted(permissions)),
|
|
94
|
+
token_hash=token_hash,
|
|
95
|
+
expires_at=expires_at,
|
|
96
|
+
rotated_from=rotated_from,
|
|
97
|
+
user_agent=user_agent,
|
|
98
|
+
ip_address=ip_address,
|
|
99
|
+
role_slug=role_slug,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def hash_token(plaintext: str) -> str:
|
|
104
|
+
"""Хеширует plaintext-токен в SHA256 для хранения.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
plaintext: Opaque refresh-токен.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Hex-string SHA256 хеша.
|
|
111
|
+
"""
|
|
112
|
+
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
|
113
|
+
|
|
114
|
+
def is_expired(self, *, now: datetime | None = None) -> bool:
|
|
115
|
+
"""Проверяет, истёк ли токен.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
now: Момент проверки (default: UTC now).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True если токен истёк.
|
|
122
|
+
"""
|
|
123
|
+
check_time = now or datetime.now(UTC)
|
|
124
|
+
return check_time >= self.expires_at
|
|
125
|
+
|
|
126
|
+
def is_revoked(self) -> bool:
|
|
127
|
+
"""Проверяет, отозван ли токен.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True если revoked_at != None.
|
|
131
|
+
"""
|
|
132
|
+
return self.revoked_at is not None
|
|
133
|
+
|
|
134
|
+
def revoke(self, *, now: datetime | None = None) -> None:
|
|
135
|
+
"""Отзывает токен (устанавливает revoked_at).
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
now: Момент отзыва (default: UTC now).
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: Если токен уже отозван.
|
|
142
|
+
"""
|
|
143
|
+
if self.revoked_at is not None:
|
|
144
|
+
raise ValueError("Refresh token already revoked")
|
|
145
|
+
self.revoked_at = now or datetime.now(UTC)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class IRefreshTokenStore(Protocol):
|
|
149
|
+
"""Контракт для хранилища refresh-токенов.
|
|
150
|
+
|
|
151
|
+
Реализует асинхронное сохранение, получение и управление refresh-токенами.
|
|
152
|
+
Потребитель (ваше приложение) реализует этот Protocol
|
|
153
|
+
для своей БД.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
async def save(self, record: RefreshTokenRecord) -> None:
|
|
157
|
+
"""Сохраняет новую запись refresh-токена или обновляет существующую.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
record: Запись для сохранения.
|
|
161
|
+
"""
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
async def get_by_hash(self, token_hash: str) -> RefreshTokenRecord | None:
|
|
165
|
+
"""Получает запись по хешу токена.
|
|
166
|
+
|
|
167
|
+
Используется при верификации refresh-токена.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
token_hash: SHA256(plaintext_token).
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
RefreshTokenRecord или None если не найден.
|
|
174
|
+
"""
|
|
175
|
+
...
|
|
176
|
+
|
|
177
|
+
async def get_by_id(self, record_id: str) -> RefreshTokenRecord | None:
|
|
178
|
+
"""Получает запись по id.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
record_id: Первичный ключ записи.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
RefreshTokenRecord или None если не найден.
|
|
185
|
+
"""
|
|
186
|
+
...
|
|
187
|
+
|
|
188
|
+
async def list_active_for_user(self, user_id: str) -> list[RefreshTokenRecord]:
|
|
189
|
+
"""Список активных (не revoked, не expired) refresh-токенов пользователя.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
user_id: Идентификатор пользователя.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Список RefreshTokenRecord.
|
|
196
|
+
"""
|
|
197
|
+
...
|
|
198
|
+
|
|
199
|
+
async def revoke(self, record_id: str, *, now: datetime | None = None) -> None:
|
|
200
|
+
"""Отзывает конкретный refresh-токен.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
record_id: ID записи для отзыва.
|
|
204
|
+
now: Момент отзыва (default: UTC now).
|
|
205
|
+
"""
|
|
206
|
+
...
|
|
207
|
+
|
|
208
|
+
async def revoke_all_for_user(
|
|
209
|
+
self, user_id: str, *, now: datetime | None = None
|
|
210
|
+
) -> int:
|
|
211
|
+
"""Отзывает все refresh-токены пользователя (логаут со всех девайсов).
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
user_id: Идентификатор пользователя.
|
|
215
|
+
now: Момент отзыва (default: UTC now).
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Количество отозванных токенов.
|
|
219
|
+
"""
|
|
220
|
+
...
|
|
221
|
+
|
|
222
|
+
async def revoke_all_for_user_except(
|
|
223
|
+
self, user_id: str, except_id: str, *, now: datetime | None = None
|
|
224
|
+
) -> int:
|
|
225
|
+
"""Отзывает все refresh-токены КРОМЕ одного (для ротации на текущем девайсе).
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
user_id: Идентификатор пользователя.
|
|
229
|
+
except_id: ID записи, которую НЕ трогать.
|
|
230
|
+
now: Момент отзыва (default: UTC now).
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Количество отозванных токенов.
|
|
234
|
+
"""
|
|
235
|
+
...
|
|
236
|
+
|
|
237
|
+
async def delete_expired(self, now: datetime) -> int:
|
|
238
|
+
"""Удаляет истёкшие refresh-токены (для очистки, запускается scheduler'ом).
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
now: Момент проверки (обычно UTC now).
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Количество удалённых записей.
|
|
245
|
+
"""
|
|
246
|
+
...
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
__all__ = ["RefreshTokenRecord", "IRefreshTokenStore"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""JWT token management: issuing, verification, key generation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from authkit_server.token.jose_issuer import ITokenIssuer, JoseTokenIssuer, TokenIssuer
|
|
5
|
+
from authkit_server.token.keys import ensure_jwt_keypair
|
|
6
|
+
from authkit_server.token.models import TokenClaims, TokenIssueRequest, TokenPair
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"TokenPair",
|
|
10
|
+
"TokenClaims",
|
|
11
|
+
"TokenIssueRequest",
|
|
12
|
+
"ITokenIssuer",
|
|
13
|
+
"TokenIssuer",
|
|
14
|
+
"JoseTokenIssuer",
|
|
15
|
+
"ensure_jwt_keypair",
|
|
16
|
+
]
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""JWT RS256 issuer + persistent refresh-tokens через IRefreshTokenStore."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import secrets
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from jose import jwt
|
|
10
|
+
|
|
11
|
+
from authkit_server.exceptions import RefreshTokenError, TokenError
|
|
12
|
+
from authkit_server.refresh.models import IRefreshTokenStore, RefreshTokenRecord
|
|
13
|
+
from authkit_server.token.models import TokenClaims, TokenIssueRequest, TokenPair
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class ITokenIssuer(Protocol):
|
|
18
|
+
"""Ролевой контракт издателя токенов (structural typing).
|
|
19
|
+
|
|
20
|
+
Позволяет типизировать потребителей против абстракции, а не против
|
|
21
|
+
конкретной реализации ``TokenIssuer``. Помечен ``@runtime_checkable``,
|
|
22
|
+
поэтому доступна проверка ``isinstance(x, ITokenIssuer)`` по наличию
|
|
23
|
+
методов издателя: issue (выпуск пары) / verify (верификация access).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def issue_pair(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
user_id: str,
|
|
30
|
+
permissions: set[str],
|
|
31
|
+
) -> TokenPair:
|
|
32
|
+
"""Выпускает пару токенов (access + refresh)."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def verify_access(self, token: str) -> TokenClaims:
|
|
36
|
+
"""Верифицирует и декодирует access-токен."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
async def rotate_refresh(self, refresh_token: str) -> TokenPair:
|
|
40
|
+
"""Ротирует refresh-токен, выдавая новую пару."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
async def revoke_all_for_user(self, user_id: str) -> int:
|
|
44
|
+
"""Отзывает все refresh-токены пользователя."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TokenIssuer:
|
|
49
|
+
"""JWT RS256 access-токены + persistent opaque refresh-токены.
|
|
50
|
+
|
|
51
|
+
Access-токен — короткоживущий JWT, не требует БД для верификации.
|
|
52
|
+
Refresh-токен — opaque urlsafe-строка, хранится как хеш в IRefreshTokenStore.
|
|
53
|
+
На rotation старый токен помечается revoked, выдаётся новый
|
|
54
|
+
(rotated_from = old.id для audit-trail).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
private_key_pem: str,
|
|
61
|
+
public_key_pem: str,
|
|
62
|
+
issuer: str,
|
|
63
|
+
access_ttl_min: int,
|
|
64
|
+
refresh_ttl_days: int,
|
|
65
|
+
refresh_store: IRefreshTokenStore,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Инициализирует issuer.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
private_key_pem: Приватный RSA ключ в PEM-формате.
|
|
71
|
+
public_key_pem: Публичный RSA ключ в PEM-формате.
|
|
72
|
+
issuer: Строка issuer'а для JWT (напр. "skillery.ru").
|
|
73
|
+
access_ttl_min: TTL access-токена в минутах.
|
|
74
|
+
refresh_ttl_days: TTL refresh-токена в днях.
|
|
75
|
+
refresh_store: Реализация IRefreshTokenStore для хранения refresh-токенов.
|
|
76
|
+
"""
|
|
77
|
+
self._priv = private_key_pem
|
|
78
|
+
self._pub = public_key_pem
|
|
79
|
+
self._issuer = issuer
|
|
80
|
+
self._access_ttl = timedelta(minutes=access_ttl_min)
|
|
81
|
+
self._refresh_ttl = timedelta(days=refresh_ttl_days)
|
|
82
|
+
self._store = refresh_store
|
|
83
|
+
|
|
84
|
+
async def issue_pair(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
user_id: str,
|
|
88
|
+
company_id: str | None = None,
|
|
89
|
+
role_id: str | None = None,
|
|
90
|
+
permissions: set[str],
|
|
91
|
+
role_slug: str | None = None,
|
|
92
|
+
password_changed_at: datetime | None = None,
|
|
93
|
+
) -> TokenPair:
|
|
94
|
+
"""Выпускает пару токенов (access + refresh).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
user_id: Идентификатор пользователя.
|
|
98
|
+
company_id: Идентификатор активной компании (опционально).
|
|
99
|
+
role_id: Идентификатор роли (опционально, не кладётся в JWT).
|
|
100
|
+
permissions: Набор прав пользователя (snapshot для backward-compat).
|
|
101
|
+
role_slug: Slug роли (если есть, кладётся в токен для резолва прав из БД).
|
|
102
|
+
password_changed_at: Момент последней смены пароля (informational claim).
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
TokenPair с access и refresh токенами.
|
|
106
|
+
"""
|
|
107
|
+
return await self._issue_internal(
|
|
108
|
+
user_id=user_id,
|
|
109
|
+
company_id=company_id,
|
|
110
|
+
role_id=role_id,
|
|
111
|
+
permissions=permissions,
|
|
112
|
+
rotated_from=None,
|
|
113
|
+
role_slug=role_slug,
|
|
114
|
+
password_changed_at=password_changed_at,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def issue(self, request: TokenIssueRequest) -> TokenPair:
|
|
118
|
+
"""Выпускает пару токенов из сгруппированного запроса.
|
|
119
|
+
|
|
120
|
+
Удобная обёртка над ``issue_pair``: принимает ``TokenIssueRequest``
|
|
121
|
+
вместо длинного списка kwargs. Эквивалентна ``issue_pair`` с теми же
|
|
122
|
+
полями.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
request: Параметры выпуска (см. TokenIssueRequest).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
TokenPair с access и refresh токенами.
|
|
129
|
+
"""
|
|
130
|
+
return await self.issue_pair(
|
|
131
|
+
user_id=request.user_id,
|
|
132
|
+
company_id=request.company_id,
|
|
133
|
+
role_id=request.role_id,
|
|
134
|
+
permissions=set(request.permissions),
|
|
135
|
+
role_slug=request.role_slug,
|
|
136
|
+
password_changed_at=request.password_changed_at,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def _issue_internal(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
user_id: str,
|
|
143
|
+
company_id: str | None,
|
|
144
|
+
role_id: str | None,
|
|
145
|
+
permissions: set[str],
|
|
146
|
+
rotated_from: str | None,
|
|
147
|
+
role_slug: str | None = None,
|
|
148
|
+
password_changed_at: datetime | None = None,
|
|
149
|
+
) -> TokenPair:
|
|
150
|
+
"""Внутренняя реализация выпуска пары токенов."""
|
|
151
|
+
now = datetime.now(UTC)
|
|
152
|
+
access_exp = now + self._access_ttl
|
|
153
|
+
refresh_exp = now + self._refresh_ttl
|
|
154
|
+
jti = uuid.uuid4().hex
|
|
155
|
+
|
|
156
|
+
# Формируем payload access-токена
|
|
157
|
+
access_payload: dict[str, object] = {
|
|
158
|
+
"iss": self._issuer,
|
|
159
|
+
"sub": user_id,
|
|
160
|
+
"company_id": company_id if company_id else None,
|
|
161
|
+
# Snapshot прав кладётся только если role_slug отсутствует
|
|
162
|
+
# (backward-compat с legacy токенами, но новые токены с role_slug
|
|
163
|
+
# не кладут snapshot — права резолвятся из БД).
|
|
164
|
+
**(
|
|
165
|
+
{"permissions": sorted(permissions)}
|
|
166
|
+
if role_slug is None
|
|
167
|
+
else {}
|
|
168
|
+
),
|
|
169
|
+
"iat": int(now.timestamp()),
|
|
170
|
+
"exp": int(access_exp.timestamp()),
|
|
171
|
+
"jti": jti,
|
|
172
|
+
# Момент смены пароля (informational; enforcement читает свежее из БД).
|
|
173
|
+
**(
|
|
174
|
+
{"pwd_changed_at": int(password_changed_at.timestamp())}
|
|
175
|
+
if password_changed_at is not None
|
|
176
|
+
else {}
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if role_slug is not None:
|
|
181
|
+
access_payload["role_slug"] = role_slug
|
|
182
|
+
|
|
183
|
+
# Кодируем JWT
|
|
184
|
+
access_token = jwt.encode(access_payload, self._priv, algorithm="RS256")
|
|
185
|
+
|
|
186
|
+
# Генерируем opaque refresh-токен
|
|
187
|
+
refresh_token = secrets.token_urlsafe(32)
|
|
188
|
+
|
|
189
|
+
# Сохраняем запись в store
|
|
190
|
+
record = RefreshTokenRecord.create(
|
|
191
|
+
user_id=user_id,
|
|
192
|
+
company_id=company_id,
|
|
193
|
+
role_id=role_id,
|
|
194
|
+
permissions=permissions,
|
|
195
|
+
token_hash=RefreshTokenRecord.hash_token(refresh_token),
|
|
196
|
+
expires_at=refresh_exp,
|
|
197
|
+
rotated_from=rotated_from,
|
|
198
|
+
role_slug=role_slug,
|
|
199
|
+
)
|
|
200
|
+
await self._store.save(record)
|
|
201
|
+
|
|
202
|
+
return TokenPair(
|
|
203
|
+
access_token=access_token,
|
|
204
|
+
refresh_token=refresh_token,
|
|
205
|
+
access_expires_at=access_exp,
|
|
206
|
+
refresh_expires_at=refresh_exp,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def verify_access(self, token: str) -> TokenClaims:
|
|
210
|
+
"""Декодирует и верифицирует JWT access-токен.
|
|
211
|
+
|
|
212
|
+
Проверяет подпись (RS256), issuer и срок действия.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
token: JWT access-токен.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
TokenClaims с распарсенными claims.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
TokenError: Если токен невалиден, срок истёк или подпись неверна.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
payload = jwt.decode(
|
|
225
|
+
token,
|
|
226
|
+
self._pub,
|
|
227
|
+
algorithms=["RS256"],
|
|
228
|
+
issuer=self._issuer,
|
|
229
|
+
options={"verify_aud": False},
|
|
230
|
+
)
|
|
231
|
+
except Exception as e:
|
|
232
|
+
raise TokenError(f"Invalid token: {e}") from e
|
|
233
|
+
|
|
234
|
+
# Подпись/issuer/срок валидны, но payload может недосчитаться обязательных
|
|
235
|
+
# claim'ов (sub/iat/exp/jti) — не роняем KeyError/TypeError наружу,
|
|
236
|
+
# а превращаем в доменную TokenError.
|
|
237
|
+
try:
|
|
238
|
+
return TokenClaims(
|
|
239
|
+
user_id=payload["sub"],
|
|
240
|
+
company_id=payload.get("company_id"),
|
|
241
|
+
permissions=frozenset(payload.get("permissions", [])),
|
|
242
|
+
issued_at=datetime.fromtimestamp(payload["iat"], UTC),
|
|
243
|
+
expires_at=datetime.fromtimestamp(payload["exp"], UTC),
|
|
244
|
+
jti=payload["jti"],
|
|
245
|
+
role_slug=payload.get("role_slug"),
|
|
246
|
+
)
|
|
247
|
+
except (KeyError, TypeError, ValueError, OverflowError, OSError) as e:
|
|
248
|
+
raise TokenError(f"Malformed token claims: {e}") from e
|
|
249
|
+
|
|
250
|
+
async def rotate_refresh(self, refresh_token: str) -> TokenPair:
|
|
251
|
+
"""Проверяет refresh-токен, отзывает старый, выпускает новую пару.
|
|
252
|
+
|
|
253
|
+
Implements refresh token rotation: старый токен помечается revoked,
|
|
254
|
+
новая пара выпускается с роль, правами и метаданными из старой записи.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
refresh_token: Opaque refresh-токен (plaintext).
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Новая TokenPair.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
RefreshTokenError: Если токен невалиден, истёк, отозван или неизвестен.
|
|
264
|
+
"""
|
|
265
|
+
token_hash = RefreshTokenRecord.hash_token(refresh_token)
|
|
266
|
+
record = await self._store.get_by_hash(token_hash)
|
|
267
|
+
|
|
268
|
+
if record is None:
|
|
269
|
+
raise RefreshTokenError("Unknown refresh token")
|
|
270
|
+
|
|
271
|
+
if record.is_revoked():
|
|
272
|
+
# Переиспользование отозванного токена — security incident.
|
|
273
|
+
# Отзываем все токены юзера для безопасности.
|
|
274
|
+
await self._store.revoke_all_for_user(record.user_id)
|
|
275
|
+
raise RefreshTokenError(
|
|
276
|
+
"Refresh token already revoked; all user sessions revoked for security"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if record.is_expired():
|
|
280
|
+
raise RefreshTokenError("Refresh token expired")
|
|
281
|
+
|
|
282
|
+
# Отзываем старый токен
|
|
283
|
+
if record.id is not None:
|
|
284
|
+
await self._store.revoke(record.id)
|
|
285
|
+
|
|
286
|
+
# Выпускаем новую пару с сохранением контекста
|
|
287
|
+
return await self._issue_internal(
|
|
288
|
+
user_id=record.user_id,
|
|
289
|
+
company_id=record.company_id,
|
|
290
|
+
role_id=record.role_id,
|
|
291
|
+
permissions=set(record.permissions),
|
|
292
|
+
rotated_from=record.id,
|
|
293
|
+
role_slug=record.role_slug,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async def revoke_all_for_user(self, user_id: str) -> int:
|
|
297
|
+
"""Отзывает все refresh-токены пользователя (логаут со всех девайсов).
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
user_id: Идентификатор пользователя.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Количество отозванных токенов.
|
|
304
|
+
"""
|
|
305
|
+
return await self._store.revoke_all_for_user(user_id)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# Deprecated-алиас: публичное имя класса переименовано JoseTokenIssuer → TokenIssuer
|
|
309
|
+
# (модуль jose_issuer остаётся внутренним). Старое имя остаётся импортируемым для
|
|
310
|
+
# обратной совместимости — используйте TokenIssuer в новом коде.
|
|
311
|
+
JoseTokenIssuer = TokenIssuer
|
|
312
|
+
|
|
313
|
+
# Статическая проверка: TokenIssuer структурно удовлетворяет контракту ITokenIssuer.
|
|
314
|
+
_conforms_to_protocol: type[ITokenIssuer] = TokenIssuer
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
__all__ = ["ITokenIssuer", "TokenIssuer", "JoseTokenIssuer"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Генерация и загрузка RSA-ключей для JWT."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from cryptography.hazmat.primitives import serialization
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_jwt_keypair(*, private_path: Path, public_path: Path) -> None:
|
|
11
|
+
"""Создаёт RSA 2048 keypair если файлов нет.
|
|
12
|
+
|
|
13
|
+
Если файлы уже существуют, ничего не делает (идемпотентная операция).
|
|
14
|
+
Если директория не существует, создаёт её (mkdir -p).
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
private_path: Путь для сохранения приватного ключа (PEM format).
|
|
18
|
+
public_path: Путь для сохранения публичного ключа (PEM format).
|
|
19
|
+
"""
|
|
20
|
+
if private_path.exists() and public_path.exists():
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
# Создаём директории если их нет
|
|
24
|
+
private_path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
public_path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
# Генерируем RSA 2048 keypair
|
|
28
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
29
|
+
|
|
30
|
+
# Сохраняем приватный ключ (PKCS8, без шифрования)
|
|
31
|
+
private_pem = key.private_bytes(
|
|
32
|
+
encoding=serialization.Encoding.PEM,
|
|
33
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
34
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
35
|
+
)
|
|
36
|
+
private_path.write_bytes(private_pem)
|
|
37
|
+
|
|
38
|
+
# Сохраняем публичный ключ
|
|
39
|
+
public_pem = key.public_key().public_bytes(
|
|
40
|
+
encoding=serialization.Encoding.PEM,
|
|
41
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
42
|
+
)
|
|
43
|
+
public_path.write_bytes(public_pem)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ["ensure_jwt_keypair"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Value objects для токенов: TokenPair, TokenClaims, TokenIssueRequest."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class TokenIssueRequest:
|
|
10
|
+
"""Параметры выпуска пары токенов, сгруппированные в один объект.
|
|
11
|
+
|
|
12
|
+
Заменяет длинный список именованных аргументов ``issue_pair`` одним
|
|
13
|
+
value-объектом: удобнее передавать, расширять и тестировать. Обязателен
|
|
14
|
+
только ``user_id``; остальное — опциональный контекст авторизации.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
user_id: str
|
|
18
|
+
"""Идентификатор пользователя."""
|
|
19
|
+
|
|
20
|
+
permissions: frozenset[str] = field(default_factory=frozenset)
|
|
21
|
+
"""Набор прав (snapshot для backward-compat; пуст по умолчанию)."""
|
|
22
|
+
|
|
23
|
+
company_id: str | None = None
|
|
24
|
+
"""Идентификатор активной компании (или None)."""
|
|
25
|
+
|
|
26
|
+
role_id: str | None = None
|
|
27
|
+
"""Идентификатор роли (не кладётся в JWT; для audit в refresh-записи)."""
|
|
28
|
+
|
|
29
|
+
role_slug: str | None = None
|
|
30
|
+
"""Slug роли (если есть — кладётся в токен для резолва прав из БД)."""
|
|
31
|
+
|
|
32
|
+
password_changed_at: datetime | None = None
|
|
33
|
+
"""Момент последней смены пароля (informational claim)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class TokenPair:
|
|
38
|
+
"""Пара access + refresh JWT токенов.
|
|
39
|
+
|
|
40
|
+
Access-токен — короткоживущий JWT RS256 (не требует БД для верификации).
|
|
41
|
+
Refresh-токен — opaque urlsafe-строка, хранится как хеш в IRefreshTokenStore.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
access_token: str
|
|
45
|
+
"""JWT access-токен (RS256)."""
|
|
46
|
+
|
|
47
|
+
refresh_token: str
|
|
48
|
+
"""Opaque refresh-токен (urlsafe)."""
|
|
49
|
+
|
|
50
|
+
access_expires_at: datetime
|
|
51
|
+
"""Момент истечения access-токена (UTC)."""
|
|
52
|
+
|
|
53
|
+
refresh_expires_at: datetime
|
|
54
|
+
"""Момент истечения refresh-токена (UTC)."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class TokenClaims:
|
|
59
|
+
"""Расшифрованные claims из JWT access-токена.
|
|
60
|
+
|
|
61
|
+
Содержит идентификационные данные юзера и опциональный снимок прав
|
|
62
|
+
(для backward-compat с legacy токенами). В современных токенах права
|
|
63
|
+
резолвятся из БД по role_slug.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
user_id: str
|
|
67
|
+
"""Идентификатор пользователя (типизирует потребитель)."""
|
|
68
|
+
|
|
69
|
+
company_id: str | None
|
|
70
|
+
"""Идентификатор активной компании (None если логин без компании)."""
|
|
71
|
+
|
|
72
|
+
permissions: frozenset[str]
|
|
73
|
+
"""Снимок прав на момент выдачи токена.
|
|
74
|
+
|
|
75
|
+
Для backward-compat с legacy токенами (до E5). Современные токены
|
|
76
|
+
резолвят права из БД по role_slug, не доверяя snapshot'у.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
issued_at: datetime
|
|
80
|
+
"""Момент выдачи токена (UTC)."""
|
|
81
|
+
|
|
82
|
+
expires_at: datetime
|
|
83
|
+
"""Момент истечения токена (UTC)."""
|
|
84
|
+
|
|
85
|
+
jti: str
|
|
86
|
+
"""JWT ID — уникальный идентификатор этого токена (UUID hex).
|
|
87
|
+
|
|
88
|
+
Используется для revocation tracking по RFC 7519 §4.1.7.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
role_slug: str | None = None
|
|
92
|
+
"""Slug роли на момент выдачи (если есть).
|
|
93
|
+
|
|
94
|
+
Новые токены (E5+) кладут role_slug для резолва прав из БД.
|
|
95
|
+
Legacy токены (до E5) = None.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = ["TokenPair", "TokenClaims", "TokenIssueRequest"]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: s-authkit-server
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Серверная авторизация своих пользователей: JWT RS256 + refresh-tokens + Argon2 hashing. Пара к s-authkit-client (доступ к чужим сервисам). 0 завязок на конкретное приложение.
|
|
5
|
+
Author: Dmitry
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: cryptography>=41.0.0
|
|
10
|
+
Requires-Dist: passlib[argon2]>=1.7.4
|
|
11
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# s-authkit-server
|
|
19
|
+
|
|
20
|
+
СЕРВЕРНОЕ ядро авторизации для Skillery проектов: JWT RS256, refresh-token management,
|
|
21
|
+
Argon2 hashing. Импорт — `authkit_server`.
|
|
22
|
+
|
|
23
|
+
**Версия:** 0.1.1
|
|
24
|
+
**Лицензия:** MIT
|
|
25
|
+
**Зависимости:** `python-jose[cryptography]`, `passlib[argon2]`, `cryptography`
|
|
26
|
+
|
|
27
|
+
## Какая это сторона
|
|
28
|
+
|
|
29
|
+
| Кит | Импорт | Чью авторизацию решает |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `s-authkit-server` (этот) | `authkit_server` | НАШ сервис авторизует СВОИХ пользователей: свои токены, свои пароли, свои refresh |
|
|
32
|
+
| `s-authkit-client` | `authkit_client` | НАШ код получает доступ к ЧУЖИМ сервисам: сессии, секреты, OAuth-обновление, живая проба |
|
|
33
|
+
|
|
34
|
+
Суффикс имени и есть указатель стороны. До 0.1.1 этот кит назывался `s-authkit`
|
|
35
|
+
(импорт `authkit`) — из-за пары «`authkit` / `authkit-client`» серверный кит читался
|
|
36
|
+
как SDK к клиентскому. См. раздел «Переименование» ниже.
|
|
37
|
+
|
|
38
|
+
## Что входит
|
|
39
|
+
|
|
40
|
+
### JWT access-токены (RS256)
|
|
41
|
+
- `JoseTokenIssuer` — выпуск и верификация JWT
|
|
42
|
+
- `TokenPair` / `TokenClaims` — value objects для типизации
|
|
43
|
+
- `ensure_jwt_keypair` — генерация RSA 2048 ключей
|
|
44
|
+
|
|
45
|
+
### Persistent refresh-tokens
|
|
46
|
+
- `IRefreshTokenStore` — protocol для БД-реализации
|
|
47
|
+
- `RefreshTokenRecord` — агрегат с хешированием и валидацией
|
|
48
|
+
- Rotation с audit-trail (`rotated_from`)
|
|
49
|
+
|
|
50
|
+
### Password hashing
|
|
51
|
+
- `Argon2PasswordHasher` — Argon2id через passlib
|
|
52
|
+
|
|
53
|
+
## Примеры
|
|
54
|
+
|
|
55
|
+
### 1. Инициализация
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from authkit_server import (
|
|
60
|
+
JoseTokenIssuer,
|
|
61
|
+
Argon2PasswordHasher,
|
|
62
|
+
ensure_jwt_keypair,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Создаём ключи (если нет)
|
|
66
|
+
keys_dir = Path.home() / ".myapp" / "keys"
|
|
67
|
+
ensure_jwt_keypair(
|
|
68
|
+
private_path=keys_dir / "jwt_private.pem",
|
|
69
|
+
public_path=keys_dir / "jwt_public.pem",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Прочитаем ключи
|
|
73
|
+
private_key = (keys_dir / "jwt_private.pem").read_text()
|
|
74
|
+
public_key = (keys_dir / "jwt_public.pem").read_text()
|
|
75
|
+
|
|
76
|
+
# Создаём компоненты
|
|
77
|
+
hasher = Argon2PasswordHasher()
|
|
78
|
+
issuer = JoseTokenIssuer(
|
|
79
|
+
private_key_pem=private_key,
|
|
80
|
+
public_key_pem=public_key,
|
|
81
|
+
issuer="myproject.com",
|
|
82
|
+
access_ttl_min=15,
|
|
83
|
+
refresh_ttl_days=30,
|
|
84
|
+
refresh_store=your_store_impl, # реализуете вы
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 2. Login (выпуск пары токенов)
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
async def login_with_password(username: str, password: str):
|
|
92
|
+
# Получаем юзера из БД
|
|
93
|
+
user = await db.get_user_by_username(username)
|
|
94
|
+
if not user:
|
|
95
|
+
raise ValueError("User not found")
|
|
96
|
+
|
|
97
|
+
# Проверяем пароль
|
|
98
|
+
if not hasher.verify(password, user.password_hash):
|
|
99
|
+
raise ValueError("Invalid password")
|
|
100
|
+
|
|
101
|
+
# Выпускаем пару
|
|
102
|
+
pair = await issuer.issue_pair(
|
|
103
|
+
user_id=str(user.id),
|
|
104
|
+
company_id=str(user.active_company_id) if user.active_company_id else None,
|
|
105
|
+
permissions={"skill.read", "skill.install"},
|
|
106
|
+
)
|
|
107
|
+
return pair
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 3. Middleware (верификация токена)
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from authkit_server import TokenError
|
|
114
|
+
|
|
115
|
+
async def auth_middleware(request, call_next):
|
|
116
|
+
auth_header = request.headers.get("Authorization", "")
|
|
117
|
+
if not auth_header.startswith("Bearer "):
|
|
118
|
+
return Response("Unauthorized", status_code=401)
|
|
119
|
+
|
|
120
|
+
token = auth_header[7:]
|
|
121
|
+
try:
|
|
122
|
+
claims = issuer.verify_access(token)
|
|
123
|
+
except TokenError as e:
|
|
124
|
+
return Response(f"Invalid token: {e}", status_code=401)
|
|
125
|
+
|
|
126
|
+
request.state.claims = claims
|
|
127
|
+
return await call_next(request)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 4. Refresh (rotation)
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
async def refresh_session(refresh_token: str):
|
|
134
|
+
try:
|
|
135
|
+
new_pair = await issuer.rotate_refresh(refresh_token)
|
|
136
|
+
except RefreshTokenError as e:
|
|
137
|
+
raise Unauthorized(f"Refresh failed: {e}")
|
|
138
|
+
return new_pair
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### 5. Реализация IRefreshTokenStore
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from authkit_server import RefreshTokenRecord, IRefreshTokenStore
|
|
145
|
+
|
|
146
|
+
class PostgresRefreshTokenStore:
|
|
147
|
+
def __init__(self, db_engine):
|
|
148
|
+
self.engine = db_engine
|
|
149
|
+
|
|
150
|
+
async def save(self, record: RefreshTokenRecord) -> None:
|
|
151
|
+
# INSERT/UPDATE в БД
|
|
152
|
+
async with self.engine.begin() as conn:
|
|
153
|
+
await conn.execute(
|
|
154
|
+
"INSERT INTO refresh_tokens (user_id, token_hash, ...) VALUES (...)"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def get_by_hash(self, token_hash: str) -> RefreshTokenRecord | None:
|
|
158
|
+
# SELECT * FROM refresh_tokens WHERE token_hash = ?
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
async def revoke_all_for_user(self, user_id: str) -> int:
|
|
162
|
+
# UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = ?
|
|
163
|
+
...
|
|
164
|
+
|
|
165
|
+
# Реализуете остальные методы Protocol'а
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Архитектура
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
authkit_server/
|
|
172
|
+
├── __init__.py # Public API
|
|
173
|
+
├── exceptions.py # AuthKitError, TokenError, ...
|
|
174
|
+
├── token/
|
|
175
|
+
│ ├── models.py # TokenPair, TokenClaims
|
|
176
|
+
│ ├── jose_issuer.py # JoseTokenIssuer
|
|
177
|
+
│ └── keys.py # ensure_jwt_keypair
|
|
178
|
+
├── refresh/
|
|
179
|
+
│ └── models.py # RefreshTokenRecord, IRefreshTokenStore
|
|
180
|
+
└── password/
|
|
181
|
+
└── hasher.py # Argon2PasswordHasher
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Тестирование
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
pytest tests/ # Все тесты
|
|
188
|
+
pytest tests/ -v --cov # С coverage report
|
|
189
|
+
pytest tests/ -k "test_verify" # Конкретный тест
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Coverage gate: ≥ 80%.
|
|
193
|
+
|
|
194
|
+
## Что НЕ входит (v0.1)
|
|
195
|
+
|
|
196
|
+
- **OAuth** (YandexOAuth, ExchangeCode) — в v0.2+
|
|
197
|
+
- **PermissionResolver** — остаются на стороне приложения
|
|
198
|
+
- **Redis cache invalidation** — в v0.2+ как optional
|
|
199
|
+
- **Signing PK/SK rotation** — будущая фича
|
|
200
|
+
- **Sync обёртки** — только async API в v0.1
|
|
201
|
+
|
|
202
|
+
## Интеграция в существующее приложение
|
|
203
|
+
|
|
204
|
+
Типовой путь перевода существующего приложения на модуль:
|
|
205
|
+
1. Импортирует `from authkit_server import ...` вместо локального кода
|
|
206
|
+
2. Обёрнет `RefreshTokenRecord` в ORM-адаптер для своей БД
|
|
207
|
+
3. Оставит PermissionResolver/OAuth/ExchangeCode локально
|
|
208
|
+
|
|
209
|
+
## Переименование (0.1.0 → 0.1.1)
|
|
210
|
+
|
|
211
|
+
Кит переименован: дистрибутив `s-authkit` → `s-authkit-server`, импорт `authkit` →
|
|
212
|
+
`authkit_server`. Публичный API (имена классов и функций) не менялся — правится только
|
|
213
|
+
строка импорта и строка зависимости.
|
|
214
|
+
|
|
215
|
+
Со старым дистрибутивом на PyPI:
|
|
216
|
+
|
|
217
|
+
- `s-authkit==0.1.0` остаётся опубликованным как есть — его не отзываем (yank) и не
|
|
218
|
+
удаляем: он рабочий, а отзыв сломал бы любую уже собранную сборку;
|
|
219
|
+
- **новых выпусков под именем `s-authkit` больше не будет** — имя заморожено на 0.1.0;
|
|
220
|
+
- переименование на PyPI «на месте» невозможно: `s-authkit-server` — это НОВЫЙ
|
|
221
|
+
дистрибутив, первый его выпуск — 0.1.1 (нумерация продолжает историю кита, а не
|
|
222
|
+
начинается заново, чтобы версия читалась как «то же ядро, новое имя»).
|
|
223
|
+
|
|
224
|
+
Миграция потребителя — две строки:
|
|
225
|
+
|
|
226
|
+
```diff
|
|
227
|
+
-"s-authkit>=0.1.0",
|
|
228
|
+
+"s-authkit-server>=0.1.1",
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
```diff
|
|
232
|
+
-from authkit import Argon2PasswordHasher, ensure_jwt_keypair
|
|
233
|
+
+from authkit_server import Argon2PasswordHasher, ensure_jwt_keypair
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Лицензия
|
|
237
|
+
|
|
238
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
authkit_server/__init__.py,sha256=6AiNtjSY9JOGmY6vb3VD_HerS2gNoTkjTgO7tKYkRJE,2419
|
|
2
|
+
authkit_server/exceptions.py,sha256=dfXKyAWAOwe2mBLdjdN_fUpY4ayrxyNLAHsRjAjdmMg,662
|
|
3
|
+
authkit_server/password/__init__.py,sha256=uCHRUoo4nmyoCdqT-oRTU7GsBpj-7mR5WW7N_2yk4J8,247
|
|
4
|
+
authkit_server/password/hasher.py,sha256=EQlSH1hkEZtgXZz4apU8DAVe9rm2hkkLd7cvzKYFdGY,3481
|
|
5
|
+
authkit_server/refresh/__init__.py,sha256=bbPK7zcTLpX8LmVSPpQRRL3CZvZMlcfu6OumDR0Z5rA,235
|
|
6
|
+
authkit_server/refresh/models.py,sha256=_Uyohqs3d0AAG5eFGG0VS7K_ScfVl-sQrJZHM-fD2Rg,8826
|
|
7
|
+
authkit_server/token/__init__.py,sha256=p25ucap6qG0EwCYKNxmgiIR2zfqfWcugkseZmCqKhBU,494
|
|
8
|
+
authkit_server/token/jose_issuer.py,sha256=fKTPXC4I_hjoEb_Gfaj8N7rbTldr4VlU2ZwlxAfSFtU,13120
|
|
9
|
+
authkit_server/token/keys.py,sha256=hkFB2lx28aOQZg86nj-FwU8Xtsc9-BA9RmMbKMVap44,1840
|
|
10
|
+
authkit_server/token/models.py,sha256=EFzuoWWqqEl3wOtrL4nHnACwkwkEuCBSe8JARnxcqgo,3966
|
|
11
|
+
s_authkit_server-0.1.1.dist-info/METADATA,sha256=F_8fxzi_EMsOcsv28opj5ZbwHWYkdoovlJQ_Dxb3iug,8907
|
|
12
|
+
s_authkit_server-0.1.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
13
|
+
s_authkit_server-0.1.1.dist-info/licenses/LICENSE,sha256=j9GKJmUNdQuKRUbKhbpv0uyMaL99xsxE6L2TDtXuaZ4,1063
|
|
14
|
+
s_authkit_server-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmitry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|