Skip to content

Commit

Permalink
Improve session handling
Browse files Browse the repository at this point in the history
  • Loading branch information
jsangmeister committed Dec 5, 2023
1 parent 86b7892 commit 455f5ea
Show file tree
Hide file tree
Showing 31 changed files with 330 additions and 66 deletions.
2 changes: 2 additions & 0 deletions Dockerfile.test
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ COPY auth/nodemon.json ./
COPY auth/tsconfig*.json ./
COPY auth/wait-for.sh ./

RUN chmod 777 -R .

EXPOSE 9004

ENV OPENSLIDES_DEVELOPMENT 1
Expand Down
7 changes: 3 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,16 @@ build-dev:
build-test:
docker build -t openslides-auth-dev -f Dockerfile.test .

run-dev: | build-dev
run-dev-standalone: | build-dev
docker-compose -f docker-compose.dev.yml up
stop-dev

run-pre-test: | build-test
docker-compose -f docker-compose.dev.yml up -d
docker-compose -f docker-compose.dev.yml exec -T auth ./wait-for.sh auth:9004

run-bash: | run-pre-test
docker-compose -f docker-compose.dev.yml exec auth sh
docker-compose -f docker-compose.dev.yml down
run-bash run-dev: | run-pre-test
USER_ID=$$(id -u $${USER}) GROUP_ID=$$(id -g $${USER}) docker-compose -f docker-compose.dev.yml exec auth sh

run-check-lint:
docker-compose -f docker-compose.dev.yml exec -T auth npm run lint-check
Expand Down
16 changes: 15 additions & 1 deletion auth/libraries/pip-auth/authlib/auth_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
from requests import Response

from .constants import ANONYMOUS_USER
from .database import Database
from .exceptions import AuthorizationException
from .hashing_handler import HashingHandler
from .http_handler import HttpHandler
from .session_handler import SessionHandler
from .token_factory import TokenFactory
from .validator import Validator

