s-authkit 0.1.0__tar.gz
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.
- s_authkit-0.1.0/.coverage +0 -0
- s_authkit-0.1.0/CHANGELOG.md +25 -0
- s_authkit-0.1.0/LICENSE +21 -0
- s_authkit-0.1.0/PKG-INFO +199 -0
- s_authkit-0.1.0/README.md +182 -0
- s_authkit-0.1.0/authkit/__init__.py +45 -0
- s_authkit-0.1.0/authkit/exceptions.py +34 -0
- s_authkit-0.1.0/authkit/password/__init__.py +6 -0
- s_authkit-0.1.0/authkit/password/hasher.py +55 -0
- s_authkit-0.1.0/authkit/refresh/__init__.py +9 -0
- s_authkit-0.1.0/authkit/refresh/models.py +249 -0
- s_authkit-0.1.0/authkit/token/__init__.py +13 -0
- s_authkit-0.1.0/authkit/token/jose_issuer.py +247 -0
- s_authkit-0.1.0/authkit/token/keys.py +46 -0
- s_authkit-0.1.0/authkit/token/models.py +71 -0
- s_authkit-0.1.0/pyproject.toml +56 -0
- s_authkit-0.1.0/tests/__init__.py +1 -0
- s_authkit-0.1.0/tests/conftest.py +170 -0
- s_authkit-0.1.0/tests/integration/__init__.py +1 -0
- s_authkit-0.1.0/tests/integration/test_jose_issuer_with_mock_store.py +122 -0
- s_authkit-0.1.0/tests/unit/__init__.py +1 -0
- s_authkit-0.1.0/tests/unit/password/__init__.py +1 -0
- s_authkit-0.1.0/tests/unit/password/test_argon2_hasher.py +56 -0
- s_authkit-0.1.0/tests/unit/refresh/__init__.py +1 -0
- s_authkit-0.1.0/tests/unit/refresh/test_refresh_token_record.py +172 -0
- s_authkit-0.1.0/tests/unit/token/__init__.py +1 -0
- s_authkit-0.1.0/tests/unit/token/test_jose_issuer.py +236 -0
- s_authkit-0.1.0/tests/unit/token/test_keys.py +68 -0
- s_authkit-0.1.0/tests/unit/token/test_models.py +78 -0
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Все значительные изменения этого проекта будут документироваться в этом файле.
|
|
4
|
+
|
|
5
|
+
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
и этот проект соответствует [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-07-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **JWT RS256 issuer** (`JoseTokenIssuer`): выпуск и верификация JWT access-токенов с асимметричным ключом
|
|
13
|
+
- **Persistent refresh-tokens** (`RefreshTokenRecord`, `IRefreshTokenStore`): хранение и ротация refresh-токенов с хешированием SHA256
|
|
14
|
+
- **Argon2 password hashing** (`Argon2PasswordHasher`): хеширование паролей через passlib с Argon2id
|
|
15
|
+
- **RSA keypair generation** (`ensure_jwt_keypair`): утилита для создания RSA 2048 ключей в PEM-формате
|
|
16
|
+
- **Comprehensive test suite**: 38 unit и integration тестов с coverage ≥80%
|
|
17
|
+
- **Protocol-based architecture**: `IRefreshTokenStore` как Protocol для контрактного дизайна
|
|
18
|
+
|
|
19
|
+
### Notes
|
|
20
|
+
|
|
21
|
+
- Первый релиз модуля `s-authkit` как отделённого ядра авторизации для Skillery проектов
|
|
22
|
+
- Нет завязок на конкретное приложение: только чистая инфраструктура
|
|
23
|
+
- OAuth, PermissionResolver, ExchangeCode отложены на v0.2+
|
|
24
|
+
- Все методы асинхронные (async/await)
|
|
25
|
+
- Python ≥3.11, зависимости: python-jose[cryptography], passlib[argon2], cryptography
|
s_authkit-0.1.0/LICENSE
ADDED
|
@@ -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.
|
s_authkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: s-authkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: JWT RS256 + refresh-tokens + Argon2 hashing — чистая инфраструктура авторизации для Skillery проектов. 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
|
|
19
|
+
|
|
20
|
+
Ядро авторизации для Skillery проектов: JWT RS256, refresh-token management, Argon2 hashing.
|
|
21
|
+
|
|
22
|
+
**Версия:** 0.1 (первый релиз)
|
|
23
|
+
**Лицензия:** MIT
|
|
24
|
+
**Зависимости:** `python-jose[cryptography]`, `passlib[argon2]`, `cryptography`
|
|
25
|
+
|
|
26
|
+
## Что входит
|
|
27
|
+
|
|
28
|
+
### JWT access-токены (RS256)
|
|
29
|
+
- `JoseTokenIssuer` — выпуск и верификация JWT
|
|
30
|
+
- `TokenPair` / `TokenClaims` — value objects для типизации
|
|
31
|
+
- `ensure_jwt_keypair` — генерация RSA 2048 ключей
|
|
32
|
+
|
|
33
|
+
### Persistent refresh-tokens
|
|
34
|
+
- `IRefreshTokenStore` — protocol для БД-реализации
|
|
35
|
+
- `RefreshTokenRecord` — агрегат с хешированием и валидацией
|
|
36
|
+
- Rotation с audit-trail (`rotated_from`)
|
|
37
|
+
|
|
38
|
+
### Password hashing
|
|
39
|
+
- `Argon2PasswordHasher` — Argon2id через passlib
|
|
40
|
+
|
|
41
|
+
## Примеры
|
|
42
|
+
|
|
43
|
+
### 1. Инициализация
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from authkit import (
|
|
48
|
+
JoseTokenIssuer,
|
|
49
|
+
Argon2PasswordHasher,
|
|
50
|
+
ensure_jwt_keypair,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Создаём ключи (если нет)
|
|
54
|
+
keys_dir = Path.home() / ".myapp" / "keys"
|
|
55
|
+
ensure_jwt_keypair(
|
|
56
|
+
private_path=keys_dir / "jwt_private.pem",
|
|
57
|
+
public_path=keys_dir / "jwt_public.pem",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Прочитаем ключи
|
|
61
|
+
private_key = (keys_dir / "jwt_private.pem").read_text()
|
|
62
|
+
public_key = (keys_dir / "jwt_public.pem").read_text()
|
|
63
|
+
|
|
64
|
+
# Создаём компоненты
|
|
65
|
+
hasher = Argon2PasswordHasher()
|
|
66
|
+
issuer = JoseTokenIssuer(
|
|
67
|
+
private_key_pem=private_key,
|
|
68
|
+
public_key_pem=public_key,
|
|
69
|
+
issuer="myproject.com",
|
|
70
|
+
access_ttl_min=15,
|
|
71
|
+
refresh_ttl_days=30,
|
|
72
|
+
refresh_store=your_store_impl, # реализуете вы
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Login (выпуск пары токенов)
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
async def login_with_password(username: str, password: str):
|
|
80
|
+
# Получаем юзера из БД
|
|
81
|
+
user = await db.get_user_by_username(username)
|
|
82
|
+
if not user:
|
|
83
|
+
raise ValueError("User not found")
|
|
84
|
+
|
|
85
|
+
# Проверяем пароль
|
|
86
|
+
if not hasher.verify(password, user.password_hash):
|
|
87
|
+
raise ValueError("Invalid password")
|
|
88
|
+
|
|
89
|
+
# Выпускаем пару
|
|
90
|
+
pair = await issuer.issue_pair(
|
|
91
|
+
user_id=str(user.id),
|
|
92
|
+
company_id=str(user.active_company_id) if user.active_company_id else None,
|
|
93
|
+
permissions={"skill.read", "skill.install"},
|
|
94
|
+
)
|
|
95
|
+
return pair
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 3. Middleware (верификация токена)
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from authkit import TokenError
|
|
102
|
+
|
|
103
|
+
async def auth_middleware(request, call_next):
|
|
104
|
+
auth_header = request.headers.get("Authorization", "")
|
|
105
|
+
if not auth_header.startswith("Bearer "):
|
|
106
|
+
return Response("Unauthorized", status_code=401)
|
|
107
|
+
|
|
108
|
+
token = auth_header[7:]
|
|
109
|
+
try:
|
|
110
|
+
claims = issuer.verify_access(token)
|
|
111
|
+
except TokenError as e:
|
|
112
|
+
return Response(f"Invalid token: {e}", status_code=401)
|
|
113
|
+
|
|
114
|
+
request.state.claims = claims
|
|
115
|
+
return await call_next(request)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 4. Refresh (rotation)
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
async def refresh_session(refresh_token: str):
|
|
122
|
+
try:
|
|
123
|
+
new_pair = await issuer.rotate_refresh(refresh_token)
|
|
124
|
+
except RefreshTokenError as e:
|
|
125
|
+
raise Unauthorized(f"Refresh failed: {e}")
|
|
126
|
+
return new_pair
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 5. Реализация IRefreshTokenStore
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from authkit import RefreshTokenRecord, IRefreshTokenStore
|
|
133
|
+
|
|
134
|
+
class PostgresRefreshTokenStore:
|
|
135
|
+
def __init__(self, db_engine):
|
|
136
|
+
self.engine = db_engine
|
|
137
|
+
|
|
138
|
+
async def save(self, record: RefreshTokenRecord) -> None:
|
|
139
|
+
# INSERT/UPDATE в БД
|
|
140
|
+
async with self.engine.begin() as conn:
|
|
141
|
+
await conn.execute(
|
|
142
|
+
"INSERT INTO refresh_tokens (user_id, token_hash, ...) VALUES (...)"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def get_by_hash(self, token_hash: str) -> RefreshTokenRecord | None:
|
|
146
|
+
# SELECT * FROM refresh_tokens WHERE token_hash = ?
|
|
147
|
+
...
|
|
148
|
+
|
|
149
|
+
async def revoke_all_for_user(self, user_id: str) -> int:
|
|
150
|
+
# UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = ?
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
# Реализуете остальные методы Protocol'а
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Архитектура
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
authkit/
|
|
160
|
+
├── __init__.py # Public API
|
|
161
|
+
├── exceptions.py # AuthKitError, TokenError, ...
|
|
162
|
+
├── token/
|
|
163
|
+
│ ├── models.py # TokenPair, TokenClaims
|
|
164
|
+
│ ├── jose_issuer.py # JoseTokenIssuer
|
|
165
|
+
│ └── keys.py # ensure_jwt_keypair
|
|
166
|
+
├── refresh/
|
|
167
|
+
│ └── models.py # RefreshTokenRecord, IRefreshTokenStore
|
|
168
|
+
└── password/
|
|
169
|
+
└── hasher.py # Argon2PasswordHasher
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Тестирование
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
pytest tests/ # Все тесты
|
|
176
|
+
pytest tests/ -v --cov # С coverage report
|
|
177
|
+
pytest tests/ -k "test_verify" # Конкретный тест
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Coverage gate: ≥ 80%.
|
|
181
|
+
|
|
182
|
+
## Что НЕ входит (v0.1)
|
|
183
|
+
|
|
184
|
+
- **OAuth** (YandexOAuth, ExchangeCode) — в v0.2+
|
|
185
|
+
- **PermissionResolver** — остаются на стороне приложения
|
|
186
|
+
- **Redis cache invalidation** — в v0.2+ как optional
|
|
187
|
+
- **Signing PK/SK rotation** — будущая фича
|
|
188
|
+
- **Sync обёртки** — только async API в v0.1
|
|
189
|
+
|
|
190
|
+
## Интеграция в существующее приложение
|
|
191
|
+
|
|
192
|
+
Типовой путь перевода существующего приложения на модуль:
|
|
193
|
+
1. Импортирует `from authkit import ...` вместо локального кода
|
|
194
|
+
2. Обёрнет `RefreshTokenRecord` в ORM-адаптер для своей БД
|
|
195
|
+
3. Оставит PermissionResolver/OAuth/ExchangeCode локально
|
|
196
|
+
|
|
197
|
+
## Лицензия
|
|
198
|
+
|
|
199
|
+
MIT
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# s-authkit
|
|
2
|
+
|
|
3
|
+
Ядро авторизации для Skillery проектов: JWT RS256, refresh-token management, Argon2 hashing.
|
|
4
|
+
|
|
5
|
+
**Версия:** 0.1 (первый релиз)
|
|
6
|
+
**Лицензия:** MIT
|
|
7
|
+
**Зависимости:** `python-jose[cryptography]`, `passlib[argon2]`, `cryptography`
|
|
8
|
+
|
|
9
|
+
## Что входит
|
|
10
|
+
|
|
11
|
+
### JWT access-токены (RS256)
|
|
12
|
+
- `JoseTokenIssuer` — выпуск и верификация JWT
|
|
13
|
+
- `TokenPair` / `TokenClaims` — value objects для типизации
|
|
14
|
+
- `ensure_jwt_keypair` — генерация RSA 2048 ключей
|
|
15
|
+
|
|
16
|
+
### Persistent refresh-tokens
|
|
17
|
+
- `IRefreshTokenStore` — protocol для БД-реализации
|
|
18
|
+
- `RefreshTokenRecord` — агрегат с хешированием и валидацией
|
|
19
|
+
- Rotation с audit-trail (`rotated_from`)
|
|
20
|
+
|
|
21
|
+
### Password hashing
|
|
22
|
+
- `Argon2PasswordHasher` — Argon2id через passlib
|
|
23
|
+
|
|
24
|
+
## Примеры
|
|
25
|
+
|
|
26
|
+
### 1. Инициализация
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from authkit import (
|
|
31
|
+
JoseTokenIssuer,
|
|
32
|
+
Argon2PasswordHasher,
|
|
33
|
+
ensure_jwt_keypair,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Создаём ключи (если нет)
|
|
37
|
+
keys_dir = Path.home() / ".myapp" / "keys"
|
|
38
|
+
ensure_jwt_keypair(
|
|
39
|
+
private_path=keys_dir / "jwt_private.pem",
|
|
40
|
+
public_path=keys_dir / "jwt_public.pem",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Прочитаем ключи
|
|
44
|
+
private_key = (keys_dir / "jwt_private.pem").read_text()
|
|
45
|
+
public_key = (keys_dir / "jwt_public.pem").read_text()
|
|
46
|
+
|
|
47
|
+
# Создаём компоненты
|
|
48
|
+
hasher = Argon2PasswordHasher()
|
|
49
|
+
issuer = JoseTokenIssuer(
|
|
50
|
+
private_key_pem=private_key,
|
|
51
|
+
public_key_pem=public_key,
|
|
52
|
+
issuer="myproject.com",
|
|
53
|
+
access_ttl_min=15,
|
|
54
|
+
refresh_ttl_days=30,
|
|
55
|
+
refresh_store=your_store_impl, # реализуете вы
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. Login (выпуск пары токенов)
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
async def login_with_password(username: str, password: str):
|
|
63
|
+
# Получаем юзера из БД
|
|
64
|
+
user = await db.get_user_by_username(username)
|
|
65
|
+
if not user:
|
|
66
|
+
raise ValueError("User not found")
|
|
67
|
+
|
|
68
|
+
# Проверяем пароль
|
|
69
|
+
if not hasher.verify(password, user.password_hash):
|
|
70
|
+
raise ValueError("Invalid password")
|
|
71
|
+
|
|
72
|
+
# Выпускаем пару
|
|
73
|
+
pair = await issuer.issue_pair(
|
|
74
|
+
user_id=str(user.id),
|
|
75
|
+
company_id=str(user.active_company_id) if user.active_company_id else None,
|
|
76
|
+
permissions={"skill.read", "skill.install"},
|
|
77
|
+
)
|
|
78
|
+
return pair
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 3. Middleware (верификация токена)
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from authkit import TokenError
|
|
85
|
+
|
|
86
|
+
async def auth_middleware(request, call_next):
|
|
87
|
+
auth_header = request.headers.get("Authorization", "")
|
|
88
|
+
if not auth_header.startswith("Bearer "):
|
|
89
|
+
return Response("Unauthorized", status_code=401)
|
|
90
|
+
|
|
91
|
+
token = auth_header[7:]
|
|
92
|
+
try:
|
|
93
|
+
claims = issuer.verify_access(token)
|
|
94
|
+
except TokenError as e:
|
|
95
|
+
return Response(f"Invalid token: {e}", status_code=401)
|
|
96
|
+
|
|
97
|
+
request.state.claims = claims
|
|
98
|
+
return await call_next(request)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 4. Refresh (rotation)
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
async def refresh_session(refresh_token: str):
|
|
105
|
+
try:
|
|
106
|
+
new_pair = await issuer.rotate_refresh(refresh_token)
|
|
107
|
+
except RefreshTokenError as e:
|
|
108
|
+
raise Unauthorized(f"Refresh failed: {e}")
|
|
109
|
+
return new_pair
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 5. Реализация IRefreshTokenStore
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from authkit import RefreshTokenRecord, IRefreshTokenStore
|
|
116
|
+
|
|
117
|
+
class PostgresRefreshTokenStore:
|
|
118
|
+
def __init__(self, db_engine):
|
|
119
|
+
self.engine = db_engine
|
|
120
|
+
|
|
121
|
+
async def save(self, record: RefreshTokenRecord) -> None:
|
|
122
|
+
# INSERT/UPDATE в БД
|
|
123
|
+
async with self.engine.begin() as conn:
|
|
124
|
+
await conn.execute(
|
|
125
|
+
"INSERT INTO refresh_tokens (user_id, token_hash, ...) VALUES (...)"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def get_by_hash(self, token_hash: str) -> RefreshTokenRecord | None:
|
|
129
|
+
# SELECT * FROM refresh_tokens WHERE token_hash = ?
|
|
130
|
+
...
|
|
131
|
+
|
|
132
|
+
async def revoke_all_for_user(self, user_id: str) -> int:
|
|
133
|
+
# UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = ?
|
|
134
|
+
...
|
|
135
|
+
|
|
136
|
+
# Реализуете остальные методы Protocol'а
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Архитектура
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
authkit/
|
|
143
|
+
├── __init__.py # Public API
|
|
144
|
+
├── exceptions.py # AuthKitError, TokenError, ...
|
|
145
|
+
├── token/
|
|
146
|
+
│ ├── models.py # TokenPair, TokenClaims
|
|
147
|
+
│ ├── jose_issuer.py # JoseTokenIssuer
|
|
148
|
+
│ └── keys.py # ensure_jwt_keypair
|
|
149
|
+
├── refresh/
|
|
150
|
+
│ └── models.py # RefreshTokenRecord, IRefreshTokenStore
|
|
151
|
+
└── password/
|
|
152
|
+
└── hasher.py # Argon2PasswordHasher
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Тестирование
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
pytest tests/ # Все тесты
|
|
159
|
+
pytest tests/ -v --cov # С coverage report
|
|
160
|
+
pytest tests/ -k "test_verify" # Конкретный тест
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Coverage gate: ≥ 80%.
|
|
164
|
+
|
|
165
|
+
## Что НЕ входит (v0.1)
|
|
166
|
+
|
|
167
|
+
- **OAuth** (YandexOAuth, ExchangeCode) — в v0.2+
|
|
168
|
+
- **PermissionResolver** — остаются на стороне приложения
|
|
169
|
+
- **Redis cache invalidation** — в v0.2+ как optional
|
|
170
|
+
- **Signing PK/SK rotation** — будущая фича
|
|
171
|
+
- **Sync обёртки** — только async API в v0.1
|
|
172
|
+
|
|
173
|
+
## Интеграция в существующее приложение
|
|
174
|
+
|
|
175
|
+
Типовой путь перевода существующего приложения на модуль:
|
|
176
|
+
1. Импортирует `from authkit import ...` вместо локального кода
|
|
177
|
+
2. Обёрнет `RefreshTokenRecord` в ORM-адаптер для своей БД
|
|
178
|
+
3. Оставит PermissionResolver/OAuth/ExchangeCode локально
|
|
179
|
+
|
|
180
|
+
## Лицензия
|
|
181
|
+
|
|
182
|
+
MIT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""s-authkit — JWT ядро авторизации для Skillery проектов.
|
|
2
|
+
|
|
3
|
+
Чистая инфраструктура без завязок на конкретное приложение: JWT RS256 issuing/verification,
|
|
4
|
+
refresh-token management, password hashing. Переиспользуется в будущих
|
|
5
|
+
микросервисах и интеграциях.
|
|
6
|
+
|
|
7
|
+
**Ядро (v0.1):**
|
|
8
|
+
- JWT RS256 issuer/verifier (JoseTokenIssuer)
|
|
9
|
+
- Persistent refresh-token store (IRefreshTokenStore + RefreshTokenRecord)
|
|
10
|
+
- Argon2 password hasher (Argon2PasswordHasher)
|
|
11
|
+
- RSA keypair generation (ensure_jwt_keypair)
|
|
12
|
+
|
|
13
|
+
**Отложено (v0.2+):** OAuth, PermissionResolver, ExchangeCode (app-specific).
|
|
14
|
+
|
|
15
|
+
Зависимости: python-jose[cryptography], passlib[argon2], cryptography.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from authkit.exceptions import (
|
|
20
|
+
AuthKitError,
|
|
21
|
+
PasswordHashError,
|
|
22
|
+
RefreshTokenError,
|
|
23
|
+
TokenError,
|
|
24
|
+
)
|
|
25
|
+
from authkit.password import Argon2PasswordHasher
|
|
26
|
+
from authkit.refresh import IRefreshTokenStore, RefreshTokenRecord
|
|
27
|
+
from authkit.token import JoseTokenIssuer, TokenClaims, TokenPair, ensure_jwt_keypair
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
# exceptions
|
|
31
|
+
"AuthKitError",
|
|
32
|
+
"TokenError",
|
|
33
|
+
"RefreshTokenError",
|
|
34
|
+
"PasswordHashError",
|
|
35
|
+
# token
|
|
36
|
+
"TokenPair",
|
|
37
|
+
"TokenClaims",
|
|
38
|
+
"JoseTokenIssuer",
|
|
39
|
+
"ensure_jwt_keypair",
|
|
40
|
+
# refresh
|
|
41
|
+
"RefreshTokenRecord",
|
|
42
|
+
"IRefreshTokenStore",
|
|
43
|
+
# password
|
|
44
|
+
"Argon2PasswordHasher",
|
|
45
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Исключения модуля authkit."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthKitError(Exception):
|
|
6
|
+
"""Базовое исключение модуля authkit."""
|
|
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,55 @@
|
|
|
1
|
+
"""Argon2 password hasher через passlib."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from passlib.context import CryptContext
|
|
5
|
+
|
|
6
|
+
from authkit.exceptions import PasswordHashError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Argon2PasswordHasher:
|
|
10
|
+
"""Argon2id password hasher через passlib.
|
|
11
|
+
|
|
12
|
+
Использует passlib с Argon2id схемой, настроенной для интерактивных flow'ов
|
|
13
|
+
(~50-100ms на современном CPU). Возвращает self-contained хеши с salt,
|
|
14
|
+
верификация не требует отдельного хранения salt.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
"""Инициализирует hasher с Argon2id (passlib defaults)."""
|
|
19
|
+
self._ctx = CryptContext(schemes=["argon2"], deprecated="auto")
|
|
20
|
+
|
|
21
|
+
def hash(self, raw: str) -> str:
|
|
22
|
+
"""Хеширует пароль.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
raw: Plaintext пароль.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Self-contained хеш (algorithm + salt + hash).
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
PasswordHashError: Если хеширование не удалось.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
return str(self._ctx.hash(raw))
|
|
35
|
+
except Exception as e:
|
|
36
|
+
raise PasswordHashError(f"Failed to hash password: {e}") from e
|
|
37
|
+
|
|
38
|
+
def verify(self, raw: str, hashed: str) -> bool:
|
|
39
|
+
"""Проверяет пароль против хеша.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
raw: Plaintext пароль для проверки.
|
|
43
|
+
hashed: Self-contained хеш для сравнения.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True если пароль совпадает, False иначе.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
return bool(self._ctx.verify(raw, hashed))
|
|
50
|
+
except Exception:
|
|
51
|
+
# Любая ошибка (невалидный хеш, невалидный пароль) → False
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["Argon2PasswordHasher"]
|