From ceef272d06f09c72d6bff73ee00f94d0c9d7e8b9 Mon Sep 17 00:00:00 2001
From: Patrick Jentsch <p.jentsch@uni-bielefeld.de>
Date: Fri, 2 Sep 2022 13:24:14 +0200
Subject: [PATCH] Add API functionality

---
 app/__init__.py                      |   9 ++
 app/api/__init__.py                  |  29 ++---
 app/api/auth.py                      |  43 ++++---
 app/api/jobs.py                      | 102 +++++++++++++++++
 app/api/schemas.py                   | 165 +++++++++++++++++++++++++++
 app/api/tokens.py                    |  71 ++++++++----
 app/api/users.py                     |  99 ++++++++++++++++
 app/models.py                        |  65 +++++++++++
 app/templates/_sidenav.html.j2       |   3 +
 config.py                            |  10 ++
 migrations/versions/116b4ab3ef9c_.py |  41 +++++++
 11 files changed, 583 insertions(+), 54 deletions(-)
 create mode 100644 app/api/jobs.py
 create mode 100644 app/api/schemas.py
 create mode 100644 app/api/users.py
 create mode 100644 migrations/versions/116b4ab3ef9c_.py

diff --git a/app/__init__.py b/app/__init__.py
index 1a749ed4..1ed0fd7c 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -1,3 +1,4 @@
+from apifairy import APIFairy
 from config import Config
 from docker import DockerClient
 from flask import Flask
@@ -5,6 +6,7 @@ from flask_apscheduler import APScheduler
 from flask_assets import Environment
 from flask_login import LoginManager
 from flask_mail import Mail
+from flask_marshmallow import Marshmallow
 from flask_migrate import Migrate
 from flask_paranoid import Paranoid
 from flask_socketio import SocketIO
@@ -12,6 +14,7 @@ from flask_sqlalchemy import SQLAlchemy
 from flask_hashids import Hashids
 
 
+apifairy = APIFairy()
 assets = Environment()
 db = SQLAlchemy()
 docker_client = DockerClient()
@@ -19,6 +22,7 @@ hashids = Hashids()
 login = LoginManager()
 login.login_view = 'auth.login'
 login.login_message = 'Please log in to access this page.'
+ma = Marshmallow()
 mail = Mail()
 migrate = Migrate()
 paranoid = Paranoid()
@@ -38,10 +42,12 @@ def create_app(config: Config = Config) -> Flask:
         registry=app.config['NOPAQUE_DOCKER_REGISTRY']
     )
 
+    apifairy.init_app(app)
     assets.init_app(app)
     db.init_app(app)
     hashids.init_app(app)
     login.init_app(app)
+    ma.init_app(app)
     mail.init_app(app)
     migrate.init_app(app, db)
     paranoid.init_app(app)
@@ -51,6 +57,9 @@ def create_app(config: Config = Config) -> Flask:
     from .admin import bp as admin_blueprint
     app.register_blueprint(admin_blueprint, url_prefix='/admin')
 
+    from .api import bp as api_blueprint
+    app.register_blueprint(api_blueprint, url_prefix='/api')
+
     from .auth import bp as auth_blueprint
     app.register_blueprint(auth_blueprint, url_prefix='/auth')
 
diff --git a/app/api/__init__.py b/app/api/__init__.py
index e7674c87..39e40db1 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -1,25 +1,14 @@
 from flask import Blueprint
-from flask_restx import Api
 
-from .tokens import ns as tokens_ns
 
 bp = Blueprint('api', __name__)
-authorizations = {
-    'basicAuth': {
-        'type': 'basic'
-    },
-    'apiKey': {
-        'type': 'apiKey',
-        'in': 'header',
-        'name': 'Authorization'
-    }
-}
-api = Api(
-    bp,
-    authorizations=authorizations,
-    description='An API to interact with nopaque',
-    title='nopaque API',
-    version='1.0'
-)
 
-api.add_namespace(tokens_ns)
+
+from .tokens import bp as tokens_blueprint
+bp.register_blueprint(tokens_blueprint, url_prefix='/tokens')
+
+from .users import bp as users_blueprint
+bp.register_blueprint(users_blueprint, url_prefix='/users')
+
+from .jobs import bp as jobs_blueprint
+bp.register_blueprint(jobs_blueprint, url_prefix='/jobs')
diff --git a/app/api/auth.py b/app/api/auth.py
index 4c6a3dd9..afda3a30 100644
--- a/app/api/auth.py
+++ b/app/api/auth.py
@@ -1,34 +1,49 @@
-from app.models import User
+from flask import current_app
 from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
