Skip to content

Commit

Permalink
Merge pull request #153 from SeaweedbrainCY/dev
Browse files Browse the repository at this point in the history
Token based session instead of JWTs
  • Loading branch information
SeaweedbrainCY authored Dec 5, 2024
2 parents 249760b + 30da67f commit 98ee2a5
Show file tree
Hide file tree
Showing 68 changed files with 1,610 additions and 2,479 deletions.
57 changes: 0 additions & 57 deletions api/CryptoClasses/jwt_func.py

This file was deleted.

40 changes: 20 additions & 20 deletions api/CryptoClasses/refresh_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,43 @@
from database.refresh_token_repo import RefreshTokenRepo
from environment import logging
import datetime as dt
from CryptoClasses.jwt_func import generate_jwt, verify_jwt
from connexion.exceptions import Forbidden, Unauthorized
from database.rate_limiting_repo import RateLimitingRepo
from database.session_token_repo import SessionTokenRepo
import Utils.utils as utils



def generate_refresh_token(user_id, jti, expiration=-1):
def generate_refresh_token(user_id, session_token_id, expiration=-1):
token = str(uuid4())
hashed_token = sha256(token.encode()).hexdigest()
rt_repo = RefreshTokenRepo()
rt = rt_repo.create_refresh_token(user_id, jti, hashed_token, expiration=expiration)
rt = rt_repo.create_refresh_token(user_id, session_token_id, hashed_token, expiration=expiration)
return token if rt else None


def refresh_token_flow(jti, rt, jwt_user_id, ip):
def refresh_token_flow(refresh, session, ip):
rate_limiting = RateLimitingRepo()
rt_repo = RefreshTokenRepo()
if rt.jti == jti and rt.user_id == jwt_user_id:
if rt.revoke_timestamp == None:
if float(rt.expiration) > dt.datetime.now(dt.UTC).timestamp():
new_jwt = generate_jwt(rt.user_id)
new_jti = verify_jwt(new_jwt)["jti"]
new_refresh_token = generate_refresh_token(rt.user_id, new_jti, expiration=rt.expiration)
rt_repo.revoke(rt.id)
return new_jwt, new_refresh_token
session_repo = SessionTokenRepo()
if refresh.session_token_id == session.id and refresh.user_id == session.user_id:
if refresh.revoke_timestamp == None:
if float(refresh.expiration) > dt.datetime.now(dt.UTC).timestamp():
new_session_id, new_session_token = session_repo.generate_session_token(session.user_id)
new_refresh_token = generate_refresh_token(session.user_id, new_session_id, expiration=refresh.expiration)
utils.revoke_session(session_id=session.id, refresh_id=refresh.id)
return new_session_token, new_refresh_token
else:
rate_limiting.add_failed_login(ip, rt.user_id)
logging.warning(f"The user {rt.user_id} tried to refresh a token that has expired: {rt.id}. Refresh flow aborted. Token expired at: {rt.expiration}")
rate_limiting.add_failed_login(ip, refresh.user_id)
logging.warning(f"The user {refresh.user_id} tried to refresh a token that has expired: {refresh.id}. Refresh flow aborted. Token expired at: {refresh.expiration}")
raise Forbidden("Access denied")
else:
rate_limiting.add_failed_login(ip, rt.user_id)
logging.warning(f"The user {rt.user_id} tried to refresh a token that has been revoked: {rt.id}. Refresh flow aborted. Token revoked at: {rt.revoke_timestamp}")
rate_limiting.add_failed_login(ip, refresh.user_id)
logging.warning(f"The user {refresh.user_id} tried to refresh a token that has been revoked: {refresh.id}. Refresh flow aborted. Token revoked at: {refresh.revoke_timestamp}")
raise Forbidden("Access denied")
else:
rate_limiting.add_failed_login(ip, rt.user_id)
logging.warning(f"A refresh token has been asked, but invalid context for refresh token {rt.id}. Expected jti: {rt.jti}, user_id: {rt.user_id}. But git jti: {jti}, user_id: {jwt_user_id}. Refresh flow aborted.")
rt_repo.revoke(rt.id)
rate_limiting.add_failed_login(ip, session.user_id)
logging.warning(f"A refresh token has been asked, but invalid context for refresh token {refresh.id}. Session : Id : {session.id}, User {session.user_id}. Refresh : Id : {refresh.id}, User: {refresh.user_id}, Association session id : {refresh.session_token_id}. Refresh flow aborted.")
utils.revoke_session(session_id=session.id, refresh_id=refresh.id)
raise Forbidden("Access denied")


