diff --git a/Dockerfile.test b/Dockerfile.test index 03b7c180..9882b5df 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -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 diff --git a/Makefile b/Makefile index 77f71b42..cbc52a6e 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ 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 @@ -15,9 +15,8 @@ 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 diff --git a/auth/libraries/pip-auth/authlib/auth_handler.py b/auth/libraries/pip-auth/authlib/auth_handler.py index 57ff4dbb..ece0583d 100644 --- a/auth/libraries/pip-auth/authlib/auth_handler.py +++ b/auth/libraries/pip-auth/authlib/auth_handler.py @@ -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 @@ -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] @@ -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) diff --git a/auth/libraries/pip-auth/authlib/constants.py b/auth/libraries/pip-auth/authlib/constants.py index 394f7288..c0b6c53d 100644 --- a/auth/libraries/pip-auth/authlib/constants.py +++ b/auth/libraries/pip-auth/authlib/constants.py @@ -3,3 +3,5 @@ COOKIE_NAME = "refreshId" USER_ID_PROPERTY = "userId" ANONYMOUS_USER = 0 + +PASSWORD_RESET_TOKEN_EXPIRATION_TIME = 600 diff --git a/auth/libraries/pip-auth/authlib/database.py b/auth/libraries/pip-auth/authlib/database.py new file mode 100644 index 00000000..e6126c8d --- /dev/null +++ b/auth/libraries/pip-auth/authlib/database.py @@ -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}" diff --git a/auth/libraries/pip-auth/authlib/exceptions.py b/auth/libraries/pip-auth/authlib/exceptions.py index 341b3761..09c443ea 100644 --- a/auth/libraries/pip-auth/authlib/exceptions.py +++ b/auth/libraries/pip-auth/authlib/exceptions.py @@ -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) diff --git a/auth/libraries/pip-auth/authlib/http_handler.py b/auth/libraries/pip-auth/authlib/http_handler.py index 7d6ca2bd..2e56c96a 100644 --- a/auth/libraries/pip-auth/authlib/http_handler.py +++ b/auth/libraries/pip-auth/authlib/http_handler.py @@ -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 @@ -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 ) diff --git a/auth/libraries/pip-auth/authlib/message_bus.py b/auth/libraries/pip-auth/authlib/message_bus.py new file mode 100644 index 00000000..18142249 --- /dev/null +++ b/auth/libraries/pip-auth/authlib/message_bus.py @@ -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 [] diff --git a/auth/libraries/pip-auth/authlib/session_handler.py b/auth/libraries/pip-auth/authlib/session_handler.py new file mode 100644 index 00000000..dcbd5a67 --- /dev/null +++ b/auth/libraries/pip-auth/authlib/session_handler.py @@ -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) diff --git a/auth/libraries/pip-auth/authlib/validator.py b/auth/libraries/pip-auth/authlib/validator.py index e94d6266..ae5e5718 100644 --- a/auth/libraries/pip-auth/authlib/validator.py +++ b/auth/libraries/pip-auth/authlib/validator.py @@ -10,6 +10,7 @@ InvalidCredentialsException, ) from .http_handler import HttpHandler +from .session_handler import SessionHandler class Validator: @@ -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 @@ -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 @@ -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") @@ -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): @@ -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" @@ -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 diff --git a/auth/libraries/pip-auth/py.typed b/auth/libraries/pip-auth/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/auth/libraries/pip-auth/requirements.txt b/auth/libraries/pip-auth/requirements.txt index 7eeef035..282482bc 100644 --- a/auth/libraries/pip-auth/requirements.txt +++ b/auth/libraries/pip-auth/requirements.txt @@ -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 diff --git a/auth/libraries/pip-auth/requirements_development.txt b/auth/libraries/pip-auth/requirements_development.txt index 5c448033..160633df 100644 --- a/auth/libraries/pip-auth/requirements_development.txt +++ b/auth/libraries/pip-auth/requirements_development.txt @@ -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 diff --git a/auth/libraries/pip-auth/setup.cfg b/auth/libraries/pip-auth/setup.cfg index 58e91fcd..e58a21af 100644 --- a/auth/libraries/pip-auth/setup.cfg +++ b/auth/libraries/pip-auth/setup.cfg @@ -6,7 +6,7 @@ recursive = true [flake8] max-line-length = 88 -extend-ignore = E731 +extend-ignore = E203,E731 [isort] include_trailing_comma = true diff --git a/auth/libraries/pip-auth/setup.py b/auth/libraries/pip-auth/setup.py index f6957a4f..3fb705b9 100644 --- a/auth/libraries/pip-auth/setup.py +++ b/auth/libraries/pip-auth/setup.py @@ -7,6 +7,9 @@ with open("README.md", "r") as fh: long_description = fh.read() +with open("requirements.txt") as requirements: + install_requires = [x.strip() for x in requirements.readlines()] + setuptools.setup( name="authlib", version="1.0.0", @@ -16,12 +19,8 @@ long_description=long_description, url="https://github.com/OpenSlides/openslides-auth-service/auth/libraries/pip-auth", packages=setuptools.find_packages(), - install_requires=[ - "pyjwt", - "requests", - "simplejson", - "pytest", - ], + install_requires=install_requires, + package_data={"authlib": ["py.typed"]}, classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", diff --git a/auth/libraries/pip-auth/tests/base.py b/auth/libraries/pip-auth/tests/base.py index 65c4ea17..2df607b6 100644 --- a/auth/libraries/pip-auth/tests/base.py +++ b/auth/libraries/pip-auth/tests/base.py @@ -1,6 +1,7 @@ import unittest from authlib.auth_handler import AuthHandler +from authlib.message_bus import MessageBus from .fake_request import FakeRequest @@ -8,6 +9,10 @@ class BaseTestEnvironment(unittest.TestCase): auth_handler = AuthHandler() fake_request = FakeRequest() + message_bus = MessageBus() + + def setUp(self) -> None: + self.message_bus.redis.flushall() def get_invalid_access_token(self): return "bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHBpcmVzSW4iOiIxMG0iLCJzZXNzaW9uSWQiOiI2NjUzYWMwNmJhNjVkYmQzNDE5NTQwOGQ1MDI5NjU1ZSIsInVzZXJJZCI6MSwaWF0IjoxNTk3MTQ5NDI0LCJleHAiOjE1OTcxNTAwMjR9.z21-bSIj_xZAoCbwXTqqf_ODAIEbbeSehYIE33dmYUs" # noqa diff --git a/auth/libraries/pip-auth/tests/fake_request.py b/auth/libraries/pip-auth/tests/fake_request.py index e1f526f8..309dc442 100644 --- a/auth/libraries/pip-auth/tests/fake_request.py +++ b/auth/libraries/pip-auth/tests/fake_request.py @@ -25,3 +25,7 @@ def login(self) -> Tuple[Optional[str], Optional[str]]: response.headers.get(AUTHENTICATION_HEADER, None), parse.unquote(cookie), ) + + def logout(self, token: str, cookie: str) -> None: + response = self.http_handler.send_secure_request("logout", token, cookie) + assert response.status_code == 200 diff --git a/auth/libraries/pip-auth/tests/test_authenticate.py b/auth/libraries/pip-auth/tests/test_authenticate.py index 13046205..7f92c3c7 100644 --- a/auth/libraries/pip-auth/tests/test_authenticate.py +++ b/auth/libraries/pip-auth/tests/test_authenticate.py @@ -1,10 +1,11 @@ from datetime import datetime import jwt +import pytest from authlib.config import Environment from authlib.constants import USER_ID_PROPERTY -from authlib.exceptions import InvalidCredentialsException +from authlib.exceptions import AuthenticateException, InvalidCredentialsException from .base import BaseTestEnvironment @@ -62,3 +63,18 @@ def test_authenticate_with_expired_access_token(self): ) self.assertEqual(1, user_id) self.assertIsNotNone(access_token) + + def test_authenticate_after_logout(self): + token, cookie = self.fake_request.login() + assert token and cookie + self.auth_handler.authenticate(token, cookie) + self.fake_request.logout(token, cookie) + with pytest.raises(AuthenticateException): + self.auth_handler.authenticate(token, cookie) + + def test_clear_sessions(self): + token, cookie = self.fake_request.login() + assert token and cookie + self.auth_handler.clear_all_sessions(token, cookie) + with pytest.raises(AuthenticateException): + self.auth_handler.authenticate(token, cookie) diff --git a/auth/libraries/pip-auth/tests/test_authorize.py b/auth/libraries/pip-auth/tests/test_authorize.py index 7a19f28b..1e85e7c4 100644 --- a/auth/libraries/pip-auth/tests/test_authorize.py +++ b/auth/libraries/pip-auth/tests/test_authorize.py @@ -1,5 +1,8 @@ +import pytest + from authlib.config import Environment from authlib.constants import AUTHORIZATION_HEADER +from authlib.exceptions import AuthorizationException from .base import BaseTestEnvironment @@ -16,8 +19,10 @@ def test_create_authorize(self): def test_verify_authorize(self): response = self.auth_handler.create_authorization_token(1, self.example_email) - user_id, email = self.auth_handler.verify_authorization_token( - response.headers.get(AUTHORIZATION_HEADER, "") - ) + token = response.headers.get(AUTHORIZATION_HEADER, "") + user_id, email = self.auth_handler.verify_authorization_token(token) self.assertEqual(user_id, 1) self.assertEqual(email, self.example_email) + # assert that the token is only valid once + with pytest.raises(AuthorizationException): + self.auth_handler.verify_authorization_token(token) diff --git a/auth/src/adapter/redis-database-adapter.ts b/auth/src/adapter/redis-database-adapter.ts index ed1b17d9..b2f341ce 100644 --- a/auth/src/adapter/redis-database-adapter.ts +++ b/auth/src/adapter/redis-database-adapter.ts @@ -2,6 +2,7 @@ import Redis from 'ioredis'; import { Database, KeyType } from '../api/interfaces/database'; import { Logger } from '../api/services/logger'; +import { Config } from '../config'; export class RedisDatabaseAdapter extends Database { private _database: Redis.Redis; @@ -30,23 +31,36 @@ export class RedisDatabaseAdapter extends Database { }); } - public async set(key: KeyType, obj: T): Promise { + public async set(key: KeyType, obj: T, expire: boolean = false): Promise { + const redisKey = this.getPrefixedKey(key); await new Promise((resolve, reject) => { - this._database.hset(this.getHashKey(), this.getPrefixedKey(key), JSON.stringify(obj), (error, result) => { + this._database.hset(this.getHashKey(), redisKey, JSON.stringify(obj), (error, result) => { if (error) { return reject(error); } resolve(result); }); }); - await new Promise((resolve, reject) => { - this._database.sadd(`${this.getPrefix()}:index`, key, (error, result) => { - if (error) { - return reject(error); - } - resolve(result); + if (expire) { + await new Promise((resolve, reject) => { + // just to be sure, multiple timeout by 1.1 to avoid timing issues + this._database.expire(redisKey, Config.TOKEN_EXPIRATION_TIME * 1.1, (error, result) => { + if (error) { + return reject(error); + } + resolve(result); + }); }); - }); + } else { + await new Promise((resolve, reject) => { + this._database.sadd(`${this.getPrefix()}:index`, key, (error, result) => { + if (error) { + return reject(error); + } + resolve(result); + }); + }); + } } public async get(key: KeyType): Promise { @@ -101,7 +115,7 @@ export class RedisDatabaseAdapter extends Database { } private getHashKey(): string { - return `${Database.PREFIX}:${this.prefix}`; + return this.getPrefix(); } private getPrefix(): string { diff --git a/auth/src/api/interfaces/auth-handler.ts b/auth/src/api/interfaces/auth-handler.ts index 491898ec..b73e6dd4 100644 --- a/auth/src/api/interfaces/auth-handler.ts +++ b/auth/src/api/interfaces/auth-handler.ts @@ -1,3 +1,4 @@ +import { Id } from '../../core/key-transforms'; import { Ticket, Token } from '../../core/ticket'; import { JwtPayload } from '../../core/ticket/base-jwt'; @@ -5,16 +6,18 @@ export abstract class AuthHandler { public static readonly COOKIE_NAME = 'refreshId'; public static readonly AUTHENTICATION_HEADER = 'authentication'; public static readonly AUTHORIZATION_HEADER = 'authorization'; + public static readonly TOKEN_DB_KEY = 'tokens'; public abstract login(username: string, password: string): Promise; public abstract doSamlLogin(userId: number): Promise; public abstract whoAmI(cookieAsString: string): Promise; public abstract createAuthorizationToken(payload: JwtPayload): string; - public abstract verifyAuthorizationToken(token: string): Token; + public abstract verifyAuthorizationToken(token: string): Promise; public abstract logout(token: Token): void; public abstract getListOfSessions(): Promise; public abstract clearUserSessionById(sessionId: string): Promise; public abstract clearAllSessionsExceptThemselves(sessionId: string): Promise; + public abstract clearAllSessions(userId: Id): Promise; public abstract toHash(toHash: string): Promise; public abstract isEquals(toHash: string, toCompare: string): Promise; } diff --git a/auth/src/api/interfaces/database.ts b/auth/src/api/interfaces/database.ts index de87c513..dea13d98 100644 --- a/auth/src/api/interfaces/database.ts +++ b/auth/src/api/interfaces/database.ts @@ -17,10 +17,12 @@ export abstract class Database { * * @param key The key, where the object is found. * @param obj The object to store. + * @param expire Optional: If true, the key expires after PASSWORD_RESET_TOKEN_EXPIRATION_TIME. If false, + * the key is added to the index set. * * @returns A boolean, if everything is okay - if `false`, the key is already existing in the database. */ - public abstract set(key: KeyType, obj: T): Promise; + public abstract set(key: KeyType, obj: T, expire?: boolean): Promise; /** * This returns an object stored by the given key. diff --git a/auth/src/api/interfaces/session-handler.ts b/auth/src/api/interfaces/session-handler.ts index 4536e90d..f2eded38 100644 --- a/auth/src/api/interfaces/session-handler.ts +++ b/auth/src/api/interfaces/session-handler.ts @@ -1,4 +1,5 @@ import { User } from '../../core/models/user'; +import { Id } from '../../core/key-transforms'; export abstract class SessionHandler { public static readonly SESSION_KEY = 'session:sessions'; @@ -26,6 +27,7 @@ export abstract class SessionHandler { * @param exceptSessionId The sessionId that is not removed. */ public abstract clearAllSessionsExceptThemselves(exceptSessionId: string): Promise; + public abstract clearAllSessions(userId: Id): Promise; public abstract hasSession(sessionId: string): Promise; public abstract getUserIdBySessionId(sessionId: string): Promise; diff --git a/auth/src/api/services/auth-service.ts b/auth/src/api/services/auth-service.ts index d6e8adf3..96f9ff13 100644 --- a/auth/src/api/services/auth-service.ts +++ b/auth/src/api/services/auth-service.ts @@ -1,11 +1,14 @@ import { Factory, Inject } from 'final-di'; +import { RedisDatabaseAdapter } from '../../adapter/redis-database-adapter'; import { AnonymousException } from '../../core/exceptions/anonymous-exception'; import { AuthenticationException } from '../../core/exceptions/authentication-exception'; import { Ticket, Token } from '../../core/ticket'; +import { Id } from '../../core/key-transforms'; import { JwtPayload } from '../../core/ticket/base-jwt'; import { Cookie } from '../../core/ticket/cookie'; import { AuthHandler } from '../interfaces/auth-handler'; +import { Database } from '../interfaces/database'; import { HashingHandler } from '../interfaces/hashing-handler'; import { SessionHandler } from '../interfaces/session-handler'; import { TicketHandler } from '../interfaces/ticket-handler'; @@ -29,6 +32,9 @@ export class AuthService implements AuthHandler { @Inject(SessionService) private _sessionHandler: SessionHandler; + @Factory(RedisDatabaseAdapter, AuthHandler.TOKEN_DB_KEY) + private readonly _tokenDatabase: Database; + public async login(username: string, password: string): Promise { if (!username || !password) { throw new AuthenticationException('Authentication failed! Username or password is not provided!'); @@ -79,8 +85,14 @@ export class AuthService implements AuthHandler { return this._ticketHandler.createJwt(payload).toString(); } - public verifyAuthorizationToken(token: string): Token { - return this._ticketHandler.verifyJwt(token); + public async verifyAuthorizationToken(token: string): Promise { + if (await this._tokenDatabase.get(token)) { + throw new AuthenticationException('Token is already used'); + } + const result = this._ticketHandler.verifyJwt(token); + // set token as invalid + await this._tokenDatabase.set(token, true, true); + return result; } public async logout(token: Token): Promise { @@ -99,6 +111,10 @@ export class AuthService implements AuthHandler { await this._sessionHandler.clearAllSessionsExceptThemselves(sessionId); } + public async clearAllSessions(userId: Id): Promise { + await this._sessionHandler.clearAllSessions(userId); + } + public async toHash(input: string): Promise { return await this._hashHandler.hash(input); } diff --git a/auth/src/api/services/session-service.ts b/auth/src/api/services/session-service.ts index 1442b92f..6fd4d1aa 100644 --- a/auth/src/api/services/session-service.ts +++ b/auth/src/api/services/session-service.ts @@ -3,6 +3,7 @@ import { Factory } from 'final-di'; import { RedisDatabaseAdapter } from '../../adapter/redis-database-adapter'; import { RedisMessageBusAdapter } from '../../adapter/redis-message-bus-adapter'; import { User } from '../../core/models/user'; +import { Id } from '../../core/key-transforms'; import { Random } from '../../util/helper'; import { Database } from '../interfaces/database'; import { MessageBus } from '../interfaces/message-bus'; @@ -71,6 +72,14 @@ export class SessionService extends SessionHandler { await this._userDatabase.set(userId, [exceptSessionId]); } + public async clearAllSessions(userId: Id): Promise { + const currentSessions = (await this._userDatabase.get(userId)) || []; + await Promise.all( + currentSessions.map(session => this.removeSession(session)) + ); + await this._userDatabase.remove(userId); + } + public async hasSession(sessionId: string): Promise { return !!(await this._sessionDatabase.get(sessionId)); } diff --git a/auth/src/api/services/ticket-service.ts b/auth/src/api/services/ticket-service.ts index e1bc6eeb..6fb662aa 100644 --- a/auth/src/api/services/ticket-service.ts +++ b/auth/src/api/services/ticket-service.ts @@ -2,6 +2,7 @@ import { Factory } from 'final-di'; import jwt from 'jsonwebtoken'; import { Id } from 'src/core/key-transforms'; +import { Config } from '../../config'; import { AuthenticationException } from '../../core/exceptions/authentication-exception'; import { ValidationException } from '../../core/exceptions/validation-exception'; import { Cookie, Ticket, Token } from '../../core/ticket'; @@ -97,7 +98,7 @@ export class TicketService extends TicketHandler { private generateToken(sessionId: string | JwtPayload, userId?: Id): Token { const payload: JwtPayload = typeof sessionId === 'string' && userId ? { sessionId, userId } : (sessionId as JwtPayload); - return new Token(payload, this.tokenSecret, { expiresIn: '10m' }); + return new Token(payload, this.tokenSecret, { expiresIn: `${Config.TOKEN_EXPIRATION_TIME}s` }); } private generateCookie(payload: JwtPayload): Cookie; diff --git a/auth/src/config/index.ts b/auth/src/config/index.ts index 79a84703..a4983f97 100644 --- a/auth/src/config/index.ts +++ b/auth/src/config/index.ts @@ -13,6 +13,8 @@ export class Config { public static readonly DATASTORE_WRITER = getUrl('DATASTORE_WRITER_HOST', 'DATASTORE_WRITER_PORT'); public static readonly ACTION_URL = getUrl('ACTION_HOST', 'ACTION_PORT'); + public static readonly TOKEN_EXPIRATION_TIME = 600; + private static readonly VERBOSE_TRUE_FIELDS = ['1', 'true', 'on']; public static isDevMode(): boolean { diff --git a/auth/src/express/controllers/private-controller.ts b/auth/src/express/controllers/private-controller.ts index 30160b8b..dd9305e2 100644 --- a/auth/src/express/controllers/private-controller.ts +++ b/auth/src/express/controllers/private-controller.ts @@ -45,10 +45,10 @@ export class PrivateController { } @OnPost('verify-authorization-token') - public verifyAuthorizationToken(@Req() req: Request): AuthServiceResponse { + public async verifyAuthorizationToken(@Req() req: Request): Promise { const authorizationToken = req.get(AuthHandler.AUTHORIZATION_HEADER); if (authorizationToken) { - return createResponse(this._authHandler.verifyAuthorizationToken(authorizationToken)); + return createResponse(await this._authHandler.verifyAuthorizationToken(authorizationToken)); } else { throw new AuthorizationException('You are not authorized'); } diff --git a/auth/src/express/controllers/secure-controller.ts b/auth/src/express/controllers/secure-controller.ts index 216ad6d7..3bfd5123 100644 --- a/auth/src/express/controllers/secure-controller.ts +++ b/auth/src/express/controllers/secure-controller.ts @@ -42,6 +42,13 @@ export class SecureController { return createResponse(); } + @OnPost('clear-all-sessions') + public async clearAllSessions(@Res() res: Response): Promise { + const token = res.locals['token'] as Token; + await this._authHandler.clearAllSessions(token.userId); + return createResponse(); + } + @OnPost('clear-session-by-id') public async clearSessionById(@Body('sessionId') sessionId: string): Promise { await this._authHandler.clearUserSessionById(sessionId); diff --git a/auth/test/authorization.spec.ts b/auth/test/authorization.spec.ts index 93a39737..13a6b0e1 100644 --- a/auth/test/authorization.spec.ts +++ b/auth/test/authorization.spec.ts @@ -32,16 +32,22 @@ test('Create authorization token', async () => { }); test('Verify authorization token', async () => { - const response = await container.http.post('create-authorization-token', { + var response = await container.http.post('create-authorization-token', { data: { userId: 1, email: exampleEmail }, internal: true }); const token = response.headers[AuthHandler.AUTHORIZATION_HEADER] as string; - const next = await container.http.post('verify-authorization-token', { + response = await container.http.post('verify-authorization-token', { + headers: { [AuthHandler.AUTHORIZATION_HEADER]: token }, + internal: true + }); + expect(response.status).toBe(200); + expect(response.userId).toBe(1); + expect(response.email).toBe(exampleEmail); + // assert that the token is only valid once + response = await container.http.post('verify-authorization-token', { headers: { [AuthHandler.AUTHORIZATION_HEADER]: token }, internal: true }); - expect(next.status).toBe(200); - expect(next.userId).toBe(1); - expect(next.email).toBe(exampleEmail); + expect(response.status).toBe(403); }); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ad73584b..2b1a908e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -3,6 +3,7 @@ version: "3" services: auth: image: openslides-auth-dev + user: $USER_ID:$GROUP_ID restart: always depends_on: - datastore-reader