Skip to content

Commit

Permalink
Merge pull request #250 from haasal/master
Browse files Browse the repository at this point in the history
REST API cookie authentication and refactor
  • Loading branch information
giffels authored Aug 24, 2022
2 parents bcbfdd0 + d28a0c8 commit cc13dfd
Show file tree
Hide file tree
Showing 25 changed files with 611 additions and 527 deletions.
1 change: 1 addition & 0 deletions CONTRIBUTORS
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ Matthias Schnepf <maschnepf@schnepf-net.de>
Peter Wienemann <peter.wienemann@uni-bonn.de>
rfvc <florian.voncube@gmail.com>
PSchuhmacher <leon_schuhmacher@yahoo.de>
Alexander Haas <mail@haas-alexander.com>
25 changes: 17 additions & 8 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
long_description = read_me.read()

TESTS_REQUIRE = ["flake8", "httpx"]
REST_REQUIRES = [
"fastapi-jwt-auth",
"fastapi",
"python-jose",
"uvicorn[standard]<=0.14.0", # to support python3.6 (Centos 7)
"typer",
"bcrypt",
"python-multipart",
]


def get_cryptography_version():
Expand Down Expand Up @@ -83,12 +92,6 @@ def get_cryptography_version():
"kubernetes_asyncio",
"pydantic",
"asyncstdlib",
"fastapi",
"python-jose",
"uvicorn[standard]<=0.14.0", # to support python3.6 (Centos 7)
"typer",
"bcrypt",
"python-multipart",
"typing_extensions",
"backports.cached_property",
],
Expand All @@ -99,9 +102,15 @@ def get_cryptography_version():
"sphinxcontrib-contentui",
"myst_parser",
],
"rest": REST_REQUIRES,
"test": TESTS_REQUIRE,
"contrib": ["flake8", "flake8-bugbear", "black; implementation_name=='cpython'"]
+ TESTS_REQUIRE,
"contrib": [
"flake8",
"flake8-bugbear",
"black; implementation_name=='cpython'",
]
+ TESTS_REQUIRE
+ REST_REQUIRES,
},
tests_require=TESTS_REQUIRE,
zip_safe=False,
Expand Down
2 changes: 1 addition & 1 deletion tardis/interfaces/borg.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from abc import ABCMeta


class Borg(metaclass=ABCMeta):
class Borg(metaclass=ABCMeta): # noqa B024
_shared_state = {} # should be overwritten in all classes inheriting the borg

