#98 Add user auth with email confirmation
Opened 6 years ago by fabio1079. Modified 6 years ago
kiskadeemes/kiskadee kiskadee_user_auth  into  master

@@ -0,0 +1,30 @@ 

+ """Added is_active to user model.

+ 

+ Revision ID: 2dce48cbfea6

+ Revises: cecf8298b202

+ Create Date: 2017-12-11 18:39:46.343369

+ 

+ """

+ from alembic import op

+ import sqlalchemy as sa

+ 

+ 

+ # revision identifiers, used by Alembic.

+ revision = '2dce48cbfea6'

+ down_revision = 'cecf8298b202'

+ branch_labels = None

+ depends_on = None

+ 

+ 

+ def upgrade():

+     """Add is_active to user table"""

+     # ### commands auto generated by Alembic - please adjust! ###

+     op.add_column('users', sa.Column('is_active', sa.Boolean(), nullable=True))

+     # ### end Alembic commands ###

+ 

+ 

+ def downgrade():

+     """Remove is_active from user table"""

+     # ### commands auto generated by Alembic - please adjust! ###

+     op.drop_column('users', 'is_active')

+     # ### end Alembic commands ###

@@ -0,0 +1,37 @@ 

+ """Added user table.

+ 

+ Revision ID: cecf8298b202

+ Revises: 9ee67bf38f1f

+ Create Date: 2017-11-09 16:50:47.759236

+ 

+ """

+ from alembic import op

+ import sqlalchemy as sa

+ 

+ 

+ # revision identifiers, used by Alembic.

+ revision = 'cecf8298b202'

+ down_revision = '9ee67bf38f1f'

+ branch_labels = None

+ depends_on = None

+ 

+ 

+ def upgrade():

+     """TODO: Add an upgrade description."""

+     # ### commands auto generated by Alembic - please adjust! ###

+     op.create_table('users',

+     sa.Column('id', sa.Integer(), nullable=False),

+     sa.Column('name', sa.Unicode(length=255), nullable=False),

+     sa.Column('email', sa.String(length=255), nullable=False),

+     sa.Column('password_hash', sa.String(length=128), nullable=True),

+     sa.PrimaryKeyConstraint('id'),

+     sa.UniqueConstraint('email')

+     )

+     # ### end Alembic commands ###

+ 

+ 

+ def downgrade():

+     """TODO: Add a downgrade description."""

+     # ### commands auto generated by Alembic - please adjust! ###

+     op.drop_table('users')

+     # ### end Alembic commands ###

file modified
+210 -3
@@ -1,20 +1,54 @@ 

  """kiskadee API."""

- from flask import Flask, jsonify

+ from flask import Flask, jsonify, abort, make_response

  from flask import request

  from flask_cors import CORS

+ from marshmallow.exceptions import ValidationError

  

  from kiskadee.database import Database

- from kiskadee.model import Package, Fetcher, Version, Analysis

+ from kiskadee.model import Package, Fetcher, Version, Analysis, User

  from kiskadee.api.serializers import PackageSchema, FetcherSchema,\

-         AnalysisSchema

+     AnalysisSchema, UserSchema

+ from kiskadee.api.token import token_required

  import json

  from sqlalchemy.orm import eagerload

  

  kiskadee = Flask(__name__)

  

+ from . import mail

+ 

  CORS(kiskadee)

  

  

+ @kiskadee.route('/login', methods=['POST'])

+ def login():

+     """Token based login

+ 

+     POST /login

+ 

+     Possible status code:

+         - 200 Ok -> User token

+         - 401 Unauthorized -> Unauthorized, invalid user credentials

+         - 403 Forbidden -> User email not confirmed

+     """

+     json_data = request.get_json()

+     email, password = [json_data.get('email'), json_data.get('password')]

+ 

+     if email and password:

+         db_session = kiskadee_db_session()

+         user = db_session.query(User).filter_by(email=email).first()

+ 

+         if user is not None and not user.is_active:

+             return make_response(jsonify({'error': 'User email not confirmed'}), 403)

+ 

+         if user is not None and user.verify_password(password):

+             token = user.generate_token()

+ 

+             response = {'token': token, 'user': UserSchema().dump(user).data}

+             return make_response(jsonify(response), 200)

+ 

+     return make_response(jsonify({'error': 'Unauthorized, invalid user credentials'}), 401)

+ 

+ 

  @kiskadee.route('/fetchers')

  def index():

      """Get the list of available fetchers."""
@@ -105,6 +139,176 @@ 

      return jsonify({'analysis_report': report})

  

  

+ @kiskadee.route('/users', methods=['GET'])

+ @token_required

+ def get_users(token_data):

+     """Get the list of users

+ 

+     GET /users

+ 

+     Possible status code:

+         - 200 Ok -> Users list

+     """

+     db_session = kiskadee_db_session()

+     users = db_session.query(User).all()

+     user_schema = UserSchema(many=True)

+     result = user_schema.dump(users)

+ 

+     return make_response(jsonify({'users': result.data}), 200)

+ 

+ 

+ @kiskadee.route('/users', methods=['POST'])

+ def create_user():

+     """Create a new user

+ 

+     POST /users

+ 

+     Possible status code:

+         - 201 Created -> User created

+         - 400 Bad Request -> Validation error

+         - 403 Forbidden -> User already exists

+     """

+     db_session = kiskadee_db_session()

+     data = request.get_json()

+ 

+     # Verify is user already exists

+     if data.get('email'):

