Skip to content
Snippets Groups Projects
Commit a5b1df9e authored by Patrick Jentsch's avatar Patrick Jentsch
Browse files

Add simple api package including authentication (BasicAuth and TokenAuth)

parent def01d47
No related branches found
No related tags found
No related merge requests found
...@@ -37,6 +37,9 @@ def create_app(config_name): ...@@ -37,6 +37,9 @@ def create_app(config_name):
from .admin import bp as admin_blueprint from .admin import bp as admin_blueprint
app.register_blueprint(admin_blueprint, url_prefix='/admin') 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 from .auth import bp as auth_blueprint
app.register_blueprint(auth_blueprint, url_prefix='/auth') app.register_blueprint(auth_blueprint, url_prefix='/auth')
......
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)
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
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]
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
from datetime import datetime from datetime import datetime, timedelta
from flask import current_app, url_for from flask import current_app, url_for
from flask_login import UserMixin, AnonymousUserMixin from flask_login import UserMixin, AnonymousUserMixin
from itsdangerous import BadSignature, TimedJSONWebSignatureSerializer from itsdangerous import BadSignature, TimedJSONWebSignatureSerializer
...@@ -6,6 +6,7 @@ from time import sleep ...@@ -6,6 +6,7 @@ from time import sleep
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from . import db, login_manager from . import db, login_manager
import base64
import logging import logging
import os import os
import shutil import shutil
...@@ -127,6 +128,8 @@ class User(UserMixin, db.Model): ...@@ -127,6 +128,8 @@ class User(UserMixin, db.Model):
default='end') default='end')
setting_job_status_site_notifications = db.Column(db.String(16), setting_job_status_site_notifications = db.Column(db.String(16),
default='all') 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) username = db.Column(db.String(64), unique=True, index=True)
# Relationships # Relationships
corpora = db.relationship('Corpus', backref='creator', lazy='dynamic', corpora = db.relationship('Corpus', backref='creator', lazy='dynamic',
...@@ -157,8 +160,8 @@ class User(UserMixin, db.Model): ...@@ -157,8 +160,8 @@ class User(UserMixin, db.Model):
'role_id': self.role_id, 'role_id': self.role_id,
'confirmed': self.confirmed, 'confirmed': self.confirmed,
'email': self.email, 'email': self.email,
'last_seen': self.last_seen.isoformat(), 'last_seen': self.last_seen.isoformat() + 'Z',
'member_since': self.member_since.isoformat(), 'member_since': self.member_since.isoformat() + 'Z',
'settings': {'dark_mode': self.setting_dark_mode, 'settings': {'dark_mode': self.setting_dark_mode,
'job_status_mail_notifications': 'job_status_mail_notifications':
self.setting_job_status_mail_notifications, self.setting_job_status_mail_notifications,
...@@ -262,6 +265,25 @@ class User(UserMixin, db.Model): ...@@ -262,6 +265,25 @@ class User(UserMixin, db.Model):
shutil.rmtree(self.path, ignore_errors=True) shutil.rmtree(self.path, ignore_errors=True)
db.session.delete(self) 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): class AnonymousUser(AnonymousUserMixin):
''' '''
...@@ -453,9 +475,9 @@ class Job(db.Model): ...@@ -453,9 +475,9 @@ class Job(db.Model):
'url': self.url, 'url': self.url,
'id': self.id, 'id': self.id,
'user_id': self.user_id, 'user_id': self.user_id,
'creation_date': self.creation_date.isoformat(), 'creation_date': self.creation_date.isoformat() + 'Z',
'description': self.description, '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': self.service,
'service_args': self.service_args, 'service_args': self.service_args,
'service_version': self.service_version, 'service_version': self.service_version,
...@@ -589,11 +611,11 @@ class Corpus(db.Model): ...@@ -589,11 +611,11 @@ class Corpus(db.Model):
'url': self.url, 'url': self.url,
'id': self.id, 'id': self.id,
'user_id': self.user_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, 'current_nr_of_tokens': self.current_nr_of_tokens,
'description': self.description, 'description': self.description,
'status': self.status, '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, 'max_nr_of_tokens': self.max_nr_of_tokens,
'title': self.title, 'title': self.title,
} }
......
"""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 ###
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment