keepup-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.
- keepup/THIRD-PARTY.md +44 -0
- keepup/__init__.py +41 -0
- keepup/api_versions.py +100 -0
- keepup/audit.py +499 -0
- keepup/auth/__init__.py +7 -0
- keepup/auth/config.py +128 -0
- keepup/auth/dependencies.py +485 -0
- keepup/auth/dto/__init__.py +0 -0
- keepup/auth/dto/token.py +31 -0
- keepup/auth/factory.py +28 -0
- keepup/auth/login_throttle.py +151 -0
- keepup/auth/oidc.py +245 -0
- keepup/auth/oidc_policy.py +111 -0
- keepup/auth/oidc_routes.py +288 -0
- keepup/auth/panel_session.py +220 -0
- keepup/auth/permissions.py +59 -0
- keepup/auth/providers/__init__.py +0 -0
- keepup/auth/providers/base.py +168 -0
- keepup/auth/providers/local.py +180 -0
- keepup/auth/routes.py +660 -0
- keepup/auth/seed_accounts.py +322 -0
- keepup/auth/session_lifetime.py +104 -0
- keepup/auth/signing_key.py +138 -0
- keepup/auth/websocket.py +86 -0
- keepup/cluster.py +634 -0
- keepup/db.py +663 -0
- keepup/events.py +764 -0
- keepup/factory.py +484 -0
- keepup/instance.py +46 -0
- keepup/integrations.py +260 -0
- keepup/locks.py +412 -0
- keepup/logging_setup.py +690 -0
- keepup/metrics.py +818 -0
- keepup/metrics_retention.py +376 -0
- keepup/modules.py +572 -0
- keepup/notification_bus.py +355 -0
- keepup/plugins/__init__.py +9 -0
- keepup/plugins/admin.py +246 -0
- keepup/plugins/base.py +234 -0
- keepup/plugins/enablement.py +184 -0
- keepup/plugins/registry.py +171 -0
- keepup/plugins/route_mask.py +338 -0
- keepup/plugins/routes.py +376 -0
- keepup/roles.py +17 -0
- keepup/scheduler.py +93 -0
- keepup/schema.py +295 -0
- keepup/sections.json +104 -0
- keepup/settings.py +184 -0
- keepup/static/css/aos.css +1 -0
- keepup/static/css/main_nebula.css +232 -0
- keepup/static/css/main_new.css +852 -0
- keepup/static/css/tailwind.css +1 -0
- keepup/static/index_nebula.html +293 -0
- keepup/static/index_new.html +286 -0
- keepup/static/js/aos.js +1 -0
- keepup/static/js/feather-icons.js +13 -0
- keepup/static/js/main_new.js +1861 -0
- keepup/static/js/tailwind.js +83 -0
- keepup/static/modules/css/background_tasks.css +233 -0
- keepup/static/modules/css/cluster.css +16 -0
- keepup/static/modules/css/event_manager.css +386 -0
- keepup/static/modules/css/integration_logs.css +33 -0
- keepup/static/modules/css/metrics.css +115 -0
- keepup/static/modules/css/modules.css +189 -0
- keepup/static/modules/css/themes.css +563 -0
- keepup/static/modules/css/users.css +278 -0
- keepup/static/modules/js/background_tasks.js +657 -0
- keepup/static/modules/js/chart.js +14 -0
- keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup/static/modules/js/cluster.js +363 -0
- keepup/static/modules/js/event_manager.js +979 -0
- keepup/static/modules/js/integration_logs.js +767 -0
- keepup/static/modules/js/metrics.js +908 -0
- keepup/static/modules/js/modules.js +1086 -0
- keepup/static/modules/js/themes.js +653 -0
- keepup/static/modules/js/users.js +784 -0
- keepup/tables.py +302 -0
- keepup/themes.py +496 -0
- keepup/web.py +182 -0
- keepup_admin-0.1.0.dist-info/METADATA +117 -0
- keepup_admin-0.1.0.dist-info/RECORD +86 -0
- keepup_admin-0.1.0.dist-info/WHEEL +5 -0
- keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
- keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
- keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
- keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Counting failed logins, so a password cannot be guessed at full speed.
|
|
2
|
+
|
|
3
|
+
The counter lives in the database rather than in the process. The application is
|
|
4
|
+
meant to run as several replicas against one database, and a counter held in
|
|
5
|
+
memory is one the next attempt walks around simply by landing on another replica.
|
|
6
|
+
|
|
7
|
+
Attempts are counted per username, not per address. An address behind a shared
|
|
8
|
+
uplink belongs to many people, and locking it out shuts the door on everyone who
|
|
9
|
+
happens to share it. The trade is that somebody can keep a known name locked for
|
|
10
|
+
the length of the window; that window is short, and the alternative is worse.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from sqlalchemy import Column, DateTime, Integer, String, Text
|
|
19
|
+
|
|
20
|
+
from keepup import tables
|
|
21
|
+
from keepup.db import DatabaseManagerV2
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
#: How many failures in a row close the door, and for how long.
|
|
26
|
+
MAX_ATTEMPTS_ENV = "LOGIN_MAX_ATTEMPTS"
|
|
27
|
+
LOCKOUT_MINUTES_ENV = "LOGIN_LOCKOUT_MINUTES"
|
|
28
|
+
DEFAULT_MAX_ATTEMPTS = 10
|
|
29
|
+
DEFAULT_LOCKOUT_MINUTES = 15
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def max_attempts(environ=None) -> int:
|
|
33
|
+
"""Failures tolerated before the lockout window starts."""
|
|
34
|
+
environ = environ if environ is not None else os.environ
|
|
35
|
+
try:
|
|
36
|
+
value = int(environ.get(MAX_ATTEMPTS_ENV, DEFAULT_MAX_ATTEMPTS))
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return DEFAULT_MAX_ATTEMPTS
|
|
39
|
+
return value if value > 0 else DEFAULT_MAX_ATTEMPTS
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def lockout_window(environ=None) -> timedelta:
|
|
43
|
+
"""How long the door stays closed once the attempts are spent."""
|
|
44
|
+
environ = environ if environ is not None else os.environ
|
|
45
|
+
try:
|
|
46
|
+
minutes = int(environ.get(LOCKOUT_MINUTES_ENV, DEFAULT_LOCKOUT_MINUTES))
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
minutes = DEFAULT_LOCKOUT_MINUTES
|
|
49
|
+
return timedelta(minutes=minutes if minutes > 0 else DEFAULT_LOCKOUT_MINUTES)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
LOGIN_ATTEMPTS = tables.table(
|
|
53
|
+
"login_attempts",
|
|
54
|
+
Column("username", String(255).with_variant(Text(), "sqlite"), primary_key=True, nullable=True),
|
|
55
|
+
Column("failures", Integer, nullable=False, server_default=tables.sql_text("0")),
|
|
56
|
+
Column("last_failure_at", DateTime),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def init_login_attempts_table() -> None:
|
|
61
|
+
"""Create the table this module keeps its count in.
|
|
62
|
+
|
|
63
|
+
There are no migrations: each start creates what is missing.
|
|
64
|
+
"""
|
|
65
|
+
tables.ensure_tables(LOGIN_ATTEMPTS)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _row(username: str) -> Optional[dict]:
|
|
69
|
+
return DatabaseManagerV2.execute_one(
|
|
70
|
+
"SELECT username, failures, last_failure_at FROM login_attempts WHERE username = :username",
|
|
71
|
+
{"username": username}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _as_datetime(value) -> Optional[datetime]:
|
|
76
|
+
if value is None or isinstance(value, datetime):
|
|
77
|
+
return value
|
|
78
|
+
try:
|
|
79
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def locked_until(username: str, now: Optional[datetime] = None) -> Optional[datetime]:
|
|
85
|
+
"""When this name may try again, or None when it may try now."""
|
|
86
|
+
if not username:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
now = now or datetime.utcnow()
|
|
90
|
+
try:
|
|
91
|
+
row = _row(username)
|
|
92
|
+
except Exception as error:
|
|
93
|
+
# A throttle that cannot read its own table must not become the reason
|
|
94
|
+
# nobody can log in.
|
|
95
|
+
logger.warning(f"Could not read the login attempts of {username}: {error}")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
if not row or (row.get("failures") or 0) < max_attempts():
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
last_failure = _as_datetime(row.get("last_failure_at"))
|
|
102
|
+
if last_failure is None:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
until = last_failure + lockout_window()
|
|
106
|
+
return until if until > now else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def record_failure(username: str, now: Optional[datetime] = None) -> None:
|
|
110
|
+
"""Count one failed attempt against this name."""
|
|
111
|
+
if not username:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
now = now or datetime.utcnow()
|
|
115
|
+
try:
|
|
116
|
+
row = _row(username)
|
|
117
|
+
if row is None:
|
|
118
|
+
DatabaseManagerV2.execute_commit(
|
|
119
|
+
"INSERT INTO login_attempts (username, failures, last_failure_at) "
|
|
120
|
+
"VALUES (:username, 1, :now)",
|
|
121
|
+
{"username": username, "now": now}
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# A name whose window has already passed starts its count again, so old
|
|
126
|
+
# failures do not add up to a lockout weeks later.
|
|
127
|
+
failures = (row.get("failures") or 0)
|
|
128
|
+
last_failure = _as_datetime(row.get("last_failure_at"))
|
|
129
|
+
if last_failure is not None and last_failure + lockout_window() <= now:
|
|
130
|
+
failures = 0
|
|
131
|
+
|
|
132
|
+
DatabaseManagerV2.execute_commit(
|
|
133
|
+
"UPDATE login_attempts SET failures = :failures, last_failure_at = :now "
|
|
134
|
+
"WHERE username = :username",
|
|
135
|
+
{"failures": failures + 1, "now": now, "username": username}
|
|
136
|
+
)
|
|
137
|
+
except Exception as error:
|
|
138
|
+
logger.warning(f"Could not record a failed login of {username}: {error}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def record_success(username: str) -> None:
|
|
142
|
+
"""Forget the failures of a name that has just got in."""
|
|
143
|
+
if not username:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
DatabaseManagerV2.execute_commit(
|
|
147
|
+
"DELETE FROM login_attempts WHERE username = :username",
|
|
148
|
+
{"username": username}
|
|
149
|
+
)
|
|
150
|
+
except Exception as error:
|
|
151
|
+
logger.warning(f"Could not clear the login attempts of {username}: {error}")
|
keepup/auth/oidc.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Signing in through an external identity provider, over OpenID Connect.
|
|
2
|
+
|
|
3
|
+
The person proves who they are to their provider, and this application never
|
|
4
|
+
sees their password. What comes back is a code, which is exchanged for an
|
|
5
|
+
``id_token`` -- a signed statement by the provider about who just signed in.
|
|
6
|
+
|
|
7
|
+
Two things are worth knowing before reading further.
|
|
8
|
+
|
|
9
|
+
**Everything is checked before anybody is believed.** The order is: the state
|
|
10
|
+
we issued, then the exchange, then the signature against the provider's keys,
|
|
11
|
+
then issuer, audience, expiry and nonce. A failure at any point answers exactly
|
|
12
|
+
like a failure at any other; the reason goes to the log. Different answers for
|
|
13
|
+
different failures would tell whoever is probing which check they got past.
|
|
14
|
+
|
|
15
|
+
**Nothing of the provider's is kept.** The access and refresh tokens are not
|
|
16
|
+
stored: they are keys to somebody's account at the provider, and this
|
|
17
|
+
application has no business holding them. The ``id_token`` is read once and
|
|
18
|
+
dropped. What is kept is the pair (issuer, subject) -- who this is -- and that
|
|
19
|
+
is the whole point of the exchange.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import hashlib
|
|
24
|
+
import logging
|
|
25
|
+
import secrets
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any, Dict, Optional, Tuple
|
|
29
|
+
from urllib.parse import urlencode
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
from jose import JWTError, jwt
|
|
33
|
+
|
|
34
|
+
from keepup.auth.signing_key import resolve_signing_key
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
#: The cookie carrying the state of one sign-in attempt. SameSite=Lax rather
|
|
39
|
+
#: than Strict on purpose: the return from the provider is a navigation from
|
|
40
|
+
#: another site, and Strict would withhold the cookie exactly then -- that is,
|
|
41
|
+
#: always.
|
|
42
|
+
FLOW_COOKIE = "ss_oidc"
|
|
43
|
+
FLOW_COOKIE_MAX_AGE = 600
|
|
44
|
+
|
|
45
|
+
#: Symmetric algorithms and "none" are never accepted for an id_token: the
|
|
46
|
+
#: provider signs with its private key, and anything else means the token was
|
|
47
|
+
#: signed by whoever handed it to us.
|
|
48
|
+
ALLOWED_ALGORITHMS = ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256")
|
|
49
|
+
|
|
50
|
+
#: The one answer every refusal gives. See the module docstring.
|
|
51
|
+
REFUSAL = "Sign-in failed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SignInRefused(Exception):
|
|
55
|
+
"""Raised for every failed check; the reason is for the log, not the caller."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ProviderMetadata:
|
|
60
|
+
"""What the provider says about itself, from its discovery document."""
|
|
61
|
+
|
|
62
|
+
issuer: str
|
|
63
|
+
authorization_endpoint: str
|
|
64
|
+
token_endpoint: str
|
|
65
|
+
jwks_uri: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProviderDirectory:
|
|
69
|
+
"""The provider's metadata and signing keys, kept for the process.
|
|
70
|
+
|
|
71
|
+
Held in memory rather than in the database: none of it is secret, losing it
|
|
72
|
+
costs one request, and a shared copy would need invalidating across
|
|
73
|
+
replicas -- which costs more than it saves. Keys are re-read when a token
|
|
74
|
+
arrives signed by a key id we do not know, because providers rotate keys
|
|
75
|
+
without telling anyone and sign-in must not stay broken until a restart.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, settings, client_factory=None):
|
|
79
|
+
self._settings = settings
|
|
80
|
+
self._client_factory = client_factory or self._default_client
|
|
81
|
+
self._metadata: Optional[ProviderMetadata] = None
|
|
82
|
+
self._keys: Dict[str, Dict[str, Any]] = {}
|
|
83
|
+
|
|
84
|
+
def _default_client(self):
|
|
85
|
+
return httpx.AsyncClient(timeout=self._settings.timeout_seconds)
|
|
86
|
+
|
|
87
|
+
async def _get_json(self, url: str) -> Dict[str, Any]:
|
|
88
|
+
async with self._client_factory() as client:
|
|
89
|
+
response = await client.get(url)
|
|
90
|
+
response.raise_for_status()
|
|
91
|
+
return response.json()
|
|
92
|
+
|
|
93
|
+
async def metadata(self) -> ProviderMetadata:
|
|
94
|
+
"""The discovery document, fetched once."""
|
|
95
|
+
if self._metadata is None:
|
|
96
|
+
url = self._settings.issuer.rstrip("/") + "/.well-known/openid-configuration"
|
|
97
|
+
document = await self._get_json(url)
|
|
98
|
+
self._metadata = ProviderMetadata(
|
|
99
|
+
issuer=document["issuer"],
|
|
100
|
+
authorization_endpoint=document["authorization_endpoint"],
|
|
101
|
+
token_endpoint=document["token_endpoint"],
|
|
102
|
+
jwks_uri=document["jwks_uri"],
|
|
103
|
+
)
|
|
104
|
+
logger.info(f"OIDC provider discovered: {self._metadata.issuer}")
|
|
105
|
+
return self._metadata
|
|
106
|
+
|
|
107
|
+
async def key_for(self, kid: Optional[str], refresh: bool = False) -> Dict[str, Any]:
|
|
108
|
+
"""The signing key with this id, re-reading the key set if it is new."""
|
|
109
|
+
if refresh or not self._keys:
|
|
110
|
+
metadata = await self.metadata()
|
|
111
|
+
document = await self._get_json(metadata.jwks_uri)
|
|
112
|
+
self._keys = {key.get("kid"): key for key in document.get("keys", [])}
|
|
113
|
+
logger.debug(f"OIDC keys loaded: {sorted(k for k in self._keys if k)}")
|
|
114
|
+
|
|
115
|
+
if kid in self._keys:
|
|
116
|
+
return self._keys[kid]
|
|
117
|
+
if not refresh:
|
|
118
|
+
# A key we have not seen: the provider has rotated, not lied.
|
|
119
|
+
return await self.key_for(kid, refresh=True)
|
|
120
|
+
raise SignInRefused(f"the provider has no signing key {kid!r}")
|
|
121
|
+
|
|
122
|
+
def forget(self):
|
|
123
|
+
"""Drop what is cached; for tests and for a changed configuration."""
|
|
124
|
+
self._metadata = None
|
|
125
|
+
self._keys = {}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _b64url(raw: bytes) -> str:
|
|
129
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def make_pkce_pair() -> Tuple[str, str]:
|
|
133
|
+
"""A verifier that stays here and the challenge that goes to the provider."""
|
|
134
|
+
verifier = _b64url(secrets.token_bytes(32))
|
|
135
|
+
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
136
|
+
return verifier, challenge
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def issue_flow_token(state: str, nonce: str, verifier: str, now: Optional[int] = None) -> str:
|
|
140
|
+
"""Pack one sign-in attempt into a short-lived signed value.
|
|
141
|
+
|
|
142
|
+
In a cookie rather than a row: a table of unfinished sign-ins would need a
|
|
143
|
+
row per attempt and a sweep for the abandoned ones, to hold what a signed
|
|
144
|
+
value holds by itself. One-time use is enforced by clearing the cookie at
|
|
145
|
+
the return.
|
|
146
|
+
"""
|
|
147
|
+
issued = now if now is not None else int(time.time())
|
|
148
|
+
return jwt.encode(
|
|
149
|
+
{"state": state, "nonce": nonce, "verifier": verifier,
|
|
150
|
+
"iat": issued, "exp": issued + FLOW_COOKIE_MAX_AGE},
|
|
151
|
+
resolve_signing_key(),
|
|
152
|
+
algorithm="HS256",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def read_flow_token(token: Optional[str]) -> Dict[str, Any]:
|
|
157
|
+
"""Unpack the attempt, refusing anything expired or not ours."""
|
|
158
|
+
if not token:
|
|
159
|
+
raise SignInRefused("no sign-in attempt cookie")
|
|
160
|
+
try:
|
|
161
|
+
return jwt.decode(token, resolve_signing_key(), algorithms=["HS256"])
|
|
162
|
+
except JWTError as error:
|
|
163
|
+
raise SignInRefused(f"sign-in attempt cookie rejected: {error}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def authorization_url(metadata: ProviderMetadata, settings, state: str, nonce: str,
|
|
167
|
+
challenge: str) -> str:
|
|
168
|
+
"""Where to send the browser to have the person identified."""
|
|
169
|
+
query = {
|
|
170
|
+
"response_type": "code",
|
|
171
|
+
"client_id": settings.client_id,
|
|
172
|
+
"redirect_uri": settings.redirect_uri,
|
|
173
|
+
"scope": " ".join(settings.scopes),
|
|
174
|
+
"state": state,
|
|
175
|
+
"nonce": nonce,
|
|
176
|
+
"code_challenge": challenge,
|
|
177
|
+
"code_challenge_method": "S256",
|
|
178
|
+
}
|
|
179
|
+
return f"{metadata.authorization_endpoint}?{urlencode(query)}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def exchange_code(directory: ProviderDirectory, settings, code: str,
|
|
183
|
+
verifier: str) -> Dict[str, Any]:
|
|
184
|
+
"""Trade the code for tokens, proving we started this attempt.
|
|
185
|
+
|
|
186
|
+
The verifier proves it: it never left this server, and only the party that
|
|
187
|
+
began the attempt can produce it.
|
|
188
|
+
"""
|
|
189
|
+
metadata = await directory.metadata()
|
|
190
|
+
form = {
|
|
191
|
+
"grant_type": "authorization_code",
|
|
192
|
+
"code": code,
|
|
193
|
+
"redirect_uri": settings.redirect_uri,
|
|
194
|
+
"client_id": settings.client_id,
|
|
195
|
+
"client_secret": settings.client_secret,
|
|
196
|
+
"code_verifier": verifier,
|
|
197
|
+
}
|
|
198
|
+
async with directory._client_factory() as client:
|
|
199
|
+
response = await client.post(metadata.token_endpoint, data=form)
|
|
200
|
+
if response.status_code != 200:
|
|
201
|
+
raise SignInRefused(f"the provider refused the code: {response.status_code}")
|
|
202
|
+
tokens = response.json()
|
|
203
|
+
if "id_token" not in tokens:
|
|
204
|
+
raise SignInRefused("the provider returned no id_token")
|
|
205
|
+
return tokens
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def verify_id_token(directory: ProviderDirectory, settings, id_token: str,
|
|
209
|
+
nonce: str) -> Dict[str, Any]:
|
|
210
|
+
"""Check the provider's statement, and refuse it on any doubt.
|
|
211
|
+
|
|
212
|
+
Checked here rather than trusted: an id_token is a bearer statement, and
|
|
213
|
+
the only thing making it worth anything is that the signature belongs to
|
|
214
|
+
the issuer we were configured with.
|
|
215
|
+
"""
|
|
216
|
+
try:
|
|
217
|
+
header = jwt.get_unverified_header(id_token)
|
|
218
|
+
except JWTError as error:
|
|
219
|
+
raise SignInRefused(f"unreadable id_token: {error}")
|
|
220
|
+
|
|
221
|
+
algorithm = header.get("alg")
|
|
222
|
+
if algorithm not in ALLOWED_ALGORITHMS:
|
|
223
|
+
raise SignInRefused(f"id_token signed with {algorithm!r}")
|
|
224
|
+
|
|
225
|
+
key = await directory.key_for(header.get("kid"))
|
|
226
|
+
metadata = await directory.metadata()
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
claims = jwt.decode(
|
|
230
|
+
id_token,
|
|
231
|
+
key,
|
|
232
|
+
algorithms=[algorithm],
|
|
233
|
+
audience=settings.client_id,
|
|
234
|
+
issuer=metadata.issuer,
|
|
235
|
+
options={"verify_at_hash": False},
|
|
236
|
+
)
|
|
237
|
+
except JWTError as error:
|
|
238
|
+
raise SignInRefused(f"id_token rejected: {error}")
|
|
239
|
+
|
|
240
|
+
if claims.get("nonce") != nonce:
|
|
241
|
+
raise SignInRefused("id_token carries another attempt's nonce")
|
|
242
|
+
if not claims.get("sub"):
|
|
243
|
+
raise SignInRefused("id_token names no subject")
|
|
244
|
+
|
|
245
|
+
return claims
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""What to do with somebody the application has never seen before.
|
|
2
|
+
|
|
3
|
+
The provider has just proved who this person is. It has not said anything
|
|
4
|
+
about whether they may use this product -- that is not a question an identity
|
|
5
|
+
provider can answer, and the framework cannot answer it either: for one
|
|
6
|
+
application everyone at the provider is welcome, for another access is by
|
|
7
|
+
invitation and the provider is only a way of proving a name.
|
|
8
|
+
|
|
9
|
+
So the decision belongs to the application, and the framework's default is to
|
|
10
|
+
refuse. The opposite default would turn switching a provider on into open
|
|
11
|
+
registration for a product that never asked for it -- and it would do so
|
|
12
|
+
quietly, which is the worst way for it to happen.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Callable, Dict, Iterable, Optional
|
|
18
|
+
|
|
19
|
+
from keepup.roles import ROLE_CLIENT
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AccountDecision:
|
|
26
|
+
"""Either "no", or the account to create."""
|
|
27
|
+
|
|
28
|
+
admit: bool
|
|
29
|
+
role: str = ROLE_CLIENT
|
|
30
|
+
status: str = "active"
|
|
31
|
+
#: Why, for the log. Never shown to whoever is signing in.
|
|
32
|
+
reason: str = ""
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def refuse(cls, reason: str) -> "AccountDecision":
|
|
36
|
+
return cls(admit=False, reason=reason)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def create(cls, role: str = ROLE_CLIENT, status: str = "active",
|
|
40
|
+
reason: str = "") -> "AccountDecision":
|
|
41
|
+
return cls(admit=True, role=role, status=status, reason=reason)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def refuse_unknown() -> Callable[[Dict[str, Any]], AccountDecision]:
|
|
45
|
+
"""The default: prove who you are all you like, there is no account here."""
|
|
46
|
+
|
|
47
|
+
def decide(claims: Dict[str, Any]) -> AccountDecision:
|
|
48
|
+
return AccountDecision.refuse("the application admits nobody it does not already know")
|
|
49
|
+
|
|
50
|
+
return decide
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_account(role: str = ROLE_CLIENT,
|
|
54
|
+
status: str = "active") -> Callable[[Dict[str, Any]], AccountDecision]:
|
|
55
|
+
"""Anyone the provider vouches for gets an account.
|
|
56
|
+
|
|
57
|
+
Reasonable when the provider is the company's own directory and everybody
|
|
58
|
+
behind it is staff. Open registration when the provider is a public one --
|
|
59
|
+
which is the application's call to make, deliberately.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def decide(claims: Dict[str, Any]) -> AccountDecision:
|
|
63
|
+
return AccountDecision.create(role=role, status=status,
|
|
64
|
+
reason="the application admits everyone the provider vouches for")
|
|
65
|
+
|
|
66
|
+
return decide
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def create_if_email_domain(domains: Iterable[str], role: str = ROLE_CLIENT,
|
|
70
|
+
status: str = "active") -> Callable[[Dict[str, Any]], AccountDecision]:
|
|
71
|
+
"""An account for staff, nothing for everyone else at the same provider.
|
|
72
|
+
|
|
73
|
+
The email is compared only when the provider says it verified it: an
|
|
74
|
+
unverified address is a string the person typed, and several public
|
|
75
|
+
providers will hand it over unchecked.
|
|
76
|
+
"""
|
|
77
|
+
allowed = {domain.lower().lstrip("@") for domain in domains}
|
|
78
|
+
|
|
79
|
+
def decide(claims: Dict[str, Any]) -> AccountDecision:
|
|
80
|
+
email = (claims.get("email") or "").lower()
|
|
81
|
+
if not email or "@" not in email:
|
|
82
|
+
return AccountDecision.refuse("no email in the provider's claims")
|
|
83
|
+
if claims.get("email_verified") is False:
|
|
84
|
+
return AccountDecision.refuse("the provider did not verify this email")
|
|
85
|
+
domain = email.rsplit("@", 1)[1]
|
|
86
|
+
if domain not in allowed:
|
|
87
|
+
return AccountDecision.refuse(f"email domain {domain!r} is not admitted")
|
|
88
|
+
return AccountDecision.create(role=role, status=status,
|
|
89
|
+
reason=f"email domain {domain!r} is admitted")
|
|
90
|
+
|
|
91
|
+
return decide
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def decide(policy: Optional[Callable], claims: Dict[str, Any]) -> AccountDecision:
|
|
95
|
+
"""Ask the application, and refuse if it has not said anything.
|
|
96
|
+
|
|
97
|
+
A policy that raises is a refusal too: an application whose rule fell over
|
|
98
|
+
has not admitted anybody, and treating the failure as consent is how a door
|
|
99
|
+
opens by accident.
|
|
100
|
+
"""
|
|
101
|
+
if policy is None:
|
|
102
|
+
return refuse_unknown()(claims)
|
|
103
|
+
try:
|
|
104
|
+
decision = policy(claims)
|
|
105
|
+
except Exception as error:
|
|
106
|
+
logger.error(f"The account policy raised; refusing: {error}")
|
|
107
|
+
return AccountDecision.refuse("the application's policy failed")
|
|
108
|
+
if not isinstance(decision, AccountDecision):
|
|
109
|
+
logger.error(f"The account policy returned {type(decision).__name__}, not a decision; refusing")
|
|
110
|
+
return AccountDecision.refuse("the application's policy answered nothing usable")
|
|
111
|
+
return decision
|