Skip to content

Commit

Permalink
Merge pull request #147 from SeaweedbrainCY/refresh_token
Browse files Browse the repository at this point in the history
Add refresh token feature and expand the duration of the open vault
  • Loading branch information
SeaweedbrainCY authored Oct 31, 2024
2 parents d210f3d + 7fb7841 commit 8fd7e87
Show file tree
Hide file tree
Showing 39 changed files with 784 additions and 101 deletions.
24 changes: 13 additions & 11 deletions api/CryptoClasses/jwt_func.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import jwt
from environment import conf
from functools import wraps
import uuid
from uuid import uuid4
import jwt
import datetime
from flask import jsonify, request
import logging
from connexion.exceptions import Forbidden
from connexion.exceptions import Forbidden, Unauthorized

ALG = 'HS256'
ISSUER = "https://api.zero-totp.com"
ISSUER = conf.environment.frontend_URI + "/api/v1"

# Verification performed by openAPI
def verify_jwt(jwt_token):
def verify_jwt(jwt_token, verify_exp=True):
try:
data = jwt.decode(jwt_token,
conf.api.jwt_secret,
Expand All @@ -22,27 +22,29 @@ def verify_jwt(jwt_token):
options={
"verify_iss": True,
"verify_nbf": True,
"verify_exp": True,
"verify_exp": verify_exp,
"verify_iat":True})
return data
except jwt.ExpiredSignatureError as e:
raise Unauthorized("API key expired")
except Exception as e:
logging.warning("Invalid token : " + str(e))
logging.warning("Token verification failed. Invalid token : " + str(e))
raise Forbidden("Invalid token")



def generate_jwt(user_id, admin=False):
try:
payload = {
"iss": ISSUER,
"sub": user_id,
"iat": datetime.datetime.utcnow(),
"nbf": datetime.datetime.utcnow(),
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1),
"iat": datetime.datetime.now(datetime.UTC),
"nbf": datetime.datetime.now(datetime.UTC),
"exp": datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=conf.api.access_token_validity),
"jti": str(uuid4())
}
if admin:
payload["admin"] = True
payload["exp"] = datetime.datetime.utcnow() + datetime.timedelta(minutes=10)
payload["exp"] = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=10)
return jwt.encode(payload, conf.api.jwt_secret, algorithm=ALG)
except Exception as e:
logging.warning("Error while generating JWT : " + str(e))
Expand Down
45 changes: 45 additions & 0 deletions api/CryptoClasses/refresh_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from uuid import uuid4
from hashlib import sha256
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



def generate_refresh_token(user_id, jti, 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)
return token if rt else None


def refresh_token_flow(jti, rt, jwt_user_id, 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
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}")
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}")
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)
raise Forbidden("Access denied")


11 changes: 11 additions & 0 deletions api/Utils/env_requirements_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ def test_conf(conf) -> bool:
ipaddress.ip_network(ip)
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.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"


## Environment
assert conf.environment.type in ["local", "development", "production"], f"environment.type is not valid. Was expecting local, development or production, got {conf.environment.type}"
Expand Down
7 changes: 7 additions & 0 deletions api/Utils/http_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from flask import Response
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/")
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")
15 changes: 15 additions & 0 deletions api/Utils/security_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
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

def require_admin_role(func):
def wrapper(context_, user, token_info,*args, **kwargs):
Expand Down Expand Up @@ -105,4 +107,17 @@ def wrapper(user_id, *args, **kwargs):
return func(user_id, *args, **kwargs)
logging.info(f"User {user_id} tried to login with wrong password. require_passphrase_verification wrapper rejected the request")
return {"error": "Unauthorized"}, 403
return wrapper

def ip_rate_limit(func):
def wrapper(*args, **kwargs):
ip = utils.get_ip(connexion.request)
rate_limiting_db = Rate_Limiting_DB()
if ip:
if rate_limiting_db.is_login_rate_limited(ip):
logging.info(f"IP {ip} is rate limited")
return {"message": "Too many requests", 'ban_time':conf.features.rate_limiting.login_ban_time}, 429
else:
logging.error("The remote IP used to login is private. The headers are not set correctly")
return func(ip, *args, **kwargs)
return wrapper
1 change: 0 additions & 1 deletion api/Utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,3 @@ 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."

11 changes: 11 additions & 0 deletions api/config/config-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ api:
## See https://developers.google.com/identity/protocols/oauth2 for more information.
# client_secret_file_path: "path/to/client_secret.json"

## Token validity in seconds.
## The access token is used to authenticate the user.
## Default: 10 minutes
# access_token_validity: 600 # 10 minutes

## The refresh token is used to generate new access tokens.
## Default: 1day
# refresh_token_validity: 86400