-from sqlalchemy import or_
-from werkzeug.http import HTTP_STATUS_CODES
+from werkzeug.exceptions import Forbidden, Unauthorized
+from app.models import User
+
 
 basic_auth = HTTPBasicAuth()
 token_auth = HTTPTokenAuth()
-
+auth_error_responses = {
+    Unauthorized.code: Unauthorized.description,
+    Forbidden.code: Forbidden.description
+}
 
 @basic_auth.verify_password
 def verify_password(email_or_username, password):
-    user = User.query.filter(
-        or_(
-            User.username == email_or_username,
-            User.email == email_or_username.lower()
-        )
-    ).first()
-    if user and user.verify_password(password):
+    user = User.query.filter((User.email == email_or_username.lower()) | (User.username == email_or_username)).first()
+    if user is not None and user.verify_password(password):
         return user
 
 
 @basic_auth.error_handler
 def basic_auth_error(status):
-    return {'error': HTTP_STATUS_CODES.get(status, 'Unknown error')}, status
+    error = (Forbidden if status == 403 else Unauthorized)()
+    return {
+        'code': error.code,
+        'message': error.name,
+        'description': error.description,
+    }, error.code, {'WWW-Authenticate': 'Form'}
 
 
 @token_auth.verify_token
 def verify_token(token):
-    return User.check_token(token) if token else None
+    return User.verify_access_token(token) if token else None
 
 
 @token_auth.error_handler
 def token_auth_error(status):
