20
20
import hashlib
21
21
import uuid
22
22
import requests
23
- from typing import Callable , cast
23
+ from typing import Callable , cast , Union
24
24
from inspect import Parameter , signature
25
25
from functools import wraps
26
26
from fastapi import APIRouter , Depends , Query as FastApiQuery , Request , HTTPException , status
@@ -51,6 +51,10 @@ class SignatureToken(BaseModel):
51
51
signature_token : str
52
52
53
53
54
+ class AppToken (BaseModel ):
55
+ app_token : str
56
+
57
+
54
58
oauth2_scheme = OAuth2PasswordBearer (tokenUrl = f'{ root_path } /auth/token' , auto_error = False )
55
59
56
60
@@ -64,7 +68,6 @@ def create_user_dependency(
64
68
Creates a dependency for getting the authenticated user. The parameters define if
65
69
the authentication is required or not, and which authentication methods are allowed.
66
70
'''
67
-
68
71
def user_dependency (** kwargs ) -> User :
69
72
user = None
70
73
if basic_auth_allowed :
@@ -180,17 +183,26 @@ def _get_user_basic_auth(form_data: OAuth2PasswordRequestForm) -> User:
180
183
def _get_user_bearer_token_auth (bearer_token : str ) -> User :
181
184
'''
182
185
Verifies bearer_token (throwing exception if illegal value provided) and returns the
183
- corresponding user object, or None, if no bearer_token provided.
186
+ corresponding user object, or None if no bearer_token provided.
184
187
'''
185
- if bearer_token :
186
- try :
187
- user = cast (datamodel .User , infrastructure .keycloak .tokenauth (bearer_token ))
188
+ if not bearer_token :
189
+ return None
190
+
191
+ try :
192
+ unverified_payload = jwt .decode (bearer_token , options = {"verify_signature" : False })
193
+ if unverified_payload .keys () == set (['user' , 'exp' ]):
194
+ user = _get_user_from_simple_token (bearer_token )
188
195
return user
189
- except infrastructure .KeycloakError as e :
190
- raise HTTPException (
191
- status_code = status .HTTP_401_UNAUTHORIZED ,
192
- detail = str (e ), headers = {'WWW-Authenticate' : 'Bearer' })
193
- return None
196
+ except jwt .exceptions .DecodeError :
197
+ pass # token could be non-JWT, e.g. for testing
198
+
199
+ try :
200
+ user = cast (datamodel .User , infrastructure .keycloak .tokenauth (bearer_token ))
201
+ return user
202
+ except infrastructure .KeycloakError as e :
203
+ raise HTTPException (
204
+ status_code = status .HTTP_401_UNAUTHORIZED ,
205
+ detail = str (e ), headers = {'WWW-Authenticate' : 'Bearer' })
194
206
195
207
196
208
def _get_user_upload_token_auth (upload_token : str ) -> User :
@@ -227,21 +239,8 @@ def _get_user_signature_token_auth(signature_token: str, request: Request) -> Us
227
239
corresponding user object, or None, if no upload_token provided.
228
240
'''
229
241
if signature_token :
230
- try :
231
- decoded = jwt .decode (signature_token , config .services .api_secret , algorithms = ['HS256' ])
232
- return datamodel .User .get (user_id = decoded ['user' ])
233
- except KeyError :
234
- raise HTTPException (
235
- status_code = status .HTTP_401_UNAUTHORIZED ,
236
- detail = 'Token with invalid/unexpected payload.' )
237
- except jwt .ExpiredSignatureError :
238
- raise HTTPException (
239
- status_code = status .HTTP_401_UNAUTHORIZED ,
240
- detail = 'Expired token.' )
241
- except jwt .InvalidTokenError :
242
- raise HTTPException (
243
- status_code = status .HTTP_401_UNAUTHORIZED ,
244
- detail = 'Invalid token.' )
242
+ user = _get_user_from_simple_token (signature_token )
243
+ return user
245
244
elif request :
246
245
auth_cookie = request .cookies .get ('Authorization' )
247
246
if auth_cookie :
@@ -261,6 +260,28 @@ def _get_user_signature_token_auth(signature_token: str, request: Request) -> Us
261
260
return None
262
261
263
262
263
+ def _get_user_from_simple_token (token ):
264
+ '''
265
+ Verifies a simple token (throwing exception if illegal value provided) and returns the
266
+ corresponding user object, or None if no token was provided.
267
+ '''
268
+ try :
269
+ decoded = jwt .decode (token , config .services .api_secret , algorithms = ['HS256' ])
270
+ return datamodel .User .get (user_id = decoded ['user' ])
271
+ except KeyError :
272
+ raise HTTPException (
273
+ status_code = status .HTTP_401_UNAUTHORIZED ,
274
+ detail = 'Token with invalid/unexpected payload.' )
275
+ except jwt .ExpiredSignatureError :
276
+ raise HTTPException (
277
+ status_code = status .HTTP_401_UNAUTHORIZED ,
278
+ detail = 'Expired token.' )
279
+ except jwt .InvalidTokenError :
280
+ raise HTTPException (
281
+ status_code = status .HTTP_401_UNAUTHORIZED ,
282
+ detail = 'Invalid token.' )
283
+
284
+
264
285
_bad_credentials_response = status .HTTP_401_UNAUTHORIZED , {
265
286
'model' : HTTPExceptionModel ,
266
287
'description' : strip ('''
@@ -287,7 +308,6 @@ async def get_token(form_data: OAuth2PasswordRequestForm = Depends()):
287
308
You only need to provide `username` and `password` values. You can ignore the other
288
309
parameters.
289
310
'''
290
-
291
311
try :
292
312
access_token = infrastructure .keycloak .basicauth (
293
313
form_data .username , form_data .password )
@@ -311,7 +331,6 @@ async def get_token_via_query(username: str, password: str):
311
331
This is an convenience alternative to the **POST** version of this operation.
312
332
It allows you to retrieve an *access token* by providing username and password.
313
333
'''
314
-
315
334
try :
316
335
access_token = infrastructure .keycloak .basicauth (username , password )
317
336
except infrastructure .KeycloakError :
@@ -328,21 +347,51 @@ async def get_token_via_query(username: str, password: str):
328
347
tags = [default_tag ],
329
348
summary = 'Get a signature token' ,
330
349
response_model = SignatureToken )
331
- async def get_signature_token (user : User = Depends (create_user_dependency ())):
350
+ async def get_signature_token (
351
+ user : Union [User , None ] = Depends (create_user_dependency (required = True ))):
332
352
'''
333
353
Generates and returns a signature token for the authenticated user. Authentication
334
354
has to be provided with another method, e.g. access token.
335
355
'''
356
+ signature_token = generate_simple_token (user .user_id , expires_in = 10 )
357
+ return {'signature_token' : signature_token }
336
358
337
- expires_at = datetime .datetime .utcnow () + datetime .timedelta (seconds = 10 )
338
- signature_token = jwt .encode (
339
- dict (user = user .user_id , exp = expires_at ),
340
- config .services .api_secret , 'HS256' )
341
359
342
- return {'signature_token' : signature_token }
360
+ @router .get (
361
+ '/app_token' ,
362
+ tags = [default_tag ],
363
+ summary = 'Get an app token' ,
364
+ response_model = AppToken )
365
+ async def get_app_token (
366
+ expires_in : int = FastApiQuery (gt = 0 , le = config .services .app_token_max_expires_in ),
367
+ user : User = Depends (create_user_dependency (required = True ))):
368
+ '''
369
+ Generates and returns an app token with the requested expiration time for the
370
+ authenticated user. Authentication has to be provided with another method,
371
+ e.g. access token.
372
+
373
+ This app token can be used like the access token (see `/auth/token`) on subsequent API
374
+ calls to authenticate you using the HTTP header `Authorization: Bearer <app token>`.
375
+ It is provided for user convenience as a shorter token with a user-defined (probably
376
+ longer) expiration time than the access token.
377
+ '''
378
+ app_token = generate_simple_token (user .user_id , expires_in )
379
+ return {'app_token' : app_token }
380
+
381
+
382
+ def generate_simple_token (user_id , expires_in : int ):
383
+ '''
384
+ Generates and returns JWT encoding just user_id and expiration time, signed with the
385
+ API secret.
386
+ '''
387
+ expires_at = datetime .datetime .utcnow () + datetime .timedelta (seconds = expires_in )
388
+ payload = dict (user = user_id , exp = expires_at )
389
+ token = jwt .encode (payload , config .services .api_secret , 'HS256' )
390
+ return token
343
391
344
392
345
393
def generate_upload_token (user ):
394
+ '''Generates and returns upload token for user.'''
346
395
payload = uuid .UUID (user .user_id ).bytes
347
396
signature = hmac .new (
348
397
bytes (config .services .api_secret , 'utf-8' ),
0 commit comments