diff --git a/admin/requirements.txt b/admin/requirements.txt index 47038e9..2e4f967 100644 --- a/admin/requirements.txt +++ b/admin/requirements.txt @@ -12,3 +12,4 @@ pytz stripe passlib argon2_cffi +PyJWT diff --git a/app/lib_general.py b/app/lib_general.py index a01b987..20fffd9 100644 --- a/app/lib_general.py +++ b/app/lib_general.py @@ -1,5 +1,5 @@ from __future__ import annotations -import datetime, pytz, redis +import datetime, jwt, pytz, redis, time from passlib.hash import argon2 #from datetime import datetime, time, timedelta @@ -45,8 +45,6 @@ async def get_account_header(x_account_id:str = Header(...)): # ### END ### API Lib General ### async get_account_header() ### - - def secure_hash_string(string:str): string_hash = argon2.using(rounds=14, memory_cost=1536, parallelism=2).hash(string) @@ -57,4 +55,55 @@ def verify_secure_hash_string(string:str, string_hash:str): if argon2.verify(string, string_hash): return True else: - return False \ No newline at end of file + return False + + +# Updated 2021-07-14 +def sign_jwt( + secret_key: str, # Secret/Private/Password + public_key: str, # Will be part of the token. Use to look up secret when verifying. + ttl: int = 60, # Default to 60 seconds + max_renew: int = 0, # Default to 0 + account_id: str = None, + person_id: str = None, + user_id: str = None, + ) -> Dict[str, str]: + log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + payload = { + 'iat': time.time(), # Issued at + 'eat': time.time() + ttl, # Expires at + 'max_renew': max_renew, # Number of times allowed to request renew without API secret key + 'public_key': public_key, # Use to lookup the secret/private/password key when verifying + 'account_id': account_id, + 'person_id': person_id, + 'user_id': user_id, + } + secret = secret_key + algorithm = 'HS256' + token = jwt.encode(payload, secret, algorithm=algorithm) + + log.debug(token) + + return token + + +# Updated 2021-07-14 +def decode_jwt( + secret_key: str, + token: str, + ) -> dict: + log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + secret = secret_key + algorithm = 'HS256' + + try: + decoded_token = jwt.decode(token, secret, algorithms=[algorithm]) + log.debug(decoded_token) + if decoded_token['eat'] >= time.time(): return decoded_token + else: return False + except: + return None diff --git a/app/main.py b/app/main.py index 17b7b29..67d7db9 100644 --- a/app/main.py +++ b/app/main.py @@ -274,6 +274,7 @@ origins = [ 'http://localhost:7800', 'https://oneskyit.com', 'http://dev-idaa.localhost:5000', + 'http://dev.home:5000', 'http://connect.home:5000', 'http://connect.localhost:5000', 'http://svelte.localhost:5555', diff --git a/app/routers/api.py b/app/routers/api.py index 2dedad8..7832ef9 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, stat from pydantic import BaseModel, EmailStr, Field from typing import Dict, List, Optional, Set, Union -from app.lib_general import log, logging +from app.lib_general import log, logging, sign_jwt, decode_jwt from app.config import settings from app.db_sql import sql_insert, sql_update, sql_insert_or_update, sql_select, sql_delete, redis_lookup_id_random @@ -17,6 +17,161 @@ from app.models.response_models import Resp_Body_Base, mk_resp router = APIRouter() +# Generate JWT using associated API private key +# Verify JWT using the API public key's associated API private key +# API server or trusted app can generate JWTs +# JWT contains: +# * client_token (to request a new short term client token) +# * iat +# * eat +# * account_id +# * client_id +# * order_cart_id +# * person_id +# * user_id +# API server verifies JWTs +@router.get('/request_jwt', response_model=Resp_Body_Base) +async def request_jwt( + x_aether_api_secret_key: Optional[str] = Header(None, min_length=22, max_length=22), # If passed then can also set TTL + x_aether_api_public_key: Optional[str] = Header(None, min_length=22, max_length=22), # Used to look up the API secret if not given + x_aether_api_token: Optional[str] = Header(None), # Token given to client by an API key holder (short max TTL) + account_id: str = None, + session_id: str = None, # End client (web browser) + client_id: str = None, # End client (web browser) + person_id: str = None, + user_id: str = None, + max_ttl: int = 300, # Number of seconds to live. Only use if given the API secret key. + # Seconds: 3600 = 1 hr; 300 = 5 min + max_renew: int = 5, # Decrease count by 1 until 0 if only sent a current API token. + ): + log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + if x_aether_api_secret_key or x_aether_api_token: pass + else: return mk_resp(data=False, status_code=400) # Bad Request + + api_secret_key = x_aether_api_secret_key + + if x_aether_api_secret_key: + log.debug(f'Contains a value in x_aether_api_secret_key: {x_aether_api_secret_key}') + + table_name_select = 'api_key' + field_name = 'secret_key' + field_value = api_secret_key + + if api_key_rec_select_result := sql_select(table_name=table_name_select, field_name=field_name, field_value=field_value): pass + else: + log.warning('No results when looking up the API secret key') + return mk_resp(data=False, status_code=401) # Unauthorized + + # if api_key_rec_select_result.get('enable', None): + # api_key_rec = api_key_rec_select_result + # else: + # log.warning('API secret key not enabled') + # return mk_resp(data=False, status_code=401) # Unauthorized + + # current_datetime = datetime.datetime.utcnow() # datetime.datetime.now() Gets server local datetime + # if api_key_rec.get('enable_from', None) <= current_datetime and api_key_rec.get('enable_to', None) >= current_datetime: + # pass + # else: + # log.warning('API secret key expired') + # return mk_resp(data=False, status_code=401) # Unauthorized + + # if api_public_key := api_key_rec.get('public_key', None): pass + # else: + # log.warning('Public key was not found with the API secret key that was looked up') + # return mk_resp(data=False, status_code=400) # Bad Request + + # max_ttl = 3600 + elif x_aether_api_public_key and x_aether_api_token: + table_name_select = 'api_key' + field_name = 'public_key' + field_value = x_aether_api_public_key + + if api_key_rec_select_result := sql_select(table_name=table_name_select, field_name=field_name, field_value=field_value): pass + else: + log.warning('No results when looking up the API public key') + return mk_resp(data=False, status_code=401) # Unauthorized + + # Check if the API keys are valid + if api_key_rec_select_result.get('enable', None): + api_key_rec = api_key_rec_select_result + else: + log.warning('API secret key not enabled') + return mk_resp(data=False, status_code=401) # Unauthorized + + current_datetime = datetime.datetime.utcnow() # datetime.datetime.now() Gets server local datetime + if api_key_rec.get('enable_from', None) <= current_datetime and api_key_rec.get('enable_to', None) >= current_datetime: + pass + else: + log.warning('API secret key expired') + return mk_resp(data=False, status_code=401) # Unauthorized + + if api_secret_key := api_key_rec.get('secret_key', None): pass + else: + log.warning('Secret key was not found') + return mk_resp(data=False, status_code=400) # Bad Request + + if api_public_key := api_key_rec.get('public_key', None): pass + else: + log.warning('Public key was not found') + return mk_resp(data=False, status_code=400) # Bad Request + + # Decode the JWT if an API token was sent and the API secret key was sent/found. + if x_aether_api_token and api_public_key and api_secret_key: + if current_token := decode_jwt(secret_key=api_secret_key, token=x_aether_api_token): + if current_token.get('max_renew', 0) > 0: pass + else: + message = 'The JWT sent is out of allowed renewals. Try again with a current JWT or just the API secret key.' + log.warning(message) + return mk_resp(data=False, status_code=401, status_message=message) # Unauthorized + max_ttl = 300 + max_renew = current_token.get('max_renew', 0) - 1 + if not account_id: account_id = current_token.get('account_id', None) + if not person_id: person_id = current_token.get('person_id', None) + if not user_id: user_id = current_token.get('user_id', None) + else: + message = 'The JWT sent is either expired or otherwise invalid. Try again with a current JWT or just the API secret key.' + log.warning(message) + return mk_resp(data=False, status_code=401, status_message=message) # Unauthorized + + # api_key_rec = api_key_rec_select_result + # api_secret_key = x_aether_api_secret_key + + # if api_key_rec_select_result.get('enable', None): + # api_key_rec = api_key_rec_select_result + # else: + # log.warning('API secret key not enabled') + # return mk_resp(data=False, status_code=401) # Unauthorized + + # if x_aether_api_token: + # if current_token := decode_jwt(secret_key=api_secret_key, token=x_aether_api_token): + # if current_token.get('count', 0) > 0: pass + # else: + # message = 'The JWT sent is out of allowed renewals. Try again with a current JWT or just the API secret key.' + # log.warning(message) + # return mk_resp(data=False, status_code=401, status_message=message) # Unauthorized + # max_ttl = 300 + # max_renew = current_token.get('max_renew', 0) - 1 + # if not account_id: account_id = current_token.get('account_id', None) + # if not person_id: person_id = current_token.get('person_id', None) + # if not user_id: user_id = current_token.get('user_id', None) + # else: + # message = 'The JWT sent is either expired or otherwise invalid. Try again with a current JWT or just the API secret key.' + # log.warning(message) + # return mk_resp(data=False, status_code=401, status_message=message) # Unauthorized + + payload = {} + payload['account_id'] = account_id + payload['person_id'] = person_id + payload['user_id'] = user_id + token = sign_jwt(secret_key=api_secret_key, public_key=api_public_key, ttl=max_ttl, max_renew=max_renew, **payload) + + response_data = { 'api_access_jwt': token } + + return mk_resp(data=response_data) + + @router.get('/temp_token', response_model=Resp_Body_Base) async def get_api_temp_token( x_aether_api_key: Optional[str] = Header(None), @@ -34,7 +189,7 @@ async def get_api_temp_token( log.debug(f'Contains a value in x_aether_api_key: {x_aether_api_key}') sql_result = sql_select(table_name=table_name_select, field_name=field_name, field_value=field_value) else: - return mk_resp(data=False, status_code=400) + return mk_resp(data=False, status_code=400) # Bad Request log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL if sql_result: