Skip to content

Commit

Permalink
Merge pull request #149 from ral-facilities/feature/python-icat-backe…
Browse files Browse the repository at this point in the history
…nd-login-endpoints-#135

Python ICAT Backend: Login endpoints & session validation
  • Loading branch information
MRichards99 authored Jul 14, 2020
2 parents e4db23b + 5195edb commit ddca280
Show file tree
Hide file tree
Showing 12 changed files with 2,084 additions and 1,873 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ venv/
*.pyc
logs.log
config.json
.vscode/
2 changes: 1 addition & 1 deletion common/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class Backend(ABC):
def login(self, credentials):
"""
Attempt to log a user in using the provided credentials
:param credentials: The user's credentials
:param credentials: The user's credentials (including mechanism)
:returns: a session ID
"""
pass
Expand Down
3 changes: 3 additions & 0 deletions common/backends.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from common.database_backend import DatabaseBackend
from common.python_icat_backend import PythonICATBackend
from common.backend import Backend
from common.config import config
import sys
Expand All @@ -7,6 +8,8 @@

if backend_type == "db":
backend = DatabaseBackend()
elif backend_type == "python_icat":
backend = PythonICATBackend()
else:
sys.exit(
f"Invalid config value '{backend_type}' for config option backend")
Expand Down
15 changes: 15 additions & 0 deletions common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ def get_db_url(self):
except:
sys.exit("Missing config value, DB_URL")

def get_icat_url(self):
try:
return self.config["ICAT_URL"]
except:
sys.exit("Missing config value, ICAT_URL")

def get_icat_check_cert(self):
try:
return self.config["icat_check_cert"]
except:
# This could be set to true if there's no value, and log a warning
# that no value has been found from the config - save app from
# exiting
sys.exit("Missing config value, icat_check_cert")

def get_log_level(self):
try:
return self.config["log_level"]
Expand Down
9 changes: 6 additions & 3 deletions common/database_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
get_investigations_for_instrument_in_facility_cycle, get_investigations_for_instrument_in_facility_cycle_count, \
get_rows_by_filter, create_rows_from_json, patch_entities, get_row_by_id, insert_row_into_table, \
delete_row_by_id, update_row_from_id, get_filtered_row_count, get_first_filtered_row
from common.helpers import requires_session_id, queries_records
from common.database_helpers import requires_session_id
from common.helpers import queries_records
from common.models.db_models import SESSION
import uuid
from common.exceptions import AuthenticationError
import datetime

import logging
log = logging.getLogger()

class DatabaseBackend(Backend):
"""
Expand All @@ -18,15 +21,15 @@ class DatabaseBackend(Backend):
def login(self, credentials):
if credentials["username"] == "user" and credentials["password"] == "password":
session_id = str(uuid.uuid1())
insert_row_into_table(SESSION, SESSION(ID=session_id, USERNAME="simple/root",
insert_row_into_table(SESSION, SESSION(ID=session_id, USERNAME=f"{credentials['mechanism']}/root",
EXPIREDATETIME=datetime.datetime.now() + datetime.timedelta(days=1)))
return session_id
else:
raise AuthenticationError("Username and password are incorrect")

@requires_session_id
def get_session_details(self, session_id):
return get_row_by_id(SESSION, session_id)
return get_row_by_id(SESSION, session_id).to_dict()

@requires_session_id
def refresh(self, session_id):
Expand Down
32 changes: 30 additions & 2 deletions common/database_helpers.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,46 @@
import datetime
import logging
from abc import ABC, abstractmethod
from functools import wraps

from sqlalchemy import asc, desc
from sqlalchemy.orm import aliased

from common.exceptions import MissingRecordError, BadFilterError, BadRequestError, MultipleIncludeError
from common.exceptions import AuthenticationError, MissingRecordError, BadFilterError, BadRequestError, MultipleIncludeError
from common.models import db_models
from common.models.db_models import INVESTIGATIONUSER, INVESTIGATION, INSTRUMENT, FACILITYCYCLE, \
INVESTIGATIONINSTRUMENT, FACILITY
INVESTIGATIONINSTRUMENT, FACILITY, SESSION
from common.session_manager import session_manager

log = logging.getLogger()

def requires_session_id(method):
"""
Decorator for database backend methods that makes sure a valid session_id is provided
It expects that session_id is the second argument supplied to the function
:param method: The method for the backend operation
:raises AuthenticationError, if a valid session_id is not provided with the request
"""

@wraps(method)
def wrapper_requires_session(*args, **kwargs):
log.info(" Authenticating consumer")
session = session_manager.get_icat_db_session()
query = session.query(SESSION).filter(
SESSION.ID == args[1]).first()
if query is not None:
log.info(" Closing DB session")
session.close()
session.close()
log.info(" Consumer authenticated")
return method(*args, **kwargs)
else:
log.info(" Could not authenticate consumer, closing DB session")
session.close()
raise AuthenticationError("Forbidden")

return wrapper_requires_session


class Query(ABC):
"""
Expand Down
30 changes: 0 additions & 30 deletions common/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,10 @@

from common.database_helpers import QueryFilterFactory
from common.exceptions import ApiError, AuthenticationError, BadFilterError, BadRequestError, MissingCredentialsError, MissingRecordError, MultipleIncludeError
from common.models.db_models import SESSION
from common.session_manager import session_manager

log = logging.getLogger()


def requires_session_id(method):
"""
Decorator for database backend methods that makes sure a valid session_id is provided
It expects that session_id is the second argument supplied to the function
:param method: The method for the backend operation
:raises AuthenticationError, if a valid session_id is not provided with the request
"""