-    return {'error': HTTP_STATUS_CODES.get(status, 'Unknown error')}, status
+    error = (Forbidden if status == 403 else Unauthorized)()
+    return {
+        'code': error.code,
+        'message': error.name,
+        'description': error.description,
+    }, error.code
+
+
+@basic_auth.get_user_roles
+@token_auth.get_user_roles
+def get_user_roles(user):
+    return [user.role.name]
diff --git a/app/api/jobs.py b/app/api/jobs.py
new file mode 100644
index 00000000..e730f2e6
--- /dev/null
+++ b/app/api/jobs.py
@@ -0,0 +1,102 @@
+
+from apifairy import authenticate, response
+from apifairy.decorators import body, other_responses
+from flask import abort, Blueprint
+from werkzeug.exceptions import InternalServerError
+from app import db, hashids
+from app.models import Job, JobInput, JobStatus, TesseractOCRModel
+from .schemas import EmptySchema, JobSchema, SpaCyNLPPipelineJobSchema, TesseractOCRPipelineJobSchema, TesseractOCRModelSchema
+from .auth import auth_error_responses, token_auth
+
+
+bp = Blueprint('jobs', __name__)
+job_schema = JobSchema()
+jobs_schema = JobSchema(many=True)
+spacy_nlp_pipeline_job_schema = SpaCyNLPPipelineJobSchema()
+tesseract_ocr_pipeline_job_schema = TesseractOCRPipelineJobSchema()
+tesseract_ocr_model_schema = TesseractOCRModelSchema()
+tesseract_ocr_models_schema = TesseractOCRModelSchema(many=True)
+
+
+@bp.route('', methods=['GET'])
+@authenticate(token_auth, role='Administrator')
+@response(jobs_schema)
+@other_responses(auth_error_responses)
+def get_jobs():
+    """Get all jobs"""
+    return Job.query.all()
+
+
+@bp.route('/tesseract-ocr-pipeline', methods=['POST'])
+@authenticate(token_auth)
+@body(tesseract_ocr_pipeline_job_schema, location='form')
+@response(job_schema)
+@other_responses({**auth_error_responses, InternalServerError.code: InternalServerError.description})
+def create_tesseract_ocr_pipeline_job(args):
+    """Create a new Tesseract OCR Pipeline job"""
+    current_user = token_auth.current_user()
+    try:
+        job = Job.create(
+            title=args['title'],
+            description=args['description'],
+            service='tesseract-ocr-pipeline',
+            service_args={
+                'model': hashids.decode(args['model_id']),
+                'binarization': args['binarization']
+            },
+            service_version=args['service_version'],
+            user=current_user
+        )
+    except OSError:
+        abort(500)
+    try:
+        JobInput.create(args['pdf'], job=job)
+    except OSError:
+        abort(500)
+    job.status = JobStatus.SUBMITTED
+    db.session.commit()
+    return job, 201
+
+
+@bp.route('/tesseract-ocr-pipeline/models', methods=['GET'])
+@authenticate(token_auth)
+@response(tesseract_ocr_models_schema)
+@other_responses(auth_error_responses)
+def get_tesseract_ocr_models():
+    """Get all Tesseract OCR Models"""
+    return TesseractOCRModel.query.all()
+
+
+@bp.route('/<hashid:job_id>', methods=['DELETE'])
+@authenticate(token_auth)
+@response(EmptySchema, status_code=204)
+@other_responses(auth_error_responses)
+def delete_job(job_id):
+    """Delete a job by id"""
+    current_user = token_auth.current_user()
+    job = Job.query.get(job_id)
+    if job is None:
+        abort(404)
+    if not (job.user == current_user or current_user.is_administrator()):
+        abort(403)
+    try:
+        job.delete()
+    except OSError as e:
+        abort(500)
+    db.session.commit()
+    return {}, 204
+
+
+@bp.route('/<hashid:job_id>', methods=['GET'])
+@authenticate(token_auth)
+@response(job_schema)
+@other_responses(auth_error_responses)
+def get_job(job_id):
+    """Get a job by id"""
+    current_user = token_auth.current_user()
+    job = Job.query.get(job_id)
+    if job is None:
+        abort(404)
+    if not (job.user == current_user or current_user.is_administrator()):
+        abort(403)
+    return job
diff --git a/app/api/schemas.py b/app/api/schemas.py
new file mode 100644
index 00000000..394b1ebb
--- /dev/null
+++ b/app/api/schemas.py
@@ -0,0 +1,165 @@
+from apifairy.fields import FileField
+from marshmallow import validate, validates, ValidationError
+from marshmallow.decorators import post_dump
+from app import ma
+from app.auth import USERNAME_REGEX
+from app.models import Job, JobStatus, TesseractOCRModel, Token, User, UserSettingJobStatusMailNotificationLevel
+from app.services import SERVICES
+
+
+
+class EmptySchema(ma.Schema):
+    pass
+
+
+class TokenSchema(ma.SQLAlchemySchema):
+    class Meta:
+        model = Token
+        ordered = True
+
+    access_token = ma.String(required=True)
+    refresh_token = ma.String()
+
+
+class TesseractOCRModelSchema(ma.SQLAlchemySchema):
+    class Meta:
+        model = TesseractOCRModel
+        ordered = True
+
+    hashid = ma.String(data_key='id', dump_only=True)
+    user_hashid = ma.String(data_key='user_id', dump_only=True)
+    title = ma.auto_field(
+        required=True,
+        validate=validate.Length(min=1, max=64)
+    )
+    description = ma.auto_field(
+        required=True,
+        validate=validate.Length(min=1, max=255)
+    )
+    version = ma.String(
+        required=True,
+        validate=validate.Length(min=1, max=16)
+    )
+    compatible_service_versions = ma.List(
+        ma.String(required=True, validate=validate.Length(min=1, max=16)),
+        required=True,
+        validate=validate.Length(min=1, max=255)
+    )
+    publisher = ma.String(
+        required=True,
+        validate=validate.Length(min=1, max=128)
+    )
+    publisher_url = ma.String(
+        validate=[validate.URL(), validate.Length(min=1, max=512)]
+    )
+    publishing_url = ma.String(
+        required=True,
+        validate=[validate.URL(), validate.Length(min=1, max=512)]
+    )
+    publishing_year = ma.Int(
+        required=True
+    )
+    shared = ma.Boolean(required=True)
+
+
+class JobSchema(ma.SQLAlchemySchema):
+    class Meta:
+        model = Job
+        ordered = True
+
+    hashid = ma.String(data_key='id', dump_only=True)
+    user_hashid = ma.String(data_key='user_id', dump_only=True)
+    title = ma.auto_field(
+        required=True,
+        validate=validate.Length(min=1, max=32)
+    )
+    description = ma.auto_field(
+        required=True,
+        validate=validate.Length(min=1, max=255)
+    )
+    creation_date = ma.auto_field(dump_only=True)
+    end_date = ma.auto_field(dump_only=True)
+    service = ma.String(
+        dump_only=True,
+        validate=validate.OneOf(SERVICES.keys())
+    )
+    service_args = ma.Dict(dump_only=True)
+    service_version = ma.String(dump_only=True)
+    status = ma.String(
+        dump_only=True,
+        validate=validate.OneOf(list(JobStatus.__members__.keys()))
+    )
+
+    @post_dump(pass_original=True)
+    def post_dump(self, serialized_job, job, **kwargs):
+        serialized_job['status'] = job.status.name
+        return serialized_job
+
+
+class TesseractOCRPipelineJobSchema(JobSchema):
+    binarization = ma.Boolean(load_only=True, missing=False)
+    model_id = ma.String(required=True, load_only=True)
+    service_version = ma.auto_field(
+        required=True,
+        validate=[validate.Length(min=1, max=16), validate.OneOf(list(SERVICES['tesseract-ocr-pipeline']['versions'].keys()))]
+    )
+    pdf = FileField()
+
+    @validates('pdf')
+    def validate_pdf(self, value):
+        if value.mimetype != 'application/pdf':
+            raise ValidationError('PDF files only!')
+
+
+class SpaCyNLPPipelineJobSchema(JobSchema):
+    binarization = ma.Boolean(load_only=True, missing=False)
+    model_id = ma.String(required=True, load_only=True)
+    service_version = ma.auto_field(
+        required=True,
+        validate=[validate.Length(min=1, max=16), validate.OneOf(list(SERVICES['tesseract-ocr-pipeline']['versions'].keys()))]
+    )
+    txt = FileField(required=True)
+
+    @validates('txt')
+    def validate_txt(self, value):
+        if value.mimetype != 'text/plain':
+            raise ValidationError('Plain text files only!')
+
+
+class UserSchema(ma.SQLAlchemySchema):
+    class Meta:
+        model = User
+        ordered = True
+
+    hashid = ma.String(data_key='id', dump_only=True)
+    username = ma.auto_field(
+        validate=[
+            validate.Length(min=1, max=64),
+            validate.Regexp(USERNAME_REGEX, error='Usernames must have only letters, numbers, dots or underscores')
+        ]
+    )
+    email = ma.auto_field(validate=validate.Email())
+    member_since = ma.auto_field(dump_only=True)
+    last_seen = ma.auto_field(dump_only=True)
+    password = ma.String(load_only=True)
+    last_seen = ma.auto_field(dump_only=True)
+    setting_dark_mode = ma.auto_field()
+    setting_job_status_mail_notification_level = ma.String(
+        validate=validate.OneOf(list(UserSettingJobStatusMailNotificationLevel.__members__.keys()))
+    )
+
+    @validates('email')
+    def validate_email(self, email):
+        if User.query.filter(User.email == email).first():
+            raise ValidationError('Email already registered')
+
+    @validates('username')
+    def validate_username(self, username):
+        if User.query.filter(User.username == username).first():
+            raise ValidationError('Username already in use')
+
+    @post_dump(pass_original=True)
+    def post_dump(self, serialized_user, user, **kwargs):
+        serialized_user['setting_job_status_mail_notification_level'] = \
+            user.setting_job_status_mail_notification_level.name
+        return serialized_user
diff --git a/app/api/tokens.py b/app/api/tokens.py
index e1b55527..0aaa3415 100644
--- a/app/api/tokens.py
+++ b/app/api/tokens.py
@@ -1,27 +1,58 @@
+from apifairy import authenticate, body, response, other_responses
+from flask import Blueprint, request, abort
 from app import db