+         user = db_session.query(User).filter_by(

+             email=data.get('email')).first()

+ 

+         if user is not None:

+             return make_response(

+                 jsonify({

+                     'error': 'user already exists'

+                 }), 403)

+ 

+     # Try to create user

+     try:

+         user = UserSchema.create(**data)

+     except ValidationError as error:

+         return make_response(

+             jsonify({

+                 'error': 'Validation error',

+                 'validations': error.args[0]

+             }), 400)

+ 

+     db_session.add(user)

+     db_session.commit()

+     mail.send_confirmation_email(user)

+ 

+     user_schema = UserSchema()

+     result = user_schema.dump(user)

+ 

+     token = user.generate_token()

+ 

+     return make_response(jsonify({'user': result.data, 'token': token}), 201)

+ 

+ 

+ @kiskadee.route('/users/<int:user_id>', methods=['GET'])

+ @token_required

+ def get_user_data(token_data, user_id):

+     """Get the user data

+ 

+     GET /users/:id

+ 

+     Possible status code:

+         - 200 Ok -> User data

+         - 404 Not Found -> User not found

+     """

+     db_session = kiskadee_db_session()

+     user = db_session.query(User).filter_by(id=user_id).first()

+ 

+     if user is None:

+         return make_response(jsonify({'error': 'user not found'}), 404)

+ 

+     user_schema = UserSchema()

+     result = user_schema.dump(user)

+ 

+     return make_response(jsonify({'user': result.data}), 200)

+ 

+ 

+ @kiskadee.route('/users/<int:user_id>', methods=['PUT'])

+ @token_required

+ def update_user(token_data, user_id):

+     """Updates a user

+ 

+     PUT /users/:id

+ 

+     Possible status code:

+         - 200 Ok -> User updated

+         - 400 Bad Request -> Validation error

+         - 403 Forbidden -> Token user does not match to requested user

+         - 404 Not Found -> User not found

+     """

+     db_session = kiskadee_db_session()

+     user = db_session.query(User).filter_by(id=user_id).first()

+ 

+     if user is None:

+         return make_response(jsonify({'error': 'user not found'}), 404)

+ 

+     if token_data['user_id'] != user_id:

+         return make_response(

+             jsonify({

+                 'error': 'token user does not match to requested user'

+             }), 403)

+ 

+     json_data = request.get_json()

+     user_data = UserSchema().dump(user).data

+     user_data.update(json_data)

+ 

+     validation = UserSchema().load(user_data)

+ 

+     if bool(validation.errors):

+         return make_response(

+             jsonify({

+                 'error': 'Validation error',

+                 'validations': validation.errors

+             }), 400)

+ 

+     password = validation.data.get('validation')

+     if password is not None:

+         user.hash_password(password)

+         del validation.data['password']

+ 

+     for (key, value) in validation.data.items():

+         setattr(user, key, value)

+ 

+     db_session.commit()

+ 

+     result = UserSchema().dump(user)

+     return make_response(jsonify({'user': result.data}), 200)

+ 

+ 

+ @kiskadee.route('/users/<int:user_id>', methods=['DELETE'])

+ @token_required

+ def delete_user(token_data, user_id):

+     """Deletes a user

+ 

+     DELETE /users/:id

+ 

+     Possible status code:

+         - 204 No Content -> User deleted

+         - 403 Forbidden -> Token user does not match to requested user

+         - 404 Not Found -> User not found

+     """

+     db_session = kiskadee_db_session()

+     user = db_session.query(User).filter_by(id=user_id).first()

+ 

+     if user is None:

+         return make_response(jsonify({'error': 'user not found'}), 404)

+ 

+     if token_data['user_id'] != user_id:

+         return make_response(

+             jsonify({

+                 'error': 'token user does not match to requested user'

+             }), 403)

+ 

+     db_session.delete(user)

+     db_session.commit()

+ 

+     return make_response(jsonify({}), 204)

+ 

+ 

  def kiskadee_db_session():

      """Return a kiskadee database session."""

      return Database().session
@@ -112,4 +316,7 @@ 

  

  def main():

      """Initialize the kiskadee API."""

+     cleaner = mail.UnconfirmedEmailsCleaner()

+     cleaner.start()

+ 

      kiskadee.run('0.0.0.0')

file added
+140
@@ -0,0 +1,140 @@ 

+ import os

+ import datetime

+ import jwt

+ 

+ from threading import Thread

+ from time import sleep

+ 

+ from flask import make_response, jsonify, url_for

+ from flask_mail import Mail, Message

+ 

+ from kiskadee import config

+ from kiskadee.database import Database

+ from kiskadee.model import User

+ from kiskadee.api.app import kiskadee

+ from kiskadee.api.token import token_vefirication

+ 

+ kiskadee.config.update({

+     'MAIL_ENABLED':

+     config['mail']['MAIL_ENABLED'] == 'True',

+     'MAIL_SERVER':

+     config['mail']['MAIL_SERVER'],

+     'MAIL_PORT':

+     config['mail']['MAIL_PORT'],

+     'MAIL_USERNAME':

+     os.environ.get('MAIL_USERNAME'),

+     'MAIL_PASSWORD':

+     os.environ.get('MAIL_PASSWORD'),

+     'MAIL_USE_TLS':

+     config['mail']['MAIL_USE_TLS'] == 'True',

+     'MAIL_USE_SSL':

+     config['mail']['MAIL_USE_SSL'] == 'True',

+     'EMAIL_TOKEN_SECRET_KEY':

+     os.environ.get('EMAIL_TOKEN_SECRET_KEY', 'dev email token'),

+     'MAIL_CONFIRM_REDIRECT':

+     config['mail']['MAIL_CONFIRM_REDIRECT'],

+     'MAIL_UNCONFIRMED_CLEANER_TIMER':

+     config['mail']['MAIL_UNCONFIRMED_CLEANER_TIMER']

+ })