@wraps(method)
def wrapper_requires_session(*args, **kwargs):
log.info(" Authenticating consumer")
session = session_manager.get_icat_db_session()
query = session.query(SESSION).filter(
SESSION.ID == args[1]).first()
if query is not None:
log.info(" Closing DB session")
session.close()
session.close()
log.info(" Consumer authenticated")
return method(*args, **kwargs)
else:
log.info(" Could not authenticate consumer, closing DB session")
session.close()
raise AuthenticationError("Forbidden")

return wrapper_requires_session


def queries_records(method):
"""
Decorator for endpoint resources that search for a record in a table
Expand Down
115 changes: 115 additions & 0 deletions common/python_icat_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import logging

import icat.client
from icat.exception import ICATSessionError

from common.backend import Backend
from common.helpers import queries_records
from common.python_icat_helpers import requires_session_id, get_session_details_helper, logout_icat_client, refresh_client_session
from common.config import config
from common.exceptions import AuthenticationError
from common.models.db_models import SESSION

log = logging.getLogger()

class PythonICATBackend(Backend):
"""
Class that contains functions to access and modify data in an ICAT database directly
"""

def __init__(self):
# Client object is created here as well as in login() to avoid uncaught exceptions
# where the object is None. This could happen where a user tries to use an endpoint before
# logging in. Also helps to give a bit of certainty to what's stored here
self.client = icat.client.Client(config.get_icat_url(), checkCert=config.get_icat_check_cert())

def login(self, credentials):
# Client object is re-created here so session IDs aren't overwritten in the database
self.client = icat.client.Client(config.get_icat_url(), checkCert=config.get_icat_check_cert())

# Syntax for Python ICAT
login_details = {'username': credentials['username'], 'password': credentials['password']}
try:
session_id = self.client.login(credentials["mechanism"], login_details)
return session_id
except ICATSessionError:
raise AuthenticationError("User credentials are incorrect")

@requires_session_id
def get_session_details(self, session_id):
self.client.sessionId = session_id
return get_session_details_helper(self.client)

@requires_session_id
def refresh(self, session_id):
self.client.sessionId = session_id
return refresh_client_session(self.client)

@requires_session_id
@queries_records
def logout(self, session_id):
self.client.sessionId = session_id
return logout_icat_client(self.client)

@requires_session_id
@queries_records
def get_with_filters(self, session_id, table, filters):
pass

@requires_session_id
@queries_records
def create(self, session_id, table, data):
pass

@requires_session_id
@queries_records
def update(self, session_id, table, data):
pass

@requires_session_id
@queries_records
def get_one_with_filters(self, session_id, table, filters):
pass

@requires_session_id
@queries_records
def count_with_filters(self, session_id, table, filters):
pass

@requires_session_id
@queries_records
def get_with_id(self, session_id, table, id):
pass

@requires_session_id
@queries_records
def delete_with_id(self, session_id, table, id):
pass

@requires_session_id
@queries_records
def update_with_id(self, session_id, table, id, data):
pass

@requires_session_id
@queries_records
def get_instrument_facilitycycles_with_filters(self, session_id, instrument_id, filters):
pass

@requires_session_id
@queries_records
def count_instrument_facilitycycles_with_filters(self, session_id, instrument_id, filters):
pass
#return get_facility_cycles_for_instrument_count(instrument_id, filters)

@requires_session_id
@queries_records
def get_instrument_facilitycycle_investigations_with_filters(self, session_id, instrument_id, facilitycycle_id, filters):
pass
#return get_investigations_for_instrument_in_facility_cycle(instrument_id, facilitycycle_id, filters)

@requires_session_id
@queries_records
def count_instrument_facilitycycles_investigations_with_filters(self, session_id, instrument_id, facilitycycle_id, filters):
pass
#return get_investigations_for_instrument_in_facility_cycle_count(instrument_id, facilitycycle_id, filters)
65 changes: 65 additions & 0 deletions common/python_icat_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from functools import wraps
import logging
from datetime import datetime, timedelta


from icat.exception import ICATSessionError
from common.exceptions import AuthenticationError

log = logging.getLogger()

def requires_session_id(method):
"""
Decorator for Python ICAT backend methods that looks out for session errors when using the API.
The API call runs and an ICATSessionError may be raised due to an expired session, invalid
session ID etc. This does not explictly check whether a session ID is valid or not,
:param method: The method for the backend operation
:raises AuthenticationError, if a valid session_id is not provided with the request
"""

@wraps(method)
def wrapper_requires_session(*args, **kwargs):
try:

client = args[0].client
# Find out if session has expired
session_time = client.getRemainingMinutes()
log.info("Session time: {}".format(session_time))
if session_time < 0:
raise AuthenticationError("Forbidden")
else:
return method(*args, **kwargs)
except ICATSessionError:
raise AuthenticationError("Forbidden")

return wrapper_requires_session


def queries_records(method):
"""
Docstring
"""

@wraps(method)
def wrapper_gets_records(*args, **kwargs):
pass

return wrapper_gets_records


def get_session_details_helper(client):
# Remove rounding
session_time_remaining = client.getRemainingMinutes()
session_expiry_time = datetime.now() + timedelta(minutes=session_time_remaining)

username = client.getUserName()

return {"ID": client.sessionId, "EXPIREDATETIME": str(session_expiry_time), "USERNAME": username}


def logout_icat_client(client):
client.logout()


def refresh_client_session(client):
client.refresh()
2 changes: 2 additions & 0 deletions config.json.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"backend": "db",
"DB_URL": "mysql+pymysql://root:rootpw@localhost:13306/icatdb",
"ICAT_URL": "https://localhost.localdomain:8181",
"icat_check_cert": false,
"log_level": "WARN",
"debug_mode": false,
"generate_swagger": false,
Expand Down
Loading

0 comments on commit ddca280

Please sign in to comment.