diff --git a/doc/plugin.md b/doc/plugin.md new file mode 100644 index 0000000..8fbad99 --- /dev/null +++ b/doc/plugin.md @@ -0,0 +1,82 @@ +# Example on Plugin Development + +## File Structure +``` +- root + - flaschengeist_example + - __init__.py + - plugin.py + [- migrations] + - setup.cfg +``` + +In a minimal example, you would only need two python source files and +your setup configuration. +If you also use custom database tables, you would also need a directory +containing your _alembic_-migration files. + +## Source code +Code is seperated in two locations, the meta data of your plugin goes into the `__init__.py` and all logic goes into the `plugin.py`, of cause you could add more files, but make sure to not depend on some external packages in the `__init__.py`. + +### Project data / setuptools +For **Flaschengeist** to find your plugin, you have to +install it corretly, especially the entry points have to be set. +```ini +[metadata] +license = MIT +version = 1.0.0 +name = flaschengeist-example +description = Example plugin for Flaschengeist +# ... + +[options] +packages = + flaschengeist_example +install_requires = + flaschengeist == 2.0.* + +[options.entry_points] + # Path to your meta data instance + flaschengeist.plugins = + example = flaschengeist_example:plugin + +``` + + +### Metadata +The `__init__.py` contains the plugin meta data. + +```python +# __init__.py +from flaschengeist.plugins import PluginMetadata +plugin = PluginMetadata( + id="com.example.plugin", # unique, recommend FQN + name="Example Plugin", # Human readable name + version="1.0.0", # Optional + module="flaschengeist_example.plugin.ExamplePlugin" # module and class of your plugin +) +``` + +`version` is optional, if not provided the version of your distribution (set in setup.cfg) is used. + +### Implementation +You plugin implementation goes into the `plugin.py`. +Here you define your Plugin class, make sure it inheritates from `flaschengeist.plugins.Plugin` or `flaschengeist.plugins.AuthPlugin` respectivly. + +It will get initalized with the meta data you defined in the `__init__.py` and the configuration object. + +```python +from flaschengeist.plugins import Plugin +from flask import Blueprint + +bp = Blueprint(...) + +class ExamplePlugin(Plugin): + blueprint = bp + ... + +@bp.route("/example") +def get_example(): + ... + +``` \ No newline at end of file diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index fefd82d..cd53f9a 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -68,19 +68,6 @@ class PluginMetadata: If not provided the version will be set to the distribution version of the package providing the module""" - blueprint: Blueprint = None - """Override with a `flask.blueprint` if the plugin uses custom routes""" - - permissions: list[str] = field(default_factory=list) - """Override to add custom permissions used by the plugin - - A good style is to name the permissions with a prefix related to the plugin name, - to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*. - """ - - models = None # You have to override - """Override with models module""" - migrations_path = None # Override this with the location of your db migrations directory """Override with path to migration files, if custome db tables are used""" @@ -102,7 +89,29 @@ class PluginMetadata: class Plugin(PluginMetadata): """Base class for all Plugins - If your class uses custom models add a static property called ``models``""" + + All plugins must interitate from this class for adding their logic. + This class gets filled with the meta data of the plugin automatically. + + Additionally the `flask.Blueprint` can be added for API routes. + """ + + blueprint: Blueprint = None + """Override with a `flask.blueprint` if the plugin uses custom routes""" + + models = None + """Module with all custom sqlalchemy models + + Override this with the module containing all your sqlalchemy models module + to enable the `flaschengeist` tool to generate TS API. + """ + + permissions: list[str] = [] + """Override to add custom permissions used by the plugin + + A good style is to name the permissions with a prefix related to the plugin name, + to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*. + """ def __init__(self, metadata: PluginMetadata, config=None): """Constructor called by create_app diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 9c08463..34a40a0 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -1,177 +1,7 @@ -"""Authentication plugin, provides basic routes +from flaschengeist.plugins import PluginMetadata -Allow management of authentication, login, logout, etc. -""" -from flask import Blueprint, request, jsonify -from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized - -from flaschengeist import logger -from flaschengeist.plugins import Plugin -from flaschengeist.utils.HTTP import no_content, created -from flaschengeist.utils.decorators import login_required -from flaschengeist.controller import sessionController, userController - - -class AuthRoutePlugin(Plugin): - name = "auth" - blueprint = Blueprint(name, __name__) - - -@AuthRoutePlugin.blueprint.route("/auth", methods=["POST"]) -def login(): - """Login in an user and create a session - - Route: ``/auth`` | Method: ``POST`` - - POST-data: ``{userid: string, password: string}`` - - Returns: - A JSON object with `flaschengeist.models.user.User` and created - `flaschengeist.models.session.Session` or HTTP error - """ - logger.debug("Start log in.") - data = request.get_json() - try: - userid = str(data["userid"]) - password = str(data["password"]) - except (KeyError, ValueError, TypeError): - raise BadRequest("Missing parameter(s)") - - logger.debug(f"search user {userid} in database") - user = userController.login_user(userid, password) - if not user: - raise Unauthorized - session = sessionController.create(user, user_agent=request.user_agent) - logger.debug(f"token is {session.token}") - logger.info(f"User {userid} logged in.") - - # Lets cleanup the DB - sessionController.clear_expired() - return created(session) - - -@AuthRoutePlugin.blueprint.route("/auth", methods=["GET"]) -@login_required() -def get_sessions(current_session, **kwargs): - """Get all valid sessions of current user - - Route: ``/auth`` | Method: ``GET`` - - Returns: - A JSON array of `flaschengeist.models.session.Session` or HTTP error - """ - sessions = sessionController.get_users_sessions(current_session.user_) - return jsonify(sessions) - - -@AuthRoutePlugin.blueprint.route("/auth/", methods=["DELETE"]) -@login_required() -def delete_session(token, current_session, **kwargs): - """Delete a session aka "logout" - - Route: ``/auth/`` | Method: ``DELETE`` - - Returns: - 200 Status (empty) or HTTP error - """ - logger.debug("Try to delete access token {{ {} }}".format(token)) - session = sessionController.get_session(token, current_session.user_) - if not session: - logger.debug("Token not found in database!") - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - sessionController.delete_session(session) - sessionController.clear_expired() - return "" - - -@AuthRoutePlugin.blueprint.route("/auth/", methods=["GET"]) -@login_required() -def get_session(token, current_session, **kwargs): - """Retrieve information about a session - - Route: ``/auth/`` | Method: ``GET`` - - Attributes: - token: Token identifying session to retrieve - current_session: Session sent with Authorization Header - - Returns: - JSON encoded `flaschengeist.models.session.Session` or HTTP error - """ - logger.debug("get token {{ {} }}".format(token)) - session = sessionController.get_session(token, current_session.user_) - if not session: - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - return jsonify(session) - - -@AuthRoutePlugin.blueprint.route("/auth/", methods=["PUT"]) -@login_required() -def set_lifetime(token, current_session, **kwargs): - """Set lifetime of a session - - Route: ``/auth/`` | Method: ``PUT`` - - POST-data: ``{value: int}`` - - Attributes: - token: Token identifying the session - current_session: Session sent with Authorization Header - - Returns: - HTTP-204 or HTTP error - """ - session = sessionController.get_session(token, current_session.user_) - if not session: - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - try: - lifetime = request.get_json()["value"] - logger.debug(f"set lifetime >{lifetime}< to access token >{token}<") - sessionController.set_lifetime(session, lifetime) - return jsonify(sessionController.get_session(token, current_session.user_)) - except (KeyError, TypeError): - raise BadRequest - - -@AuthRoutePlugin.blueprint.route("/auth//user", methods=["GET"]) -@login_required() -def get_assocd_user(token, current_session, **kwargs): - """Retrieve user owning a session - - Route: ``/auth//user`` | Method: ``GET`` - - Attributes: - token: Token identifying the session - current_session: Session sent with Authorization Header - - Returns: - JSON encoded `flaschengeist.models.user.User` or HTTP error - """ - logger.debug("get token {{ {} }}".format(token)) - session = sessionController.get_session(token, current_session.user_) - if not session: - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - return jsonify(session.user_) - - -@AuthRoutePlugin.blueprint.route("/auth/reset", methods=["POST"]) -def reset_password(): - data = request.get_json() - if "userid" in data: - user = userController.find_user(data["userid"]) - if user: - userController.request_reset(user) - elif "password" in data and "token" in data: - userController.reset_password(data["token"], data["password"]) - else: - raise BadRequest("Missing parameter(s)") - - return no_content() +plugin = PluginMetadata( + id = "auth", + name="auth", + module=__name__ + ".plugin.AuthPlugin" +) \ No newline at end of file diff --git a/flaschengeist/plugins/auth/plugin.py b/flaschengeist/plugins/auth/plugin.py new file mode 100644 index 0000000..afcc854 --- /dev/null +++ b/flaschengeist/plugins/auth/plugin.py @@ -0,0 +1,176 @@ +"""Authentication plugin, provides basic routes + +Allow management of authentication, login, logout, etc. +""" +from flask import Blueprint, request, jsonify +from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized + +from flaschengeist import logger +from flaschengeist.plugins import Plugin +from flaschengeist.utils.HTTP import no_content, created +from flaschengeist.utils.decorators import login_required +from flaschengeist.controller import sessionController, userController + + +class AuthRoutePlugin(Plugin): + blueprint = Blueprint("auth", __name__) + + +@AuthRoutePlugin.blueprint.route("/auth", methods=["POST"]) +def login(): + """Login in an user and create a session + + Route: ``/auth`` | Method: ``POST`` + + POST-data: ``{userid: string, password: string}`` + + Returns: + A JSON object with `flaschengeist.models.user.User` and created + `flaschengeist.models.session.Session` or HTTP error + """ + logger.debug("Start log in.") + data = request.get_json() + try: + userid = str(data["userid"]) + password = str(data["password"]) + except (KeyError, ValueError, TypeError): + raise BadRequest("Missing parameter(s)") + + logger.debug(f"search user {userid} in database") + user = userController.login_user(userid, password) + if not user: + raise Unauthorized + session = sessionController.create(user, user_agent=request.user_agent) + logger.debug(f"token is {session.token}") + logger.info(f"User {userid} logged in.") + + # Lets cleanup the DB + sessionController.clear_expired() + return created(session) + + +@AuthRoutePlugin.blueprint.route("/auth", methods=["GET"]) +@login_required() +def get_sessions(current_session, **kwargs): + """Get all valid sessions of current user + + Route: ``/auth`` | Method: ``GET`` + + Returns: + A JSON array of `flaschengeist.models.session.Session` or HTTP error + """ + sessions = sessionController.get_users_sessions(current_session.user_) + return jsonify(sessions) + + +@AuthRoutePlugin.blueprint.route("/auth/", methods=["DELETE"]) +@login_required() +def delete_session(token, current_session, **kwargs): + """Delete a session aka "logout" + + Route: ``/auth/`` | Method: ``DELETE`` + + Returns: + 200 Status (empty) or HTTP error + """ + logger.debug("Try to delete access token {{ {} }}".format(token)) + session = sessionController.get_session(token, current_session.user_) + if not session: + logger.debug("Token not found in database!") + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + sessionController.delete_session(session) + sessionController.clear_expired() + return "" + + +@AuthRoutePlugin.blueprint.route("/auth/", methods=["GET"]) +@login_required() +def get_session(token, current_session, **kwargs): + """Retrieve information about a session + + Route: ``/auth/`` | Method: ``GET`` + + Attributes: + token: Token identifying session to retrieve + current_session: Session sent with Authorization Header + + Returns: + JSON encoded `flaschengeist.models.session.Session` or HTTP error + """ + logger.debug("get token {{ {} }}".format(token)) + session = sessionController.get_session(token, current_session.user_) + if not session: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + return jsonify(session) + + +@AuthRoutePlugin.blueprint.route("/auth/", methods=["PUT"]) +@login_required() +def set_lifetime(token, current_session, **kwargs): + """Set lifetime of a session + + Route: ``/auth/`` | Method: ``PUT`` + + POST-data: ``{value: int}`` + + Attributes: + token: Token identifying the session + current_session: Session sent with Authorization Header + + Returns: + HTTP-204 or HTTP error + """ + session = sessionController.get_session(token, current_session.user_) + if not session: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + try: + lifetime = request.get_json()["value"] + logger.debug(f"set lifetime >{lifetime}< to access token >{token}<") + sessionController.set_lifetime(session, lifetime) + return jsonify(sessionController.get_session(token, current_session.user_)) + except (KeyError, TypeError): + raise BadRequest + + +@AuthRoutePlugin.blueprint.route("/auth//user", methods=["GET"]) +@login_required() +def get_assocd_user(token, current_session, **kwargs): + """Retrieve user owning a session + + Route: ``/auth//user`` | Method: ``GET`` + + Attributes: + token: Token identifying the session + current_session: Session sent with Authorization Header + + Returns: + JSON encoded `flaschengeist.models.user.User` or HTTP error + """ + logger.debug("get token {{ {} }}".format(token)) + session = sessionController.get_session(token, current_session.user_) + if not session: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + return jsonify(session.user_) + + +@AuthRoutePlugin.blueprint.route("/auth/reset", methods=["POST"]) +def reset_password(): + data = request.get_json() + if "userid" in data: + user = userController.find_user(data["userid"]) + if user: + userController.request_reset(user) + elif "password" in data and "token" in data: + userController.reset_password(data["token"], data["password"]) + else: + raise BadRequest("Missing parameter(s)") + + return no_content() diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 1bcdb0d..4f114e7 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -1,10 +1,7 @@ from flaschengeist.plugins import PluginMetadata -def loader(): - from .plugin import AuthLDAP - - return AuthLDAP - - -Plugin = PluginMetadata(id="auth_ldap", name="auth_ldap", plugin=loader) +plugin = PluginMetadata( + id="auth_ldap", + +)