22 changes: 22 additions & 0 deletions api/CryptoClasses/session_verification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from database.session_token_repo import SessionTokenRepo
from app import app
from connexion.exceptions import Unauthorized, Forbidden
from hashlib import sha256
from environment import logging
from Utils.utils import get_ip
import datetime as dt


def verify_session(token):
if not token:
raise Unauthorized("No session token provided")
with app.app.app_context():
session_token = SessionTokenRepo().get_session_token(token)
if not session_token:
raise Forbidden("Invalid session token")
if float(session_token.expiration) < dt.datetime.now(dt.UTC).timestamp():
raise Forbidden("API key expired")
if session_token.revoke_timestamp is not None:
logging.info(f"Rejected session token {session_token.id} because it was revoked. User {session_token.user_id}")
raise Forbidden("Invalid session token")
return {"uid" : session_token.user_id}
6 changes: 3 additions & 3 deletions api/Oauth/google_drive_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def get_last_backup_file(drive)-> (any, datetime):
raise utils.FileNotFound("No backup file found")
result = get_files_from_folder(folder.get('id'), drive)
if len(result) == 0:
logging.info("No backup file found in the drive")
logging.debug("No backup file found in the drive")
raise utils.FileNotFound("No backup file found")
else :
return utils.extract_last_backup_from_list(result)
Expand Down Expand Up @@ -134,7 +134,7 @@ def clean_backup_retention(credentials, user_id) -> bool:
google_drive_integration_db.update_last_backup_clean_date(user_id, datetime.utcnow().strftime('%Y-%m-%d'))
return True
result = get_files_from_folder(folder.get('id'), drive)
logging.info("Found " + str(len(result)) + " backups")
logging.debug("Found " + str(len(result)) + " backups")
if len(result) <= MINIMUM_NB_BACKUP or len(result) == 0:
logging.info("No backup to clean (too few backups)")
google_drive_integration_db.update_last_backup_clean_date(user_id, datetime.utcnow().strftime('%Y-%m-%d'))
Expand All @@ -151,7 +151,7 @@ def clean_backup_retention(credentials, user_id) -> bool:
for file in sorted_files[:-MINIMUM_NB_BACKUP]:
date_str = file.get("name").split("_")[0]
date = datetime.strptime(date_str, '%d-%m-%Y-%H-%M-%S')
logging.info('Inspecting backup file ' + file.get("name") + " created on " + date_str + ". Age : " + str((datetime.utcnow() - date).days) + " days")
logging.debug('Inspecting backup file ' + file.get("name") + " created on " + date_str + ". Age : " + str((datetime.utcnow() - date).days) + " days")

if (datetime.utcnow() - date).days > MAXIMUM_BACKUP_AGE:
logging.info("Deleting backup file " + file.get("name"))
Expand Down
13 changes: 5 additions & 8 deletions api/Utils/env_requirements_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ def test_conf(conf) -> bool:
except Exception as e:
raise Exception(f"api.trusted_proxy contains an invalid ip address. {e}")