-from flask_restx import Namespace, Resource
-from .auth import basic_auth, token_auth
+from app.models import Token, User
+from .auth import basic_auth
+from .schemas import EmptySchema, TokenSchema
 
 
-ns = Namespace('tokens', description='Token operations')
+bp = Blueprint('tokens', __name__)
+token_schema = TokenSchema()
 
 
-@ns.route('')
-class API_Tokens(Resource):
-    '''Get or revoke a user authentication token'''
+@bp.route('', methods=['DELETE'])
+@response(EmptySchema, status_code=204, description='Token revoked')
+@other_responses({401: 'Invalid access token'})
+def delete_token():
+    """Revoke an access token"""
+    access_token = request.headers['Authorization'].split()[1]
+    token = Token.query.filter(Token.access_token == access_token).first()
+    if token is None:  # pragma: no cover
+        abort(401)
+    token.expire()
+    db.session.commit()
+    return {}
 
-    @ns.doc(security='basicAuth')
-    @basic_auth.login_required
-    def post(self):
-        '''Get a user token'''
-        token = basic_auth.current_user().get_token()
-        db.session.commit()
-        return {'token': 'Bearer ' + token}
 
-    @ns.doc(security='apiKey')
-    @token_auth.login_required
-    def delete(self):
-        '''Revoke a user token'''
-        token_auth.current_user().revoke_token()
-        db.session.commit()
-        return '', 204
+@bp.route('', methods=['POST'])
+@authenticate(basic_auth)
+@response(token_schema)
+@other_responses({401: 'Invalid username or password'})
+def create_token():
+    """Create new access and refresh tokens"""
+    user = basic_auth.current_user()
+    token = user.generate_auth_token()
+    db.session.add(token)
+    Token.clean()  # keep token table clean of old tokens
+    db.session.commit()
+    return token, 200
+
+
+@bp.route('', methods=['PUT'])
+@body(token_schema)
+@response(token_schema, description='Newly issued access and refresh tokens')
+@other_responses({401: 'Invalid access or refresh token'})
+def refresh_token(args):
+    """Refresh an access token"""
+    access_token = args.get('access_token')
+    refresh_token = args.get('refresh_token')
+    if access_token is None or refresh_token is None:
+        abort(401)
+    token = User.verify_refresh_token(refresh_token, access_token)
+    if token is None:
+        abort(401)
+    token.expire()
+    new_token = token.user.generate_auth_token()
+    db.session.add_all([token, new_token])
+    db.session.commit()
+    return new_token, 200
diff --git a/app/api/users.py b/app/api/users.py
new file mode 100644
index 00000000..fc180df0
--- /dev/null
+++ b/app/api/users.py
@@ -0,0 +1,99 @@
+
+from apifairy import authenticate, body, response
+from apifairy.decorators import other_responses
+from flask import abort, Blueprint, current_app
+from werkzeug.exceptions import InternalServerError
+from app import db
+from app.email import create_message, send
+from app.models import User
+from .schemas import EmptySchema, UserSchema
+from .auth import auth_error_responses, token_auth
+
+
+bp = Blueprint('users', __name__)
+user_schema = UserSchema()
+users_schema = UserSchema(many=True)
+
+
+@bp.route('', methods=['GET'])
+@authenticate(token_auth, role='Administrator')
+@response(users_schema)
+@other_responses(auth_error_responses)
+def get_users():
+    """Get all users"""
+    return User.query.all()
+
+
+@bp.route('', methods=['POST'])
+@body(user_schema)
+@response(user_schema, 201)
+@other_responses({InternalServerError.code: InternalServerError.description})
+def create_user(args):
+    """Create a new user"""
+    try:
+        user = User.create(
+            email=args['email'].lower(),
+            password=args['password'],
+            username=args['username']
+        )
+    except OSError:
+        abort(500)
+    msg = create_message(
+        user.email,
+        'Confirm Your Account',
+        'auth/email/confirm',
+        token=user.generate_confirm_token(),
+        user=user
+    )
+    send(msg)
+    db.session.commit()
+    return user, 201
+
+
+@bp.route('/<hashid:user_id>', methods=['DELETE'])
+@authenticate(token_auth)
+@response(EmptySchema, status_code=204)
+@other_responses(auth_error_responses)
+def delete_user(user_id):
+    """Delete a user by id"""
+    current_user = token_auth.current_user()
+    user = User.query.get(user_id)
+    if user is None:
+        abort(404)
+    if not (user == current_user or current_user.is_administrator()):
+        abort(403)
+    user.delete()
+    db.session.commit()
+    return {}, 204
+
+
+@bp.route('/<hashid:user_id>', methods=['GET'])
+@authenticate(token_auth)
+@response(user_schema)
+@other_responses(auth_error_responses)
+@other_responses({404: 'User not found'})
+def get_user(user_id):
+    """Retrieve a user by id"""
+    current_user = token_auth.current_user()
+    user = User.query.get(user_id)
+    if user is None:
+        abort(404)
+    if not (user == current_user or current_user.is_administrator()):
+        abort(403)
+    return user
+
+
+@bp.route('/<username>', methods=['GET'])
+@authenticate(token_auth)
+@response(user_schema)
+@other_responses(auth_error_responses)
+@other_responses({404: 'User not found'})
+def get_user_by_username(username):
+    """Retrieve a user by username"""
+    current_user = token_auth.current_user()
+    user = User.query.filter(User.username == username).first()
+    if user is None:
+        abort(404)
+    if not (user == current_user or current_user.is_administrator()):
+        abort(403)
+    return user
diff --git a/app/models.py b/app/models.py
index 9ae31b1c..8efc4bd2 100644
--- a/app/models.py
+++ b/app/models.py
@@ -11,6 +11,7 @@ import json
 import jwt
 import os
 import requests
