diff --git a/pyproject.toml b/pyproject.toml index e9e1a93..dad28e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires = [ [project] name = "reliqua" -version = "0.0.1" +version = "0.0.2" description = "Simple, efficient, intuitive API Framework" readme = "README.md" requires-python = ">=3.8" diff --git a/src/reliqua/api.py b/src/reliqua/api.py index 5005330..15a7704 100644 --- a/src/reliqua/api.py +++ b/src/reliqua/api.py @@ -15,7 +15,7 @@ import falcon from falcon_cors import CORS -from .auth import Auth +from .auth import AuthMiddleware from .docs import Docs from .media_handlers import JSONHandler, TextHandler, YAMLHandler from .openapi import OpenApi @@ -69,7 +69,7 @@ def __init__( self.title = title self.version = version self.resources = [] - self.auth = [x for x in middleware if isinstance(x, Auth)] + self.auth = [x for x in middleware if isinstance(x, AuthMiddleware)] self.config = config or {} self.license = license self.license_url = license_url diff --git a/src/reliqua/auth.py b/src/reliqua/auth.py index 64ddfe2..412cfa6 100644 --- a/src/reliqua/auth.py +++ b/src/reliqua/auth.py @@ -32,163 +32,318 @@ class AccessControl: Access control class. The abstract base class for access control. The subclass is - expected to implement all logic and an `exempt` method which - is called by the Auth instance. + expected to implement all logic. + + The `authentication_required` method returns whether a call + requires authentication. + + The `authorized` method returns whether the client is authorized + to execute the call. """ - def exempt(self, _route, _method, _resource): + def authorized(self, role, _route, _method, _resource): + """Return whether client is allowed to access resource.""" + raise NotImplementedError("authorized method not implemented") + + def authentication_required(self, _route, _method, _resource): """Return whether authentication is required.""" - raise NotImplementedError("authenticate method not implemented") + raise NotImplementedError("authentication required method not implemented") -class AccessCallback: +class AccessCallback(AccessControl): """ Access callback. - Access control is checked by calling a user defined method. The called method - will be supplied the Endpoint, Route, and HTTP method. The called method + Access control is checked by calling a user defined methods. The called method + will be supplied the endpoint, route, and HTTP method. The called method must return `True` or `False`. """ - def __init__(self, callback): + def __init__(self, authenticate_callback, authorized_callback): """ Create the AccessCallback instance. :param callable callback: Callback method :return: """ - self.callback = callback + self.authenticate_callback = authenticate_callback + self.authorized_callback = authorized_callback + + def authorized(self, role, route, method, _resource): + """ + Return whether the route or method is authorized. + + Return whether the route or method is allowed for the + given role. - def exempt(self, route, method, _resource): + :param str role: Role of client + :param str route: Route being called + :param str method: HTTP method invoked + :return bool: True if authorized + """ + return self.authorized_callback(role, route, method) + + def authentication_required(self, route, method, _resource): """ Return whether the route or method is exempt. - Return whether the route or method is exempt from requiring + Return whether the route or method requires authentication by calling the callback method. - :param str route: The route being called - :param str method: The http method invoked - :return bool: True if exempt + :param str route: Route being called + :param str method: HTTP method invoked + :return bool: True if authentication is required """ - return self.callback(route=route, method=method) + return self.authenticate_callback(route=route, method=method) -class AccessList: +class AccessList(AccessControl): """ Access List. - Access control is checked by the routes and/or method specified in the - the exempt lists. + Access control is checked by the routes and/or method specified in + simple lists. If default mode is allow, then only routes/methods specified + will required authentication. If default mode is deny, then only the + routes/specified will be exempted. """ - def __init__(self, routes=None, methods=None): + def __init__(self, routes=None, methods=None, default_mode="allow"): """ Create the AccessList instance. - :param list routes: List of exempt routes - :param list methods: List of exempt methods + :param list routes: List of routes to apply rules + :param list methods: List of methods to apply rules + :param str default_mode: Default mode (allow|deny) """ self.routes = [x.lower() for x in routes] self.methods = [x.lower() for x in methods] + self.default_mode = default_mode + + def authorized(self, role, _route, _method, _resource): + """Return whether client is allowed to access resource.""" + raise NotImplementedError("authorized method not implemented") - def exempt(self, route, method, _resource): + def authentication_required(self, route, method, _resource): """ - Return whether the route or method is exempt. + Return whether the route or method requires authentication. - Return whether the route or method is exempt from requiring - authentication by comparing the route and method against the - exempts lists. + Return whether the route or method requires authentication + by comparing the route or method against the rule lists. - :param str route: The route being called - :param str method: The http method invoked - :return bool: True if exempt + :param str route: Route being called + :param str method: HTTP method invoked + :return bool: True if authentication is required """ + matched = False + required = self.default_mode != "allowed" + if route.lower() in self.routes or method.lower() in self.methods: + matched = True + + # check if authentication is required + if self.default_mode == "allow": + # if default mode is allow, then a match means auth required + required = not matched + else: + # default mode is deny, then a match means auth not required + required = matched + + return required + + +class AccessMap(AccessControl): + """ + Access Map. + + Access control is checked by the routes and/or method specified in the + the dictionary. Only items defined will be checked and everything else will + be denied. Therefore, this must be a complete map. + + The access map follows the form: + + route: { + http_method: [roles] + } + + Example: + + "/users": { + "GET": ["admin", "user"] + } + + You can use "*" to apply the access to all http methods. + + You can add "*" to the roles list to exempt the + route/method from requiring authentication. + """ + + def __init__(self, access_map): + """ + Create the AccessList instance. + + :param list access_map: Dictionary of rules + :return: + """ + self.access_map = access_map + + def authorized(self, role, route, method, _resource): + """ + Return whether client is allowed to access resource. + + :param str role: Client role + :param str route: Route being called + :param str method: HTTP method being invoked + :return bool: True if authorized + """ + route = self.access_map.get(route) + roles = route.get("*") or route.get(method) + if role in roles or "*" in roles: return True return False + def authentication_required(self, route, method, _resource): + """ + Return whether the route/method requires authentication. + + Return whether the route or method requires authentication + by checking the resource auth dictionary. + + :param str route: The route being called + :param str method: The http method invoked + :return bool: True if e + """ + route = self.access_map.get(route) + roles = route.get("*") or route.get(method) -class AccessResource: + # if method is specified and has a wildcard role + # then authentication is not required + if roles and "*" in roles: + return False + + return True + + +class AccessResource(AccessControl): """ - Access Resource. + Access Resource Map. + + Access control is checked by the routes and/or methods specified in the resource. + If the default mode is `allow` then routes or actions with no definition will be allowed. + When the default mode is `deny`, then all undefine routes and methods will be denied. + + The resource map follows the form: + + __auth__: { + http_method: [roles] + } + + Example: + + __auth__: { + "GET": ["admin", "user"] + } + + You can use "*" to apply the access to all http methods. - Access control is checked at the resource level. The resource defines - which actions require authentication. + You can add "*" to the roles list or set roles to an empty list + to exempt the route/method from requiring authentication. """ - def exempt(self, _route, method, resource): + def __init__(self, default_mode="deny", raise_on_undefined=False): """ - Return whether the route or method is exempt. + Create the AccessList instance. - Return whether the route or method is exempt from requiring - authentication by checking the resource itself. + :param str default_mode: Default mode (allow|deny) + :param bool raise_on_undefined: If a resource has undefined auth attributes, raise exception + :return: + """ + self.default_mode = default_mode + self.raise_on_undefined = raise_on_undefined - :param str method: The http method invoked - :param Resource resource: The resource - :return bool: True if exempt + def authorized(self, role, _route, method, resource): """ - auth = getattr(resource, "__auth_actions__", []) - methods = [x.lower() for x in auth] + Return whether client is allowed to access resource. + + :param str role: Client role + :param str method: HTTP method being invoked + :param Resource resource: Route resource + :return bool: True if authorized + """ + auth = getattr(resource, "__auth__", {}) + roles = auth.get("*", []) or auth.get(method, []) + + # if no roles are defined skip authorization + # or raise an exception + if not roles: + if self.raise_on_undefined: + raise falcon.HTTPNotImplemented(f"no roles are defined for {resource.name} {method}") + + return True - if method.lower() not in methods: + if role in roles or "*" in roles: return True return False + def authentication_required(self, _route, method, resource): + """ + Return whether the route or method requires authentication. + + Return whether the route or method for the resource requires + authentication by comparing the route and method against the + resources dictionary. + + :param str method: HTTP method invoked + :param Resource resource: Route resource + :return bool: True if authentication is required + """ + auth = getattr(resource, "__auth__", {}) + roles = auth.get(method.lower(), []) or auth.get(method.upper(), []) or auth.get("*", []) -class Auth: - """Auth abstract base class.""" + # If no roles are defined and default mode is allow + # then no authentication is required. + if not roles and self.default_mode == "allow": + return False - control = AccessControl() + return True + + +class Authentication: + """Authentication abstract base class.""" @property def name(self): - """Return auth name.""" + """Return authentication name.""" return self.__class__.__name__ def authenticate(self, _req, _resp, _resource): """Return whether client is authenticated.""" raise NotImplementedError("authenticate method not implemented") - def process_resource(self, req, resp, resource, _params): - """ - Process request after routing. - - Process the http resource. This method is called if the - request was routed to a resource. If the user is authenticated, - the user key will be added/set in the request context. - - :param Request req: Request object - :param Response resp: Response object - :param Resource resource: Resource object - :param dict params: Additional parameters from the URI - :return: - """ - if self.control.exempt(req.uri_template, req.method, resource): - return - - req.context["user"] = self.authenticate(req, resp, resource) +class ApiAuthentication(Authentication): + """ + API Authentication. -class ApiAuth(Auth): - """API Authentication.""" + An abstract base class for API key type authentications. + """ location = " any" - def __init__(self, name, description=None, validation=None, control=None): + def __init__(self, name, description=None, validation=None): """ Create an API authentication instance. - :param str name: Parameter name - :param description: Description + :param str name: Parameter name + :param str description: Description + :param callable validation: Validation callback :return: """ self.kind = "apiKey" self.parameter_name = name self.description = description self.validation = validation - self.control = control @property def name(self): @@ -211,7 +366,7 @@ def dict(self): } -class CookieAuth(ApiAuth): +class CookieAuthentication(ApiAuthentication): """ Cookie Authentication. @@ -246,7 +401,7 @@ def authenticate(self, req, _resp, _resource): return username -class HeaderAuth(ApiAuth): +class HeaderAuthentication(ApiAuthentication): """ Header Authentication. @@ -279,7 +434,7 @@ def authenticate(self, req, _resp, _resource): return username -class QueryAuth(ApiAuth): +class QueryAuthentication(ApiAuthentication): """ Query Authentication. @@ -312,7 +467,7 @@ def authenticate(self, req, _resp, _resource): return username -class BasicAuth(Auth): +class BasicAuthentication(Authentication): """ Basic Authentication. @@ -321,7 +476,7 @@ class BasicAuth(Auth): user string. Any False like value will raise an error. """ - def __init__(self, validation=None, control=None): + def __init__(self, validation=None): """ Create BasicAuth instance. @@ -332,7 +487,6 @@ def __init__(self, validation=None, control=None): self.kind = "http" self.scheme = "basic" self.validation = validation - self.control = control @property def name(self): @@ -395,7 +549,7 @@ def authenticate(self, req, _resp, _resource): return username -class BearerAuth(Auth): +class BearerAuthentication(Authentication): """Bearer Token Authentication.""" def __init__(self): @@ -426,15 +580,16 @@ def dict(self): } -class MultiAuth(Auth): +class MultiAuthentication(Authentication): """ MultiAuth class. MultiAuth allows you to specify multiple auth mechanisms. They - will then be iterated until one if successful + will then be iterated until one is successful. The access control + specified will override any of the individual authentications. """ - def __init__(self, authenticators, control=None): + def __init__(self, authenticators): """ Create a MultiAuth instance. @@ -442,7 +597,6 @@ def __init__(self, authenticators, control=None): :return bool: True if authenticated """ self.authenticators = authenticators - self.control = control def authenticate(self, request, response, resource): """ @@ -467,3 +621,78 @@ def dict(self): schema.update(auth.dict()) return schema + + +class AuthMiddleware: + """Auth middleware.""" + + def __init__(self, authenticators, control=None): + """ + Create a MultiAuth instance. + + :param list[Auth] auth: A list of Authenticators + :return bool: True if authenticated + """ + self.authenticators = authenticators + self.control = control + + def dict(self): + """Return OpenAPI Schema.""" + schema = {} + for auth in self.authenticators: + schema.update(auth.dict()) + + return schema + + def authenticate(self, request, response, resource): + """ + Authenticate user. + + Authenticate with the credentials + + :return bool: True if authenticated + """ + for auth in self.authenticators: + try: + return auth.authenticate(request, response, resource) + except falcon.HTTPUnauthorized: + pass + + raise falcon.HTTPUnauthorized(description="Invalid authorization") + + def process_resource(self, req, resp, resource, _params): + """ + Process request after routing. + + Process the http resource. This method is called if the + request was routed to a resource. If the user is authenticated, + the user key will be added/set in the request context. + + :param Request req: Request object + :param Response resp: Response object + :param Resource resource: Resource object + :param dict params: Additional parameters from the URI + :return: + """ + user = None + authorized = False + + # check if request requires authentication + if not self.control.authentication_required(req.uri_template, req.method, resource): + return + + # authenticate user + user, *role = self.authenticate(req, resp, resource) + role = role[0] if role else None + + # if a role is returned check if authorized + if role: + authorized = self.control.authorized(role, req.uri_template, req.method, resource) + if not authorized: + raise falcon.HTTPUnauthorized( + description=f"role {role} is not authorized to {req.method} {req.uri_template}" + ) + + req.context["role"] = role + req.context["authorized"] = authorized + req.context["user"] = user diff --git a/src/reliqua/example/__main__.py b/src/reliqua/example/__main__.py index 240ad44..43f4809 100644 --- a/src/reliqua/example/__main__.py +++ b/src/reliqua/example/__main__.py @@ -9,17 +9,28 @@ import sys from reliqua import Application, load_config -from reliqua.auth import AccessResource, BasicAuth, CookieAuth, MultiAuth +from reliqua.auth import ( + AccessResource, + AuthMiddleware, + BasicAuthentication, + CookieAuthentication, +) def check_user(username, _password): """Return if user is authenticated.""" - return username == "ted" + if username == "ted": + return ("ted", "admin") + + return None def check_api_key(api_key): """Return if user is authenticated.""" - return api_key == "abc123" + if api_key == "abc123": + return ("ted", "admin") + + return None def main(): @@ -42,17 +53,15 @@ def main(): parser.add_argument("--workers", help="Number of worker threads", default=workers) parser.add_argument("--config", help="Configuration file", default=None) - basic_auth = BasicAuth( - control=AccessResource(), + basic_auth = BasicAuthentication( validation=check_user, ) - cookie_auth = CookieAuth( + cookie_auth = CookieAuthentication( "api_key", - control=AccessResource(), validation=check_api_key, ) - auth = MultiAuth([basic_auth, cookie_auth], control=AccessResource()) + auth = AuthMiddleware([basic_auth, cookie_auth], control=AccessResource(default_mode="deny")) args = parser.parse_args() middleware = [auth] diff --git a/src/reliqua/example/resources/users.py b/src/reliqua/example/resources/users.py index 8c08ad7..c6931d3 100644 --- a/src/reliqua/example/resources/users.py +++ b/src/reliqua/example/resources/users.py @@ -44,8 +44,6 @@ class User(Resource): "users", ] - __auth_actions__ = ["POST", "DELETE"] - user = USER phones = phones @@ -90,7 +88,12 @@ class Users(Resource): "users", ] - __auth_actions__ = ["GET", "POST", "DELETE"] + __auth2__ = { + "GET": ["admin"], + "POST": ["admin"], + "DELETE": ["admin"], + } + users = USERS def on_get(self, req, resp): diff --git a/src/reliqua/resources/base.py b/src/reliqua/resources/base.py index 3b7ca07..3d29614 100644 --- a/src/reliqua/resources/base.py +++ b/src/reliqua/resources/base.py @@ -13,6 +13,11 @@ class Resource: the application finds and adds routes. """ + @property + def name(self): + """Return class name.""" + return self.__class__.__name__ + def get_params(self, req, keys=None, exclude=None): """ Retrieve the params from the request.