if conf.api.access_token_validity != None:
assert isinstance(conf.api.access_token_validity, int), "api.access_token_validity is not an integer"
assert conf.api.access_token_validity > 0, "api.access_token_validity must be greater than 0"
if conf.api.session_token_validity != None:
assert isinstance(conf.api.session_token_validity, int), "api.session_token_validity is not an integer"
assert conf.api.session_token_validity > 0, "api.session_token_validity must be greater than 0"
if conf.api.refresh_token_validity != None:
assert isinstance(conf.api.refresh_token_validity, int), "api.refresh_token_validity is not an integer"
assert conf.api.refresh_token_validity > 0, "api.refresh_token_validity must be greater than 0"

if conf.api.access_token_validity != None:
assert conf.api.refresh_token_validity > conf.api.access_token_validity, "api.refresh_token_validity must be greater than api.access_token"
if conf.api.session_token_validity != None:
assert conf.api.refresh_token_validity > conf.api.session_token_validity, "api.refresh_token_validity must be greater than api.access_token"


## Environment
Expand All @@ -57,9 +57,6 @@ def test_conf(conf) -> bool:
assert re.match(r"mysql:\/\/.*:.*@.*:[0-9]*\/.*", conf.database.database_uri) or conf.database.database_uri == "sqlite:///:memory:", "database.database_uri is not a valid uri. Was expecting something like 'mysql://user:password@hostname:port/dbname'"

## Features
## Admins
assert isinstance(conf.features.admins.admin_can_delete_users, bool), "features.admins.admin_can_delete_users is not a boolean"

## Emails
assert isinstance(conf.features.emails.require_email_validation, bool), "features.emails.require_email_validation is not a boolean"
if conf.features.emails.require_email_validation:
Expand Down
4 changes: 2 additions & 2 deletions api/Utils/http_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
from environment import conf

class Response(Response):
def set_auth_cookies(self, jwt, refresh_token):
self.set_cookie("api-key", jwt, httponly=True, secure=True, samesite="Lax", max_age=conf.api.refresh_token_validity, path="/api/")
def set_auth_cookies(self, session_token, refresh_token):
self.set_cookie("session-token", session_token, httponly=True, secure=True, samesite="Lax", max_age=conf.api.refresh_token_validity, path="/api/")
self.set_cookie("refresh-token", refresh_token, httponly=True, secure=True, samesite="Lax", max_age=conf.api.refresh_token_validity, path="/api/v1/auth/refresh")
51 changes: 3 additions & 48 deletions api/Utils/security_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,59 +1,13 @@
from database.user_repo import User as UserDB
from CryptoClasses.jwt_func import verify_jwt
import connexion
from environment import logging, conf
from CryptoClasses.hash_func import Bcrypt
import random
import string
import Utils.utils as utils
from database.rate_limiting_repo import RateLimitingRepo as Rate_Limiting_DB
from flask import request

def require_admin_role(func):
def wrapper(context_, user, token_info,*args, **kwargs):
try:
user_id = context_["user"]
except:
return {"error": "Unauthorized"}, 401
user_obj = UserDB().getById(user_id)
if user_obj == None:
return {"error": "Forbidden"}, 403
if user_obj.role == "admin":
return func(user_id,*args, **kwargs)
return {"error": "Unauthorized"}, 403
return wrapper


# By design, the admin role is required and checked before the admin token.
# The require_admin_role wrapper must not be removed without adding the admin role check in the require_admin_token wrapper.
def require_admin_token(func):
@require_admin_role
def wrapper(user_id,*args, **kwargs):
try:
admin_cookie = connexion.request.cookies["admin-api-key"]
except:
logging.info("Admin token rejected because of missing cookie or user id")
return {"error": "Unauthorized"}, 403
try:
jwt_token = verify_jwt(admin_cookie)
except Exception as e:
logging.info("Admin token rejected because of bad admin cookie. " + str(e))
return {"error": "Unauthorized"}, 403
user = UserDB().getById(user_id)
if jwt_token == None:
logging.info("Admin token rejected because of bad admin cookie")
return {"error": "Unauthorized"}, 403
if "admin" not in jwt_token:
logging.info("Admin token rejected because of admin cookie doesn't have admin field")
return {"error": "Unauthorized"}, 403
if user_id != int(jwt_token["sub"]):
logging.info("Admin token rejected because of admin cookie doesn't have same user id as the main cookie")
return {"error": "Unauthorized"}, 403
if jwt_token["admin"] == True:
logging.info("Admin token accepted for user " + str(user_id))
return func(user_id, *args, **kwargs)
logging.info("Admin token rejected because of admin cookie admin field is false")
return {"error": "Unauthorized"}, 403
return wrapper