+import secrets
 import shutil
 import xml.etree.ElementTree as ET
 import yaml
@@ -209,6 +210,30 @@ class Role(HashidMixin, db.Model):
         db.session.commit()
 
 
+class Token(db.Model):
+    __tablename__ = 'tokens'
+    # Primary key
+    id = db.Column(db.Integer, primary_key=True)
+    # Foreign keys
+    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
+    # Fields
+    access_token = db.Column(db.String(64), index=True)
+    access_expiration = db.Column(db.DateTime)
+    refresh_token = db.Column(db.String(64), index=True)
+    refresh_expiration = db.Column(db.DateTime)
+    # Backrefs: user: User
+
+    def expire(self):
+        self.access_expiration = datetime.utcnow()
+        self.refresh_expiration = datetime.utcnow()
+
+    @staticmethod
+    def clean():
+        """Remove any tokens that have been expired for more than a day."""
+        yesterday = datetime.utcnow() - timedelta(days=1)
+        Token.query.filter(Token.refresh_expiration < yesterday).delete()
+
+
 class User(HashidMixin, UserMixin, db.Model):
     __tablename__ = 'users'
     # Primary key
@@ -253,6 +278,12 @@ class User(HashidMixin, UserMixin, db.Model):
         cascade='all, delete-orphan',
         lazy='dynamic'
     )