+ 

+ mail = Mail(kiskadee)

+ 

+ 

+ def generate_activation_token(user):

+     """

+     Given a user it generates an activation token and returns it

+     """

+     token = jwt.encode(

+         {

+             'user_id': user.id,

+             'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24)

+         },

+         kiskadee.config['EMAIL_TOKEN_SECRET_KEY'],

+         algorithm='HS256')

+ 

+     return token

+ 

+ 

+ def send_confirmation_email(user):

+     """

+     Given a user, it check MAIL_ENABLED and if it is True then sends an e-mail

+     to the given user email with a tokenized link to activate the user.

+     """

+     if not kiskadee.config['MAIL_ENABLED']:

+         user.is_active = True

+         return None

+ 

+     token = generate_activation_token(user)

+ 

+     msg = Message(

+         "Kiskadee email confirmation",

+         sender=kiskadee.config['MAIL_USERNAME'],

+         recipients=[user.email])

+ 

+     link = url_for("confirm_email", token=token, _external=True)

+ 

+     msg.body = '''

+     Thank you for registering on kiskadee. Now please confirm your e-mail

+     or your account will be deleted from our servers in a few hours.

+ 

+     Confirmation link: {}

+ 

+     Now if you are receiving this email by mistake, sorry for the

+     inconvenience and please just ignore this message and the account

+     refers to this message will be deleted.

+     '''.format(link)

+ 

+     mail.send(msg)

+ 

+ 

+ @kiskadee.route('/users/confirm_email/<token>', methods=['GET'])

+ def confirm_email(token):

+     """

+     Given an activation token generated by mail.send_confirmation_email

+     if its token is valid it activated the token user

+     or returns a validation error

+ 

+     GET /users/confirm_email/<token>

+ 

+     Possible status code:

+         - 200 Ok -> E-mail confirmed, go back to kiskadee: <Kiskadee link>

+         - 403 Forbidden -> Token expired or Invalid token

+     """

+     vefirication = token_vefirication(

+         token, kiskadee.config['EMAIL_TOKEN_SECRET_KEY'])

+ 

+     if 'error' in vefirication:

+         return make_response(jsonify(vefirication), 403)

+ 

+     db_session = Database().session

+ 

+     user = db_session.query(User).filter_by(

+         id=vefirication['data']['user_id']).first()

+     user.is_active = True

+ 

+     db_session.commit()

+ 

+     confirm_message = '''

+     E-mail confirmed, go back to kiskadee: <a href="{}">Kiskadee</a>

+     '''.format(kiskadee.config['MAIL_CONFIRM_REDIRECT'])

+ 

+     return make_response(confirm_message, 200)

+ 

+ 

+ class UnconfirmedEmailsCleaner(Thread):

+     """

+     If MAIL_ENABLED is True then for every MAIL_UNCONFIRMED_CLEANER_TIMER time

+     it will search for unactivated users and deletes it.

+     """

+ 

+     def run(self):

+         if not kiskadee.config['MAIL_ENABLED']:

+             return None

+ 

+         sleep_time = int(kiskadee.config['MAIL_UNCONFIRMED_CLEANER_TIMER'])

+ 

+         while True:

+             sleep(sleep_time)

+             db_session = Database().session

+             db_session.query(User).filter_by(is_active=False).delete()

+             db_session.commit()

file modified
+51 -2
@@ -1,8 +1,8 @@ 

  """Provide objects to serialize the kiskadee models."""

  

- from marshmallow import Schema, fields

+ from marshmallow import Schema, fields, validate, exceptions

  from kiskadee.model import Package, Fetcher, Analysis, Version,\

-         Report, Analyzer

+         Report, Analyzer, User

  

  

  class ReportsSchema(Schema):
@@ -88,3 +88,52 @@ 

          """Serialize a Package object."""

          print('MAKING OBJECT FROM', data)

          return Package(**data)

+ 

+ 

+ class UserSchema(Schema):

+     """Provide a serializer to the User model."""

+ 

+     id = fields.Int(dump_only=True)

+     name = fields.Str(required=True, validate=validate.Length(min=4, max=255))

+     email = fields.Str(

+         required=True,

+         validate=[validate.Email(error='Not a valid email address'),

+                   validate.Length(min=4, max=255)])

+     password = fields.Str(load_only=True,

+                           validate=validate.Length(min=4, max=255))

+     is_active = fields.Bool(default=False)

+ 

+     def make_object(self, data):

+         """Serialize a User object."""

+         print('MAKING OBJECT FROM', data)

+         return User(**data)

+ 

+     @classmethod

+     def create(cls, **data):

+         """User factory that creates a User object without saving it.

+ 

+         If the given data has errors it raises an

+         marshmallow.exceptions.ValidationError, else create an User model

+         and return it widthout saving.

+ 

+         :data: User model attributes

+         """

+         validation = UserSchema().load(data)

+ 

+         if bool(validation.errors):

+             raise exceptions.ValidationError(validation.errors)

+ 

+         password = validation.data.get('password')

+         if password is not None:

+             del validation.data['password']

+ 

