keepup-auth 0.4.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_auth/__init__.py +22 -0
- keepup_auth/config.py +128 -0
- keepup_auth/dependencies.py +556 -0
- keepup_auth/dto/__init__.py +0 -0
- keepup_auth/dto/token.py +31 -0
- keepup_auth/external_accounts.py +144 -0
- keepup_auth/factory.py +28 -0
- keepup_auth/identity/__init__.py +33 -0
- keepup_auth/identity/access.py +119 -0
- keepup_auth/identity/accounts.py +86 -0
- keepup_auth/identity/config.py +220 -0
- keepup_auth/identity/contract.py +166 -0
- keepup_auth/identity/loader.py +119 -0
- keepup_auth/identity/runtime.py +265 -0
- keepup_auth/login_throttle.py +179 -0
- keepup_auth/oidc.py +255 -0
- keepup_auth/oidc_policy.py +122 -0
- keepup_auth/oidc_routes.py +265 -0
- keepup_auth/panel_session.py +379 -0
- keepup_auth/permissions.py +59 -0
- keepup_auth/providers/__init__.py +0 -0
- keepup_auth/providers/base.py +216 -0
- keepup_auth/providers/local.py +212 -0
- keepup_auth/routes.py +514 -0
- keepup_auth/seed_accounts.py +352 -0
- keepup_auth/session_lifetime.py +104 -0
- keepup_auth/signing_key.py +138 -0
- keepup_auth/socket_sessions.py +176 -0
- keepup_auth/tables.py +35 -0
- keepup_auth/user_roles.py +376 -0
- keepup_auth/user_routes.py +313 -0
- keepup_auth/usernames.py +36 -0
- keepup_auth/websocket.py +88 -0
- keepup_auth-0.4.0.dist-info/METADATA +21 -0
- keepup_auth-0.4.0.dist-info/RECORD +38 -0
- keepup_auth-0.4.0.dist-info/WHEEL +5 -0
- keepup_auth-0.4.0.dist-info/entry_points.txt +2 -0
- keepup_auth-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Who is making this request, as FastAPI dependencies.
|
|
2
|
+
|
|
3
|
+
`get_current_user` and `get_current_admin` are what a route says instead of
|
|
4
|
+
checking a token itself; every plugin route generated by the registry gets the
|
|
5
|
+
result handed to it. Issuing and reading tokens lives here too, at module level
|
|
6
|
+
rather than inside the route, so that a sign-in can be exercised without HTTP.
|
|
7
|
+
|
|
8
|
+
The password rule and where a sign-in is written down are not decided here: the
|
|
9
|
+
application supplies both through KeepupSettings, because a rule the framework
|
|
10
|
+
invented would be a different rule from the one its invitations enforce.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
from datetime import timedelta, datetime
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import bcrypt
|
|
19
|
+
from fastapi import HTTPException, Depends, Request
|
|
20
|
+
import jwt
|
|
21
|
+
from jwt import PyJWTError as JWTError
|
|
22
|
+
from starlette import status
|
|
23
|
+
|
|
24
|
+
from keepup_auth.usernames import is_valid_username
|
|
25
|
+
from keepup_auth import panel_session
|
|
26
|
+
from keepup_auth.dto.token import TokenData
|
|
27
|
+
from keepup_auth.external_accounts import AccountUnavailable
|
|
28
|
+
from keepup_auth.factory import AuthProviderFactory
|
|
29
|
+
from keepup_auth.identity import access
|
|
30
|
+
from keepup_auth.identity import runtime as identity_runtime
|
|
31
|
+
from keepup_auth.identity.contract import IdentityRejected, ProviderUnavailable
|
|
32
|
+
from keepup_auth.providers.base import AuthProvider, ALGORITHM, oauth2_scheme
|
|
33
|
+
from keepup_auth.signing_key import resolve_signing_key
|
|
34
|
+
from keepup_auth import user_roles
|
|
35
|
+
from keepup.roles import ROLE_ADMIN, ROLE_CLIENT
|
|
36
|
+
from keepup.db import DatabaseManagerV2
|
|
37
|
+
|
|
38
|
+
#: What an application may import from this module. Everything else is
|
|
39
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
40
|
+
__all__ = [
|
|
41
|
+
"create_user",
|
|
42
|
+
"get_all_users",
|
|
43
|
+
"get_current_admin",
|
|
44
|
+
"get_current_user",
|
|
45
|
+
"get_optional_user",
|
|
46
|
+
"get_panel_user",
|
|
47
|
+
"get_user_by_id",
|
|
48
|
+
"get_user_by_username",
|
|
49
|
+
"issue_session_token",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
from keepup_auth import seed_accounts
|
|
55
|
+
|
|
56
|
+
auth_provider: AuthProvider = AuthProviderFactory.get_provider()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def get_user_by_username_async(username: str):
|
|
60
|
+
"""Return a user by name, including the extra fields."""
|
|
61
|
+
return await auth_provider.get_user_info(username)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_user_by_username(username: str):
|
|
65
|
+
"""Return a user by name, including the extra fields.
|
|
66
|
+
|
|
67
|
+
Synchronous, for plugins and background jobs that cannot await. It used to
|
|
68
|
+
wrap the provider's coroutine -- and, called from a running loop, ran it on a
|
|
69
|
+
loop of its own in a thread that the caller waited for, holding the loop.
|
|
70
|
+
The provider answers synchronously itself now.
|
|
71
|
+
"""
|
|
72
|
+
return user_roles.attach_roles(auth_provider.lookup_user(username))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_user_by_id(user_id: int):
|
|
76
|
+
"""Return a user by id, with the roles it holds.
|
|
77
|
+
|
|
78
|
+
The set travels with the account wherever the framework hands one out: code
|
|
79
|
+
that asks whether this person holds a role must not have to know which of
|
|
80
|
+
the two reads it came from.
|
|
81
|
+
"""
|
|
82
|
+
# TODO: auth_provider needs a lookup by id as well; without it this only
|
|
83
|
+
# works against the local database.
|
|
84
|
+
return user_roles.attach_roles(
|
|
85
|
+
DatabaseManagerV2.execute_one("SELECT * FROM users WHERE id = :id", {"id": user_id}))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_system_user_id():
|
|
89
|
+
"""Return the id of the system user that background work is signed with.
|
|
90
|
+
|
|
91
|
+
The account itself is created once, at start-up, by
|
|
92
|
+
`keepup.auth.seed_accounts` -- without a password anyone could sign in with.
|
|
93
|
+
Creating it here as well is what let three spellings of it drift apart; a
|
|
94
|
+
missing account now means the schema has not been initialised, and inventing
|
|
95
|
+
one with a password of our own would only hide that.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
system_user = get_user_by_username(seed_accounts.SYSTEM_USERNAME)
|
|
99
|
+
if system_user:
|
|
100
|
+
return system_user['id']
|
|
101
|
+
logger.error(
|
|
102
|
+
"The service account '%s' is not in the database: the schema was "
|
|
103
|
+
"not initialised. Background work will go unsigned.",
|
|
104
|
+
seed_accounts.SYSTEM_USERNAME)
|
|
105
|
+
return None
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"Error getting system user: {e}")
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_all_users():
|
|
112
|
+
"""Return every user, with the roles each of them holds."""
|
|
113
|
+
users = DatabaseManagerV2.execute(
|
|
114
|
+
"SELECT id, username, status, role, created_at FROM users ORDER BY created_at DESC"
|
|
115
|
+
) or []
|
|
116
|
+
held = {}
|
|
117
|
+
for row in DatabaseManagerV2.execute(
|
|
118
|
+
"SELECT user_id, role_name FROM user_roles") or []:
|
|
119
|
+
held.setdefault(row["user_id"], []).append(row["role_name"])
|
|
120
|
+
# One read for the whole list rather than one per user: this is the panel's
|
|
121
|
+
# first screen, and a query per row is what makes it slow on a real stand.
|
|
122
|
+
# An account whose set has never been filled in reads as its mirror says,
|
|
123
|
+
# which is known from the row already read.
|
|
124
|
+
return [dict(user, roles=(sorted(held.get(user["id"], []))
|
|
125
|
+
or user_roles.held_by(user) or [ROLE_CLIENT]))
|
|
126
|
+
for user in users]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def save_user_to_db(username: str, password: str):
|
|
130
|
+
"""Create a user, for any configured auth provider."""
|
|
131
|
+
if not is_valid_username(username):
|
|
132
|
+
raise HTTPException(status_code=400, detail="Username may contain letters, digits "
|
|
133
|
+
"and . _ @ + -, at most 64 characters")
|
|
134
|
+
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
135
|
+
|
|
136
|
+
query = """
|
|
137
|
+
INSERT INTO users (username, password_hash, status, role)
|
|
138
|
+
VALUES (:username, :password_hash, :status, :role)
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
row = DatabaseManagerV2.execute_commit_returning(
|
|
143
|
+
query,
|
|
144
|
+
{"username": username, "password_hash": password_hash, "status": "blocked",
|
|
145
|
+
"role": ROLE_CLIENT})
|
|
146
|
+
user_id = row["id"] if row else None
|
|
147
|
+
if user_id is not None:
|
|
148
|
+
# The set is the truth about who this is, so a new account gets its
|
|
149
|
+
# row now rather than relying on the read's fallback until the next
|
|
150
|
+
# start (keepup/auth/user_roles.py).
|
|
151
|
+
user_roles.set_roles(user_id, [ROLE_CLIENT])
|
|
152
|
+
return user_id
|
|
153
|
+
|
|
154
|
+
except Exception as e:
|
|
155
|
+
if "unique constraint" in str(e).lower() or "duplicate" in str(e).lower():
|
|
156
|
+
raise HTTPException(status_code=400, detail="Username already exists")
|
|
157
|
+
raise e
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def create_user(
|
|
161
|
+
username: str,
|
|
162
|
+
password: str,
|
|
163
|
+
email: str = None,
|
|
164
|
+
phone: str = None,
|
|
165
|
+
full_name: str = None,
|
|
166
|
+
agree_terms: bool = False
|
|
167
|
+
):
|
|
168
|
+
user_id = auth_provider.create_user_in_db(username, password, email, phone, full_name, agree_terms)
|
|
169
|
+
|
|
170
|
+
# The account is created once, above. This used to hand the provider the
|
|
171
|
+
# same account again -- with the name and password as one-element tuples --
|
|
172
|
+
# and the provider's create_user calls back into this function: a second
|
|
173
|
+
# insert that failed on every creation and was logged and swallowed
|
|
174
|
+
# (keepup-79).
|
|
175
|
+
|
|
176
|
+
# The id is returned to the caller: it was computed and then thrown away,
|
|
177
|
+
# while callers assigned the result to a variable and got None. The nearest
|
|
178
|
+
# consequence was that creating test users considered itself unsuccessful
|
|
179
|
+
# every time and never got as far as their wallets.
|
|
180
|
+
return user_id
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def update_user(user_id: int, status: Optional[str] = None, role: Optional[str] = None,
|
|
184
|
+
email: Optional[str] = None, phone: Optional[str] = None, full_name: Optional[str] = None):
|
|
185
|
+
"""Update a user, including the extra fields."""
|
|
186
|
+
changes = {"status": status, "role": role, "email": email, "phone": phone,
|
|
187
|
+
"full_name": full_name}
|
|
188
|
+
changes = {column: value for column, value in changes.items() if value is not None}
|
|
189
|
+
if not changes:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
# The column names come from the fixed set above, never from the caller.
|
|
193
|
+
assignments = ", ".join(f"{column} = :{column}" for column in changes)
|
|
194
|
+
DatabaseManagerV2.execute_commit(
|
|
195
|
+
f"UPDATE users SET {assignments}, updated_at = CURRENT_TIMESTAMP WHERE id = :id",
|
|
196
|
+
{**changes, "id": user_id})
|
|
197
|
+
|
|
198
|
+
# A caller setting the single role means "this person is a client now", so
|
|
199
|
+
# the whole set becomes that role. Leaving ADMIN in it would quietly keep a
|
|
200
|
+
# right the administrator had just taken away (keepup-51). Unchecked, because
|
|
201
|
+
# this column never checked: turning a call that worked into a refusal is not
|
|
202
|
+
# what a deprecated field should do on its way out.
|
|
203
|
+
if role is not None:
|
|
204
|
+
user_roles.set_roles(user_id, [role], checked=False)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def authenticate(username: str, password: str):
|
|
208
|
+
return await auth_provider.authenticate(username=username, password=password)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def verify_password(plain_password: str, hashed_password: str):
|
|
212
|
+
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
#: When the session this token belongs to began. Carried by the token rather
|
|
216
|
+
#: than kept on the server, which keeps no session state; renewal reads it to
|
|
217
|
+
#: know whether the session has gone on long enough.
|
|
218
|
+
SESSION_START_CLAIM = "sst"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def create_access_token(
|
|
222
|
+
data: dict,
|
|
223
|
+
expires_delta: Optional[timedelta] = None,
|
|
224
|
+
session_started_at: Optional[datetime] = None,
|
|
225
|
+
):
|
|
226
|
+
to_encode = data.copy()
|
|
227
|
+
if expires_delta:
|
|
228
|
+
expire = datetime.utcnow() + expires_delta
|
|
229
|
+
else:
|
|
230
|
+
expire = datetime.utcnow() + timedelta(minutes=15)
|
|
231
|
+
to_encode.update({"exp": expire})
|
|
232
|
+
|
|
233
|
+
# A renewal carries the beginning of the session forward unchanged. Stamping
|
|
234
|
+
# "now" on every renewal is what made a session endless: each exchange would
|
|
235
|
+
# reset the clock that is supposed to be running out.
|
|
236
|
+
started = session_started_at or datetime.utcnow()
|
|
237
|
+
to_encode.setdefault(SESSION_START_CLAIM, int(started.timestamp()))
|
|
238
|
+
|
|
239
|
+
encoded_jwt = jwt.encode(to_encode, resolve_signing_key(), algorithm=ALGORITHM)
|
|
240
|
+
return encoded_jwt
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def issue_session_token(user_id: int, username: str, sid: Optional[str] = None,
|
|
244
|
+
session_started_at: Optional[datetime] = None) -> dict:
|
|
245
|
+
"""A token bound to a session record: a new session, or `sid` carried on.
|
|
246
|
+
|
|
247
|
+
Every token the server hands a person or an agent goes through here, so every
|
|
248
|
+
one of them can be revoked.
|
|
249
|
+
"""
|
|
250
|
+
from keepup_auth.providers.base import ACCESS_TOKEN_EXPIRE_MINUTES
|
|
251
|
+
lifetime = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
252
|
+
if sid is None:
|
|
253
|
+
sid = panel_session.open_session(user_id, lifetime)
|
|
254
|
+
else:
|
|
255
|
+
# Every renewal is held to the window, not only /api/auth/refresh: the
|
|
256
|
+
# exchange of a token for the cookie carried a session on without the
|
|
257
|
+
# check, so a stolen token could be renewed for ever (keepup-64).
|
|
258
|
+
from keepup_auth import session_lifetime
|
|
259
|
+
if not session_lifetime.is_renewable(session_started_at):
|
|
260
|
+
raise HTTPException(
|
|
261
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
262
|
+
detail="This session has run its course. Sign in again.",
|
|
263
|
+
)
|
|
264
|
+
panel_session.extend(sid, lifetime)
|
|
265
|
+
token = create_access_token({"sub": username, panel_session.SESSION_CLAIM: sid},
|
|
266
|
+
expires_delta=lifetime, session_started_at=session_started_at)
|
|
267
|
+
return {"access_token": token, "session_id": sid,
|
|
268
|
+
"expires_in": int(lifetime.total_seconds())}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _session_start(payload: dict) -> Optional[datetime]:
|
|
272
|
+
"""Read the session's beginning out of a decoded token, or None."""
|
|
273
|
+
started = payload.get(SESSION_START_CLAIM)
|
|
274
|
+
if started is None:
|
|
275
|
+
return None
|
|
276
|
+
try:
|
|
277
|
+
return datetime.utcfromtimestamp(int(started))
|
|
278
|
+
except (TypeError, ValueError, OSError):
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def request_token(request: Request, bearer: Optional[str] = Depends(oauth2_scheme)) -> Optional[str]:
|
|
283
|
+
"""The request's token: a real bearer header, else the panel session cookie."""
|
|
284
|
+
return panel_session.token_from_request(request, bearer)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _session_owner(sid: str) -> Optional[int]:
|
|
288
|
+
"""The account the live session ``sid`` belongs to, or None."""
|
|
289
|
+
try:
|
|
290
|
+
return panel_session.session_owner(sid)
|
|
291
|
+
except Exception as error:
|
|
292
|
+
# Failing open here would make revocation depend on the database being
|
|
293
|
+
# up; failing closed only costs a retry.
|
|
294
|
+
logger.error(f"Could not check session {str(sid)[:8]}...: {error}")
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _session_and_user(payload: dict, username: str):
|
|
299
|
+
"""Whether the token's session is live and, only if it is, whose account it is.
|
|
300
|
+
|
|
301
|
+
A token is good only while its session is live and belongs to the account
|
|
302
|
+
the token names: otherwise it was not issued by this server, or names an
|
|
303
|
+
account recreated under the same name (keepup-64). A token without a
|
|
304
|
+
session is refused: tokens from before sessions were recorded were accepted
|
|
305
|
+
until they expired, and that transition is over (keepup-81).
|
|
306
|
+
|
|
307
|
+
The role set is read here too, in the same hop out of the event loop: one
|
|
308
|
+
more statement on the thread that read the account, not a second thread and
|
|
309
|
+
a second loop (keepup-43). It is not cached -- see keepup/auth/user_roles.py.
|
|
310
|
+
"""
|
|
311
|
+
sid = payload.get(panel_session.SESSION_CLAIM)
|
|
312
|
+
if not sid:
|
|
313
|
+
return False, None
|
|
314
|
+
# One connection and one commit for the three reads, not one each: this
|
|
315
|
+
# runs on every request, and it was half of what a replica spent (keepup-85).
|
|
316
|
+
try:
|
|
317
|
+
with DatabaseManagerV2.shared_session():
|
|
318
|
+
owner = _session_owner(sid)
|
|
319
|
+
if owner is None:
|
|
320
|
+
return False, None
|
|
321
|
+
user = auth_provider.lookup_user(username)
|
|
322
|
+
if user is not None and int(user["id"]) != owner:
|
|
323
|
+
return False, None
|
|
324
|
+
return True, user_roles.attach_roles(user)
|
|
325
|
+
except Exception as error:
|
|
326
|
+
# A failed read leaves the shared session unable to commit; either way
|
|
327
|
+
# the answer is the one a failed check always gave -- refuse.
|
|
328
|
+
logger.error(f"Could not check the session of {username}: {error}")
|
|
329
|
+
return False, None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def get_current_user(token: str = Depends(request_token)):
|
|
333
|
+
"""Return the current user of this token.
|
|
334
|
+
|
|
335
|
+
The signature carries nothing but the token, and that is a rule rather than
|
|
336
|
+
a detail: a scalar parameter with a default on a dependency is a *query*
|
|
337
|
+
parameter to FastAPI. One used to stand here -- `ignore_empty_user` -- and
|
|
338
|
+
it was published on every route of the framework and of every plugin, as a
|
|
339
|
+
switch a caller could set to turn the check off. Code that wants None
|
|
340
|
+
instead of a refusal calls get_optional_user() below, which is not a
|
|
341
|
+
dependency and takes the token as an argument.
|
|
342
|
+
|
|
343
|
+
One branch since task 170: the accounts live in this application's table.
|
|
344
|
+
Somebody who signed in through an external provider is here too -- that
|
|
345
|
+
path ends by issuing this same session, so by the time a request arrives
|
|
346
|
+
there is nothing left to tell apart.
|
|
347
|
+
"""
|
|
348
|
+
return await _get_local_user(token)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _token_expiry(payload: dict) -> Optional[datetime]:
|
|
352
|
+
"""Read the expiry out of a decoded token payload, or None."""
|
|
353
|
+
expires_at = payload.get("exp")
|
|
354
|
+
if expires_at is None:
|
|
355
|
+
return None
|
|
356
|
+
try:
|
|
357
|
+
return datetime.utcfromtimestamp(int(expires_at))
|
|
358
|
+
except (TypeError, ValueError, OSError):
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def _get_local_user(token: Optional[str], ignore_empty_user: bool = False):
|
|
363
|
+
credentials_exception = HTTPException(
|
|
364
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
365
|
+
detail="Could not validate credentials",
|
|
366
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
367
|
+
)
|
|
368
|
+
try:
|
|
369
|
+
if token is None:
|
|
370
|
+
if ignore_empty_user:
|
|
371
|
+
return None
|
|
372
|
+
raise credentials_exception
|
|
373
|
+
# Every token this server issues has an expiry, a subject and a session;
|
|
374
|
+
# one without them was not issued here, or was issued before sessions
|
|
375
|
+
# were recorded -- long enough ago to have expired (keepup-64, keepup-81).
|
|
376
|
+
payload = jwt.decode(token, resolve_signing_key(), algorithms=[ALGORITHM],
|
|
377
|
+
options={"require": ["exp", "sub", panel_session.SESSION_CLAIM]})
|
|
378
|
+
username: str = payload.get("sub")
|
|
379
|
+
if username is None:
|
|
380
|
+
if ignore_empty_user:
|
|
381
|
+
return None
|
|
382
|
+
raise credentials_exception
|
|
383
|
+
token_data = TokenData(username=username)
|
|
384
|
+
except _OUR_TOKEN_REFUSED:
|
|
385
|
+
# The signature is ours and the rest is not right: expired, not yet
|
|
386
|
+
# valid, or missing what every token of ours carries. It is refused
|
|
387
|
+
# here and never shown to the provider, which has no business seeing
|
|
388
|
+
# this framework's tokens.
|
|
389
|
+
if ignore_empty_user:
|
|
390
|
+
return None
|
|
391
|
+
raise credentials_exception
|
|
392
|
+
except JWTError:
|
|
393
|
+
# Not a token of this framework at all: another signature, another
|
|
394
|
+
# algorithm, not a JWT. The configured provider may know it.
|
|
395
|
+
return await _foreign_user(token, credentials_exception, ignore_empty_user)
|
|
396
|
+
|
|
397
|
+
# Both reads are blocking queries, so they leave the loop together: one hop
|
|
398
|
+
# to a worker thread per request, and the account is read once -- it used
|
|
399
|
+
# to be read twice, each time on a new event loop in a thread the request
|
|
400
|
+
# waited for while holding the loop (keepup-43).
|
|
401
|
+
live, user = await asyncio.to_thread(_session_and_user, payload, token_data.username)
|
|
402
|
+
if not live or user is None:
|
|
403
|
+
raise credentials_exception
|
|
404
|
+
|
|
405
|
+
if user["status"] != "active":
|
|
406
|
+
raise HTTPException(
|
|
407
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
408
|
+
detail="User account is blocked",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# When this session ends. The server keeps no session state, so the moment
|
|
412
|
+
# the token is decoded is the only place that knows -- and a handler that
|
|
413
|
+
# needs to warn about the end (or record it alongside a websocket key)
|
|
414
|
+
# cannot decode the token itself: it never sees it.
|
|
415
|
+
user = dict(user)
|
|
416
|
+
user["token_expires_at"] = _token_expiry(payload)
|
|
417
|
+
user["session_started_at"] = _session_start(payload)
|
|
418
|
+
user["session_id"] = payload.get(panel_session.SESSION_CLAIM)
|
|
419
|
+
|
|
420
|
+
return user
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
#: Refusals that mean the token is ours: the signature matched and something
|
|
424
|
+
#: else did not. Everything else PyJWT raises means somebody else signed it.
|
|
425
|
+
_OUR_TOKEN_REFUSED = (jwt.ExpiredSignatureError, jwt.ImmatureSignatureError,
|
|
426
|
+
jwt.MissingRequiredClaimError)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def _foreign_user(token: str, credentials_exception: HTTPException,
|
|
430
|
+
ignore_empty_user: bool = False):
|
|
431
|
+
"""The user of a token some other system issued, through the configured provider.
|
|
432
|
+
|
|
433
|
+
Only when the deployment switched the provider's token mode on; otherwise a
|
|
434
|
+
token that is not ours is simply not a token. Every refusal answers the same
|
|
435
|
+
as a bad token of ours. An unavailable provider is a 503: a client told its
|
|
436
|
+
token is bad throws it away, and it must not do that because somebody else's
|
|
437
|
+
server is down.
|
|
438
|
+
"""
|
|
439
|
+
runtime = identity_runtime.current()
|
|
440
|
+
if runtime is None or not runtime.config.accept_tokens:
|
|
441
|
+
if ignore_empty_user:
|
|
442
|
+
return None
|
|
443
|
+
raise credentials_exception
|
|
444
|
+
try:
|
|
445
|
+
identity = await runtime.identity_for_token(token)
|
|
446
|
+
account = await runtime.user_for_identity(identity)
|
|
447
|
+
except IdentityRejected as refusal:
|
|
448
|
+
logger.info("Token refused by identity provider %s: %s", runtime.config.name, refusal)
|
|
449
|
+
if ignore_empty_user:
|
|
450
|
+
return None
|
|
451
|
+
raise credentials_exception
|
|
452
|
+
except (ProviderUnavailable, AccountUnavailable) as error:
|
|
453
|
+
if isinstance(error, AccountUnavailable):
|
|
454
|
+
logger.error("Account for identity provider %s unavailable: %s",
|
|
455
|
+
runtime.config.name, error)
|
|
456
|
+
if ignore_empty_user:
|
|
457
|
+
return None
|
|
458
|
+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
459
|
+
detail=access.UNAVAILABLE)
|
|
460
|
+
|
|
461
|
+
if account.get("status") != "active":
|
|
462
|
+
# Vouched for there is not the same as allowed in here.
|
|
463
|
+
raise HTTPException(
|
|
464
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
465
|
+
detail="User account is blocked",
|
|
466
|
+
)
|
|
467
|
+
return runtime.signed_in(account, identity)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def refuse_foreign_session(current_user: dict) -> None:
|
|
471
|
+
"""Refuse to turn a token of another system into a session of this one.
|
|
472
|
+
|
|
473
|
+
Both renewal and the move into the panel cookie issue a session here. For a
|
|
474
|
+
caller who came with the provider's token that would be a session the
|
|
475
|
+
other system cannot revoke, outliving the token it was traded for.
|
|
476
|
+
"""
|
|
477
|
+
source = (current_user or {}).get("authenticated_by")
|
|
478
|
+
if source:
|
|
479
|
+
raise HTTPException(
|
|
480
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
481
|
+
detail=f"This token was issued by {source}; renew it there.",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
async def get_optional_user(token: Optional[str]):
|
|
486
|
+
"""The user of this token, or None when there is none.
|
|
487
|
+
|
|
488
|
+
For callers outside the request cycle -- a route that serves both a signed-in
|
|
489
|
+
user and a guest, a socket that authenticates itself. Deliberately not a
|
|
490
|
+
dependency: see get_current_user() for what happens when a switch like this
|
|
491
|
+
ends up in a dependency's signature.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
token: the bearer or panel token, or None.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
The user, or None when the token is absent, unreadable or names nobody.
|
|
498
|
+
"""
|
|
499
|
+
return await _get_local_user(token, ignore_empty_user=True)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
async def get_current_admin(current_user: dict = Depends(get_current_user)):
|
|
503
|
+
# The right is ADMIN being among the roles, not one role being equal to it:
|
|
504
|
+
# somebody who is both an administrator and a client holds two (keepup-51).
|
|
505
|
+
if not user_roles.has_role(current_user, ROLE_ADMIN):
|
|
506
|
+
raise HTTPException(
|
|
507
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
508
|
+
detail="Admin access required",
|
|
509
|
+
)
|
|
510
|
+
return current_user
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# --- The panel session's user -------------------------------------------------
|
|
514
|
+
|
|
515
|
+
#: Set by the application through configure_panel_gate(): given a user, it
|
|
516
|
+
#: returns the documents that user has yet to accept. The framework has no
|
|
517
|
+
#: documents of its own, so without it the gate is simply open.
|
|
518
|
+
pending_documents = None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def configure_panel_gate(pending=None):
|
|
522
|
+
"""Supply the application's "documents this user must accept" rule.
|
|
523
|
+
|
|
524
|
+
Setting it to None opens the gate: an application with no documents is not
|
|
525
|
+
the same as an application that keeps the previous one's.
|
|
526
|
+
"""
|
|
527
|
+
global pending_documents
|
|
528
|
+
pending_documents = pending
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _authenticated_by_cookie(request: Request) -> bool:
|
|
532
|
+
return panel_session.real_bearer(_bearer_of(request)) is None \
|
|
533
|
+
and bool(panel_session.session_token_in(request.cookies))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _bearer_of(request: Request) -> Optional[str]:
|
|
537
|
+
scheme, _, value = (request.headers.get("authorization") or "").partition(" ")
|
|
538
|
+
return value if scheme.lower() == "bearer" else None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
async def get_panel_user(request: Request, current_user: dict = Depends(get_current_user)):
|
|
542
|
+
"""The user of a plugin route, refused while a required document is not accepted.
|
|
543
|
+
|
|
544
|
+
Only for the panel's cookie session. An agent or a script sends Bearer, and a
|
|
545
|
+
new version of a document must not stop an agent in the middle of a rental;
|
|
546
|
+
what a developer needs a script for -- an API key -- is created in the panel.
|
|
547
|
+
"""
|
|
548
|
+
if _authenticated_by_cookie(request) and pending_documents is not None:
|
|
549
|
+
missing = pending_documents(current_user)
|
|
550
|
+
if missing:
|
|
551
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={
|
|
552
|
+
"code": "agreements_required",
|
|
553
|
+
"message": "Accept " + ", ".join(d.title for d in missing) + " to continue",
|
|
554
|
+
"pending": [d.id for d in missing],
|
|
555
|
+
})
|
|
556
|
+
return current_user
|
|
File without changes
|
keepup_auth/dto/token.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""What a sign-in hands back, and what a token carries.
|
|
2
|
+
|
|
3
|
+
Two shapes rather than dictionaries: the reply is part of the API and the claim
|
|
4
|
+
set is read on every request, and both used to be assembled by hand in several
|
|
5
|
+
places that disagreed about the field names.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
#: What an application may import from this module. Everything else is
|
|
13
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Token",
|
|
16
|
+
"TokenData",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Token(BaseModel):
|
|
21
|
+
"""What a successful sign-in hands back."""
|
|
22
|
+
|
|
23
|
+
access_token: str
|
|
24
|
+
token_type: str
|
|
25
|
+
success: bool = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TokenData(BaseModel):
|
|
29
|
+
"""The claims read back out of a token."""
|
|
30
|
+
|
|
31
|
+
username: Optional[str] = None
|