mirror of
https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git
synced 2024-12-26 03:14:19 +00:00
Add API functionality
This commit is contained in:
parent
dedccad70a
commit
ceef272d06
@ -1,3 +1,4 @@
|
|||||||
|
from apifairy import APIFairy
|
||||||
from config import Config
|
from config import Config
|
||||||
from docker import DockerClient
|
from docker import DockerClient
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@ -5,6 +6,7 @@ from flask_apscheduler import APScheduler
|
|||||||
from flask_assets import Environment
|
from flask_assets import Environment
|
||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
from flask_mail import Mail
|
from flask_mail import Mail
|
||||||
|
from flask_marshmallow import Marshmallow
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_paranoid import Paranoid
|
from flask_paranoid import Paranoid
|
||||||
from flask_socketio import SocketIO
|
from flask_socketio import SocketIO
|
||||||
@ -12,6 +14,7 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
from flask_hashids import Hashids
|
from flask_hashids import Hashids
|
||||||
|
|
||||||
|
|
||||||
|
apifairy = APIFairy()
|
||||||
assets = Environment()
|
assets = Environment()
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
docker_client = DockerClient()
|
docker_client = DockerClient()
|
||||||
@ -19,6 +22,7 @@ hashids = Hashids()
|
|||||||
login = LoginManager()
|
login = LoginManager()
|
||||||
login.login_view = 'auth.login'
|
login.login_view = 'auth.login'
|
||||||
login.login_message = 'Please log in to access this page.'
|
login.login_message = 'Please log in to access this page.'
|
||||||
|
ma = Marshmallow()
|
||||||
mail = Mail()
|
mail = Mail()
|
||||||
migrate = Migrate()
|
migrate = Migrate()
|
||||||
paranoid = Paranoid()
|
paranoid = Paranoid()
|
||||||
@ -38,10 +42,12 @@ def create_app(config: Config = Config) -> Flask:
|
|||||||
registry=app.config['NOPAQUE_DOCKER_REGISTRY']
|
registry=app.config['NOPAQUE_DOCKER_REGISTRY']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
apifairy.init_app(app)
|
||||||
assets.init_app(app)
|
assets.init_app(app)
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
hashids.init_app(app)
|
hashids.init_app(app)
|
||||||
login.init_app(app)
|
login.init_app(app)
|
||||||
|
ma.init_app(app)
|
||||||
mail.init_app(app)
|
mail.init_app(app)
|
||||||
migrate.init_app(app, db)
|
migrate.init_app(app, db)
|
||||||
paranoid.init_app(app)
|
paranoid.init_app(app)
|
||||||
@ -51,6 +57,9 @@ def create_app(config: Config = Config) -> Flask:
|
|||||||
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')
|
||||||
|
|
||||||
|
@ -1,25 +1,14 @@
|
|||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from flask_restx import Api
|
|
||||||
|
|
||||||
from .tokens import ns as tokens_ns
|
|
||||||
|
|
||||||
bp = Blueprint('api', __name__)
|
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')
|
||||||
|
@ -1,34 +1,49 @@
|
|||||||
from app.models import User
|
from flask import current_app
|
||||||
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
|
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
|
||||||
from sqlalchemy import or_
|
from werkzeug.exceptions import Forbidden, Unauthorized
|
||||||
from werkzeug.http import HTTP_STATUS_CODES
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
basic_auth = HTTPBasicAuth()
|
basic_auth = HTTPBasicAuth()
|
||||||
token_auth = HTTPTokenAuth()
|
token_auth = HTTPTokenAuth()
|
||||||
|
auth_error_responses = {
|
||||||
|
Unauthorized.code: Unauthorized.description,
|
||||||
|
Forbidden.code: Forbidden.description
|
||||||
|
}
|
||||||
|
|
||||||
@basic_auth.verify_password
|
@basic_auth.verify_password
|
||||||
def verify_password(email_or_username, password):
|
def verify_password(email_or_username, password):
|
||||||
user = User.query.filter(
|
user = User.query.filter((User.email == email_or_username.lower()) | (User.username == email_or_username)).first()
|
||||||
or_(
|
if user is not None and user.verify_password(password):
|
||||||
User.username == email_or_username,
|
|
||||||
User.email == email_or_username.lower()
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if user and user.verify_password(password):
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@basic_auth.error_handler
|
@basic_auth.error_handler
|
||||||
def basic_auth_error(status):
|
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
|
@token_auth.verify_token
|
||||||
def verify_token(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
|
@token_auth.error_handler
|
||||||
def token_auth_error(status):
|
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]
|
||||||
|
102
app/api/jobs.py
Normal file
102
app/api/jobs.py
Normal file
@ -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
|
165
app/api/schemas.py
Normal file
165
app/api/schemas.py
Normal file
@ -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
|
@ -1,27 +1,58 @@
|
|||||||
|
from apifairy import authenticate, body, response, other_responses
|
||||||
|
from flask import Blueprint, request, abort
|
||||||
from app import db
|
from app import db
|
||||||
from flask_restx import Namespace, Resource
|
from app.models import Token, User
|
||||||
from .auth import basic_auth, token_auth
|
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('')
|
@bp.route('', methods=['DELETE'])
|
||||||
class API_Tokens(Resource):
|
@response(EmptySchema, status_code=204, description='Token revoked')
|
||||||
'''Get or revoke a user authentication token'''
|
@other_responses({401: 'Invalid access token'})
|
||||||
|
def delete_token():
|
||||||
@ns.doc(security='basicAuth')
|
"""Revoke an access token"""
|
||||||
@basic_auth.login_required
|
access_token = request.headers['Authorization'].split()[1]
|
||||||
def post(self):
|
token = Token.query.filter(Token.access_token == access_token).first()
|
||||||
'''Get a user token'''
|
if token is None: # pragma: no cover
|
||||||
token = basic_auth.current_user().get_token()
|
abort(401)
|
||||||
|
token.expire()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return {'token': 'Bearer ' + token}
|
return {}
|
||||||
|
|
||||||
@ns.doc(security='apiKey')
|
|
||||||
@token_auth.login_required
|
@bp.route('', methods=['POST'])
|
||||||
def delete(self):
|
@authenticate(basic_auth)
|
||||||
'''Revoke a user token'''
|
@response(token_schema)
|
||||||
token_auth.current_user().revoke_token()
|
@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()
|
db.session.commit()
|
||||||
return '', 204
|
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
|
||||||
|
99
app/api/users.py
Normal file
99
app/api/users.py
Normal file
@ -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
|
@ -11,6 +11,7 @@ import json
|
|||||||
import jwt
|
import jwt
|
||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
import yaml
|
import yaml
|
||||||
@ -209,6 +210,30 @@ class Role(HashidMixin, db.Model):
|
|||||||
db.session.commit()
|
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):
|
class User(HashidMixin, UserMixin, db.Model):
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
# Primary key
|
# Primary key
|
||||||
@ -253,6 +278,12 @@ class User(HashidMixin, UserMixin, db.Model):
|
|||||||
cascade='all, delete-orphan',
|
cascade='all, delete-orphan',
|
||||||
lazy='dynamic'
|
lazy='dynamic'
|
||||||
)
|
)
|
||||||
|
tokens = db.relationship(
|
||||||
|
'Token',
|
||||||
|
backref='user',
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
lazy='dynamic'
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
@ -337,6 +368,27 @@ class User(HashidMixin, UserMixin, db.Model):
|
|||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
return True
|
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):
|
def can(self, permission):
|
||||||
return self.role.has_permission(permission)
|
return self.role.has_permission(permission)
|
||||||
|
|
||||||
@ -364,6 +416,15 @@ class User(HashidMixin, 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 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):
|
def generate_confirm_token(self, expiration=3600):
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
payload = {
|
payload = {
|
||||||
@ -400,6 +461,10 @@ class User(HashidMixin, UserMixin, db.Model):
|
|||||||
def ping(self):
|
def ping(self):
|
||||||
self.last_seen = datetime.utcnow()
|
self.last_seen = datetime.utcnow()
|
||||||
|
|
||||||
|
def revoke_auth_tokens(self):
|
||||||
|
for token in self.tokens:
|
||||||
|
db.session.delete(token)
|
||||||
|
|
||||||
def verify_password(self, password):
|
def verify_password(self, password):
|
||||||
if self.role.name == 'System user':
|
if self.role.name == 'System user':
|
||||||
return False
|
return False
|
||||||
|
@ -31,6 +31,9 @@
|
|||||||
{% if current_user.can(Permission.ADMINISTRATE) %}
|
{% if current_user.can(Permission.ADMINISTRATE) %}
|
||||||
<li><a href="{{ url_for('admin.index') }}"><i class="material-icons">admin_panel_settings</i>Administration</a></li>
|
<li><a href="{{ url_for('admin.index') }}"><i class="material-icons">admin_panel_settings</i>Administration</a></li>
|
||||||
{% endif %}
|
{% 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) %}
|
{% if current_user.can(Permission.CONTRIBUTE) %}
|
||||||
<li><a href="{{ url_for('contributions.contributions') }}"><i class="material-icons">new_label</i>Contribute</a></li>
|
<li><a href="{{ url_for('contributions.contributions') }}"><i class="material-icons">new_label</i>Contribute</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
10
config.py
10
config.py
@ -11,6 +11,13 @@ load_dotenv(os.path.join(basedir, '.env'))
|
|||||||
|
|
||||||
|
|
||||||
class Config:
|
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 # '''
|
''' # Flask # '''
|
||||||
PREFERRED_URL_SCHEME = os.environ.get('PREFERRED_URL_SCHEME', 'http')
|
PREFERRED_URL_SCHEME = os.environ.get('PREFERRED_URL_SCHEME', 'http')
|
||||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'hard to guess string')
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'hard to guess string')
|
||||||
@ -58,6 +65,9 @@ class Config:
|
|||||||
NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI = \
|
NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI = \
|
||||||
os.environ.get('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_REGISTRY = 'gitlab.ub.uni-bielefeld.de:4567'
|
||||||
NOPAQUE_DOCKER_IMAGE_PREFIX = f'{NOPAQUE_DOCKER_REGISTRY}/sfb1288inf/'
|
NOPAQUE_DOCKER_IMAGE_PREFIX = f'{NOPAQUE_DOCKER_REGISTRY}/sfb1288inf/'
|
||||||
NOPAQUE_DOCKER_REGISTRY_USERNAME = \
|
NOPAQUE_DOCKER_REGISTRY_USERNAME = \
|
||||||
|
41
migrations/versions/116b4ab3ef9c_.py
Normal file
41
migrations/versions/116b4ab3ef9c_.py
Normal file
@ -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 ###
|
Loading…
Reference in New Issue
Block a user