+         user = User(**validation.data)

+ 

+         if password is not None:

+             user.hash_password(str(password))

+         else:

+             raise exceptions.ValidationError({

+                 'password': 'Missing data for required field.'

+             })

+ 

+         return user 

\ No newline at end of file

@@ -0,0 +1,63 @@ 

+ import jwt

+ import os

+ 

+ from functools import wraps

+ 

+ from flask import request, make_response, jsonify

+ 

+ TOKEN_SECRET_KEY = os.getenv('TOKEN_SECRET_KEY', 'default development key')

+ 

+ 

+ def token_vefirication(token, verification_key):

+     """

+     Token verification. Given a token and its key it will return the token data

+     or one of the following errors: Token expired, Invalid token.

+ 

+     returns:

+         {'data': ...}

+         or

+         {'error': 'Token expired'}

+         or

+         {'error': 'Invalid token'}

+     """

+     try:

+         data = jwt.decode(token, verification_key, algorithms=['HS256'])

+         return {'data': data}

+     except jwt.ExpiredSignatureError:

+         return {'error': 'Token expired'}

+     except jwt.InvalidTokenError:

+         return {'error': 'Invalid token'}

+ 

+ 

+ def token_required(fn):

+     """Token verification decorator. When applyed on a route it will

+     look for the x-access-token on the request header.

+ 

+     If it is valid, the the route is executed.

+     Else, the token is missing or is invalid or has expired, either way

+     the user receive a 403 status code when invalid.

+ 

+     Possible status code:

+         - 403 Forbidden ->

+             "Token is missing" or "Token expired" or "Invalid token"

+     """

+ 

+     @wraps(fn)

+     def decorated(*args, **kwargs):

+         token = None

+ 

+         if 'x-access-token' in request.headers:

+             token = request.headers['x-access-token']

+ 

+         if not token:

+             return make_response(jsonify({'error': 'Token is missing'}), 403)

+ 

+         vefirication = token_vefirication(token, TOKEN_SECRET_KEY)

+ 

+         if 'error' in vefirication:

+             return make_response(jsonify(vefirication), 403)

+         else:

+             params = dict(kwargs, token_data=vefirication['data'])

+             return fn(*args, **params)

+ 

+     return decorated 

\ No newline at end of file

file modified
+66 -23
@@ -2,9 +2,17 @@ 

  

  from sqlalchemy.ext.declarative import declarative_base

  from sqlalchemy import Column, Integer, UnicodeText, UniqueConstraint,\

-                        Sequence, Unicode, ForeignKey, orm, JSON

+                        Sequence, Unicode, ForeignKey, orm, JSON, String,\

+                        Boolean

+ from passlib.apps import custom_app_context as pwd_context

  import kiskadee

  

+ import jwt

+ import os

+ import datetime

+ 

+ TOKEN_SECRET_KEY = os.getenv('TOKEN_SECRET_KEY', 'default development key')

+ 

  Base = declarative_base()

  

  
@@ -17,23 +25,21 @@ 

      """

  

      __tablename__ = 'packages'

-     id = Column(Integer,

-                 Sequence('packages_id_seq', optional=True), primary_key=True)

+     id = Column(

+         Integer, Sequence('packages_id_seq', optional=True), primary_key=True)

      name = Column(Unicode(255), nullable=False)

      homepage = Column(Unicode(255), nullable=True)

      fetcher_id = Column(Integer, ForeignKey('fetchers.id'), nullable=False)

      versions = orm.relationship('Version', backref='packages')

-     __table_args__ = (

-             UniqueConstraint('name', 'fetcher_id'),

-             )

+     __table_args__ = (UniqueConstraint('name', 'fetcher_id'), )

  

  

  class Fetcher(Base):

      """kiskadee fetcher abstraction."""

  

      __tablename__ = 'fetchers'

-     id = Column(Integer,

-                 Sequence('fetchers_id_seq', optional=True), primary_key=True)

+     id = Column(

+         Integer, Sequence('fetchers_id_seq', optional=True), primary_key=True)

      name = Column(Unicode(255), nullable=False, unique=True)

      target = Column(Unicode(255), nullable=True)

      description = Column(UnicodeText)
@@ -44,22 +50,20 @@ 

      """Abstraction of a package version."""

  

      __tablename__ = 'versions'

-     id = Column(Integer,

-                 Sequence('versions_id_seq', optional=True), primary_key=True)

+     id = Column(

+         Integer, Sequence('versions_id_seq', optional=True), primary_key=True)

      number = Column(Unicode(100), nullable=False)

      package_id = Column(Integer, ForeignKey('packages.id'), nullable=False)

      analysis = orm.relationship('Analysis', backref='versions')

-     __table_args__ = (

-             UniqueConstraint('number', 'package_id'),

-             )

+     __table_args__ = (UniqueConstraint('number', 'package_id'), )

  

  

  class Analyzer(Base):

      """Abstraction of a static analyzer."""

  

      __tablename__ = 'analyzers'

-     id = Column(Integer,

-                 Sequence('analyzers_id_seq', optional=True), primary_key=True)

+     id = Column(

+         Integer, Sequence('analyzers_id_seq', optional=True), primary_key=True)

      name = Column(Unicode(255), nullable=False, unique=True)

      version = Column(Unicode(255), nullable=True)

      analysis = orm.relationship('Analysis', backref='analyzers')
@@ -69,21 +73,21 @@ 

      """Abstraction of a package analysis."""

  

      __tablename__ = 'analysis'

-     id = Column(Integer,

-                 Sequence('analysis_id_seq', optional=True), primary_key=True)

+     id = Column(

+         Integer, Sequence('analysis_id_seq', optional=True), primary_key=True)

      version_id = Column(Integer, ForeignKey('versions.id'), nullable=False)

      analyzer_id = Column(Integer, ForeignKey('analyzers.id'), nullable=False)

      raw = Column(JSON)

-     report = orm.relationship('Report',

-                               uselist=False, back_populates='analysis')

+     report = orm.relationship(

+         'Report', uselist=False, back_populates='analysis')

  

  

  class Report(Base):

      """Abstraction of a analysis report."""

  

      __tablename__ = 'reports'

-     id = Column(Integer,

-                 Sequence('reports_id_seq', optional=True), primary_key=True)

+     id = Column(

+         Integer, Sequence('reports_id_seq', optional=True), primary_key=True)

      analysis_id = Column(Integer, ForeignKey('analysis.id'), nullable=False)

      results = Column(JSON)

      analysis = orm.relationship('Analysis', back_populates='report')
@@ -98,10 +102,49 @@ 

      """

      list_of_analyzers = dict(kiskadee.config._sections["analyzers"])

      for name, version in list_of_analyzers.items():