# only the user id is required. The request is not rejected even if the user is not verified.
Expand Down Expand Up @@ -111,7 +65,8 @@ def wrapper(user_id, *args, **kwargs):

def ip_rate_limit(func):
def wrapper(*args, **kwargs):
ip = utils.get_ip(connexion.request)
logging.debug("Rate limiting check")
ip = utils.get_ip(request)
rate_limiting_db = Rate_Limiting_DB()
if ip:
if rate_limiting_db.is_login_rate_limited(ip):
Expand Down
32 changes: 29 additions & 3 deletions api/Utils/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from app import app
import re
import html
from environment import logging
Expand All @@ -11,6 +12,7 @@
from database.email_verification_repo import EmailVerificationToken
from database.rate_limiting_repo import RateLimitingRepo
from database.refresh_token_repo import RefreshTokenRepo
from database.session_token_repo import SessionTokenRepo
import os
from hashlib import sha256
from base64 import b64encode
Expand Down Expand Up @@ -50,7 +52,7 @@ def extract_last_backup_from_list(files_list) -> (any, datetime):
last_backup_file_date = None
last_backup_file = None
for file in files_list:
logging.info("name =" +file.get("name"))
logging.debug("name =" +file.get("name"))
if "_backup" not in file.get("name") or file.get('explicitlyTrashed'):
continue
date_str = file.get("name").split("_")[0]
Expand Down Expand Up @@ -126,8 +128,9 @@ def test_ip(ip):
except Exception as e:
logging.error("Error while testing ip address : " + str(e))
return False
try :
remote_ip = ipaddress.ip_address(request.remote_addr)
try:
with app.app.app_context():
remote_ip = ipaddress.ip_address(request.remote_addr)
except Exception as e:
logging.error("Error while getting remote ip address : " + str(e))
return None
Expand Down Expand Up @@ -186,3 +189,26 @@ def unsafe_json_vault_validation(json:str) -> (bool, str):
logging.error("Error while validating vault json : " + str(e))
print(e)
return False, "The vault submitted is invalid. If you submitted this vault through the web interface, please report this issue to the support."


def revoke_session(session_id=None, refresh_id=None):
print("revoking session")
session_repo = SessionTokenRepo()
refresh_repo = RefreshTokenRepo()
session = session_repo.get_session_token_by_id(session_id)
refresh = refresh_repo.get_refresh_token_by_id(refresh_id)
if session != None:
session_repo.revoke(session.id)
logging.info(f"Revoked session {session.id}")
if not refresh or refresh.session_token_id != session.id:
associated_refresh = refresh_repo.get_refresh_token_by_session_id(session.id)
logging.info(f"Revoked refresh {session.id} because the associated session {session.id} was revoked")
refresh_repo.revoke(associated_refresh.id) if associated_refresh != None else None
if refresh != None:
refresh_repo.revoke(refresh.id)
logging.info(f"Revoked refresh {refresh.id}")
if not session or session.id != refresh.session_token_id:
associated_session = session_repo.get_session_token_by_id(refresh.session_token_id)
session_repo.revoke(associated_session.id) if associated_session != None else None
logging.info(f"Revoked session {associated_session.id} because the associated refresh {refresh.id} was revoked")
return True
Loading

0 comments on commit 98ee2a5

Please sign in to comment.