Expand Down
80 changes: 50 additions & 30 deletions api/controllers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from flask import request, Response, redirect, make_response
from flask import request, redirect, make_response
from Utils.http_response import Response
import flask
import connexion
import json
Expand All @@ -9,6 +10,7 @@
from database.preferences_repo import Preferences as PreferencesDB
from database.admin_repo import Admin as Admin_db
from database.notif_repo import Notifications as Notifications_db
from database.refresh_token_repo import RefreshTokenRepo as RefreshToken_db
from database.rate_limiting_repo import RateLimitingRepo as Rate_Limiting_DB
from CryptoClasses.hash_func import Bcrypt
from environment import logging, conf
Expand All @@ -22,12 +24,13 @@
import CryptoClasses.jwt_func as jwt_auth
from CryptoClasses.sign_func import API_signature
import CryptoClasses.jwt_func as jwt_auth
from CryptoClasses import refresh_token as refresh_token_func
import Oauth.oauth_flow as oauth_flow
import Utils.utils as utils
import os
import base64
import datetime
from Utils.security_wrapper import require_admin_token, require_admin_role, require_valid_user, require_passphrase_verification,require_valid_user, require_userid
from Utils.security_wrapper import require_admin_token, require_admin_role, require_valid_user, require_passphrase_verification,require_valid_user, require_userid, ip_rate_limit
import traceback
from hashlib import sha256
from CryptoClasses.encryption import ServiceSideEncryption
Expand Down Expand Up @@ -111,22 +114,11 @@ def signup():


# POST /login
def login():
ip = utils.get_ip(request)
@ip_rate_limit
def login(ip, body):
passphrase = body["password"].strip()
email = utils.sanitize_input(body["email"]).strip()
rate_limiting_db = Rate_Limiting_DB()
if ip:
if rate_limiting_db.is_login_rate_limited(ip):
return {"message": "Too many requests", 'ban_time':conf.features.rate_limiting.login_ban_time}, 429
else:
logging.error("The remote IP used to login is private. The headers are not set correctly")
try:
data = request.get_json()
passphrase = data["password"].strip()
email = utils.sanitize_input(data["email"]).strip()
except Exception as e:
logging.info(e)
return {"message": "generic_errors.invalid_request"}, 400

if not passphrase or not email:
return {"message": "generic_errors.missing_params"}, 400
if(not utils.check_email(email) ):
Expand All @@ -138,30 +130,29 @@ def login():
logging.info("User " + str(email) + " tried to login but does not exist. A fake password is checked to avoid timing attacks")
fakePassword = ''.join(random.choices(string.ascii_letters, k=random.randint(10, 20)))
bcrypt.checkpw(fakePassword)
if ip:
rate_limiting_db.add_failed_login(ip, None)

rate_limiting_db.add_failed_login(ip)
return {"message": "generic_errors.invalid_creds"}, 403
logging.info(f"User {user.id} is trying to logging in from gateway {request.remote_addr} and IP {ip}. X-Forwarded-For header is {request.headers.get('X-Forwarded-For')}")
checked = bcrypt.checkpw(user.password)
if not checked:
if ip:
rate_limiting_db.add_failed_login(ip, user.id)
rate_limiting_db.add_failed_login(ip, user.id)
return {"message": "generic_errors.invalid_creds"}, 403
if user.isBlocked: # only authenticated users can see the blocked status
return {"message": "blocked"}, 403

if ip:
rate_limiting_db.flush_login_limit(ip)

rate_limiting_db.flush_login_limit(ip)
jwt_token = jwt_auth.generate_jwt(user.id)
jti = jwt_auth.verify_jwt(jwt_token)["jti"]
refresh_token = refresh_token_func.generate_refresh_token(user.id, jti)
if not conf.features.emails.require_email_validation: # we fake the isVerified status if email validation is not required
response = Response(status=200, mimetype="application/json", response=json.dumps({"username": user.username, "id":user.id, "derivedKeySalt":user.derivedKeySalt, "isGoogleDriveSync": GoogleDriveIntegrationDB().is_google_drive_enabled(user.id), "role":user.role, "isVerified":True}))
elif user.isVerified:
response = Response(status=200, mimetype="application/json", response=json.dumps({"username": user.username, "id":user.id, "derivedKeySalt":user.derivedKeySalt, "isGoogleDriveSync": GoogleDriveIntegrationDB().is_google_drive_enabled(user.id), "role":user.role, "isVerified":user.isVerified}))
else:
response = Response(status=200, mimetype="application/json", response=json.dumps({"isVerified":user.isVerified}))
userDB.update_last_login_date(user.id)
response.set_cookie("api-key", jwt_token, httponly=True, secure=True, samesite="Lax", max_age=3600)
response.set_auth_cookies(jwt_token, refresh_token)
return response