-         if not (_session.query(Analyzer).filter(Analyzer.name == name).

-                 filter(Analyzer.version == version).first()):

+         if not (_session.query(Analyzer).filter(Analyzer.name == name).filter(

+                 Analyzer.version == version).first()):

              new_analyzer = kiskadee.model.Analyzer()

              new_analyzer.name = name

              new_analyzer.version = version

              _session.add(new_analyzer)

      _session.commit()

+ 

+ 

+ class User(Base):

+     __tablename__ = 'users'

+     id = Column(

+         Integer, Sequence('users_id_seq', optional=True), primary_key=True)

+     name = Column(Unicode(255), nullable=False)

+     email = Column(String(255), nullable=False, unique=True)

+     password_hash = Column(String(128))

+     is_active = Column(Boolean, unique=False, default=False)

+ 

+     def hash_password(self, password):

+         """Takes a plain password as argument

+         and stores a hash of it with the user.

+         """

+         self.password_hash = pwd_context.hash(password)

+ 

+     def verify_password(self, password):

+         """Takes a plain password as argument and returns

+         True if the password is correct

+         False if not.

+         """

+         try:

+             return pwd_context.verify(password, self.password_hash)

+         except ValueError:

+             return False

+ 

+     def generate_token(self):

+         """Generates user auth token and returns it"""

+         token = jwt.encode(

+             {

+                 'user_id': self.id,

+                 'exp':

+                 datetime.datetime.utcnow() + datetime.timedelta(hours=48)

+             },

+             TOKEN_SECRET_KEY,

+             algorithm='HS256')

+ 

+         return token.decode('UTF-8')

@@ -0,0 +1,555 @@ 

+ import json

+ import unittest

+ from sqlalchemy.orm import sessionmaker

+ from unittest.mock import patch

+ 

+ import kiskadee

+ import kiskadee.api.app

+ 

+ from kiskadee.model import User

+ from kiskadee.api.serializers import UserSchema

+ 

+ 

+ def mock_hash_password(self, password):

+     """Mock for User model hash_password method.

+     It is too slow for the tests.

+     """

+     self.password_hash = str(password)

+ 

+ 

+ class ApiUsersTestCase(unittest.TestCase):

+     def setUp(self):

+         kiskadee.api.app.kiskadee.testing = True

+         self.engine = kiskadee.database.Database('db_test').engine

+         Session = sessionmaker(bind=self.engine)

+         self.session = Session()

+         self.app = kiskadee.api.app.kiskadee.test_client()

+ 

+         kiskadee.model.Base.metadata.create_all(self.engine)

+         self._setup_mock_users()

+ 

+     @patch.object(User, 'hash_password', mock_hash_password)

+     def _setup_mock_users(self):

+         mock_users_data = [{

+             'name': 'test 1',

+             'email': 'test@user1.com',

+             'password': 'test',

+             'is_active': True

+         }, {

+             'name': 'test 2',

+             'email': 'test@user2.com',

+             'password': 'test',

+             'is_active': True

+         }, {

+             'name': 'test 3',

+             'email': 'test@user3.com',

+             'password': 'test',

+             'is_active': True

+         }]

+ 

+         for mock_data in mock_users_data:

+             user = UserSchema.create(**mock_data)

+             self.session.add(user)

+ 

+         self.session.commit()

+ 

+     def tearDown(self):

+         self.session.close()

+         kiskadee.model.Base.metadata.drop_all()

+ 

+     # POST /login -> 200 Ok

+     def test_get_user_token_on_login(self):

+         kiskadee.api.app.kiskadee_db_session = lambda: self.session

+ 

+         user_data = {

+             'name': 'login',

+             'email': 'login@email.com',

+             'password': 'login',

+             'is_active': True

+         }

+ 

+         # Creating a user as user.verify_password inside login route

+         # gives ValueError with users created with mock_hash_password

+         user = UserSchema.create(**user_data)

+         self.session.add(user)

+         self.session.commit()

+ 

+         login_data = {

+             'email': user_data['email'],

+             'password': user_data['password']

+         }

+ 

+         response = self.app.post(

+             "/login",

+             data=json.dumps(login_data),

+             content_type='application/json')

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("token", data)

+         self.assertIn("user", data)

