diff --git a/app/__init__.py b/app/__init__.py index da498c39..4649a090 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -37,6 +37,9 @@ def create_app(config_name): 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 new file mode 100644 index 00000000..f47235ea --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,27 @@ +from flask import Blueprint +from flask_restx import Api + +from .jobs import ns as jobs_ns +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(jobs_ns) +api.add_namespace(tokens_ns) diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 00000000..24e862ea --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,30 @@ +from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth +from sqlalchemy import or_ +from werkzeug.http import HTTP_STATUS_CODES +from ..models import User + +basic_auth = HTTPBasicAuth() +token_auth = HTTPTokenAuth() + + +@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): + return user + + +@basic_auth.error_handler +def basic_auth_error(status): + return {'error': HTTP_STATUS_CODES.get(status, 'Unknown error')}, status + + +@token_auth.verify_token +def verify_token(token): + return User.check_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 diff --git a/app/api/jobs.py b/app/api/jobs.py new file mode 100644 index 00000000..5ade2fe4 --- /dev/null +++ b/app/api/jobs.py @@ -0,0 +1,18 @@ +from flask_restx import Namespace, Resource +from .auth import token_auth +from ..models import Job + + +ns = Namespace('jobs', description='Job operations') + + +@ns.route('/') +class JobList(Resource): + '''Shows a list of all jobs, and lets you POST to add new job''' + + @ns.doc(security='apiKey') + @token_auth.login_required + def get(self): + '''List all jobs''' + jobs = Job.query.all() + return [job.to_dict(include_relationships=False) for job in jobs] diff --git a/app/api/tokens.py b/app/api/tokens.py new file mode 100644 index 00000000..c12b6048 --- /dev/null +++ b/app/api/tokens.py @@ -0,0 +1,27 @@ +from flask_restx import Namespace, Resource +from .auth import basic_auth, token_auth +from .. import db + + +ns = Namespace('token', description='Authentication token operations') + + +@ns.route('/') +class Token(Resource): + '''Get or revoke a user authentication token''' + + @ns.doc(security='basicAuth') + @basic_auth.login_required + def post(self): + '''Get 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 user token''' + token_auth.current_user().revoke_token() + db.session.commit() + return '', 204 diff --git a/app/models.py b/app/models.py index 1bae8060..abc6b9f2 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from flask import current_app, url_for from flask_login import UserMixin, AnonymousUserMixin from itsdangerous import BadSignature, TimedJSONWebSignatureSerializer @@ -6,6 +6,7 @@ from time import sleep from werkzeug.security import generate_password_hash, check_password_hash import xml.etree.ElementTree as ET from . import db, login_manager +import base64 import logging import os import shutil @@ -127,6 +128,8 @@ class User(UserMixin, db.Model): default='end') setting_job_status_site_notifications = db.Column(db.String(16), default='all') + token = db.Column(db.String(32), index=True, unique=True) + token_expiration = db.Column(db.DateTime) username = db.Column(db.String(64), unique=True, index=True) # Relationships corpora = db.relationship('Corpus', backref='creator', lazy='dynamic', @@ -157,8 +160,8 @@ class User(UserMixin, db.Model): 'role_id': self.role_id, 'confirmed': self.confirmed, 'email': self.email, - 'last_seen': self.last_seen.isoformat(), - 'member_since': self.member_since.isoformat(), + 'last_seen': self.last_seen.isoformat() + 'Z', + 'member_since': self.member_since.isoformat() + 'Z', 'settings': {'dark_mode': self.setting_dark_mode, 'job_status_mail_notifications': self.setting_job_status_mail_notifications, @@ -262,6 +265,25 @@ class User(UserMixin, db.Model): shutil.rmtree(self.path, ignore_errors=True) db.session.delete(self) + def get_token(self, expires_in=3600): + now = datetime.utcnow() + if self.token and self.token_expiration > now + timedelta(seconds=60): + return self.token + self.token = base64.b64encode(os.urandom(24)).decode('utf-8') + self.token_expiration = now + timedelta(seconds=expires_in) + db.session.add(self) + return self.token + + def revoke_token(self): + self.token_expiration = datetime.utcnow() - timedelta(seconds=1) + + @staticmethod + def check_token(token): + user = User.query.filter_by(token=token).first() + if user is None or user.token_expiration < datetime.utcnow(): + return None + return user + class AnonymousUser(AnonymousUserMixin): ''' @@ -453,9 +475,9 @@ class Job(db.Model): 'url': self.url, 'id': self.id, 'user_id': self.user_id, - 'creation_date': self.creation_date.isoformat(), + 'creation_date': self.creation_date.isoformat() + 'Z', 'description': self.description, - 'end_date': self.end_date.isoformat() if self.end_date else None, + 'end_date': self.end_date.isoformat() + 'Z' if self.end_date else None, 'service': self.service, 'service_args': self.service_args, 'service_version': self.service_version, @@ -589,11 +611,11 @@ class Corpus(db.Model): 'url': self.url, 'id': self.id, 'user_id': self.user_id, - 'creation_date': self.creation_date.isoformat(), + 'creation_date': self.creation_date.isoformat() + 'Z', 'current_nr_of_tokens': self.current_nr_of_tokens, 'description': self.description, 'status': self.status, - 'last_edited_date': self.last_edited_date.isoformat(), + 'last_edited_date': self.last_edited_date.isoformat() + 'Z', 'max_nr_of_tokens': self.max_nr_of_tokens, 'title': self.title, } diff --git a/migrations/versions/c384d7b3268a_.py b/migrations/versions/c384d7b3268a_.py new file mode 100644 index 00000000..aaffd8d2 --- /dev/null +++ b/migrations/versions/c384d7b3268a_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: c384d7b3268a +Revises: 55d2b1a82ba9 +Create Date: 2021-09-14 09:11:45.409350 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c384d7b3268a' +down_revision = '55d2b1a82ba9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('token', sa.String(length=32), nullable=True)) + op.add_column('users', sa.Column('token_expiration', sa.DateTime(), nullable=True)) + op.create_index(op.f('ix_users_token'), 'users', ['token'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_token'), table_name='users') + op.drop_column('users', 'token_expiration') + op.drop_column('users', 'token') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 5faa3cee..d4e23941 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,10 +3,12 @@ docker eventlet Flask~=1.1.0 Flask-Assets +Flask-HTTPAuth Flask-Login Flask-Mail Flask-Migrate Flask-Paranoid +Flask-RESTX Flask-SocketIO~=5.0.0 Flask-SQLAlchemy Flask-WTF