+    tokens = db.relationship(
+        'Token',
+        backref='user',
+        cascade='all, delete-orphan',
+        lazy='dynamic'
+    )
 
     def __init__(self, **kwargs):
         super().__init__(**kwargs)
@@ -337,6 +368,27 @@ class User(HashidMixin, UserMixin, db.Model):
         db.session.add(user)
         return True
 
+    @staticmethod
+    def verify_access_token(access_token, refresh_token=None):
+        token = Token.query.filter(Token.access_token == access_token).first()
+        if token is not None:
+            if token.access_expiration > datetime.utcnow():
+                token.user.ping()
+                db.session.commit()
+                if token.user.role.name != 'System user':
+                    return token.user
+
+    @staticmethod
+    def verify_refresh_token(refresh_token, access_token):
+        token = Token.query.filter((Token.refresh_token == refresh_token) & (Token.access_token == access_token)).first()
+        if token is not None:
+            if token.refresh_expiration > datetime.utcnow():
+                return token
+            # someone tried to refresh with an expired token
+            # revoke all tokens from this user as a precaution
+            token.user.revoke_auth_tokens()
+            db.session.commit()
+
     def can(self, permission):
         return self.role.has_permission(permission)
 
@@ -364,6 +416,15 @@ class User(HashidMixin, UserMixin, db.Model):
         shutil.rmtree(self.path, ignore_errors=True)
         db.session.delete(self)
 