+         self.assertEqual(user.id, data['user']['id'])

+         self.assertEqual(200, response.status_code)

+ 

+     # POST /login -> 403 User email not confirmed

+     def test_unconfirmed_user_cant_login(self):

+         kiskadee.api.app.kiskadee_db_session = lambda: self.session

+ 

+         user_data = {

+             'name': 'login',

+             'email': 'login@email.com',

+             'password': 'login',

+             'is_active': False

+         }

+ 

+         user = UserSchema.create(**user_data)

+         self.session.add(user)

+         self.session.commit()

+ 

+         login_data = {

+             'email': user_data['email'],

+             'password': user_data['password']

+         }

+ 

+         response = self.app.post(

+             "/login",

+             data=json.dumps(login_data),

+             content_type='application/json')

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("error", data)

+         self.assertEqual("User email not confirmed", data['error'])

+         self.assertEqual(403, response.status_code)

+ 

+     # POST /login -> 401 Unauthorized

+     def test_wrong_data_on_user_login_gives_unauthorized_response(self):

+         kiskadee.api.app.kiskadee_db_session = lambda: self.session

+ 

+         user_data = {

+             'name': 'login',

+             'email': 'login@email.com',

+             'password': 'login',

+             'is_active': True

+         }

+ 

+         user = UserSchema.create(**user_data)

+         self.session.add(user)

+         self.session.commit()

+ 

+         login_data = {

+             'email': user_data['email'],

+             'password': 'not my password'

+         }

+ 

+         response = self.app.post(

+             "/login",

+             data=json.dumps(login_data),

+             content_type='application/json')

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("error", data)

+         self.assertEqual(data["error"],

+                          "Unauthorized, invalid user credentials")

+         self.assertEqual(401, response.status_code)

+ 

+     # GET /users -> 200 ok

