fastapi-keycloak-admin 0.1.0__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.
- fastapi_keycloak_admin/__init__.py +102 -0
- fastapi_keycloak_admin/challenges.py +226 -0
- fastapi_keycloak_admin/cli.py +73 -0
- fastapi_keycloak_admin/client.py +362 -0
- fastapi_keycloak_admin/config.py +179 -0
- fastapi_keycloak_admin/dependencies.py +117 -0
- fastapi_keycloak_admin/errors.py +267 -0
- fastapi_keycloak_admin/exceptions.py +88 -0
- fastapi_keycloak_admin/integration.py +71 -0
- fastapi_keycloak_admin/models.py +53 -0
- fastapi_keycloak_admin/preferences.py +44 -0
- fastapi_keycloak_admin/py.typed +0 -0
- fastapi_keycloak_admin/routes.py +419 -0
- fastapi_keycloak_admin/stores.py +57 -0
- fastapi_keycloak_admin/token.py +134 -0
- fastapi_keycloak_admin/verification.py +63 -0
- fastapi_keycloak_admin-0.1.0.dist-info/METADATA +607 -0
- fastapi_keycloak_admin-0.1.0.dist-info/RECORD +21 -0
- fastapi_keycloak_admin-0.1.0.dist-info/WHEEL +4 -0
- fastapi_keycloak_admin-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_keycloak_admin-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from fastapi_keycloak_admin.challenges import LoginChallenge, LoginChallenges
|
|
2
|
+
from fastapi_keycloak_admin.client import KeycloakAdminClient
|
|
3
|
+
from fastapi_keycloak_admin.config import KeycloakAdminConfig, KeycloakConfig, RefreshCookieConfig
|
|
4
|
+
from fastapi_keycloak_admin.dependencies import (
|
|
5
|
+
CurrentUser,
|
|
6
|
+
KeycloakAdminDep,
|
|
7
|
+
MatchStrategy,
|
|
8
|
+
get_current_user,
|
|
9
|
+
get_keycloak_admin,
|
|
10
|
+
require_client_roles,
|
|
11
|
+
require_roles,
|
|
12
|
+
require_scopes,
|
|
13
|
+
requires_admin,
|
|
14
|
+
)
|
|
15
|
+
from fastapi_keycloak_admin.errors import (
|
|
16
|
+
ApiError,
|
|
17
|
+
ErrorItem,
|
|
18
|
+
KeycloakRoute,
|
|
19
|
+
error_response,
|
|
20
|
+
install_error_handlers,
|
|
21
|
+
)
|
|
22
|
+
from fastapi_keycloak_admin.exceptions import (
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
AuthorizationError,
|
|
25
|
+
InsufficientRoleError,
|
|
26
|
+
InsufficientScopeError,
|
|
27
|
+
InvalidTokenError,
|
|
28
|
+
JWKSFetchError,
|
|
29
|
+
KeycloakAdminError,
|
|
30
|
+
KeycloakClientError,
|
|
31
|
+
KeycloakLoginError,
|
|
32
|
+
KeycloakTokenError,
|
|
33
|
+
KeycloakUnavailableError,
|
|
34
|
+
LoginChallengeError,
|
|
35
|
+
MissingTokenError,
|
|
36
|
+
PasswordChangeRequiredError,
|
|
37
|
+
TokenExpiredError,
|
|
38
|
+
)
|
|
39
|
+
from fastapi_keycloak_admin.integration import KeycloakAdmin
|
|
40
|
+
from fastapi_keycloak_admin.models import KeycloakUser
|
|
41
|
+
from fastapi_keycloak_admin.preferences import LoginVerificationPreferences
|
|
42
|
+
from fastapi_keycloak_admin.routes import (
|
|
43
|
+
LoginChallengeResponse,
|
|
44
|
+
LoginVerificationSettings,
|
|
45
|
+
UserResponse,
|
|
46
|
+
)
|
|
47
|
+
from fastapi_keycloak_admin.stores import ChallengeStore, MemoryChallengeStore, RedisChallengeStore
|
|
48
|
+
from fastapi_keycloak_admin.verification import (
|
|
49
|
+
LoginCode,
|
|
50
|
+
LoginCodeSender,
|
|
51
|
+
LoginVerificationConfig,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__all__ = (
|
|
55
|
+
"ApiError",
|
|
56
|
+
"AuthenticationError",
|
|
57
|
+
"AuthorizationError",
|
|
58
|
+
"ChallengeStore",
|
|
59
|
+
"CurrentUser",
|
|
60
|
+
"ErrorItem",
|
|
61
|
+
"InsufficientRoleError",
|
|
62
|
+
"InsufficientScopeError",
|
|
63
|
+
"InvalidTokenError",
|
|
64
|
+
"JWKSFetchError",
|
|
65
|
+
"KeycloakAdmin",
|
|
66
|
+
"KeycloakAdminClient",
|
|
67
|
+
"KeycloakAdminConfig",
|
|
68
|
+
"KeycloakAdminDep",
|
|
69
|
+
"KeycloakAdminError",
|
|
70
|
+
"KeycloakClientError",
|
|
71
|
+
"KeycloakConfig",
|
|
72
|
+
"KeycloakLoginError",
|
|
73
|
+
"KeycloakRoute",
|
|
74
|
+
"KeycloakTokenError",
|
|
75
|
+
"KeycloakUnavailableError",
|
|
76
|
+
"KeycloakUser",
|
|
77
|
+
"LoginChallenge",
|
|
78
|
+
"LoginChallengeError",
|
|
79
|
+
"LoginChallengeResponse",
|
|
80
|
+
"LoginChallenges",
|
|
81
|
+
"LoginCode",
|
|
82
|
+
"LoginCodeSender",
|
|
83
|
+
"LoginVerificationConfig",
|
|
84
|
+
"LoginVerificationPreferences",
|
|
85
|
+
"LoginVerificationSettings",
|
|
86
|
+
"MatchStrategy",
|
|
87
|
+
"MemoryChallengeStore",
|
|
88
|
+
"MissingTokenError",
|
|
89
|
+
"PasswordChangeRequiredError",
|
|
90
|
+
"RedisChallengeStore",
|
|
91
|
+
"RefreshCookieConfig",
|
|
92
|
+
"TokenExpiredError",
|
|
93
|
+
"UserResponse",
|
|
94
|
+
"error_response",
|
|
95
|
+
"get_current_user",
|
|
96
|
+
"get_keycloak_admin",
|
|
97
|
+
"install_error_handlers",
|
|
98
|
+
"require_client_roles",
|
|
99
|
+
"require_roles",
|
|
100
|
+
"require_scopes",
|
|
101
|
+
"requires_admin",
|
|
102
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Pending second login steps, kept in a ``ChallengeStore`` until the code is confirmed."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi_keycloak_admin.client import KeycloakAdminClient
|
|
15
|
+
from fastapi_keycloak_admin.exceptions import KeycloakLoginError, LoginChallengeError
|
|
16
|
+
from fastapi_keycloak_admin.stores import ChallengeStore
|
|
17
|
+
from fastapi_keycloak_admin.verification import LoginCode, LoginVerificationConfig
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class LoginChallenge:
|
|
24
|
+
"""What the client learns about a started challenge - never the code itself."""
|
|
25
|
+
|
|
26
|
+
challenge_id: str
|
|
27
|
+
expires_in: int
|
|
28
|
+
destination: str
|
|
29
|
+
"""The masked e-mail the code went to, e.g. ``a***@example.com``."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class _Record:
|
|
34
|
+
tokens: dict[str, Any]
|
|
35
|
+
subject: str
|
|
36
|
+
email: str
|
|
37
|
+
username: str | None
|
|
38
|
+
first_name: str | None
|
|
39
|
+
code_hash: str
|
|
40
|
+
expires_at: float
|
|
41
|
+
sent_at: float
|
|
42
|
+
attempts: int = 0
|
|
43
|
+
sends: int = 1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LoginChallenges:
|
|
47
|
+
"""Issues, checks and resends login codes.
|
|
48
|
+
|
|
49
|
+
Keycloak tokens of the already checked password wait in the store and are released
|
|
50
|
+
only for the right code, so a password alone never yields a session. A challenge that
|
|
51
|
+
is used up, or has too many wrong codes, ends that Keycloak session.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self, config: LoginVerificationConfig, client: KeycloakAdminClient, store: ChallengeStore
|
|
56
|
+
) -> None:
|
|
57
|
+
self._config = config
|
|
58
|
+
self._client = client
|
|
59
|
+
self._store = store
|
|
60
|
+
|
|
61
|
+
async def start(self, tokens: dict[str, Any]) -> LoginChallenge:
|
|
62
|
+
claims = unverified_claims(str(tokens["access_token"]))
|
|
63
|
+
email = claims.get("email")
|
|
64
|
+
if not email:
|
|
65
|
+
await self._end_session(tokens)
|
|
66
|
+
raise LoginChallengeError(
|
|
67
|
+
"The account has no e-mail to send the login code to",
|
|
68
|
+
reason="emailRequired",
|
|
69
|
+
status_code=403,
|
|
70
|
+
)
|
|
71
|
+
challenge_id = secrets.token_urlsafe(32)
|
|
72
|
+
code = self._new_code()
|
|
73
|
+
now = time.time()
|
|
74
|
+
record = _Record(
|
|
75
|
+
tokens=tokens,
|
|
76
|
+
subject=str(claims.get("sub") or ""),
|
|
77
|
+
email=str(email),
|
|
78
|
+
username=claims.get("preferred_username"),
|
|
79
|
+
first_name=claims.get("given_name"),
|
|
80
|
+
code_hash=_hash(challenge_id, code),
|
|
81
|
+
expires_at=now + self._config.ttl_seconds,
|
|
82
|
+
sent_at=now,
|
|
83
|
+
)
|
|
84
|
+
await self._save(challenge_id, record)
|
|
85
|
+
await self._deliver(challenge_id, record, code)
|
|
86
|
+
return self._challenge(challenge_id, record)
|
|
87
|
+
|
|
88
|
+
async def verify(self, challenge_id: str, code: str) -> dict[str, Any]:
|
|
89
|
+
record = await self._load(challenge_id)
|
|
90
|
+
if hmac.compare_digest(record.code_hash, _hash(challenge_id, code.strip())):
|
|
91
|
+
await self._store.delete(challenge_id)
|
|
92
|
+
return await self._fresh_tokens(record.tokens)
|
|
93
|
+
record.attempts += 1
|
|
94
|
+
attempts_left = self._config.max_attempts - record.attempts
|
|
95
|
+
if attempts_left <= 0:
|
|
96
|
+
await self._drop(challenge_id, record)
|
|
97
|
+
logger.warning("Login challenge locked after wrong codes: %s", record.subject)
|
|
98
|
+
raise LoginChallengeError(
|
|
99
|
+
"Too many wrong codes, log in again", reason="tooManyAttempts", status_code=429
|
|
100
|
+
)
|
|
101
|
+
await self._save(challenge_id, record)
|
|
102
|
+
raise LoginChallengeError(
|
|
103
|
+
f"Invalid code, {attempts_left} attempt(s) left",
|
|
104
|
+
reason="invalidCode",
|
|
105
|
+
status_code=401,
|
|
106
|
+
location="code",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def resend(self, challenge_id: str) -> LoginChallenge:
|
|
110
|
+
record = await self._load(challenge_id)
|
|
111
|
+
if record.sends >= self._config.max_sends:
|
|
112
|
+
raise LoginChallengeError(
|
|
113
|
+
"No more codes can be sent, log in again", reason="tooManySends", status_code=429
|
|
114
|
+
)
|
|
115
|
+
retry_at = record.sent_at + self._config.resend_interval_seconds
|
|
116
|
+
if time.time() < retry_at:
|
|
117
|
+
wait = max(1, math.ceil(retry_at - time.time()))
|
|
118
|
+
raise LoginChallengeError(
|
|
119
|
+
f"A new code can be requested in {wait} s",
|
|
120
|
+
reason="resendTooSoon",
|
|
121
|
+
status_code=429,
|
|
122
|
+
retry_after=wait,
|
|
123
|
+
)
|
|
124
|
+
code = self._new_code()
|
|
125
|
+
record.code_hash = _hash(challenge_id, code)
|
|
126
|
+
record.sends += 1
|
|
127
|
+
record.sent_at = time.time()
|
|
128
|
+
await self._save(challenge_id, record)
|
|
129
|
+
await self._deliver(challenge_id, record, code)
|
|
130
|
+
return self._challenge(challenge_id, record)
|
|
131
|
+
|
|
132
|
+
async def _deliver(self, challenge_id: str, record: _Record, code: str) -> None:
|
|
133
|
+
delivery = LoginCode(
|
|
134
|
+
challenge_id=challenge_id,
|
|
135
|
+
subject=record.subject,
|
|
136
|
+
email=record.email,
|
|
137
|
+
code=code,
|
|
138
|
+
expires_in=_remaining(record),
|
|
139
|
+
username=record.username,
|
|
140
|
+
first_name=record.first_name,
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
await self._config.send_code(delivery)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
logger.exception("Could not send a login code to %s", record.subject)
|
|
146
|
+
await self._drop(challenge_id, record)
|
|
147
|
+
raise LoginChallengeError(
|
|
148
|
+
"Could not send the login code, try again later",
|
|
149
|
+
reason="codeDeliveryFailed",
|
|
150
|
+
status_code=503,
|
|
151
|
+
) from exc
|
|
152
|
+
|
|
153
|
+
async def _fresh_tokens(self, tokens: dict[str, Any]) -> dict[str, Any]:
|
|
154
|
+
"""The access token may have expired while the user read the e-mail."""
|
|
155
|
+
refresh_token = tokens.get("refresh_token")
|
|
156
|
+
if not refresh_token:
|
|
157
|
+
return tokens
|
|
158
|
+
try:
|
|
159
|
+
return await self._client.refresh(str(refresh_token))
|
|
160
|
+
except KeycloakLoginError as exc:
|
|
161
|
+
raise _expired() from exc
|
|
162
|
+
|
|
163
|
+
async def _load(self, challenge_id: str) -> _Record:
|
|
164
|
+
raw = await self._store.get(challenge_id) if challenge_id else None
|
|
165
|
+
if raw is None:
|
|
166
|
+
raise _expired()
|
|
167
|
+
record = _Record(**json.loads(raw))
|
|
168
|
+
if _remaining(record) <= 0:
|
|
169
|
+
await self._drop(challenge_id, record)
|
|
170
|
+
raise _expired()
|
|
171
|
+
return record
|
|
172
|
+
|
|
173
|
+
async def _save(self, challenge_id: str, record: _Record) -> None:
|
|
174
|
+
await self._store.set(
|
|
175
|
+
challenge_id, json.dumps(asdict(record)), expires_in=max(1, _remaining(record))
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def _drop(self, challenge_id: str, record: _Record) -> None:
|
|
179
|
+
await self._store.delete(challenge_id)
|
|
180
|
+
await self._end_session(record.tokens)
|
|
181
|
+
|
|
182
|
+
async def _end_session(self, tokens: dict[str, Any]) -> None:
|
|
183
|
+
if refresh_token := tokens.get("refresh_token"):
|
|
184
|
+
await self._client.logout(str(refresh_token))
|
|
185
|
+
|
|
186
|
+
def _new_code(self) -> str:
|
|
187
|
+
length = self._config.code_length
|
|
188
|
+
return f"{secrets.randbelow(10**length):0{length}d}"
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _challenge(challenge_id: str, record: _Record) -> LoginChallenge:
|
|
192
|
+
return LoginChallenge(
|
|
193
|
+
challenge_id=challenge_id,
|
|
194
|
+
expires_in=_remaining(record),
|
|
195
|
+
destination=mask_email(record.email),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def mask_email(email: str) -> str:
|
|
200
|
+
local, _, domain = email.partition("@")
|
|
201
|
+
return f"{local[:1]}***@{domain}" if domain else "***"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _hash(challenge_id: str, code: str) -> str:
|
|
205
|
+
return hmac.new(challenge_id.encode(), code.encode(), hashlib.sha256).hexdigest()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _remaining(record: _Record) -> int:
|
|
209
|
+
return int(record.expires_at - time.time())
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _expired() -> LoginChallengeError:
|
|
213
|
+
return LoginChallengeError(
|
|
214
|
+
"The login code has expired, log in again", reason="challengeExpired", status_code=401
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def unverified_claims(access_token: str) -> dict[str, Any]:
|
|
219
|
+
"""Claims of a token the client has just received from Keycloak's token endpoint
|
|
220
|
+
itself, so there is nobody to forge it - no signature check needed here."""
|
|
221
|
+
try:
|
|
222
|
+
payload = access_token.split(".")[1]
|
|
223
|
+
decoded = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
|
224
|
+
except (IndexError, ValueError) as exc:
|
|
225
|
+
raise KeycloakLoginError("Keycloak returned a malformed access token") from exc
|
|
226
|
+
return decoded if isinstance(decoded, dict) else {}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""``fastapi-keycloak-admin create-user``: create users (e.g. the first admin) from the shell.
|
|
2
|
+
|
|
3
|
+
Reads the same ``KEYCLOAK_*`` variables as ``KeycloakAdminConfig.from_env``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from fastapi_keycloak_admin.client import KeycloakAdminClient
|
|
11
|
+
from fastapi_keycloak_admin.config import KeycloakAdminConfig
|
|
12
|
+
from fastapi_keycloak_admin.exceptions import KeycloakAdminError, KeycloakClientError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group(help="Manage Keycloak users.")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--env-prefix", default="KEYCLOAK_", show_default=True, help="Prefix of the variables."
|
|
18
|
+
)
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def cli(ctx: click.Context, env_prefix: str) -> None:
|
|
21
|
+
ctx.obj = env_prefix
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@cli.command(name="create-user", help="Create a user with a temporary password.")
|
|
25
|
+
@click.argument("username")
|
|
26
|
+
@click.option(
|
|
27
|
+
"--role", "roles", multiple=True, required=True, help="Realm role; repeat for several."
|
|
28
|
+
)
|
|
29
|
+
@click.option("--password", prompt=True, hide_input=True, envvar="KEYCLOAK_NEW_USER_PASSWORD")
|
|
30
|
+
@click.option("--email", default=None)
|
|
31
|
+
@click.option("--first-name", default=None)
|
|
32
|
+
@click.option("--last-name", default=None)
|
|
33
|
+
@click.option("--exist-ok", is_flag=True, help="Exit successfully if the user already exists.")
|
|
34
|
+
@click.pass_obj
|
|
35
|
+
def create_user(
|
|
36
|
+
env_prefix: str,
|
|
37
|
+
username: str,
|
|
38
|
+
roles: tuple[str, ...],
|
|
39
|
+
password: str,
|
|
40
|
+
email: str | None,
|
|
41
|
+
first_name: str | None,
|
|
42
|
+
last_name: str | None,
|
|
43
|
+
exist_ok: bool,
|
|
44
|
+
) -> None:
|
|
45
|
+
try:
|
|
46
|
+
config = KeycloakAdminConfig.from_env(env_prefix)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise click.ClickException(str(exc)) from exc
|
|
49
|
+
client = KeycloakAdminClient(config)
|
|
50
|
+
|
|
51
|
+
async def run() -> str:
|
|
52
|
+
try:
|
|
53
|
+
return await client.create_user(
|
|
54
|
+
username=username,
|
|
55
|
+
password=password,
|
|
56
|
+
email=email,
|
|
57
|
+
first_name=first_name,
|
|
58
|
+
last_name=last_name,
|
|
59
|
+
roles=roles,
|
|
60
|
+
)
|
|
61
|
+
finally:
|
|
62
|
+
await client.close()
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
subject = asyncio.run(run())
|
|
66
|
+
except KeycloakAdminError as exc:
|
|
67
|
+
if exc.conflict and exist_ok:
|
|
68
|
+
click.echo(f"User {username!r} already exists, nothing to do")
|
|
69
|
+
return
|
|
70
|
+
raise click.ClickException(str(exc)) from exc
|
|
71
|
+
except KeycloakClientError as exc:
|
|
72
|
+
raise click.ClickException(str(exc)) from exc
|
|
73
|
+
click.echo(f"Created user {username!r} ({subject}) with roles: {', '.join(roles)}")
|