mirror of
				https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git
				synced 2025-11-04 12:22:47 +00:00 
			
		
		
		
	Add simple api package including authentication (BasicAuth and TokenAuth)
This commit is contained in:
		@@ -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')
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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_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,
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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
 | 
			
		||||
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
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user