+     def test_get_users(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         response = self.app.get(

+             "/users", headers={

+                 'x-access-token': user_token

+             })

+         data = json.loads(response.data.decode("utf-8"))

+         total_users_count = self.session.query(User).count()

+ 

+         self.assertIn("users", data)

+         self.assertEqual(len(data['users']), total_users_count)

+         self.assertEqual(200, response.status_code)

+ 

+         # no password field is given

+         user = data['users'][0]

+         self.assertIsNone(user.get('password'))

+         self.assertIsNone(user.get('password_hash'))

+ 

+     # POST /users -> 201 created

+     @patch.object(User, 'hash_password', mock_hash_password)

+     def test_successful_create_user(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         new_user_data = {

+             'name': 'new user',

+             'email': 'new@user.com',

+             'password': 'new user'

+         }

+ 

+         total_users_before_creation = self.session.query(User).count()

+         response = self.app.post(

+             "/users",

+             data=json.dumps(new_user_data),

+             content_type='application/json')

+         total_users_after_creation = self.session.query(User).count()

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("user", data)

+         self.assertIn("token", data)

+         self.assertEqual(total_users_after_creation,

+                          total_users_before_creation + 1)

+         self.assertEqual(data['user']['email'], new_user_data['email'])

+         self.assertEqual(201, response.status_code)

+ 

+     # POST /users -> 400 Bad Request

+     def test_missing_arguments_create_user(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         new_user_data = {'name': 'new user', 'password': 'new user'}

+ 

+         total_users_before_creation = self.session.query(User).count()

+         response = self.app.post(

+             "/users",

+             data=json.dumps(new_user_data),

+             content_type='application/json')

+         total_users_after_creation = self.session.query(User).count()

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("error", data)

+         self.assertIn("validations", data)

+         self.assertEqual(total_users_after_creation,

+                          total_users_before_creation)

+         self.assertEqual(data['error'], 'Validation error')

+         self.assertEqual(data['validations']['email'][0],

+                          'Missing data for required field.')

+         self.assertEqual(400, response.status_code)

+ 

+     # POST /users -> 403 Forbidden

+     def test_already_exists_create_user(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         new_user_data = {

+             'name': 'new user',

+             'password': 'new user',

+             'email': 'test@user1.com'

+         }

+ 

+         total_users_before_creation = self.session.query(User).count()

+         response = self.app.post(

+             "/users",

+             data=json.dumps(new_user_data),

+             content_type='application/json')

+         total_users_after_creation = self.session.query(User).count()

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("error", data)

+         self.assertEqual(total_users_after_creation,

+                          total_users_before_creation)

+         self.assertEqual(data['error'], 'user already exists')

+         self.assertEqual(403, response.status_code)

+ 

+     # GET /users/:id -> 200 ok

+     def test_get_user_data(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         response = self.app.get(

+             "/users/%d" % user.id, headers={

+                 'x-access-token': user_token

+             })

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("user", data)

+         self.assertEqual(data['user']['email'], user.email)

+         self.assertEqual(200, response.status_code)

+ 

+         # no password field is given

+         self.assertIsNone(data['user'].get('password'))

+         self.assertIsNone(data['user'].get('password_hash'))

+ 

+     # GET /users/:id -> 404 Not Found

+     def test_not_found_get_user_data(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         response = self.app.get(

+             "/users/%d" % 123456789, headers={

+                 'x-access-token': user_token

+             })

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("error", data)

+         self.assertEqual(data['error'], 'user not found')

+         self.assertEqual(404, response.status_code)

+ 

+     # PUT /users/:id -> 200 ok

+     @patch.object(User, 'hash_password', mock_hash_password)

+     def test_successful_update_user(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         response = self.app.put(

+             "/users/{}".format(user.id),

+             data=json.dumps({

+                 'email': 'another@email.com',

+                 'password': 'password'

+             }),

+             content_type='application/json',

+             headers={

+                 'x-access-token': user_token

+             })

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("user", data)

+         self.assertEqual(data['user']['id'], user.id)

+         self.assertEqual(data['user']['email'], 'another@email.com')

+         self.assertEqual(200, response.status_code)

+ 

+         updated_user = self.session.query(User).filter_by(id=user.id).first()

+ 

+         self.assertEqual(user.id, updated_user.id)

+         self.assertEqual(user.password_hash, updated_user.password_hash)

+         self.assertEqual(updated_user.email, 'another@email.com')

+ 

+     # PUT /users/:id -> 200 ok

+     @patch.object(User, 'hash_password', mock_hash_password)

+     def test_ignores_password_hash_on_ajax_update_user(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         response = self.app.put(

+             "/users/{}".format(user.id),

+             data=json.dumps({

+                 'password_hash': 'ignome_me'

+             }),

+             content_type='application/json',

+             headers={

+                 'x-access-token': user_token

+             })

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("user", data)

+         self.assertEqual(data['user']['id'], user.id)

+         self.assertEqual(200, response.status_code)

+ 

+         updated_user = self.session.query(User).filter_by(id=user.id).first()

+ 

+         self.assertNotEqual(updated_user.password_hash, 'ignome_me')

+         self.assertEqual(updated_user.password_hash, user.password_hash)

+ 

+     # PUT /users/:id -> 400 Bad Request

+     @patch.object(User, 'hash_password', mock_hash_password)

+     def test_validation_errors_on_update_user(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         response = self.app.put(

+             "/users/{}".format(user.id),

+             data=json.dumps({

+                 'password': 'foo',

+                 'email': 'not an email'

+             }),

+             content_type='application/json',

+             headers={

+                 'x-access-token': user_token

+             })

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("error", data)

+         self.assertIn("validations", data)

+         self.assertEqual(data['error'], 'Validation error')

+         self.assertEqual(data['validations']['email'][0],

+                          'Not a valid email address')

+         self.assertEqual(data['validations']['password'][0],

+                          'Length must be between 4 and 255.')

+         self.assertEqual(400, response.status_code)

+ 

+         updated_user = self.session.query(User).filter_by(id=user.id).first()

+ 

+         self.assertNotEqual(updated_user.email, 'not an email')

+ 

+     # PUT /users/:id -> 403 Forbidden

+     @patch.object(User, 'hash_password', mock_hash_password)

+     def test_only_the_token_user_can_updates_its_data(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         def send_request(user, data, token):

+             return self.app.put(

+                 "/users/{}".format(user.id),

+                 data=json.dumps(data),

+                 content_type='application/json',

+                 headers={

+                     'x-access-token': token

+                 })

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         user_to_update = self.session.query(User).\

+                             order_by(User.id.desc()).\

+                             first()

+ 

+         response = send_request(user_to_update, {'name': 'test'}, user_token)

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("error", data)

+         self.assertEqual(data['error'],

+                          'token user does not match to requested user')

+         self.assertEqual(403, response.status_code)

+ 

+         token = user_to_update.generate_token()

+         response = send_request(user_to_update, {'name': 'new name'}, token)

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("user", data)

+         self.assertEqual(data['user']['id'], user_to_update.id)

+         self.assertEqual(data['user']['name'], 'new name')

+         self.assertEqual(200, response.status_code)

+ 

+     # PUT /users/:id -> 404 Not Found

+     def test_not_found_update_user(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         response = self.app.put(

+             "/users/{}".format(123456789),

+             data=json.dumps({

+                 'password': 'not found ?'

+             }),

+             content_type='application/json',

+             headers={

+                 'x-access-token': user_token

+             })

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("error", data)

+         self.assertEqual(data['error'], 'user not found')

+         self.assertEqual(404, response.status_code)

+ 

+     # DELETE /users/:id -> 204 No Content

+     def test_successful_delete_user(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         response = self.app.delete(

+             "/users/{}".format(user.id),

+             headers={

+                 'x-access-token': user_token

+             })

+ 

+         self.assertEqual(response.data, b'')

+         self.assertEqual(204, response.status_code)

+ 

+         deleted_user = self.session.query(User).filter_by(id=user.id).first()

+ 

+         self.assertIsNone(deleted_user)

+ 

+     # DELETE /users/:id -> 403 Forbidden

+     def test_only_the_token_user_can_delete_it_self(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         other_user = self.session.query(User).order_by(User.id.desc()).first()

+ 

+         response = self.app.delete(

+             "/users/{}".format(other_user.id),

+             headers={

+                 'x-access-token': user_token

+             })

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("error", data)

+         self.assertEqual(data['error'],

+                          'token user does not match to requested user')

+         self.assertEqual(403, response.status_code)

+ 

+     # DELETE /users/:id -> 404 Not Found

+     def test_not_found_delete_user(self):

+         def mock_kiskadee_db_session():

+             return self.session

+ 

+         kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session

+ 

+         user = self.session.query(User).first()

+         user_token = user.generate_token()

+ 

+         response = self.app.delete(

+             "/users/{}".format(123456789),

+             headers={

+                 'x-access-token': user_token

+             })

+ 

+         data = json.loads(response.data.decode("utf-8"))

+ 

+         self.assertIn("error", data)

+         self.assertEqual(data['error'], 'user not found')

+         self.assertEqual(404, response.status_code)

+ 

+ 

+ if __name__ == '__main__':

+     unittest.main()

@@ -1,6 +1,7 @@ 

  import unittest

  from sqlalchemy import exc

  from sqlalchemy.orm import sessionmaker

+ import jwt

  

  from kiskadee import model

  from kiskadee.database import Database
@@ -174,6 +175,37 @@ 

          self.assertEqual(analysis[0].raw, "<>")

          self.assertEqual(analysis[1].raw, "><")

  

+     def test_it_hash_a_user_password(self):

+         u = model.User()

+ 

+         old_password = 'foobar'

+         u.password_hash = old_password

+         u.hash_password('test')

+ 

+         self.assertNotEqual(u.password_hash, old_password)

+         self.assertGreater(len(u.password_hash), 100)

+ 

+     def test_it_verify_a_user_password(self):

+         u = model.User()

+         u.hash_password('test')

+ 

+         self.assertTrue(u.verify_password('test'))

+         self.assertFalse(u.verify_password('wrong password'))

+ 

+     def test_it_generates_a_user_auth_token(self):

+         u = model.User(name='test', email='test@email.com')

+         u.hash_password('test')

+ 

+         self.session.add(u)

+         self.session.commit()

+ 

+         u = self.session.query(model.User)\

+                 .filter_by(email='test@email.com').first()

+         token = u.generate_token()

+         decoded_token = jwt.decode(token, model.TOKEN_SECRET_KEY, algorithms=['HS256'])

+ 

+         self.assertGreaterEqual(len(token), 121)

+         self.assertEqual(decoded_token['user_id'], u.id)

  

  if __name__ == '__main__':

      unittest.main()

@@ -0,0 +1,53 @@ 

+ import unittest

+ from marshmallow.exceptions import ValidationError

+ 

+ from kiskadee.api.serializers import UserSchema, User

+ 

+ 

+ class SerializersTestCase(unittest.TestCase):

+ 

+     def test_UserSchema_validates_user_data(self):

+         wrong_data = {'name': 'foo', 'email': 'foo', 'password': 'foo'}

+         validation = UserSchema().load(wrong_data)

+ 

+         self.assertTrue(validation.errors)

+         self.assertEqual(validation.errors['name'][0],

+                          'Length must be between 4 and 255.')

+         self.assertEqual(validation.errors['email'][0],

+                          'Not a valid email address')

+         self.assertEqual(validation.errors['email'][1],

+                          'Length must be between 4 and 255.')

+         self.assertEqual(validation.errors['password'][0],

+                          'Length must be between 4 and 255.')

+ 

+         validation = UserSchema().load({})

+ 

+         self.assertEqual(validation.errors['name'][0],

+                          'Missing data for required field.')

+ 

+         self.assertEqual(validation.errors['email'][0],

+                          'Missing data for required field.')

+ 

+     def test_UserSchema_create_a_user_instance(self):

+         data = {'name': 'Test', 'email': 'test@email.com', 'password': 'test'}

+         user = UserSchema.create(**data)

+ 

+         self.assertIsInstance(user, User)

+         self.assertNotEqual(user.password_hash, 'test')

+         self.assertGreater(len(user.password_hash), 100)

+ 

+     def test_UserSchema_raise_ValidationError_if_data_is_invalid(self):

+         with self.assertRaises(ValidationError) as context:

+             data = {}

+             UserSchema.create(**data)

+ 

+         errors = context.exception.args[0]

+         self.assertIsInstance(context.exception, ValidationError)

+         self.assertEqual(errors['name'][0],

+                          'Missing data for required field.')

+         self.assertEqual(errors['email'][0],

+                          'Missing data for required field.')

+ 

+ 

+ if __name__ == '__main__':

+     unittest.main() 

\ No newline at end of file

file modified
+4
@@ -13,3 +13,7 @@ 

  marshmallow

  alembic

  python-debian

+ passlib

+ bcrypt

+ pyjwt

+ Flask-Mail

file modified
+9
@@ -52,3 +52,12 @@ 

  flawfinder = 1.0.0

  clanganalyzer = 1.0.0

  frama_c = 1.0.0

+ 

+ [mail]

+ MAIL_ENABLED = False

+ MAIL_SERVER = smtp.googlemail.com

+ MAIL_PORT = 465

+ MAIL_USE_TLS = False

+ MAIL_USE_SSL = True

+ MAIL_CONFIRM_REDIRECT = http://localhost:8080/

+ MAIL_UNCONFIRMED_CLEANER_TIMER = 3600 

\ No newline at end of file

This PR add users and users auth to kiskadee API, the auth can use an e-mail confirmation or not based on a configuration.

For the user API and auth the following environment variables are used:
For the user auth:
* TOKEN_SECRET_KEY for user auth token, default: "default development key" for dev

For the email confirmation:
EMAIL_TOKEN_SECRET_KEY for mail confirmation token, default "dev email token" for dev
MAIL_USERNAME the mail server username
* MAIL_PASSWORD the mail server password

By default the email confirmation feature is disabled for dev purpose, but it can be enabled by setting MAIL_ENABLED=True on the "kiskadee.conf" file.
Also was added on the "kiskadee.conf" file others mail configs values.

Summary, when going on production set TOKEN_SECRET_KEY, MAIL_ENABLED=True, EMAIL_TOKEN_SECRET_KEY, MAIL_USERNAME and MAIL_PASSWORD.
If on development, just let it to its default values.