diff --git a/Makefile b/Makefile index e0fcbf9..9c3e248 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,8 @@ test-api: OCRD_WEBAPI_BASE_DIR='/tmp/ocrd_webapi_test' \ OCRD_WEBAPI_DB_NAME='ocrd_webapi_test' \ OCRD_WEBAPI_DB_URL='mongodb://localhost:6701' \ + OCRD_WEBAPI_USERNAME='test' \ + OCRD_WEBAPI_PASSWORD='test' \ pytest tests/*_api.py test-rabbitmq: diff --git a/ocrd_webapi/auth.py b/ocrd_webapi/auth.py deleted file mode 100644 index 76eb5e0..0000000 --- a/ocrd_webapi/auth.py +++ /dev/null @@ -1,24 +0,0 @@ -from os import getenv -from secrets import compare_digest - -from fastapi import HTTPException, status -from fastapi.security import HTTPBasicCredentials - - -def dummy_security_check(auth: HTTPBasicCredentials): - """ - Reference security check implementation - """ - user = auth.username.encode("utf8") - pw = auth.password.encode("utf8") - expected_user = getenv("OCRD_WEBAPI_USERNAME", "test").encode("utf8") - expected_pw = getenv("OCRD_WEBAPI_PASSWORD", "test").encode("utf8") - - user_matched = compare_digest(user, expected_user) - pw_matched = compare_digest(pw, expected_pw) - - if not user or not pw or not user_matched or not pw_matched: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - headers={"WWW-Authenticate": "Basic"} - ) diff --git a/ocrd_webapi/authentication.py b/ocrd_webapi/authentication.py new file mode 100644 index 0000000..e8239d3 --- /dev/null +++ b/ocrd_webapi/authentication.py @@ -0,0 +1,55 @@ +from hashlib import sha512 +from random import random +from typing import Tuple + +from .database import create_user, get_user +from .exceptions import AuthenticationError, RegistrationError + + +async def authenticate_user(email: str, password: str): + db_user = await get_user(email=email) + if not db_user: + raise AuthenticationError(f"User not found: {email}") + password_status = validate_password( + plain_password=password, + encrypted_password=db_user.encrypted_pass + ) + if not password_status: + raise AuthenticationError(f"Wrong credentials for: {email}") + if not db_user.approved_user: + raise AuthenticationError(f"The account was not approved by the admin yet.") + + +async def register_user(email: str, password: str, approved_user=False): + salt, encrypted_password = encrypt_password(password) + db_user = await get_user(email) + if db_user: + raise RegistrationError(f"User is already registered: {email}") + created_user = await create_user( + email=email, + encrypted_pass=encrypted_password, + salt=salt, + approved_user=approved_user + ) + if not created_user: + raise RegistrationError(f"Failed to register user: {email}") + + +def encrypt_password(plain_password: str) -> Tuple[str, str]: + salt = get_random_salt() + hashed_password = get_hex_digest(salt, plain_password) + encrypted_password = f'{salt}${hashed_password}' + return salt, encrypted_password + + +def get_hex_digest(salt: str, plain_password: str): + return sha512(f'{salt}{plain_password}'.encode('utf-8')).hexdigest() + + +def get_random_salt() -> str: + return sha512(f'{hash(str(random()))}'.encode('utf-8')).hexdigest()[:8] + + +def validate_password(plain_password: str, encrypted_password: str) -> bool: + salt, hashed_password = encrypted_password.split('$', 1) + return hashed_password == get_hex_digest(salt, plain_password) diff --git a/ocrd_webapi/database.py b/ocrd_webapi/database.py index cc07e66..d7e6bad 100644 --- a/ocrd_webapi/database.py +++ b/ocrd_webapi/database.py @@ -8,6 +8,7 @@ WorkflowDB, WorkflowJobDB, WorkspaceDB, + UserAccountDB ) from ocrd_webapi.utils import call_sync, safe_init_logging @@ -23,7 +24,7 @@ async def initiate_database(db_url: str, db_name: str = None, doc_models: List[D if db_name is None: db_name = DB_NAME if doc_models is None: - doc_models = [WorkflowDB, WorkspaceDB, WorkflowJobDB] + doc_models = [WorkflowDB, WorkspaceDB, WorkflowJobDB, UserAccountDB] if db_url: logger.info(f"MongoDB Name: {DB_NAME}") @@ -288,3 +289,30 @@ async def get_workflow_job_state(job_id) -> Union[str, None]: @call_sync async def sync_get_workflow_job_state(job_id) -> Union[str, None]: return await get_workflow_job_state(job_id) + + +async def get_user(email: str) -> Union[UserAccountDB, None]: + return await UserAccountDB.find_one(UserAccountDB.email == email) + + +@call_sync +async def sync_get_user(email: str) -> Union[UserAccountDB, None]: + return await get_user(email) + + +async def create_user(email: str, encrypted_pass: str, salt: str, approved_user: bool = False +) -> Union[UserAccountDB, None]: + user_account = UserAccountDB( + email=email, + encrypted_pass=encrypted_pass, + salt=salt, + approved_user=approved_user + ) + await user_account.save() + return user_account + + +@call_sync +async def sync_create_user(email: str, encrypted_pass: str, salt: str, approved_user: bool = False +) -> Union[UserAccountDB, None]: + return await create_user(email, encrypted_pass, salt, approved_user) diff --git a/ocrd_webapi/exceptions.py b/ocrd_webapi/exceptions.py index 78e7adf..04bd129 100644 --- a/ocrd_webapi/exceptions.py +++ b/ocrd_webapi/exceptions.py @@ -1,3 +1,9 @@ +class AuthenticationError(Exception): + pass + + +class RegistrationError(Exception): + pass # TODO: This needs a better organization and inheritance structure diff --git a/ocrd_webapi/main.py b/ocrd_webapi/main.py index 2bb050c..2b64fbe 100644 --- a/ocrd_webapi/main.py +++ b/ocrd_webapi/main.py @@ -1,14 +1,20 @@ from datetime import datetime +from os import environ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from ocrd_webapi.authentication import ( + authenticate_user, + register_user +) from ocrd_webapi.constants import DB_URL, SERVER_URL from ocrd_webapi.database import initiate_database -from ocrd_webapi.exceptions import ResponseException +from ocrd_webapi.exceptions import ResponseException, AuthenticationError from ocrd_webapi.routers import ( discovery, processor, + user, workflow, workspace, ) @@ -21,7 +27,7 @@ "name": "Apache 2.0", "url": "http://www.apache.org/licenses/LICENSE-2.0.html", }, - version="0.8.2", + version="0.11.0", servers=[ { "url": SERVER_URL, @@ -29,6 +35,7 @@ } ], ) +app.include_router(user.router) app.include_router(discovery.router) # app.include_router(processor.router) app.include_router(workflow.router) @@ -50,6 +57,21 @@ async def startup_event(): """ await initiate_database(DB_URL) + default_admin_user = environ.get("OCRD_WEBAPI_USERNAME", "test") + default_admin_pass = environ.get("OCRD_WEBAPI_PASSWORD", "test") + + # If the default admin user account is not available in the DB, create it + try: + await authenticate_user(default_admin_user, default_admin_pass) + except AuthenticationError: + # TODO: Note that this account is never removed from + # the DB automatically in the current implementation + await register_user( + default_admin_user, + default_admin_pass, + approved_user=True + ) + @app.get("/") async def test(): diff --git a/ocrd_webapi/models/database.py b/ocrd_webapi/models/database.py index 28700d1..608172e 100644 --- a/ocrd_webapi/models/database.py +++ b/ocrd_webapi/models/database.py @@ -1,10 +1,33 @@ from beanie import Document from typing import Optional - # NOTE: Database models must not reuse any -# response models [discovery, processor, workflow, workspace] +# response models [discovery, processor, user, workflow, workspace] # Database models are supposed to be low level models + + +class UserAccountDB(Document): + """ + Model to store a user account in the database + + Attributes: + email: The e-mail address of the user + encrypted_pass: The encrypted password of the user + salt: Random salt value used when encrypting the password + approved_user: Whether the user is approved by the admin + + By default, the registered user's account is not validated. + An admin must manually validate the account by assigning True value. + """ + email: str + encrypted_pass: str + salt: str + approved_user: bool = False + + class Settings: + name = "user_accounts" + + class WorkspaceDB(Document): """ Model to store a workspace in the mongo-database. diff --git a/ocrd_webapi/models/user.py b/ocrd_webapi/models/user.py new file mode 100644 index 0000000..3511967 --- /dev/null +++ b/ocrd_webapi/models/user.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, Field + + +class UserAction(BaseModel): + email: str = Field( + ..., # the field is required, no default set + description='Email linked to this User' + ) + action: str = Field( + default='Description of the user action', + description='Description of the user action' + ) + + class Config: + allow_population_by_field_name = True + + @staticmethod + def create(email: str, action: str): + if not action: + action = "User Action" + return UserAction(email=email, action=action) diff --git a/ocrd_webapi/routers/user.py b/ocrd_webapi/routers/user.py new file mode 100644 index 0000000..d1f0bde --- /dev/null +++ b/ocrd_webapi/routers/user.py @@ -0,0 +1,66 @@ +""" +module for implementing the authentication section of the api +""" +import logging +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +from ocrd_webapi.authentication import authenticate_user, register_user +from ocrd_webapi.exceptions import AuthenticationError, RegistrationError +from ocrd_webapi.models.user import UserAction + +router = APIRouter( + tags=["User"], +) + +logger = logging.getLogger(__name__) +# TODO: This may not be ideal, discussion needed +security = HTTPBasic() + + +@router.get("/user/login", responses={"200": {"model": UserAction}}) +async def user_login(auth: HTTPBasicCredentials = Depends(security)): + email = auth.username + password = auth.password + if not (email and password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + headers={"WWW-Authenticate": "Basic"}, + detail="Missing e-mail or password field" + ) + + # Authenticate user e-mail and password + try: + await authenticate_user( + email=email, + password=password + ) + except AuthenticationError as error: + logger.info(f"User failed to authenticate: {email}, reason: {error}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + headers={"WWW-Authenticate": "Basic"}, + detail=f"Invalid login credentials or unapproved account." + ) + + return UserAction(email=email, action="Successfully logged!") + + +@router.post("/user/register", responses={"201": {"model": UserAction}}) +async def user_register(email: str, password: str): + try: + await register_user( + email=email, + password=password, + approved_user=False + ) + except RegistrationError as error: + logger.info(f"User failed to register: {email}, reason: {error}") + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + headers={"WWW-Authenticate": "Basic"}, + detail=f"Failed to register user" + ) + action = f"Successfully registered new account: {email}. " \ + f"Please contact the OCR-D team to get your account validated." + return UserAction(email=email, action=action) diff --git a/ocrd_webapi/routers/workflow.py b/ocrd_webapi/routers/workflow.py index 6309114..24a9967 100644 --- a/ocrd_webapi/routers/workflow.py +++ b/ocrd_webapi/routers/workflow.py @@ -13,7 +13,7 @@ from fastapi.responses import FileResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials -from ocrd_webapi.auth import dummy_security_check +from ocrd_webapi.routers.user import user_login from ocrd_webapi.exceptions import ResponseException from ocrd_webapi.managers.workflow_manager import WorkflowManager from ocrd_webapi.managers.workspace_manager import WorkspaceManager @@ -135,7 +135,7 @@ async def run_workflow(workflow_id: str, workflow_args: WorkflowArgs, curl -X POST http://localhost:8000/workflow/{workflow_id}?workspace_id={workspace_id} """ - dummy_security_check(auth) + await user_login(auth) try: parameters = await workflow_manager.start_nf_workflow( workflow_id=workflow_id, @@ -173,7 +173,7 @@ async def upload_workflow_script(nextflow_script: UploadFile, curl -X POST http://localhost:8000/workflow -F nextflow_script=@things/nextflow.nf # noqa """ - dummy_security_check(auth) + await user_login(auth) try: workflow_id, workflow_url = await workflow_manager.create_workflow_space(nextflow_script) except Exception as e: @@ -193,7 +193,7 @@ async def update_workflow_script(nextflow_script: UploadFile, workflow_id: str, curl -X PUT http://localhost:8000/workflow/{workflow_id} -F nextflow_script=@things/nextflow-simple.nf """ - dummy_security_check(auth) + await user_login(auth) try: workflow_id, updated_workflow_url = await workflow_manager.update_workflow_space( file=nextflow_script, diff --git a/ocrd_webapi/routers/workspace.py b/ocrd_webapi/routers/workspace.py index 5fd7620..398546d 100644 --- a/ocrd_webapi/routers/workspace.py +++ b/ocrd_webapi/routers/workspace.py @@ -12,7 +12,7 @@ from fastapi.responses import FileResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials -from ocrd_webapi.auth import dummy_security_check +from ocrd_webapi.routers.user import user_login from ocrd_webapi.exceptions import ( ResponseException, WorkspaceException, @@ -94,7 +94,7 @@ async def post_workspace(workspace: UploadFile, auth: HTTPBasicCredentials = Dep curl -X POST http://localhost:8000/workspace -H 'content-type: multipart/form-data' -F workspace=@things/example_ws.ocrd.zip # noqa """ - dummy_security_check(auth) + await user_login(auth) try: ws_url, ws_id = await workspace_manager.create_workspace_from_zip(workspace) except WorkspaceNotValidException as e: @@ -113,7 +113,7 @@ async def put_workspace(workspace: UploadFile, workspace_id: str, """ Update or create a workspace """ - dummy_security_check(auth) + await user_login(auth) try: updated_workspace_url = await workspace_manager.update_workspace(file=workspace, workspace_id=workspace_id) except WorkspaceNotValidException as e: @@ -132,7 +132,7 @@ async def delete_workspace(workspace_id: str, auth: HTTPBasicCredentials = Depen Delete a workspace curl -v -X DELETE 'http://localhost:8000/workspace/{workspace_id}' """ - dummy_security_check(auth) + await user_login(auth) try: deleted_workspace_url = await workspace_manager.delete_workspace( workspace_id diff --git a/pyproject.toml b/pyproject.toml index fcda0b2..c28a135 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ocrd_webapi" -version = "0.10.0" +version = "0.11.0" description = "Implementation of the OCR-D Web API" readme = "readme.md" requires-python = ">=3.7" diff --git a/tests/test_workflow_api.py b/tests/test_workflow_api.py index df0174b..f683676 100644 --- a/tests/test_workflow_api.py +++ b/tests/test_workflow_api.py @@ -11,6 +11,11 @@ ) +def test_post_workflow_script_unauthorized(client, asset_workflow1): + response = client.post("/workflow", files=asset_workflow1, auth=("no_user", "no_pass")) + assert_status_code(response.status_code, expected_floor=4) + + # Test cases def test_post_workflow_script(client, auth, workflow_mongo_coll, asset_workflow1): # Post a new workflow script diff --git a/tests/test_workspace_api.py b/tests/test_workspace_api.py index 481d9dc..276f881 100644 --- a/tests/test_workspace_api.py +++ b/tests/test_workspace_api.py @@ -11,6 +11,11 @@ from .utils_test import parse_resource_id +def test_post_workspace_unauthorized(client, asset_workspace1): + response = client.post("/workspace", files=asset_workspace1, auth=("no_user", "no_pass")) + assert_status_code(response.status_code, expected_floor=4) + + # Test cases def test_post_workspace(client, auth, workspace_mongo_coll, asset_workspace1): response = client.post("/workspace", files=asset_workspace1, auth=auth)