#GET /login/specs
Expand Down Expand Up @@ -739,14 +730,14 @@ def set_preference(user_id, body):
else:# pragma: no cover
return {"message": "Unknown error while updating preference"}, 500
elif field == "autolock_delay":
minimum_delay = 1
maximum_delay = conf.api.refresh_token_validity/60 # autolock is limited by the ability to refresh the token
try:
value = int(value)
except:
return {"message": "Invalid request"}, 400
if value < 1 :
return {"message": "autolock delay must be at least of 1"}, 400
elif value > 60:
return {"message": "autolock delay must be at most of 60"}, 400
if value < 1 or value > maximum_delay:
return {"message": "invalid_duration", "minimum_duration_min":minimum_delay, "maximum_duration_min": maximum_delay}, 400
preferences = preferences_db.update_autolock_delay(user_id, value)
if preferences:# pragma: no cover
return {"message": "Preference updated"}, 201
Expand Down Expand Up @@ -937,4 +928,33 @@ def get_internal_notification():
"authenticated_user_only": False,
"message":notif.message,
"timestamp":float(notif.timestamp)
}
}


# PUT /auth/refresh
@ip_rate_limit
def auth_refresh_token(ip, *args, **kwargs):
jwt = request.cookies.get("api-key")
token = request.cookies.get("refresh-token")
rate_limiting = Rate_Limiting_DB()
if not jwt or not token:
rate_limiting.add_failed_login(ip)
return {"message": "Missing token"}, 401
try:
jwt_info = jwt_auth.verify_jwt(jwt, verify_exp=False)
except Exception as e:
rate_limiting.add_failed_login(ip)
raise e
jti = jwt_info["jti"]
jwt_user_id = jwt_info["sub"]
if not jti:
return {"message": "Invalid token"}, 401
refresh_token = RefreshToken_db().get_refresh_token_by_hash(sha256(token.encode("utf-8")).hexdigest())
if not refresh_token:
rate_limiting.add_failed_login(ip, user_id=jwt_user_id)
logging.warning(f"JWT of user {jwt_user_id} tried to be refreshed with an refresh token (not present in the db)")
return {"message": "Access denied"}, 403
new_jwt, new_refresh_token = refresh_token_func.refresh_token_flow(jti=jti, rt=refresh_token, jwt_user_id=jwt_user_id, ip=ip)
response = Response(status=200, mimetype="application/json", response=json.dumps({"challenge":"ok"}))
response.set_auth_cookies(new_jwt, new_refresh_token)
return response
4 changes: 3 additions & 1 deletion api/database/rate_limiting_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import datetime
from environment import conf
class RateLimitingRepo:
def add_failed_login(self, ip, user_id):
def add_failed_login(self, ip, user_id=None):
if ip == None:
return None
rl = RateLimiting(ip=ip, user_id=user_id, action_type="failed_login", timestamp= datetime.datetime.utcnow())
db.session.add(rl)
db.session.commit()
Expand Down
26 changes: 26 additions & 0 deletions api/database/refresh_token_repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from database.db import db
from zero_totp_db_model.model import RefreshToken
import datetime
from environment import conf
from uuid import uuid4


class RefreshTokenRepo:

def create_refresh_token(self, user_id, jti, hashed_token, expiration=-1):
expiration_timestamp = (datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=conf.api.refresh_token_validity)).timestamp() if expiration == -1 else expiration
id = str(uuid4())
rt = RefreshToken(id=id, user_id=user_id, jti=jti, hashed_token=hashed_token, expiration=expiration_timestamp)
db.session.add(rt)
db.session.commit()
return rt


def get_refresh_token_by_hash(self, hashed_token):
return RefreshToken.query.filter_by(hashed_token=hashed_token).first()

def revoke(self, id):
rt = RefreshToken.query.filter_by(id=id).first()
rt.revoke_timestamp = datetime.datetime.now(datetime.UTC).timestamp()
db.session.commit()
return rt
15 changes: 15 additions & 0 deletions api/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ def __init__(self, data, config_version):
except Exception as e:
logging.error(f"[FATAL] Load config fail. api.trusted_proxy contains an invalid ip address. {e}")
exit(1)
self.access_token_validity = 600
if "access_token_validity" in data:
try:
self.access_token_validity = int(data["access_token_validity"])
except Exception as e:
logging.error(f"[FATAL] Load config fail. api.access_token_validity is not valid. {e}")
exit(1)

self.refresh_token_validity = 86400
if "refresh_token_validity" in data:
try:
self.refresh_token_validity = int(data["refresh_token_validity"])
except Exception as e:
logging.error(f"[FATAL] Load config fail. api.refresh_token_validity is not valid. {e}")
exit(1)


class DatabaseConfig:
Expand Down
Loading

0 comments on commit 8fd7e87

Please sign in to comment.