From 89e958cdb719050795bf869f56d003bc5cf25e64 Mon Sep 17 00:00:00 2001 From: Joshua Jamison Date: Tue, 21 Feb 2023 20:19:40 +0100 Subject: [PATCH] Fix #88 importing config & Add Support for FastAPI 0.92.0 + * fixed issue preventing /import from /export config output. Added tests validating import/export apis * Bumpted min supported pydbantc version & allowing >= version 0.0.22 * added event loop fixture * calling include_router from EasyAuthAPIRouter init * include routers is now a normal def & removed from on_event startup * added self.include_routers to startup_tasks called by lifecycle startup * using httpx AsyncClient & manually calling lifelcycle startup coroutine server.auth.startup_tasks * converted client usage to AsyncClient * removed action / role verification from api setup * added logging to global_store_update ops * quorum establishes leader, but not RPC secret * reverted redundant include router statement * fixed self.global_store_update references, key_path ENV is now optional allowing local path usage. Fixed manager_proxy cleanup within shutdown_auth_server method. RPC_SECRET is now set from auth_secret * pinned max fastapi version * pinned max fastapi version * fixed lifecycle handling via LifespanManager * added library ensuring lifespan usage in testing * reverted client to non-async * bumped pytest-asyncio, pytest verisons * bumped uvicorn, allowed multipart, fastapi-mail bcrypt versions * added event loop & AsyncClient Usage * using async client * added httpx * passing fastapi app as sever along with test_client * test_client, server returned as tuple * adding pytest.ini * added clean db & tables_setup() to auth_test_server fixture, post auth server setup * added explicit logging for post config import checks * reducing concurrent tests * added testing flag to prevent rpc proxies from being created during testing * added testing=True to prevent rpc proxy usage in testing * modified order of building container used by client tests * adjusted how post import data is validated * added previously tested python versions 3.7+ * removed 3.10 testing for mysql due to lack of support * converted .create into normal def, added middleware setup to __init__ tasks allowing latest release of FastAPI releases * moved EasyAuthServer.create & EasyAuthAPIRouter.create outside of startup def as auth_server is now defined outside running event loop constraint * updated with auth_test_server_and_clean_db fixture * allowing latest fastapi release * moved EasyAuthServer.create out of startup task * Updated EasyAuthClient.create as normal def, to be used before event loop creation * rpc_server attribute assigned from setup coroutine * updated EasyAuthClient.create usage outside of startup task * updated documentation with new EasyAuthServer / EasyAuthClient .create() usage outside of startup events * updated fastapi versions allowed, & min pydbantic version * updated min pydbantic * moved EasyAuthServer.create outside of startup lifecycle --------- Co-authored-by: Joshua (codemation) --- .github/workflows/build.yaml | 31 ++-- README.md | 86 ++++++----- docker/server/server.py | 17 +-- docs/client_usage.md | 110 +++++++------- docs/rbac.md | 26 ++-- docs/server_usage.md | 46 +++--- easyauth/api.py | 4 - easyauth/client.py | 277 ++++++++++++++++++----------------- easyauth/models.py | 12 +- easyauth/proxy.py | 3 +- easyauth/quorum.py | 12 -- easyauth/server.py | 202 +++++++++++++------------ pytest.ini | 19 +++ requirements-dev.txt | 8 +- requirements.txt | 14 +- setup.py | 10 +- tests/conftest.py | 135 +++++++++++------ tests/test_client.py | 71 ++++----- tests/test_server.py | 24 ++- tests/test_server_api.py | 146 ++++++++++++------ 20 files changed, 679 insertions(+), 574 deletions(-) create mode 100644 pytest.ini diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 122e301..74ff33e 100755 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -34,11 +34,6 @@ jobs: python-version: [3.7,3.8,3.9,3.10.8] steps: - uses: actions/checkout@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: build and run auth server image - run: | - docker build -f docker/test-docker/Dockerfile-postgres -t joshjamison/easyauth:postgres . - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: @@ -59,6 +54,12 @@ jobs: export KEY_NAME=test_key export ENV=postgres pytest tests/test_server_api.py -s + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: build and run auth server image + run: | + docker build -f docker/test-docker/Dockerfile-postgres -t joshjamison/easyauth:postgres . - name: Test EasyAuth Client run: | export ENV=postgres @@ -71,11 +72,6 @@ jobs: python-version: [3.7,3.8,3.9] steps: - uses: actions/checkout@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: build and run auth server image - run: | - docker build -f docker/test-docker/Dockerfile-mysql -t joshjamison/easyauth:mysql . - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: @@ -99,6 +95,11 @@ jobs: echo "Sleeping to allow db to start" sleep 30 pytest tests/test_server_api.py -s + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: build and run auth server image + run: | + docker build -f docker/test-docker/Dockerfile-mysql -t joshjamison/easyauth:mysql . - name: Test EasyAuth Client run: | export ENV=mysql @@ -111,11 +112,6 @@ jobs: python-version: [3.7,3.8,3.9,3.10.8] steps: - uses: actions/checkout@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: build and run auth server image - run: | - docker build -f docker/test-docker/Dockerfile-sqlite -t joshjamison/easyauth:sqlite . - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: @@ -135,6 +131,11 @@ jobs: export KEY_NAME=test_key export ENV=sqlite pytest tests/test_server_api.py -s + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: build and run auth server image + run: | + docker build -f docker/test-docker/Dockerfile-sqlite -t joshjamison/easyauth:sqlite . - name: Test EasyAuth - Client run: | export ENV=sqlite diff --git a/README.md b/README.md index 6b133bc..73424a1 100755 --- a/README.md +++ b/README.md @@ -59,16 +59,14 @@ from easyauth.server import EasyAuthServer server = FastAPI() -@server.on_event('startup') -async def startup(): - server.auth = await EasyAuthServer.create( - server, - '/auth/token', - auth_secret='abcd1234', - admin_title='EasyAuth - Company', - admin_prefix='/admin', - env_from_file='server_env.json' - ) +server.auth = EasyAuthServer.create( + server, + '/auth/token', + auth_secret='abcd1234', + admin_title='EasyAuth - Company', + admin_prefix='/admin', + env_from_file='server_env.json' +) ``` @@ -89,41 +87,39 @@ from easyauth import get_user server = FastAPI() -@server.on_event('startup') -async def startup(): - server.auth = await EasyAuthClient.create( - server, - token_server='0.0.0.0', - token_server_port=8090, - auth_secret='abcd1234', - default_permissions={'groups': ['users']} - ) - - # grants access to users matching default_permissions - @server.auth.get('/default') - async def default(): - return f"I am default" - - # grants access to only specified users - @server.auth.get('/', users=['jane']) - async def root(): - return f"I am root" - - # grants access to members of 'users' or 'admins' group. - @server.auth.get('/groups', groups=['users', 'admins']) - async def groups(user: str = get_user()): - return f"{user} is in groups" - - # grants access to all members of 'users' group - # or a groups with role of 'basic' or advanced - @server.auth.get('/roles', roles=['basic', 'advanced'], groups=['users']) - async def roles(): - return f"Roles and Groups" - - # grants access to all members of groups with a roles granting 'BASIC_CREATE' - @server.auth.get('/actions', actions=['BASIC_CREATE']) - async def action(): - return f"I am actions" +server.auth = EasyAuthClient.create( + server, + token_server='0.0.0.0', + token_server_port=8090, + auth_secret='abcd1234', + default_permissions={'groups': ['users']} +) + +# grants access to users matching default_permissions +@server.auth.get('/default') +async def default(): + return f"I am default" + +# grants access to only specified users +@server.auth.get('/', users=['jane']) +async def root(): + return f"I am root" + +# grants access to members of 'users' or 'admins' group. +@server.auth.get('/groups', groups=['users', 'admins']) +async def groups(user: str = get_user()): + return f"{user} is in groups" + +# grants access to all members of 'users' group +# or a groups with role of 'basic' or advanced +@server.auth.get('/roles', roles=['basic', 'advanced'], groups=['users']) +async def roles(): + return f"Roles and Groups" + +# grants access to all members of groups with a roles granting 'BASIC_CREATE' +@server.auth.get('/actions', actions=['BASIC_CREATE']) +async def action(): + return f"I am actions" ``` ![](docs/images/login.png) diff --git a/docker/server/server.py b/docker/server/server.py index 2326a26..6b1101a 100755 --- a/docker/server/server.py +++ b/docker/server/server.py @@ -19,13 +19,10 @@ if not ADMIN_PREFIX: ADMIN_PREFIX = "/admin" - -@server.on_event("startup") -async def startup(): - server.auth = await EasyAuthServer.create( - server, - "/auth/token", - auth_secret=AUTH_SECRET, - admin_title=ADMIN_TITLE, - admin_prefix=ADMIN_PREFIX, - ) +server.auth = EasyAuthServer.create( + server, + "/auth/token", + auth_secret=AUTH_SECRET, + admin_title=ADMIN_TITLE, + admin_prefix=ADMIN_PREFIX, +) diff --git a/docs/client_usage.md b/docs/client_usage.md index 31d28e4..6627a52 100755 --- a/docs/client_usage.md +++ b/docs/client_usage.md @@ -11,41 +11,39 @@ from easyauth.client import EasyAuthClient server = FastAPI() -@server.on_event('startup') -async def startup(): - server.auth = await EasyAuthClient.create( - server, - token_server='0.0.0.0', - token_server_port=8090, - auth_secret='abcd1234', - default_permissions={'groups': ['users']} - ) - - # grants access to users matching default_permissions - @server.auth.get('/default') - async def default(): - return f"I am default" - - # grants access to only specified users - @server.auth.get('/', users=['jane']) - async def root(): - return f"I am root" - - # grants access to members of 'users' or 'admins' group. - @server.auth.get('/groups', groups=['users', 'admins']) - async def groups(user: get_user()): - return f"{user} is in groups" - - # grants access to all members of 'users' group - # or a groups with role of 'basic' or advanced - @server.auth.get('/roles', roles=['basic', 'advanced'], groups=['users']) - async def roles(): - return f"Roles and Groups" - - # grants access to all members of groups with a roles granting 'BASIC_CREATE' - @server.auth.get('/actions', actions=['BASIC_CREATE']) - async def action(): - return f"I am actions" +server.auth = EasyAuthClient.create( + server, + token_server='0.0.0.0', + token_server_port=8090, + auth_secret='abcd1234', + default_permissions={'groups': ['users']} +) + +# grants access to users matching default_permissions +@server.auth.get('/default') +async def default(): + return f"I am default" + +# grants access to only specified users +@server.auth.get('/', users=['jane']) +async def root(): + return f"I am root" + +# grants access to members of 'users' or 'admins' group. +@server.auth.get('/groups', groups=['users', 'admins']) +async def groups(user: get_user()): + return f"{user} is in groups" + +# grants access to all members of 'users' group +# or a groups with role of 'basic' or advanced +@server.auth.get('/roles', roles=['basic', 'advanced'], groups=['users']) +async def roles(): + return f"Roles and Groups" + +# grants access to all members of groups with a roles granting 'BASIC_CREATE' +@server.auth.get('/actions', actions=['BASIC_CREATE']) +async def action(): + return f"I am actions" ``` !!! NOTE "default_permissions, if unspecified" {'groups': ['administrators']} @@ -65,20 +63,18 @@ from easyauth.client import EasyAuthClient server = FastAPI(openapi_url="/groups/openapi.json") -@server.on_event('startup') -async def startup(): - server.auth = await EasyAuthClient.create( - server, - token_server='0.0.0.0', - token_server_port=8090, - auth_secret='abcd1234', - default_permissions={'groups': ['users']} - ) - - # import sub modules - from .finance import finance - from .hr import hr - from .marketing import marketing +server.auth = EasyAuthClient.create( + server, + token_server='0.0.0.0', + token_server_port=8090, + auth_secret='abcd1234', + default_permissions={'groups': ['users']} +) + +# import sub modules +from .finance import finance +from .hr import hr +from .marketing import marketing ``` ```python @@ -202,12 +198,12 @@ def custom_activation_page(): The default `/login` and 401 redirect path can be overloaded by setting the `default_login_path` on `EasyAuthClient.create()`. ```python - server.auth = await EasyAuthClient.create( - server, - token_server='0.0.0.0', - token_server_port=8090, - auth_secret='abcd1234', - default_permissions={'groups': ['users']}, - default_login_path='/v1/internal/login' - ) +server.auth = EasyAuthClient.create( + server, + token_server='0.0.0.0', + token_server_port=8090, + auth_secret='abcd1234', + default_permissions={'groups': ['users']}, + default_login_path='/v1/internal/login' +) ``` \ No newline at end of file diff --git a/docs/rbac.md b/docs/rbac.md index 41c080d..b31632b 100755 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -83,18 +83,16 @@ from easyauth import get_user server = FastAPI() -@server.on_event('startup') -async def startup(): - server.auth = await EasyAuthClient.create( - server, - token_server='0.0.0.0', - token_server_port=8090, - auth_secret='abcd1234', - default_permissions={'groups': ['users']} - ) - - # grants access to users matching default_permissions - @server.auth.get('/default') - async def default(user: str = get_user()): - return f"{user} is accessing default endpoint" +server.auth = await EasyAuthClient.create( + server, + token_server='0.0.0.0', + token_server_port=8090, + auth_secret='abcd1234', + default_permissions={'groups': ['users']} +) + +# grants access to users matching default_permissions +@server.auth.get('/default') +async def default(user: str = get_user()): + return f"{user} is accessing default endpoint" ``` diff --git a/docs/server_usage.md b/docs/server_usage.md index 4ea4fda..ab5e750 100755 --- a/docs/server_usage.md +++ b/docs/server_usage.md @@ -29,16 +29,14 @@ from easyauth.server import EasyAuthServer server = FastAPI() -@server.on_event('startup') -async def startup(): - server.auth = await EasyAuthServer.create( - server, - '/auth/token', - auth_secret='abcd1234', - admin_title='EasyAuth - Company', - admin_prefix='/admin', - env_from_file='server_sqlite.json' - ) +server.auth = await EasyAuthServer.create( + server, + '/auth/token', + auth_secret='abcd1234', + admin_title='EasyAuth - Company', + admin_prefix='/admin', + env_from_file='server_sqlite.json' +) ``` !!! SUCCESS "Start Server" @@ -82,21 +80,19 @@ from easyauth.server import EasyAuthServer server = FastAPI() -@server.on_event('startup') -async def startup(): - server.auth = await EasyAuthServer.create( - server, - '/auth/token', - auth_secret='abcd1234', - admin_title='EasyAuth - Company', - admin_prefix='/admin', - env_from_file='server_sqlite.json' - ) - - # import sub modules - from .finance import finance - from .hr import hr - from .marketing import marketing +server.auth = EasyAuthServer.create( + server, + '/auth/token', + auth_secret='abcd1234', + admin_title='EasyAuth - Company', + admin_prefix='/admin', + env_from_file='server_sqlite.json' +) + +# import sub modules +from .finance import finance +from .hr import hr +from .marketing import marketing ``` ```python diff --git a/easyauth/api.py b/easyauth/api.py index 25eb848..91153b5 100755 --- a/easyauth/api.py +++ b/easyauth/api.py @@ -107,16 +107,12 @@ async def import_auth_config(config: Config): role = dict(role) _role = Roles(**role) await _role.save() - for action in role["permissions"]["actions"]: - await verify_action(action) if "groups" in config: for group in config["groups"]: group = dict(group) _group = Groups(**group) await _group.save() - for role_name in group["roles"]["roles"]: - await verify_role(role_name) if "users" in config: for user in config["users"]: diff --git a/easyauth/client.py b/easyauth/client.py index 73ee211..ef260d4 100755 --- a/easyauth/client.py +++ b/easyauth/client.py @@ -67,23 +67,24 @@ class EasyAuthClient: def __init__( self, server: FastAPI, - rpc_server: EasyRpcServer, - token_url: str, - public_key: str, + auth_secret: str, + token_server: str = None, + token_server_port: int = None, logger: logging.Logger = None, env_from_file: str = None, debug: bool = False, default_permissions: dict = {"groups": ["administrators"]}, secure: bool = False, default_login_path: str = "/login", + default_login_redirect: str = "/", ): self.server = server - self.oauth2_scheme = OAuth2PasswordBearer(tokenUrl=token_url) # /token + self.token_server = token_server + self.token_server_port = token_server_port + self.default_permissions = default_permissions self.admin = Admin("EasyAuthClient", side_bar_sections=[]) - self.token_url = token_url - self._public_key = jwk.JWK.from_json(public_key) # cookie security self.cookie_security = { @@ -93,6 +94,7 @@ def __init__( # default login path self.default_login_path = default_login_path + self.default_login_redirect = default_login_redirect # ensure new routers created follow same oath scheme EasyAuthAPIRouter.parent = self @@ -105,7 +107,8 @@ def __init__( }: page.parent = self - self.rpc_server = rpc_server + self.auth_secret = auth_secret + self.oauth2_scheme = OAuth2PasswordBearer(tokenUrl=self.default_login_path) # extra routers self.api_routers = [] @@ -120,7 +123,7 @@ def __init__( self.load_env_from_file(env_from_file) @classmethod - async def create( + def create( cls, server: FastAPI, token_url: str = None, @@ -153,83 +156,139 @@ async def create( ], ) + auth_server = cls( + server, + auth_secret, + token_server, + token_server_port, + logger, + debug, + env_from_file, + default_permissions, + secure, + default_login_path, + default_login_redirect, + ) + + @server.on_event("startup") + async def auth_setup(): + await auth_server.setup() + + @server.middleware("http") + async def detect_token_in_cookie(request, call_next): + request_dict = dict(request) + token_in_cookie = None + auth_ind = None + cookie_ind = None + for i, header in enumerate(request_dict["headers"]): + if "authorization" in header[0].decode(): + if not header[1] is None: + auth_ind = i + if "cookie" in header[0].decode(): + cookie_ind = i + cookies = header[1].decode().split(",") + for cookie in cookies[0].split("; "): + key, value = cookie.split("=") + if key == "token": + token_in_cookie = value + if token_in_cookie and not token_in_cookie == "INVALID": + if auth_ind: + request.headers.__dict__["_list"].pop(auth_ind) + if request_dict["path"] != f"{auth_server.default_login_path}": + request.headers.__dict__["_list"].append( + ("authorization".encode(), f"bearer {token_in_cookie}".encode()) + ) + else: + return RedirectResponse("/logout") + else: + if not request_dict["path"] == f"{auth_server.default_login_path}": + token_in_cookie = ( + "NO_TOKEN" if not token_in_cookie else token_in_cookie + ) + request_dict["headers"].append( + ("authorization".encode(), f"bearer {token_in_cookie}".encode()) + ) + response = await call_next(request) + if response.status_code == 404 and "text/html" in request.headers["accept"]: + if hasattr(auth_server, "html_not_found_page"): + return HTMLResponse( + auth_server.html_not_found_page(), status_code=404 + ) + + return HTMLResponse(auth_server.admin.not_found_page(), status_code=404) + return response + + return auth_server + + async def setup(self): rpc_server = EasyRpcServer( - server, "/ws/easyauth", server_secret=auth_secret, logger=log + self.server, "/ws/easyauth", server_secret=self.auth_secret, logger=self.log ) + self.rpc_server = rpc_server try: await rpc_server.create_server_proxy( - token_server, - token_server_port, + self.token_server, + self.token_server_port, "/ws/easyauth", - server_secret=auth_secret, + server_secret=self.auth_secret, namespace="global_store", ) await rpc_server.create_server_proxy( - token_server, - token_server_port, + self.token_server, + self.token_server_port, "/ws/easyauth", - server_secret=auth_secret, + server_secret=self.auth_secret, namespace="easyauth", ) except Exception: - setup_error = log.exception( - f"error creating connction to EasyAuthServer {token_server}:{token_server_port}" + setup_error = self.log.exception( + f"error creating connction to EasyAuthServer {self.token_server}:{self.token_server_port}" ) - if setup_error is not None: - assert False, f"EasyAuthClient - exiting" + setup_info = await rpc_server["easyauth"]["get_setup_info"]() + + self.token_url = setup_info["token_url"] + self._public_key = jwk.JWK.from_json(setup_info["public_rsa"]) setup_info = await rpc_server["easyauth"]["get_setup_info"]() - auth_server = cls( - server, - rpc_server, - f"http://{token_server}:{token_server_port}{setup_info['token_url']}", - setup_info["public_rsa"], - logger, - debug, - env_from_file, - default_permissions, - secure, - default_login_path, - ) - auth_server.scheduler = EasyScheduler() + self.scheduler = EasyScheduler() - @auth_server.scheduler("* * * * *") + @self.scheduler("* * * * *") async def refresh_auth_public_key(): try: setup_info = await rpc_server["easyauth"]["get_setup_info"]() - auth_server._public_key = jwk.JWK.from_json(setup_info["public_rsa"]) + self._public_key = jwk.JWK.from_json(setup_info["public_rsa"]) except IndexError as e: - auth_server.log.error( - f"Unable to refresh_auth_public_key - connection with EasyAuthServer {token_server}:{token_server_port} may have failed" + self.log.error( + f"Unable to refresh_auth_public_key - connection with EasyAuthServer {self.token_server}:{self.token_server_port} may have failed" ) - asyncio.create_task(auth_server.scheduler.start()) + asyncio.create_task(self.scheduler.start()) await asyncio.sleep(5) - auth_server.store = await rpc_server["global_store"]["get_store_data"]() + self.store = await rpc_server["global_store"]["get_store_data"]() async def store_data(action: str, store: str, key: str, value: Any = None): """ actions: - put|update|delete """ - auth_server.log.debug( + self.log.debug( f"store_data action: {action} - store: {store} - key: {key} - value: {value}" ) - if not store in auth_server.store: - auth_server.store[store] = {} + if not store in self.store: + self.store[store] = {} if action in {"update", "put"}: - print(f"token updated: {key}") - auth_server.store[store][key] = value + self.log.info(f"token updated: {key}") + self.store[store][key] = value else: - if key in auth_server.store[store]: - del auth_server.store[store][key] + if key in self.store[store]: + del self.store[store][key] return f"{action} in {store} with {key} completed" @@ -239,31 +298,27 @@ async def store_data(action: str, store: str, key: str, value: Any = None): rpc_server.origin(store_data, namespace="global_store") - @server.get( - f"{default_login_path}", + @self.server.get( + f"{self.default_login_path}", response_class=HTMLResponse, include_in_schema=False, ) async def login(request: Request, response: Response): - return await auth_server.get_login_page( - message="Login to Begin", request=request - ) + return await self.get_login_page(message="Login to Begin", request=request) - @server.post( - f"{default_login_path}/re", + @self.server.post( + f"{self.default_login_path}/re", response_class=HTMLResponse, include_in_schema=False, ) async def login(request: Request, response: Response): response.delete_cookie("ref") - @server.on_event("startup") - async def setup(): - auth_server.log.warning(f"adding routers") - await auth_server.include_routers() + self.log.warning(f"adding routers") + await self.include_routers() - @server.post( - f"{default_login_path}", + @self.server.post( + f"{self.default_login_path}", tags=["Login"], response_class=HTMLResponse, include_in_schema=False, @@ -275,9 +330,10 @@ async def login_page( password: str = Form(...), ): token = None - token = await auth_server.rpc_server["easyauth"]["generate_auth_token"]( + token = await self.rpc_server["easyauth"]["generate_auth_token"]( username, password ) + if not "access_token" in token: message = ( "invalid username / password" @@ -285,17 +341,17 @@ async def login_page( else token ) return HTMLResponse( - await auth_server.get_login_page(message=message, request=request), + await self.get_login_page(message=message, request=request), status_code=401, ) token = token["access_token"] - token_id = auth_server.decode_token(token)[1]["token_id"] - auth_server.store["tokens"][token_id] = "" + token_id = self.decode_token(token)[1]["token_id"] + self.store["tokens"][token_id] = "" - response.set_cookie("token", token, **auth_server.cookie_security) + response.set_cookie("token", token, **self.cookie_security) response.status_code = 200 - redirect_ref = default_login_redirect + redirect_ref = self.default_login_redirect if "ref" in request.cookies: redirect_ref = request.cookies["ref"] response.delete_cookie("ref") @@ -304,9 +360,9 @@ async def login_page( redirect_ref, headers=response.headers, status_code=HTTP_302_FOUND ) - @server.get("/register", response_class=HTMLResponse, tags=["User"]) + @self.server.get("/register", response_class=HTMLResponse, tags=["User"]) async def admin_register(): - return server.html_register_page() + return self.server.html_register_page() @RegisterPage.mark() def default_register_page(): @@ -330,13 +386,13 @@ def default_register_page(): ) ) - @server.post("/register", response_class=HTMLResponse, tags=["User"]) + @self.server.post("/register", response_class=HTMLResponse, tags=["User"]) async def admin_register_send(user_info: dict): return await auth_server.rpc_server["easyauth"]["register_user"](user_info) - @server.get("/activate", response_class=HTMLResponse, tags=["User"]) + @self.server.get("/activate", response_class=HTMLResponse, tags=["User"]) async def admin_activate(): - return server.html_activation_page() + return self.server.html_activation_page() @ActivationPage.mark() def default_activate_page(): @@ -351,13 +407,13 @@ def default_activate_page(): ) ) - @server.post("/activate", response_class=HTMLResponse, tags=["User"]) + @self.server.post("/activate", response_class=HTMLResponse, tags=["User"]) async def admin_activate_send(activation_code: ActivationCode): - return await auth_server.rpc_server["easyauth"]["activate_user"]( + return await self.rpc_server["easyauth"]["activate_user"]( activation_code.dict() ) - @server.post("/auth/token/oauth/google", include_in_schema=False) + @self.server.post("/auth/token/oauth/google", include_in_schema=False) async def create_google_oauth_token( request: Request, response: Response, @@ -370,11 +426,11 @@ async def create_google_oauth_token( if google_client_type == "client": body_bytes = await request.body() auth_code = jsonable_encoder(body_bytes) - token = await auth_server.rpc_server["easyauth"][ - "generate_google_oauth_token" - ](auth_code) + token = await self.rpc_server["easyauth"]["generate_google_oauth_token"]( + auth_code + ) - response.set_cookie("token", token, **auth_server.cookie_security) + response.set_cookie("token", token, **self.cookie_security) redirect_ref = "/" @@ -389,7 +445,7 @@ async def create_google_oauth_token( # not redirecting - decoded_token = auth_server.decode_token(token)[1] + decoded_token = self.decode_token(token)[1] response_body = {"exp": decoded_token["exp"], "auth": True} if include_token: response_body["token"] = token @@ -399,17 +455,19 @@ async def create_google_oauth_token( headers=response.headers, ) - @server.get( + @self.server.get( "/logout", tags=["Login"], response_class=HTMLResponse, include_in_schema=False, ) async def logout_page(response: Response): - response.set_cookie("token", "INVALID", **auth_server.cookie_security) - return RedirectResponse(f"{default_login_path}", headers=response.headers) + response.set_cookie("token", "INVALID", **self.cookie_security) + return RedirectResponse( + f"{self.default_login_path}", headers=response.headers + ) - @server.get( + @self.server.get( "/logout", tags=["Login"], response_class=HTMLResponse, @@ -417,9 +475,11 @@ async def logout_page(response: Response): ) async def logout_page(response: Response): response.set_cookie("token", "INVALID") - return RedirectResponse(f"{default_login_path}", headers=response.headers) + return RedirectResponse( + f"{self.default_login_path}", headers=response.headers + ) - @server.post( + @self.server.post( "/logout", tags=["Login"], response_class=HTMLResponse, @@ -428,58 +488,11 @@ async def logout_page(response: Response): async def logout_page_post( response: Response, ): - response.set_cookie("token", "INVALID", **auth_server.cookie_security) + response.set_cookie("token", "INVALID", **self.cookie_security) return RedirectResponse( - f"{default_login_path}/re", headers=response.headers + f"{self.default_login_path}/re", headers=response.headers ) - @server.middleware("http") - async def detect_token_in_cookie(request, call_next): - request_dict = dict(request) - request_header = dict(request.headers) - token_in_cookie = None - auth_ind = None - cookie_ind = None - for i, header in enumerate(request_dict["headers"]): - if "authorization" in header[0].decode(): - if not header[1] is None: - auth_ind = i - if "cookie" in header[0].decode(): - cookie_ind = i - cookies = header[1].decode().split(",") - for cookie in cookies[0].split("; "): - key, value = cookie.split("=") - if key == "token": - token_in_cookie = value - if token_in_cookie and not token_in_cookie == "INVALID": - if auth_ind: - request.headers.__dict__["_list"].pop(auth_ind) - if request_dict["path"] != f"{default_login_path}": - request.headers.__dict__["_list"].append( - ("authorization".encode(), f"bearer {token_in_cookie}".encode()) - ) - else: - return RedirectResponse("/logout") - else: - if not request_dict["path"] == f"{default_login_path}": - token_in_cookie = ( - "NO_TOKEN" if not token_in_cookie else token_in_cookie - ) - request_dict["headers"].append( - ("authorization".encode(), f"bearer {token_in_cookie}".encode()) - ) - response = await call_next(request) - if response.status_code == 404 and "text/html" in request.headers["accept"]: - if hasattr(auth_server, "html_not_found_page"): - return HTMLResponse( - auth_server.html_not_found_page(), status_code=404 - ) - - return HTMLResponse(auth_server.admin.not_found_page(), status_code=404) - return response - - return auth_server - async def include_routers(self): for auth_api_router in self.api_routers: self.server.include_router(auth_api_router.server) diff --git a/easyauth/models.py b/easyauth/models.py index acb885a..d7ac5ff 100755 --- a/easyauth/models.py +++ b/easyauth/models.py @@ -3,7 +3,7 @@ from typing import List, Optional, Union from pydantic import BaseModel, EmailStr, root_validator -from pydbantic import DataBaseModel +from pydbantic import DataBaseModel, PrimaryKey # Model used to verify input when registering @@ -36,12 +36,12 @@ class AccountType(str, Enum): class Actions(DataBaseModel): - action: str - details: str + action: str = PrimaryKey() + details: str = None class Roles(DataBaseModel): - role: str + role: str = PrimaryKey() actions: List[Actions] = [] @@ -50,7 +50,7 @@ class RolesInput(Roles): class Groups(DataBaseModel): - group_name: str + group_name: str = PrimaryKey() roles: List[Roles] = [] @@ -59,7 +59,7 @@ class GroupsInput(Groups): class BaseUser(DataBaseModel): - username: str = None + username: str = PrimaryKey() account_type: AccountType groups: List[Groups] = [] diff --git a/easyauth/proxy.py b/easyauth/proxy.py index 3407475..dce1e98 100755 --- a/easyauth/proxy.py +++ b/easyauth/proxy.py @@ -29,6 +29,7 @@ async def manager_setup(): # Rpc Server manager = await EasyRpcServer.create(server, **rpc_config) log = manager.log + manager.scheduler = EasyScheduler() @manager.origin(namespace="manager") @@ -41,7 +42,7 @@ async def global_store_update(action, store, key, value): continue if "token_cleanup" in method: continue - + log.warning(f"global_store_update - {action} - {store} - {key}") try: asyncio.create_task( client_methods[method](action, store, key, value) diff --git a/easyauth/quorum.py b/easyauth/quorum.py index 373b412..fba0558 100755 --- a/easyauth/quorum.py +++ b/easyauth/quorum.py @@ -28,15 +28,3 @@ async def quorum_setup(cache): with open("quorum.txt", "r") as q: if q.readline().rstrip() == member_id: cache.leader = True - - if cache.leader: - RPC_SECRET = get_random_string(12) - with open(".rpc_secret", "w") as secret: - secret.write(RPC_SECRET) - os.environ["RPC_SECRET"] = RPC_SECRET - await asyncio.sleep(0.3) - else: - await asyncio.sleep(2) - with open(".rpc_secret", "r") as secret: - RPC_SECRET = secret.readline().rstrip() - os.environ["RPC_SECRET"] = RPC_SECRET diff --git a/easyauth/server.py b/easyauth/server.py index d14efa8..89dacd7 100755 --- a/easyauth/server.py +++ b/easyauth/server.py @@ -4,6 +4,7 @@ import logging import os import random +import signal import string import subprocess import uuid @@ -25,6 +26,8 @@ from google.auth.transport import requests from google.oauth2 import id_token from makefun import wraps +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware from easyauth.api import api_setup from easyauth.db import database_setup @@ -59,7 +62,6 @@ def __init__( self, server: FastAPI, token_url: str, - rpc_server: EasyRpcServer, admin_title: str = "EasyAuth", admin_prefix: str = "/admin", logger: logging.Logger = None, @@ -73,10 +75,11 @@ def __init__( self.server = server self.server.title = admin_title self.ADMIN_PREFIX = admin_prefix + self.token_url = token_url self.oauth2_scheme = OAuth2PasswordBearer(tokenUrl=token_url) # /token self.DEFAULT_PERMISSION = default_permission - self.rpc_server = rpc_server + self.manager_proxy_port = manager_proxy_port # cookie security self.cookie_security = { @@ -120,24 +123,20 @@ def __init__( # setup allowed origins - where can server receive token requests from self.cors_setup() - @server.on_event("startup") - async def setup(): - self.log.warning("adding routers") - await self.include_routers() - - @server.on_event("shutdown") - async def shutdown_auth_server(): - self.log.warning("EasyAuthServer - Starting shutdown process!") - if self.leader: - shutdown_proxies = f"for pid in $(ps aux | grep {manager_proxy_port}' | awk '{{print $2}}'); do kill $pid; done" - os.system(shutdown_proxies) + def shutdown_auth_server(): + self.log.warning( + f"EasyAuthServer - Starting shutdown process! - {self.leader}" + ) + if self.leader and hasattr(self, "manager_proxy"): + os.killpg(os.getpgid(self.manager_proxy.pid), signal.SIGTERM) self.log.warning("EasyAuthServer - Finished shutdown process!") + self.shutdown_auth_server = shutdown_auth_server + @NotFoundPage.mark() def default_not_found_page(): return HTMLResponse(self.admin.not_found_page(), status_code=404) - @server.middleware("http") async def detect_token_in_cookie(request, call_next): request_dict = dict(request) request_header = dict(request.headers) @@ -172,10 +171,12 @@ async def detect_token_in_cookie(request, call_next): return await call_next(request) - @server.middleware("http") + server.user_middleware.insert( + 0, Middleware(BaseHTTPMiddleware, dispatch=detect_token_in_cookie) + ) + async def handle_401_403(request, call_next): response = await call_next(request) - request_dict = dict(request) if response.status_code in [401, 404]: if "text/html" in request.headers["accept"]: @@ -199,12 +200,12 @@ async def handle_401_403(request, call_next): ) return response - @self.rpc_server.origin(namespace="admin") - async def login_stuff(): - pass + server.user_middleware.insert( + 0, Middleware(BaseHTTPMiddleware, dispatch=handle_401_403) + ) @classmethod - async def create( + def create( cls, server: FastAPI, token_url: str, @@ -218,14 +219,14 @@ async def create( default_permission: dict = {"groups": ["administrators"]}, secure: bool = False, private_key: str = None, + testing: bool = False, ): - rpc_server = EasyRpcServer(server, "/ws/easyauth", server_secret=auth_secret) + os.environ["RPC_SECRET"] = auth_secret auth_server = cls( server, token_url, - rpc_server, admin_title, admin_prefix, logger, @@ -237,32 +238,44 @@ async def create( private_key, ) - await database_setup(auth_server) - await api_setup(auth_server) - await frontend_setup(auth_server) + @server.on_event("startup") + async def auth_setup(): + await auth_server.setup(testing=testing) - if auth_server.leader: + return auth_server + + async def setup(self, testing=False): + self.rpc_server = EasyRpcServer( + self.server, "/ws/easyauth", server_secret=os.environ["RPC_SECRET"] + ) + await database_setup(self) + await api_setup(self) + await frontend_setup(self) + + if self.leader and not testing: # create subprocess for manager proxy - auth_server.log.warning(f"starting manager_proxy") - auth_server.manager_proxy = subprocess.Popen( + self.log.warning(f"starting manager_proxy") + self.manager_proxy = subprocess.Popen( f"gunicorn easyauth.manager_proxy:server -w 1 -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8092".split( " " ) ) - auth_server.log.warning(f"leader - waiting for members to start") + self.log.warning(f"leader - waiting for members to join") await asyncio.sleep(5) else: - auth_server.log.warning( - f"member - db setup complete - starting manager proxies" - ) + self.log.warning(f"member - db setup complete - starting manager proxies") await asyncio.sleep(5) + self.log.warning("adding routers") + + await self.startup_tasks() + async def client_update(action: str, store: str, key: str, value: Any): """ update every connected client """ - clients = auth_server.rpc_server["global_store"] + clients = self.rpc_server["global_store"] for client in clients: if client == "get_store_data": continue @@ -270,7 +283,7 @@ async def client_update(action: str, store: str, key: str, value: Any): return f"client_update completed" async def token_cleanup(): - return await auth_server.token_cleanup() + return await self.token_cleanup() client_id = "_".join(str(uuid.uuid4()).split("-")) @@ -278,83 +291,77 @@ async def token_cleanup(): token_cleanup.__name__ = token_cleanup.__name__ + client_id # initialize global storage - auth_server.store = {"tokens": {}} + self.store = {"tokens": {}} async def store_data(action: str, store: str, key: str, value: Any = None): """ actions: - put|update|delete """ - if store not in auth_server.store: - auth_server.store[store] = {} + if store not in self.store: + self.store[store] = {} if action in {"update", "put"}: - auth_server.store[store][key] = value + self.store[store][key] = value else: - if key in auth_server.store[store]: - del auth_server.store[store][key] + if key in self.store[store]: + del self.store[store][key] return f"{action} in {store} with {key} completed" store_data.__name__ = store_data.__name__ + client_id + rpc_server = self.rpc_server rpc_server.origin(store_data, namespace="global_store") @rpc_server.origin(namespace="global_store") async def get_store_data(): rpc_server.get_all_registered_functions(namespace="global_store") - return auth_server.store + return self.store # register unique client_update in clients namespace rpc_server.origin(client_update, namespace="clients") rpc_server.origin(token_cleanup, namespace="clients") # create connection to manager on 'manager' and 'clients' namespace - await rpc_server.create_server_proxy( - "127.0.0.1", - manager_proxy_port, - "/ws/manager", - server_secret=os.environ["RPC_SECRET"], - namespace="clients", - ) + if not testing: + await rpc_server.create_server_proxy( + "127.0.0.1", + self.manager_proxy_port, + "/ws/manager", + server_secret=os.environ["RPC_SECRET"], + namespace="clients", + ) - await rpc_server.create_server_proxy( - "127.0.0.1", - manager_proxy_port, - "/ws/manager", - server_secret=os.environ["RPC_SECRET"], - namespace="manager", - ) + await rpc_server.create_server_proxy( + "127.0.0.1", + self.manager_proxy_port, + "/ws/manager", + server_secret=os.environ["RPC_SECRET"], + namespace="manager", + ) @rpc_server.origin(namespace="easyauth") async def get_setup_info(): return { - "token_url": token_url, - "public_rsa": auth_server._privkey.export_public(), + "token_url": self.token_url, + "public_rsa": self._privkey.export_public(), } @rpc_server.origin(namespace="easyauth") async def get_identity_providers(): - return await auth_server.get_identity_providers() + return await self.get_identity_providers() @rpc_server.origin(namespace="easyauth") async def generate_google_oauth_token(auth_code): - return await auth_server.generate_google_oauth_token(auth_code=auth_code) + return await self.generate_google_oauth_token(auth_code=auth_code) - if auth_server.leader: - await asyncio.sleep(1) + if self.leader: valid_tokens = await Tokens.all() for token in valid_tokens: - await auth_server.global_store_update( - "update", "tokens", token.token_id, "" - ) - else: - await asyncio.sleep(3) - - auth_server.log.warning( - f"EasyAuthServer Started! - Loaded Tokens {auth_server.store['tokens']}" - ) - - return auth_server + await self.global_store_update("update", "tokens", token.token_id, "") + self.log.warning( + f"EasyAuthServer Started! - Loaded Tokens {self.store['tokens']}" + ) def load_env_from_file(self, file_path): self.log.warning(f"loading env vars from {file_path}") @@ -379,35 +386,35 @@ def setup_logger(self, logger=None, level=None): def key_setup(self): # check if keys exist in KEY_PATH else create - assert "KEY_PATH" in os.environ, f"missing KEY_PATH env variable" assert "KEY_NAME" in os.environ, f"missing KEY_NAME env variable" + key_path = os.getenv("KEY_PATH", "") + if key_path: + key_path = f"{key_path}/{os.environ['KEY_NAME']}.key" + else: + key_path = f"{os.environ['KEY_NAME']}" try: - with open( - f"{os.environ['KEY_PATH']}/{os.environ['KEY_NAME']}.key", "r" - ) as k: + with open(key_path + ".key", "r") as k: self._privkey = jwk.JWK.from_json(k.readline()) except Exception: # create private / public keys self._privkey = jwk.JWK.generate( kid=self.generate_random_string(56), kty="RSA", size=2048 ) - with open( - f"{os.environ['KEY_PATH']}/{os.environ['KEY_NAME']}.key", "w" - ) as k: + with open(key_path + ".key", "w") as k: k.write(self._privkey.export_private()) try: - with open( - f"{os.environ['KEY_PATH']}/{os.environ['KEY_NAME']}.pub", "r" - ) as k: + with open(key_path + ".pub", "r") as k: pass except Exception: - with open( - f"{os.environ['KEY_PATH']}/{os.environ['KEY_NAME']}.pub", "w" - ) as pb: + with open(key_path + ".pub", "w") as pb: pb.write(self._privkey.export_public()) - async def include_routers(self): + async def startup_tasks(self): + self.include_routers() + self.log.debug(f"completed including routers") + + def include_routers(self): for auth_api_router in self.api_routers: self.server.include_router(auth_api_router.server) @@ -559,7 +566,6 @@ async def email_send(): def cors_setup(self): origins = ["*"] - self.server.add_middleware( CORSMiddleware, allow_origins=origins, @@ -569,16 +575,25 @@ def cors_setup(self): ) async def global_store_update(self, action, store, key, value): - manager_methods = self.rpc_server["manager"] - if "global_store_update" in manager_methods: - await manager_methods["global_store_update"](action, store, key, value) - return + try: + manager_methods = self.rpc_server["manager"] + if "global_store_update" in manager_methods: + await manager_methods["global_store_update"](action, store, key, value) + except IndexError: + pass async def revoke_token(self, token_id: str): token = await Tokens.get(token_id=token_id) if token: await token.delete() - await self.global_store_update("delete", "tokens", key=token_id, value="") + self.log.warning(f"Revoking token {token}") + if token_id in self.store["tokens"]: + del self.store["tokens"][token_id] + # beware - removing this from create task will cause + # you an immense amount of time in debugging + asyncio.create_task( + self.global_store_update("delete", "tokens", key=token_id, value="") + ) async def token_cleanup(self): """ @@ -629,6 +644,9 @@ async def issue_token(self, permissions, minutes=60, hours=0, days=0): # this should be done once issue token context exits # since this can be triggered by a client, which could not # correctly update its token id store while waiting on the issue_token response + + self.store["tokens"][token_id] = "" + asyncio.create_task( self.global_store_update("update", "tokens", key=token_id, value="") ) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5d4782f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,19 @@ +[pytest] +asyncio_mode=auto +env= + TEST_INIT_PASSWORD=easyauth + ISSUER=EasyAuth + SUBJECT=EasyAuthAuth + AUDIENCE=EasyAuthApis + KEY_NAME=test_key +log_cli=true +filterwarnings = + ignore:Call to deprecated create function FieldDescriptor + ignore:Call to deprecated create function Descriptor + ignore:Call to deprecated create function EnumDescriptor + ignore:Call to deprecated create function EnumValueDescriptor + ignore:Call to deprecated create function FileDescriptor + ignore:Call to deprecated create function OneofDescriptor + ignore:Call to deprecated create function MethodDescriptor + ignore:Call to deprecated create function ServiceDescriptor + ignore:The \'postgres\' dialect name has been renamed to \'postgresql\' \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 6503954..9dc8794 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,9 @@ -r requirements.txt -pytest==6.2.5 -pytest-asyncio==0.16.0 +pytest==7.2.1 +pytest-asyncio==0.20.3 requests==2.26.0 pydbantic[sqlite] -pre-commit==2.15.0 \ No newline at end of file +pre-commit==2.15.0 +asgi-lifespan==2.0.0 +httpx==0.23.3 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 220e282..a6c96ea 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,13 @@ -pydbantic==0.0.22 -bcrypt==3.2.0 +pydbantic>=0.0.36 +bcrypt>=3.2.0 python-jwt==3.3.0 makefun==1.9.5 -fastapi>=0.88.0 -uvicorn==0.15.0 -PyJWT==2.0.0 -python-multipart==0.0.5 +fastapi>=0.89.1 +uvicorn>=0.15.0 +PyJWT>=2.0.0 +python-multipart>=0.0.5 easyadmin==0.170 -fastapi-mail==0.3.7 +fastapi-mail>=0.3.7 gunicorn==20.1.0 email-validator==1.1.3 easyrpc==0.245 diff --git a/setup.py b/setup.py index afab383..665aac5 100755 --- a/setup.py +++ b/setup.py @@ -5,20 +5,20 @@ "easyschedule==0.107", "PyJWT==2.0.0", "python-jwt==3.3.0", - "fastapi>=0.88.0", + "fastapi>=0.89.0", "uvicorn", - "python-multipart==0.0.5", + "python-multipart>=0.0.5", "easyadmin==0.170", "easyrpc==0.245", ] SERVER_REQUIREMENTS = [ - "pydbantic>=0.0.14", + "pydbantic>=0.0.36", "cryptography==35.0.0", - "bcrypt==3.2.0", + "bcrypt>=3.2.0", "example==0.1.0", "httptools==0.3.0", "gunicorn==20.1.0", - "fastapi-mail==0.3.7", + "fastapi-mail>=0.3.7", "email-validator==1.1.3", "google-api-python-client==2.31.0", ] diff --git a/tests/conftest.py b/tests/conftest.py index dffc84e..b339102 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,18 @@ +import asyncio import os import subprocess import time import pytest import requests +from asgi_lifespan import LifespanManager from fastapi import FastAPI from fastapi.testclient import TestClient +from httpx import AsyncClient from easyauth import get_user +from easyauth.db import tables_setup +from easyauth.models import Actions, Groups, Roles, Users from easyauth.router import EasyAuthAPIRouter from easyauth.server import EasyAuthServer @@ -44,6 +49,15 @@ def get_db_config(): os.environ["TEST_INIT_PASSWORD"] = "easyauth" +@pytest.fixture(scope="session") +def event_loop(): + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + + yield loop + loop.close() + + @pytest.fixture() def db_config(): for key, value in get_db_config().items(): @@ -71,6 +85,16 @@ def db_config(): ) +@pytest.fixture(scope="session") +def event_loop(): + + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + + loop.close() + + @pytest.fixture() def db_and_auth_server(): for key, value in get_db_config().items(): @@ -104,62 +128,77 @@ def db_and_auth_server(): ) +async def clean_db(): + for model in Actions, Groups, Roles, Users: + await model.delete_many(await model.all()) + + @pytest.mark.asyncio @pytest.fixture() async def auth_test_server(db_config): + server = FastAPI() + server.auth = EasyAuthServer.create( + server, + "/auth/token", + auth_secret="abcd1234", + admin_title="EasyAuth - Company", + admin_prefix="/admin", + testing=True, + ) + + from .finance import finance + from .hr import hr + from .marketing import marketing + + # test_auth_router = server.auth.create_api_router(prefix='/testing', tags=['testing']) + test_auth_router = EasyAuthAPIRouter.create(prefix="/testing", tags=["testing"]) + + # grants access to users matching default_permissions + @test_auth_router.get("/default") + async def default(): + return "I am default" + + # grants access to only specified users + @test_auth_router.get("/", users=["john"]) + async def root(): + return "I am root" + + # grants access to members of 'users' or 'admins' group. + @test_auth_router.get("/groups", groups=["basic_users", "admins"]) + async def groups(): + return "I am groups" + + # grants access to all members of 'users' group + # or a groups with role of 'basic' or advanced + @test_auth_router.get("/roles", roles=["basic", "advanced"], groups=["users"]) + async def roles(): + return "Roles and Groups" + + # grants access to all members of groups with a roles granting 'BASIC_CREATE' + @test_auth_router.get("/actions", actions=["BASIC_CREATE"]) + async def action(): + return "I am actions" + + @test_auth_router.get("/current_user", users=["john"]) + async def current_user(user: str = get_user()): + return user os.environ["EASYAUTH_PATH"] = os.environ["PWD"] - @server.on_event("startup") - async def startup(): - server.auth = await EasyAuthServer.create( - server, - "/auth/token", - auth_secret="abcd1234", - admin_title="EasyAuth - Company", - admin_prefix="/admin", - ) + async with LifespanManager(server, startup_timeout=15): + async with AsyncClient(app=server, base_url="http://test") as test_client: + yield (test_client, server) + + server.auth.shutdown_auth_server() - from .finance import finance - from .hr import hr - from .marketing import marketing - - # test_auth_router = server.auth.create_api_router(prefix='/testing', tags=['testing']) - test_auth_router = EasyAuthAPIRouter.create(prefix="/testing", tags=["testing"]) - - # grants access to users matching default_permissions - @test_auth_router.get("/default") - async def default(): - return "I am default" - - # grants access to only specified users - @test_auth_router.get("/", users=["john"]) - async def root(): - return "I am root" - - # grants access to members of 'users' or 'admins' group. - @test_auth_router.get("/groups", groups=["basic_users", "admins"]) - async def groups(): - return "I am groups" - - # grants access to all members of 'users' group - # or a groups with role of 'basic' or advanced - @test_auth_router.get("/roles", roles=["basic", "advanced"], groups=["users"]) - async def roles(): - return "Roles and Groups" - - # grants access to all members of groups with a roles granting 'BASIC_CREATE' - @test_auth_router.get("/actions", actions=["BASIC_CREATE"]) - async def action(): - return "I am actions" - - @test_auth_router.get("/current_user", users=["john"]) - async def current_user(user: str = get_user()): - return user - - with TestClient(server) as test_client: - yield test_client + +@pytest.mark.asyncio +@pytest.fixture() +async def auth_test_server_and_clean_db(auth_test_server): + await clean_db() + await tables_setup(auth_test_server[1].auth) + yield auth_test_server class AuthClient: diff --git a/tests/test_client.py b/tests/test_client.py index eb34fbd..d403892 100755 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,39 +7,40 @@ server = FastAPI(openapi_url="/groups/openapi.json") +server.auth = EasyAuthClient.create( + server, + "http://0.0.0.0:8520/auth/token", # Should be a running EasyAuthServer + auth_secret="abcd1234", + default_login_path="/login", +) -@server.on_event("startup") -async def startup(): - server.auth = await EasyAuthClient.create( - server, - "http://0.0.0.0:8520/auth/token", # Should be a running EasyAuthServer - auth_secret="abcd1234", - default_login_path="/login", - ) - - # grants access to only specified users - @server.auth.get("/", users=["jane"]) - async def root(): - return "I am root" - - # grants access to members of 'users' or 'admins' group. - @server.auth.get("/groups", groups=["users", "admins"]) - async def groups(): - return "I am groups" - - # grants access to all members of group which a role of 'basic' or advanced, or member 'users' group - @server.auth.get("/roles", roles=["basic", "advanced"], groups=["users"]) - async def roles(): - return "I am roles" - - # grants access to all members of groups with a roles granting 'BASIC_CREATE' - # accesssing the auth token - @server.auth.get( - "/actions", actions=["BASIC_CREATE"], groups=["administrators"], send_token=True - ) - async def action(access_token: Optional[str] = None): - return f"I am actions with token {access_token}" - - @NotFoundPage.mark() - def unimplemented_not_found(): - return f"TODO - not found" +# grants access to only specified users +@server.auth.get("/", users=["jane"]) +async def root(): + return "I am root" + + +# grants access to members of 'users' or 'admins' group. +@server.auth.get("/groups", groups=["users", "admins"]) +async def groups(): + return "I am groups" + + +# grants access to all members of group which a role of 'basic' or advanced, or member 'users' group +@server.auth.get("/roles", roles=["basic", "advanced"], groups=["users"]) +async def roles(): + return "I am roles" + + +# grants access to all members of groups with a roles granting 'BASIC_CREATE' +# accesssing the auth token +@server.auth.get( + "/actions", actions=["BASIC_CREATE"], groups=["administrators"], send_token=True +) +async def action(access_token: Optional[str] = None): + return f"I am actions with token {access_token}" + + +@NotFoundPage.mark() +def unimplemented_not_found(): + return f"TODO - not found" diff --git a/tests/test_server.py b/tests/test_server.py index 7957527..6e8644d 100755 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,19 +6,13 @@ server = FastAPI() -@server.on_event("startup") -async def startup(): - server.auth = await EasyAuthServer.create( - server, - "/auth/token", - auth_secret="abcd1234", - env_from_file="tests/server_sqlite.json", - ) +server.auth = EasyAuthServer.create( + server, + "/auth/token", + auth_secret="abcd1234", + env_from_file="tests/server_sqlite.json", +) - from tests.finance import finance - from tests.hr.hr import hr_router - from tests.marketing import marketing - - # @LoginPage.mark() - # def login_page(): - # return "Login Page Not Implemented" +from tests.finance import finance +from tests.hr.hr import hr_router +from tests.marketing import marketing diff --git a/tests/test_server_api.py b/tests/test_server_api.py index 7900549..eabb964 100755 --- a/tests/test_server_api.py +++ b/tests/test_server_api.py @@ -1,29 +1,31 @@ import pytest +from easyauth.models import Actions, Groups, Roles, Users + +from .conftest import AsyncClient + @pytest.mark.asyncio -async def test_server_authentication(auth_test_server): - test_client = auth_test_server - prefix = test_client.app.auth.ADMIN_PREFIX - server = test_client.app +async def test_server_authentication(auth_test_server_and_clean_db: AsyncClient): + test_client, server = auth_test_server_and_clean_db # verify endpoint access fails without token + response = await test_client.get("/finance/") - response = test_client.get("/finance/") assert response.status_code == 401, f"{response.text} - {response.status_code}" # verify token generation with bad credentials bad_credentials = {"username": "admin", "password": "BAD"} - response = test_client.post("/auth/token/login", json=bad_credentials) + response = await test_client.post("/auth/token/login", json=bad_credentials) assert response.status_code == 401, f"{response.text} - {response.status_code}" # verify token generation with correct credentials good_credentials = {"username": "admin", "password": "easyauth"} - response = test_client.post("/auth/token/login", json=good_credentials) + response = await test_client.post("/auth/token/login", json=good_credentials) assert response.status_code == 200, f"{response.text} - {response.status_code}" token = response.json() @@ -33,8 +35,9 @@ async def test_server_authentication(auth_test_server): # verify endpoint access while using token headers = {"Authorization": f"Bearer {token['access_token']}"} + # allow token to replicate - response = test_client.get("/finance/", headers=headers) + response = await test_client.get("/finance/", headers=headers) assert response.status_code == 200, f"{response.text} - {response.status_code}" # test user creation @@ -47,23 +50,23 @@ async def test_server_authentication(auth_test_server): } # check if user exists, delete if existing - response = test_client.get("/auth/users/john", headers=headers) + response = await test_client.get("/auth/users/john", headers=headers) assert response.status_code in { 200, 404, }, f"{response.text} - {response.status_code}" if response.status_code == 200: # delete user - response = test_client.delete("/auth/user?username=john", headers=headers) + response = await test_client.delete("/auth/user?username=john", headers=headers) assert response.status_code == 200, f"{response.text} - {response.status_code}" # create user - response = test_client.put("/auth/user", headers=headers, json=new_user) + response = await test_client.put("/auth/user", headers=headers, json=new_user) assert response.status_code == 201, f"{response.text} - {response.status_code}" # test updating user - response = test_client.post( + response = await test_client.post( "/auth/user/john", headers=headers, json={"full_name": "john j doe", "password": "new1234"}, @@ -72,15 +75,15 @@ async def test_server_authentication(auth_test_server): assert response.status_code == 200, f"{response.text} - {response.status_code}" # test deleting user - response = test_client.delete("/auth/user?username=john", headers=headers) + response = await test_client.delete("/auth/user?username=john", headers=headers) assert response.status_code == 200, f"{response.text} - {response.status_code}" # re-create & test token login with new user - response = test_client.put("/auth/user", headers=headers, json=new_user) + response = await test_client.put("/auth/user", headers=headers, json=new_user) assert response.status_code == 201, f"{response.text} - {response.status_code}" - response = test_client.post( + response = await test_client.post( "/auth/token/login", json={"username": new_user["username"], "password": new_user["password"]}, ) @@ -89,30 +92,30 @@ async def test_server_authentication(auth_test_server): token = response.json() headers = {"Authorization": f"Bearer {token['access_token']}"} - response = test_client.get("/finance/", headers=headers) + response = await test_client.get("/finance/", headers=headers) assert response.status_code == 200, f"{response.text} - {response.status_code}" # test permission creation # check if action exists, delete if existing - response = test_client.get("/auth/actions/BASIC_CREATE", headers=headers) + response = await test_client.get("/auth/actions/BASIC_CREATE", headers=headers) assert response.status_code in { 200, 404, }, f"{response.text} - {response.status_code}" if response.status_code == 200: - # delete user - response = test_client.delete( + # delete action + response = await test_client.delete( "/auth/action?action=BASIC_CREATE", headers=headers ) assert response.status_code == 200, f"{response.text} - {response.status_code}" new_action = {"action": "BASIC_CREATE", "details": "BASIC CREATE action"} - response = test_client.put("/auth/actions", headers=headers, json=new_action) + response = await test_client.put("/auth/actions", headers=headers, json=new_action) assert response.status_code == 201, f"{response.text} - {response.status_code}" # test updating permission - response = test_client.post( + response = await test_client.post( "/auth/actions?action=BASIC_CREATE", headers=headers, json={"details": "BASIC CREATE updated"}, @@ -123,14 +126,14 @@ async def test_server_authentication(auth_test_server): # test role creation # check if role exists, delete if existing - response = test_client.get("/auth/roles/basic", headers=headers) + response = await test_client.get("/auth/roles/basic", headers=headers) assert response.status_code in { 200, 404, }, f"{response.text} - {response.status_code}" if response.status_code == 200: # delete role - response = test_client.delete("/auth/role?role=basic", headers=headers) + response = await test_client.delete("/auth/role?role=basic", headers=headers) assert ( response.status_code == 200 ), f"delete role {response.text} - {response.status_code}" @@ -139,13 +142,13 @@ async def test_server_authentication(auth_test_server): new_role = {"role": "basic", "actions": ["BASIC_CREATE"]} - response = test_client.put("/auth/role", headers=headers, json=new_role) + response = await test_client.put("/auth/role", headers=headers, json=new_role) assert ( response.status_code == 201 ), f"create role {response.text} - {response.status_code}" # test updating role - response = test_client.post( + response = await test_client.post( "/auth/role/basic", headers=headers, json={"actions": ["BASIC_CREATE"]} ) @@ -156,14 +159,14 @@ async def test_server_authentication(auth_test_server): # create group # check if group exists, delete if existing - response = test_client.get("/auth/groups/basic_users", headers=headers) + response = await test_client.get("/auth/groups/basic_users", headers=headers) assert response.status_code in { 200, 404, }, f"get group{response.text} - {response.status_code}" if response.status_code == 200: # delete group - response = test_client.delete( + response = await test_client.delete( "/auth/group?group_name=basic_users", headers=headers ) assert ( @@ -174,13 +177,13 @@ async def test_server_authentication(auth_test_server): new_group = {"group_name": "basic_users", "roles": ["basic"]} - response = test_client.put("/auth/group", headers=headers, json=new_group) + response = await test_client.put("/auth/group", headers=headers, json=new_group) assert ( response.status_code == 201 ), f"create group {response.text} - {response.status_code}" # test updating role - response = test_client.post( + response = await test_client.post( "/auth/group/basic_users", headers=headers, json={"roles": ["basic", "admin"]} ) @@ -189,55 +192,56 @@ async def test_server_authentication(auth_test_server): ), f"update group {response.text} - {response.status_code}" # verify permission denied without required action - response = test_client.get("/testing/actions", headers=headers) + response = await test_client.get("/testing/actions", headers=headers) + assert response.status_code == 403, f"{response.text} - {response.status_code}" # verify permission denied without required role - response = test_client.get("/testing/roles", headers=headers) + response = await test_client.get("/testing/roles", headers=headers) assert response.status_code == 403, f"{response.text} - {response.status_code}" # verify permission denied without required group - response = test_client.get("/testing/groups", headers=headers) + response = await test_client.get("/testing/groups", headers=headers) assert response.status_code == 403, f"{response.text} - {response.status_code}" # test get_user {'Authorization': 'Bearer tokenstr'} - response = test_client.get("/testing/current_user", headers=headers) + response = await test_client.get("/testing/current_user", headers=headers) assert response.status_code == 200, f"{response.text} - {response.status_code}" assert "john" in response.text # test get_user - cookie only header token = headers["Authorization"].split(" ")[1] cookie_only = {"cookie": f"token={token}"} - response = test_client.get("/testing/current_user", headers=cookie_only) + response = await test_client.get("/testing/current_user", headers=cookie_only) assert response.status_code == 200, f"{response.text} - {response.status_code}" assert "john" in response.text # verify permission allowed for specific user - response = test_client.get("/testing/", headers=headers) + response = await test_client.get("/testing/", headers=headers) assert response.status_code == 200, f"{response.text} - {response.status_code}" # add user to group with - response = test_client.post( + response = await test_client.post( "/auth/user/john", headers=headers, json={"groups": ["basic_users"]} ) # verify failures pre token generation - response = test_client.get("/testing/actions", headers=headers) + response = await test_client.get("/testing/actions", headers=headers) assert response.status_code == 403, f"{response.text} - {response.status_code}" # verify permission denied without required action - response = test_client.get("/testing/roles", headers=headers) + response = await test_client.get("/testing/roles", headers=headers) assert response.status_code == 403, f"{response.text} - {response.status_code}" # verify permission denied without required action - response = test_client.get("/testing/groups", headers=headers) + response = await test_client.get("/testing/groups", headers=headers) assert response.status_code == 403, f"{response.text} - {response.status_code}" # re create token with updated permissions - response = test_client.post( + response = await test_client.post( "/auth/token/login", json={"username": new_user["username"], "password": new_user["password"]}, ) @@ -248,46 +252,92 @@ async def test_server_authentication(auth_test_server): # verify success with updated token - response = test_client.get("/testing/actions", headers=headers) + response = await test_client.get("/testing/actions", headers=headers) assert ( response.status_code == 200 ), f"new_token - actions {response.text} - {response.status_code}" # verify permission denied without required action - response = test_client.get("/testing/roles", headers=headers) + response = await test_client.get("/testing/roles", headers=headers) assert ( response.status_code == 200 ), f"new_token - roles {response.text} - {response.status_code}" # verify permission denied without required action - response = test_client.get("/testing/groups", headers=headers) + response = await test_client.get("/testing/groups", headers=headers) assert ( response.status_code == 200 ), f"new_token - groups {response.text} - {response.status_code}" decoded_token = server.auth.decode_token(token["access_token"]) - print(decoded_token) + assert "token_id" in decoded_token[1], f"{decoded_token}" # revoke token & test access failure - response = test_client.delete( + response = await test_client.delete( f"/auth/token?token_id={decoded_token[1]['token_id']}", headers=headers ) assert ( response.status_code == 200 ), f"revoke token {response.text} - {response.status_code}" - response = test_client.get("/testing/actions", headers=headers) + response = await test_client.get("/testing/actions", headers=headers) assert ( response.status_code == 403 ), f"revoke_token - actions - {response.status_code} - {response.text}" - response = test_client.get("/testing/roles", headers=headers) + response = await test_client.get("/testing/roles", headers=headers) assert ( response.status_code == 403 ), f"revoke_token - roles {response.status_code} - {response.text}" - response = test_client.get("/testing/groups", headers=headers) + response = await test_client.get("/testing/groups", headers=headers) assert ( response.status_code == 403 ), f"revoke_token - groups - {response.status_code} - {response.text}" + + # test exporting config + + good_credentials = {"username": "admin", "password": "easyauth"} + + response = await test_client.post("/auth/token/login", json=good_credentials) + assert response.status_code == 200, f"{response.text} - {response.status_code}" + + token = response.json() + + # verify endpoint access while using token + + headers = {"Authorization": f"Bearer {token['access_token']}"} + response = await test_client.get("/auth/export", headers=headers) + assert response.status_code == 200 + + config = response.json() + assert config + + for rbac_item in ["users", "groups", "roles", "actions"]: + assert rbac_item in config + + # use exported config to import + + old_users = await Users.all(order_by=Users.asc("username")) + old_groups = await Groups.all(order_by=Groups.asc("group_name")) + old_roles = await Roles.all(order_by=Roles.asc("role")) + old_actions = await Actions.all(order_by=Actions.asc("action")) + + await Users.delete_many(old_users) + await Groups.delete_many(old_groups) + await Roles.delete_many(old_roles) + await Actions.delete_many(old_actions) + + response = await test_client.post("/auth/import", headers=headers, json=config) + assert response.status_code == 200 + + new_users = await Users.all(order_by=Users.asc("username")) + new_groups = await Groups.all(order_by=Groups.asc("group_name")) + new_roles = await Roles.all(order_by=Roles.asc("role")) + new_actions = await Actions.all(order_by=Actions.asc("action")) + + assert new_users == old_users + assert new_groups == old_groups + assert new_roles == old_roles + assert new_actions == old_actions