mirror of
https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git
synced 2024-12-25 02:44:18 +00:00
Add simple api package including authentication (BasicAuth and TokenAuth)
This commit is contained in:
parent
def01d474e
commit
a5b1df9e95
@ -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')
|
||||||
|
|
||||||
|
27
app/api/__init__.py
Normal file
27
app/api/__init__.py
Normal file
@ -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)
|
30
app/api/auth.py
Normal file
30
app/api/auth.py
Normal file
@ -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
|
18
app/api/jobs.py
Normal file
18
app/api/jobs.py
Normal file
@ -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]
|
27
app/api/tokens.py
Normal file
27
app/api/tokens.py
Normal file
@ -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
|
@ -1,4 +1,4 @@
|
|||||||
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
|
|||||||
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):
|
|||||||
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):
|
|||||||
'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):
|
|||||||
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):
|
|||||||
'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):
|
|||||||
'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,
|
||||||
}
|
}
|
||||||
|
32
migrations/versions/c384d7b3268a_.py
Normal file
32
migrations/versions/c384d7b3268a_.py
Normal file
@ -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 ###
|
@ -3,10 +3,12 @@ docker
|
|||||||
eventlet
|
eventlet
|
||||||
Flask~=1.1.0
|
Flask~=1.1.0
|
||||||
Flask-Assets
|
Flask-Assets
|
||||||
|
Flask-HTTPAuth
|
||||||
Flask-Login
|
Flask-Login
|
||||||
Flask-Mail
|
Flask-Mail
|
||||||
Flask-Migrate
|
Flask-Migrate
|
||||||
Flask-Paranoid
|
Flask-Paranoid
|
||||||
|
Flask-RESTX
|
||||||
Flask-SocketIO~=5.0.0
|
Flask-SocketIO~=5.0.0
|
||||||
Flask-SQLAlchemy
|
Flask-SQLAlchemy
|
||||||
Flask-WTF
|
Flask-WTF
|
||||||
|
Loading…
Reference in New Issue
Block a user