Expand All @@ -17,12 +20,16 @@ class AuthHandler:
optionally be passed.
"""

TOKEN_DB_KEY = "tokens"

def __init__(self, debug_fn: Any = print) -> None:
self.debug_fn = debug_fn
self.http_handler = HttpHandler(debug_fn)
self.validator = Validator(self.http_handler, debug_fn)
self.token_factory = TokenFactory(self.http_handler, debug_fn)
self.hashing_handler = HashingHandler()
self.database = Database(AuthHandler.TOKEN_DB_KEY, debug_fn)
self.session_handler = SessionHandler(debug_fn)

def authenticate(
self, access_token: Optional[str], refresh_id: Optional[str]
Expand Down Expand Up @@ -64,4 +71,11 @@ def create_authorization_token(self, user_id: int, email: str) -> Response:
return self.token_factory.create(user_id, email)

def verify_authorization_token(self, authorization_token: str) -> Tuple[int, str]:
return self.validator.verify_authorization_token(authorization_token)
if self.database.get(authorization_token):
raise AuthorizationException("Token is already used")
result = self.validator.verify_authorization_token(authorization_token)
self.database.set(authorization_token, True, True)
return result

def clear_all_sessions(self, access_token: str, refresh_id: str) -> None:
return self.session_handler.clear_all_sessions(access_token, refresh_id)
2 changes: 2 additions & 0 deletions auth/libraries/pip-auth/authlib/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
COOKIE_NAME = "refreshId"
USER_ID_PROPERTY = "userId"
ANONYMOUS_USER = 0

PASSWORD_RESET_TOKEN_EXPIRATION_TIME = 600
36 changes: 36 additions & 0 deletions auth/libraries/pip-auth/authlib/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import json
import os
from typing import Any

import redis

from .constants import PASSWORD_RESET_TOKEN_EXPIRATION_TIME


class Database:
AUTH_PREFIX = "auth"

def __init__(self, prefix: str, debug_fn: Any = print) -> None:
self.prefix = prefix
self.debug_fn = debug_fn
host = os.environ.get("CACHE_HOST", "localhost")
port = int(os.environ.get("CACHE_PORT", 6379))
self.redis = redis.Redis(host=host, port=port)

def set(self, key: str, obj: Any, expire: bool = False) -> None:
redisKey = self.getPrefixedKey(key)
self.redis.hset(redisKey, redisKey, json.dumps(obj))
if expire:
self.redis.expire(redisKey, PASSWORD_RESET_TOKEN_EXPIRATION_TIME)

def get(self, key: str) -> Any:
redisKey = self.getPrefixedKey(key)
data = self.redis.hget(redisKey, redisKey)
if data:
return json.loads(data)

def getPrefix(self) -> str:
return f"{Database.AUTH_PREFIX}:{self.prefix}"

def getPrefixedKey(self, key: str) -> str:
return f"{self.getPrefix()}:{key}"
5 changes: 5 additions & 0 deletions auth/libraries/pip-auth/authlib/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ def __init__(self, message: str) -> None:
super().__init__(message)


class AuthorizationException(BaseException):
def __init__(self, message: str) -> None:
super().__init__(message)


class SecretException(BaseException):
def __init__(self, message: str) -> None:
super().__init__(message)
Expand Down
34 changes: 22 additions & 12 deletions auth/libraries/pip-auth/authlib/http_handler.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import os
from typing import Any
from urllib import parse

import requests

from .constants import AUTHENTICATION_HEADER, COOKIE_NAME
from .exceptions import AuthenticateException


Expand All @@ -21,23 +23,31 @@ def get_endpoint(self, debug_fn: Any = print) -> str:
def send_request(
self, path: str, payload=None, headers=None, cookies=None
) -> requests.Response:
try:
url = f"{self.auth_endpoint}/system/auth{self.format_url(path)}"
self.debug_fn(f"Send request to {url}")
response = requests.post(
url, data=payload, headers=headers, cookies=cookies
)
except requests.exceptions.ConnectionError as e:
raise AuthenticateException(
f"Cannot reach the authentication service on {url}. Error: {e}"
)
return response
path = f"/system/auth{self.format_url(path)}"
return self.__send_request(path, payload, headers, cookies)

def send_internal_request(
self, path: str, payload=None, headers=None, cookies=None
) -> requests.Response:
path = f"/internal/auth{self.format_url(path)}"
return self.__send_request(path, payload, headers, cookies)

def send_secure_request(
self, path: str, token: str, cookie: str, payload=None
) -> requests.Response:
path = f"/system/auth/secure{self.format_url(path)}"
return self.__send_request(
path,
payload,
{AUTHENTICATION_HEADER: token},
{COOKIE_NAME: parse.quote(cookie)},
)

def __send_request(
self, path: str, payload: Any, headers: Any, cookies: Any
) -> requests.Response:
try:
url = f"{self.auth_endpoint}/internal/auth{self.format_url(path)}"
url = f"{self.auth_endpoint}{path}"
response = requests.post(
url, data=payload, headers=headers, cookies=cookies
)
Expand Down
31 changes: 31 additions & 0 deletions auth/libraries/pip-auth/authlib/message_bus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import os
from collections import defaultdict
from time import time
from typing import Any, Dict, Optional

import redis


class MessageBus:
def __init__(self, debug_fn: Any = print) -> None:
self.debug_fn = debug_fn
host = os.environ.get("MESSAGE_BUS_HOST", "localhost")
port = int(os.environ.get("MESSAGE_BUS_PORT", 6379))
self.redis = redis.Redis(host=host, port=port)
self.last_ids: Dict[str, str] = defaultdict(lambda: "0-0")

def xread(self, topic: str, max_age: Optional[int] = None) -> Any:
last_id = self.last_ids[topic]
last_timestamp = int(last_id.split("-")[0])
if max_age:
min_timestamp = round((time() - max_age) * 1000)
if min_timestamp > last_timestamp:
last_id = str(min_timestamp)
response = self.redis.xread({topic: last_id})
if response:
entries = response[0][1]
self.last_ids[topic] = entries[-1][0].decode()
return entries
else:
self.last_ids[topic] = last_id
return []
58 changes: 58 additions & 0 deletions auth/libraries/pip-auth/authlib/session_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from time import time
from typing import Any, Dict, Tuple

from ordered_set import OrderedSet

from .http_handler import HttpHandler
from .message_bus import MessageBus


class Session:
id: str
timestamp: int

def __init__(self, id: str, timestamp: int = 0) -> None:
self.id = id
self.timestamp = timestamp

def __eq__(self, other):
return isinstance(other, Session) and self.id == other.id

def __hash__(self):
return hash(self.id)


class SessionHandler:
LOGOUT_TOPIC = "logout"
PRUNE_TIME = int(600 * 1.1)

invalid_sessions: OrderedSet[Session]

def __init__(self, debug_fn: Any = print) -> None:
self.debug_fn = debug_fn
self.http_handler = HttpHandler(debug_fn)
self.message_bus = MessageBus(debug_fn)
self.invalid_sessions = OrderedSet()

def update_invalid_sessions(self) -> None:
if len(self.invalid_sessions):
index = len(self.invalid_sessions) // 2
median = self.invalid_sessions[index]
if median.timestamp / 1000 < time() - self.PRUNE_TIME:
self.invalid_sessions = self.invalid_sessions[index + 1 :]
new_logouts = self.message_bus.xread(self.LOGOUT_TOPIC, self.PRUNE_TIME)
self.invalid_sessions.update(
[self.create_session(logout) for logout in new_logouts]
)

def create_session(self, logout_data: Tuple[bytes, Dict[bytes, bytes]]) -> Session:
timestamp = int(logout_data[0].decode().split("-")[0])
session_id = logout_data[1][b"sessionId"].decode()
return Session(session_id, timestamp)

def is_session_invalid(self, session_id: str) -> bool:
self.update_invalid_sessions()
return Session(session_id) in self.invalid_sessions

def clear_all_sessions(self, token: str, cookie: str) -> None:
self.http_handler.send_secure_request("clear-all-sessions", token, cookie)
42 changes: 27 additions & 15 deletions auth/libraries/pip-auth/authlib/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
InvalidCredentialsException,
)
from .http_handler import HttpHandler
from .session_handler import SessionHandler


class Validator:
Expand All @@ -22,6 +23,7 @@ def __init__(self, http_handler: HttpHandler, debug_fn: Any = print) -> None:
self.http_handler = http_handler
self.debug_fn = debug_fn
self.environment = Environment(debug_fn)
self.session_handler = SessionHandler(debug_fn)

def verify(
self, token_encoded: str, cookie_encoded: str
Expand All @@ -32,8 +34,8 @@ def verify(
read from the decoded jwt contained in the token.
"""
self.debug_fn("Validator.verify")
self.__assert_instance_of_encoded_jwt(token_encoded, "Token")
self.__assert_instance_of_encoded_jwt(cookie_encoded, "Cookie")
self.__assert_instance_of_encoded_jwt(token_encoded)
self.__assert_instance_of_encoded_jwt(cookie_encoded)
to_execute = lambda: [self.__verify_ticket(token_encoded, cookie_encoded), None]
to_fallback = lambda: self.__verify_ticket_from_auth_service(
token_encoded, cookie_encoded
Expand All @@ -58,6 +60,9 @@ def verify_only_cookie(self, cookie_encoded: str) -> int:
cookie_encoded, self.environment.get_cookie_secret()
)
cookie = self.__exception_handler(get_cookie)
session_id = cookie.get("sessionId")
if self.session_handler.is_session_invalid(session_id):
raise AuthenticateException("The session is invalid")
user_id = cookie.get(USER_ID_PROPERTY)
if not isinstance(user_id, int):
raise AuthenticateException("user_id is not an int")
Expand All @@ -71,6 +76,9 @@ def verify_authorization_token(self, authorization_token: str) -> Tuple[int, str
authorization_token, self.environment.get_token_secret()
)
token = self.__exception_handler(get_token)
session_id = token.get("sessionId")
if self.session_handler.is_session_invalid(session_id):
raise AuthenticateException("The session is invalid")
user_id = token.get(USER_ID_PROPERTY)
email = token.get("email")
if not isinstance(user_id, str):
Expand All @@ -83,16 +91,18 @@ def __verify_ticket(self, token_encoded: str, cookie_encoded: str) -> int:
self.debug_fn("Validator.__verify_ticket")
token_encoded = self.__get_jwt_from_bearer_jwt(token_encoded, "token")
cookie_encoded = self.__get_jwt_from_bearer_jwt(cookie_encoded, "cookie")
# this may raise an ExpiredSignatureError. We check,
# if the cookies signature is valid
# check whether the cookie signature is valid
self.__decode(cookie_encoded, self.environment.get_cookie_secret())
token = self.__decode(token_encoded, self.environment.get_token_secret())
session_id = token["sessionId"]
if self.session_handler.is_session_invalid(session_id):
raise AuthenticateException("The session is invalid")
user_id = token.get(USER_ID_PROPERTY)
if not isinstance(user_id, int):
raise AuthenticateException("user_id is not an int")
return user_id