+    def generate_auth_token(self):
+        return Token(
+            access_token=secrets.token_urlsafe(),
+            access_expiration=datetime.utcnow() + timedelta(minutes=15),
+            refresh_token=secrets.token_urlsafe(),
+            refresh_expiration=datetime.utcnow() + timedelta(days=7),
+            user=self
+        )
+
     def generate_confirm_token(self, expiration=3600):
         now = datetime.utcnow()
         payload = {
@@ -400,6 +461,10 @@ class User(HashidMixin, UserMixin, db.Model):
     def ping(self):
         self.last_seen = datetime.utcnow()
 
+    def revoke_auth_tokens(self):
+        for token in self.tokens:
+            db.session.delete(token)
+
     def verify_password(self, password):
         if self.role.name == 'System user':
             return False
diff --git a/app/templates/_sidenav.html.j2 b/app/templates/_sidenav.html.j2
index b302e9ca..553eb9db 100644
--- a/app/templates/_sidenav.html.j2
+++ b/app/templates/_sidenav.html.j2
@@ -31,6 +31,9 @@
   {% if current_user.can(Permission.ADMINISTRATE) %}
   <li><a href="{{ url_for('admin.index') }}"><i class="material-icons">admin_panel_settings</i>Administration</a></li>
   {% endif %}
+  {% if current_user.can(Permission.USE_API) %}
+  <li><a href="{{ url_for('apifairy.docs') }}"><i class="material-icons">api</i>API</a></li>
+  {% endif %}
   {% if current_user.can(Permission.CONTRIBUTE) %}
   <li><a href="{{ url_for('contributions.contributions') }}"><i class="material-icons">new_label</i>Contribute</a></li>
   {% endif %}
diff --git a/config.py b/config.py
index ec27529f..4eba99d2 100644
--- a/config.py
+++ b/config.py
@@ -11,6 +11,13 @@ load_dotenv(os.path.join(basedir, '.env'))
 
 
 class Config:
+    ''' APIFairy '''
+    APIFAIRY_TITLE = 'nopaque'
+    APIFAIRY_VERSION = '0.0.1'
+    APIFAIRY_UI = 'swagger_ui'
+    APIFAIRY_APISPEC_PATH = '/api/apispec.json'
+    APIFAIRY_UI_PATH = '/api'
+
     ''' # Flask # '''
     PREFERRED_URL_SCHEME = os.environ.get('PREFERRED_URL_SCHEME', 'http')
     SECRET_KEY = os.environ.get('SECRET_KEY', 'hard to guess string')
@@ -58,6 +65,9 @@ class Config:
     NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI = \
         os.environ.get('NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI')
 
+    NOPAQUE_JOB_EXPIRATION_ENABLED = os.environ.get('NOPAQUE_JOB_EXPIRATION_ENABLED', 'true').lower() == 'true'
+    NOPAQUE_JOB_EXPIRATION_TIME = int(os.environ.get('NOPAQUE_JOB_EXPIRATION_TIME', '120'))
+
     NOPAQUE_DOCKER_REGISTRY = 'gitlab.ub.uni-bielefeld.de:4567'
     NOPAQUE_DOCKER_IMAGE_PREFIX = f'{NOPAQUE_DOCKER_REGISTRY}/sfb1288inf/'
     NOPAQUE_DOCKER_REGISTRY_USERNAME = \
diff --git a/migrations/versions/116b4ab3ef9c_.py b/migrations/versions/116b4ab3ef9c_.py
new file mode 100644
index 00000000..f8a29e57
--- /dev/null
+++ b/migrations/versions/116b4ab3ef9c_.py
@@ -0,0 +1,41 @@
+"""Add API authentication token table
+
+Revision ID: 116b4ab3ef9c
+Revises: f9070ff1fa4a
+Create Date: 2022-09-02 11:12:01.995451
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '116b4ab3ef9c'
+down_revision = 'f9070ff1fa4a'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('tokens',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('user_id', sa.Integer(), nullable=True),
+    sa.Column('access_token', sa.String(length=64), nullable=True),
+    sa.Column('access_expiration', sa.DateTime(), nullable=True),
+    sa.Column('refresh_token', sa.String(length=64), nullable=True),
+    sa.Column('refresh_expiration', sa.DateTime(), nullable=True),
+    sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index(op.f('ix_tokens_access_token'), 'tokens', ['access_token'], unique=False)
+    op.create_index(op.f('ix_tokens_refresh_token'), 'tokens', ['refresh_token'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f('ix_tokens_refresh_token'), table_name='tokens')
+    op.drop_index(op.f('ix_tokens_access_token'), table_name='tokens')
+    op.drop_table('tokens')
+    # ### end Alembic commands ###
-- 
GitLab