mirror of
https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git
synced 2025-06-15 02:20:40 +00:00
Add API functionality
This commit is contained in:
@ -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')
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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]
|
||||
|
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 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
|
||||
|
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 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
|
||||
|
@ -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 %}
|
||||
|
Reference in New Issue
Block a user