Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User endpoint #24

Merged
merged 8 commits into from
May 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 0 additions & 24 deletions ocrd_webapi/auth.py

This file was deleted.

55 changes: 55 additions & 0 deletions ocrd_webapi/authentication.py
Original file line number Diff line number Diff line change
@@ -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)
30 changes: 29 additions & 1 deletion ocrd_webapi/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
WorkflowDB,
WorkflowJobDB,
WorkspaceDB,
UserAccountDB
)
from ocrd_webapi.utils import call_sync, safe_init_logging

Expand All @@ -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}")
Expand Down Expand Up @@ -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)
6 changes: 6 additions & 0 deletions ocrd_webapi/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
class AuthenticationError(Exception):
pass


class RegistrationError(Exception):
pass


# TODO: This needs a better organization and inheritance structure
Expand Down
26 changes: 24 additions & 2 deletions ocrd_webapi/main.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand All @@ -21,14 +27,15 @@
"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,
"description": "The URL of your server offering the OCR-D API.",
}
],
)
app.include_router(user.router)
app.include_router(discovery.router)
# app.include_router(processor.router)
app.include_router(workflow.router)
Expand All @@ -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():
Expand Down
27 changes: 25 additions & 2 deletions ocrd_webapi/models/database.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
21 changes: 21 additions & 0 deletions ocrd_webapi/models/user.py
Original file line number Diff line number Diff line change
@@ -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)
66 changes: 66 additions & 0 deletions ocrd_webapi/routers/user.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 4 additions & 4 deletions ocrd_webapi/routers/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
Loading