def __assert_instance_of_encoded_jwt(self, jwt: str, name: str = "jwt") -> None:
def __assert_instance_of_encoded_jwt(self, jwt: str) -> None:
self.debug_fn("Validator.__assert_instance_of_encoded_jwt")
if not isinstance(jwt, str):
error_message = f"{jwt} is from type {type(jwt)} -- expected: string"
Expand Down Expand Up @@ -168,27 +178,29 @@ def __handle_unhandled_exception(self, exception: Any) -> None:
self.debug_fn("Validator.__handle_unhandled_exception")
if isinstance(exception, jwt.exceptions.ExpiredSignatureError):
raise InvalidCredentialsException("The jwt is expired")
if isinstance(exception, jwt.exceptions.InvalidSignatureError):
elif isinstance(exception, jwt.exceptions.InvalidSignatureError):
raise InvalidCredentialsException("The signature of the jwt is invalid")
if isinstance(exception, jwt.exceptions.InvalidTokenError):
elif isinstance(exception, jwt.exceptions.InvalidTokenError):
raise InvalidCredentialsException("The jwt is invalid")
if isinstance(exception, jwt.exceptions.DecodeError):
elif isinstance(exception, jwt.exceptions.DecodeError):
raise InvalidCredentialsException("The jwt is invalid")
if isinstance(exception, jwt.exceptions.InvalidAudienceError):
elif isinstance(exception, jwt.exceptions.InvalidAudienceError):
raise InvalidCredentialsException("The audience of the jwt is invalid")
if isinstance(exception, jwt.exceptions.InvalidAlgorithmError):
elif isinstance(exception, jwt.exceptions.InvalidAlgorithmError):
raise InvalidCredentialsException("Unsupported algorithm detected")
if isinstance(exception, jwt.exceptions.InvalidIssuerError):
elif isinstance(exception, jwt.exceptions.InvalidIssuerError):
raise InvalidCredentialsException("Wrong issuer detected")
if isinstance(exception, jwt.exceptions.InvalidIssuedAtError):
elif isinstance(exception, jwt.exceptions.InvalidIssuedAtError):
raise InvalidCredentialsException("The 'iat'-timestamp is in the future")
if isinstance(exception, jwt.exceptions.ImmatureSignatureError):
elif isinstance(exception, jwt.exceptions.ImmatureSignatureError):
raise InvalidCredentialsException("The 'nbf'-timestamp is in the future")
if isinstance(exception, jwt.exceptions.MissingRequiredClaimError):
elif isinstance(exception, jwt.exceptions.MissingRequiredClaimError):
raise InvalidCredentialsException(
"The jwt does not contain the required fields"
)
if isinstance(exception, jwt.exceptions.InvalidKeyError):
elif isinstance(exception, jwt.exceptions.InvalidKeyError):
raise InvalidCredentialsException(
"The specified key for the jwt has a wrong format"
)
else:
raise exception
Empty file.
4 changes: 3 additions & 1 deletion auth/libraries/pip-auth/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
argon2-cffi==23.1.0
ordered-set==4.1.0
pyjwt==2.6.0
redis==4.6.0
requests==2.31.0
simplejson==3.18.0
argon2-cffi==23.1.0
1 change: 1 addition & 0 deletions auth/libraries/pip-auth/requirements_development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ isort==5.11.3
mypy==0.991
pytest==7.2.0

types-redis==4.6.0.11
types-requests==2.28.1
Loading

0 comments on commit 455f5ea

Please sign in to comment.