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.
@@ -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)}")