def __init__(self):
Expand Down
23 changes: 23 additions & 0 deletions tardis/rest/app/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,26 @@ async def get_resources(sql_registry):
JOIN Sites S ON R.site_id = S.site_id
JOIN MachineTypes MT ON R.machine_type_id = MT.machine_type_id"""
return await sql_registry.async_execute(sql_query, {})


async def get_available_states(sql_registry):
sql_query = "SELECT state FROM ResourceStates"
return await sql_registry.async_execute(sql_query, {})


async def get_available_sites(sql_registry):
sql_query = "SELECT site_name FROM Sites"
return await sql_registry.async_execute(sql_query, {})


async def get_available_machine_types(sql_registry):
sql_query = "SELECT machine_type FROM MachineTypes"
return await sql_registry.async_execute(sql_query, {})


async def set_state_to_draining(sql_registry, drone_uuid: str):
sql_query = """
UPDATE Resources
SET state_id = (SELECT state_id FROM ResourceStates WHERE state = 'DrainState')
WHERE drone_uuid = :drone_uuid"""
return await sql_registry.async_execute(sql_query, dict(drone_uuid=drone_uuid))
25 changes: 21 additions & 4 deletions tardis/rest/app/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from ...__about__ import __version__
from .routers import login, resources
from .routers import resources, user, types
from fastapi_jwt_auth.exceptions import AuthJWTException
from fastapi import Request
from fastapi.responses import JSONResponse

from fastapi import FastAPI

Expand All @@ -22,11 +25,25 @@
"description": "Information about the currently managed resources.",
},
{
"name": "login",
"description": "Handles login and creation of limited duration tokens to access APIs.", # noqa B509
"name": "user",
"description": "Handles login, refresh tokens, logout and anything related to the user.", # noqa B509
},
],
)


# the jwt auth package returns 422 on failed auth which doens't make sense
# This code changes that. There is currently an issue running: https://github.com/IndominusByte/fastapi-jwt-auth/issues/20 # noqa B950
@app.exception_handler(AuthJWTException)
def authjwt_exception_handler(request: Request, exc: AuthJWTException):
detail = exc.message

if detail == "Signature verification failed" or detail == "Signature has expired":
exc.status_code = 401

return JSONResponse(status_code=exc.status_code, content={"detail": detail})


app.include_router(resources.router)
app.include_router(login.router)
app.include_router(user.router)
app.include_router(types.router)
18 changes: 0 additions & 18 deletions tardis/rest/app/routers/login.py

This file was deleted.

23 changes: 18 additions & 5 deletions tardis/rest/app/routers/resources.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from .. import security, crud, database
from ....plugins.sqliteregistry import SqliteRegistry
from fastapi import APIRouter, Depends, HTTPException, Path, Security

from fastapi import APIRouter, Depends, HTTPException, Path, Security, status
from ..scopes import Resources
from fastapi_jwt_auth import AuthJWT

router = APIRouter(prefix="/resources", tags=["resources"])

Expand All @@ -10,20 +11,32 @@
async def get_resource_state(
drone_uuid: str = Path(..., regex=r"^\S+-[A-Fa-f0-9]{10}$"),
sql_registry: SqliteRegistry = Depends(database.get_sql_registry()),
_: str = Security(security.check_authorization, scopes=["resources:get"]),
_: AuthJWT = Security(security.check_authorization, scopes=[Resources.get]),
):
query_result = await crud.get_resource_state(sql_registry, drone_uuid)
try:
query_result = query_result[0]
except IndexError:
raise HTTPException(status_code=404, detail="Drone not found") from None
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Drone not found"
) from None
return query_result


@router.get("/", description="Get list of managed resources")
async def get_resources(
sql_registry: SqliteRegistry = Depends(database.get_sql_registry()),
_: str = Security(security.check_authorization, scopes=["resources:get"]),
_: AuthJWT = Security(security.check_authorization, scopes=[Resources.get]),
):
query_result = await crud.get_resources(sql_registry)
return query_result


@router.patch("/{drone_uuid}/drain", description="Gently shut shown drone")
async def drain_drone(
drone_uuid: str = Path(..., regex=r"^\S+-[A-Fa-f0-9]{10}$"),
sql_registry: SqliteRegistry = Depends(database.get_sql_registry()),
_: AuthJWT = Security(security.check_authorization, scopes=[Resources.patch]),
):
await crud.set_state_to_draining(sql_registry, drone_uuid)
return {"msg": "Drone set to DrainState"}
46 changes: 46 additions & 0 deletions tardis/rest/app/routers/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import Dict, List
from tardis.exceptions.tardisexceptions import TardisError
from .. import security
from .. import crud, database
from ....plugins.sqliteregistry import SqliteRegistry
from fastapi import APIRouter, Depends, Security
from ..scopes import Resources
from fastapi_jwt_auth import AuthJWT

router = APIRouter(prefix="/types", tags=["types", "resources"])


def sql_to_list(query_result: List[Dict]) -> List[str]:
try:
return [list(pair.values())[0] for pair in query_result]
except (AttributeError, IndexError, TypeError) as e:
raise TardisError(
f"Query result has invalid type/format: {type(query_result)}. Must be List[Dict]" # noqa B950
) from e


@router.get("/states", description="Get all available states")
async def get_resource_state(
sql_registry: SqliteRegistry = Depends(database.get_sql_registry()),
_: AuthJWT = Security(security.check_authorization, scopes=[Resources.get]),
):
query_result = await crud.get_available_states(sql_registry)
return sql_to_list(query_result)


@router.get("/sites", description="Get all available sites")
async def get_resource_sites(
sql_registry: SqliteRegistry = Depends(database.get_sql_registry()),
_: AuthJWT = Security(security.check_authorization, scopes=[Resources.get]),
):
query_result = await crud.get_available_sites(sql_registry)
return sql_to_list(query_result)


@router.get("/machine_types", description="Get all available machine types")
async def get_resource_types(
sql_registry: SqliteRegistry = Depends(database.get_sql_registry()),
_: AuthJWT = Security(security.check_authorization, scopes=[Resources.get]),
):
query_result = await crud.get_available_machine_types(sql_registry)
return sql_to_list(query_result)
101 changes: 101 additions & 0 deletions tardis/rest/app/routers/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from typing import Optional
from ..scopes import User
from .. import security
from fastapi import APIRouter, Depends, Security
from fastapi_jwt_auth import AuthJWT

router = APIRouter(prefix="/user", tags=["user"])


@router.post(
"/login",
description="Sets httponly access token in session cookie. The scopes are optional.", # noqa B950
)
async def login(
login_user: security.LoginUser,
expires_delta: Optional[int] = None,
Authorize: AuthJWT = Depends(),
):
user = security.check_authentication(login_user.user_name, login_user.password)

# set and check the scopes that are applied to the returned token
if login_user.scopes is None:
scopes = {"scopes": user.scopes}
else:
# The next two lines are very critical as if wrongly implemented a user can give his token unlimited scopes. # noqa B950
# This functionality has to be tested thoroughly
security.check_scope_permissions(login_user.scopes, user.scopes)
scopes = {"scopes": login_user.scopes}

access_token = Authorize.create_access_token(
subject=user.user_name, user_claims=scopes, expires_time=expires_delta
)
refresh_token = Authorize.create_refresh_token(
subject=user.user_name, user_claims=scopes
)

Authorize.set_access_cookies(access_token)
Authorize.set_refresh_cookies(refresh_token)

# If the user doesn't have the user:get scope, he can't get the user data. # noqa B950
if User.get not in user.scopes:
return {
"msg": "Successfully logged in!",
}
else:
return {
"msg": "Successfully logged in!",
"user": security.BaseUser(
user_name=user.user_name, scopes=scopes["scopes"]
),
}


@router.post(
"/logout",
description="Logout the current user by deleting all access token cookies",
)
async def logout(Authorize: AuthJWT = Depends()):
Authorize.jwt_required()

Authorize.unset_jwt_cookies()
return {"msg": "Successfully logged out!"}


@router.post(
"/refresh",
description="Use refresh token cookie to refresh expiration on cookie", # noqa B950
)
async def refresh(Authorize: AuthJWT = Depends()):
Authorize.jwt_refresh_token_required()

current_user = Authorize.get_jwt_subject()
scopes = security.get_token_scopes(Authorize)

new_access_token = Authorize.create_access_token(
subject=current_user, user_claims=({"scopes": scopes})
)

Authorize.set_access_cookies(new_access_token)
return {"msg": "Token successfully refreshed"}


@router.get(
"/me",
response_model=security.BaseUser,
description="Get the user data how it's stored in the database (no password)",
)
async def get_user_me(
Authorize: AuthJWT = Security(security.check_authorization, scopes=[User.get]),
):
Authorize.jwt_required()

user_name = Authorize.get_jwt_subject()
return security.get_user(user_name)


@router.get("/token_scopes", description="get scopes of CURRENT token (not of user)")
async def get_token_scopes(Authorize: AuthJWT = Depends()):
Authorize.jwt_required()

return security.get_token_scopes(Authorize)
12 changes: 12 additions & 0 deletions tardis/rest/app/scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from enum import Enum

# All available OAuth2 scopes


class Resources(str, Enum):
get = "resources:get"
patch = "resources:patch"


class User(str, Enum):
get = "user:get"
Loading

0 comments on commit cc13dfd

Please sign in to comment.