From 83889b8293f7f27bd8a0d7155443d65a1d834b0e Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Dec 19 2017 13:29:12 +0000 Subject: [PATCH 1/10] Add user API CRUD Signed-off-by: danmoura17 Signed-off-by: Fabio Teixeira --- diff --git a/alembic/versions/cecf8298b202_added_user_table.py b/alembic/versions/cecf8298b202_added_user_table.py new file mode 100644 index 0000000..2a18487 --- /dev/null +++ b/alembic/versions/cecf8298b202_added_user_table.py @@ -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 ### diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index 2564bf2..0304b0e 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -1,12 +1,13 @@ """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 import json from sqlalchemy.orm import eagerload @@ -105,6 +106,149 @@ def analysis_reports(pkg_name, version, analysis_id): return jsonify({'analysis_report': report}) +@kiskadee.route('/users', methods=['GET']) +def get_users(): + """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() + + user_schema = UserSchema() + result = user_schema.dump(user) + + return make_response(jsonify({'user': result.data}), 201) + + +@kiskadee.route('/users/', methods=['GET']) +def get_user_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/', methods=['PUT']) +def update_user(user_id): + """Updates a user + + PUT /users/:id + + Possible status code: + - 200 Ok -> User updated + - 400 Bad Request -> Validation error + - 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) + + 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/', methods=['DELETE']) +def delete_user(user_id): + """Deletes a user + + DELETE /users/:id + + Possible status code: + - 204 No Content -> User deleted + - 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) + + db_session.delete(user) + db_session.commit() + + return make_response(jsonify({}), 204) + + def kiskadee_db_session(): """Return a kiskadee database session.""" return Database().session diff --git a/kiskadee/api/serializers.py b/kiskadee/api/serializers.py index e7168e9..44e2f0a 100644 --- a/kiskadee/api/serializers.py +++ b/kiskadee/api/serializers.py @@ -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,51 @@ class PackageSchema(Schema): """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)) + + 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 diff --git a/kiskadee/model.py b/kiskadee/model.py index 6aa3233..86f2365 100644 --- a/kiskadee/model.py +++ b/kiskadee/model.py @@ -2,7 +2,8 @@ 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 +from passlib.apps import custom_app_context as pwd_context import kiskadee Base = declarative_base() @@ -105,3 +106,25 @@ def create_analyzers(_session): 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)) + + 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. + """ + return pwd_context.verify(password, self.password_hash) diff --git a/kiskadee/tests/test_api_users.py b/kiskadee/tests/test_api_users.py new file mode 100644 index 0000000..70e24c8 --- /dev/null +++ b/kiskadee/tests/test_api_users.py @@ -0,0 +1,318 @@ +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'}, + {'name': 'test 2', 'email': 'test@user2.com', 'password': 'test'}, + {'name': 'test 3', 'email': 'test@user3.com', 'password': 'test'} + ] + + 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() + + # 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 + + response = self.app.get("/users") + 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.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() + + response = self.app.get("/users/%d" % user.id) + 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 + + response = self.app.get("/users/%d" % 123456789) + 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() + response = self.app.put("/users/{}".format(user.id), + data=json.dumps({'email': 'another@email.com', + 'password': 'password'}), + content_type='application/json') + + 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() + response = self.app.put("/users/{}".format(user.id), + data=json.dumps({'password_hash': 'ignome_me'}), + content_type='application/json') + + 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() + response = self.app.put("/users/{}".format(user.id), + data=json.dumps({'password': 'foo', + 'email': 'not an email'}), + content_type='application/json') + + 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 -> 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 + + response = self.app.put("/users/{}".format(123456789), + data=json.dumps({'password': 'not found ?'}), + content_type='application/json') + + 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() + + response = self.app.delete("/users/{}".format(user.id)) + + 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 -> 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 + + response = self.app.delete("/users/{}".format(123456789)) + + 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() diff --git a/kiskadee/tests/test_model.py b/kiskadee/tests/test_model.py index 4a56835..8615c88 100644 --- a/kiskadee/tests/test_model.py +++ b/kiskadee/tests/test_model.py @@ -174,6 +174,22 @@ class ModelTestCase(unittest.TestCase): 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')) if __name__ == '__main__': unittest.main() diff --git a/kiskadee/tests/test_serializers.py b/kiskadee/tests/test_serializers.py new file mode 100644 index 0000000..a4482e8 --- /dev/null +++ b/kiskadee/tests/test_serializers.py @@ -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 diff --git a/requirements.txt b/requirements.txt index f1039cf..e7bf7ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,5 @@ Flask-Restless marshmallow alembic python-debian +passlib +bcrypt \ No newline at end of file From 4c15c060f07250fba2142efe32ff6bc83a97f9ad Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Dec 19 2017 13:30:01 +0000 Subject: [PATCH 2/10] Add token generation and verification --- diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index 0304b0e..2064cc9 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -5,17 +5,75 @@ from flask_cors import CORS from marshmallow.exceptions import ValidationError from kiskadee.database import Database -from kiskadee.model import Package, Fetcher, Version, Analysis, User +from kiskadee.model import Package, Fetcher, Version, Analysis, User,\ + TOKEN_SECRET_KEY from kiskadee.api.serializers import PackageSchema, FetcherSchema,\ AnalysisSchema, UserSchema import json from sqlalchemy.orm import eagerload +import jwt +from functools import wraps + kiskadee = Flask(__name__) CORS(kiskadee) +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) + + try: + data = jwt.decode(token, TOKEN_SECRET_KEY) + except jwt.ExpiredSignatureError: + return make_response(jsonify({ + 'error': 'Token expired' + }), 403) + except jwt.InvalidTokenError: + return make_response(jsonify({ + 'error': 'Invalid token' + }), 403) + + params = dict(kwargs, token_data=data) + return fn(*args, **params) + + return decorated + +@kiskadee.route('/login', methods=['POST']) +def login(): + 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 user.verify_password(password): + token = user.generate_token() + return make_response(jsonify({'token': token}), 200) + + return make_response(jsonify({'error': 'Could not verify !'}), 401) + + @kiskadee.route('/fetchers') def index(): """Get the list of available fetchers.""" @@ -107,7 +165,8 @@ def analysis_reports(pkg_name, version, analysis_id): @kiskadee.route('/users', methods=['GET']) -def get_users(): +@token_required +def get_users(token_data): """Get the list of users GET /users diff --git a/kiskadee/model.py b/kiskadee/model.py index 86f2365..b6cab39 100644 --- a/kiskadee/model.py +++ b/kiskadee/model.py @@ -6,8 +6,13 @@ from sqlalchemy import Column, Integer, UnicodeText, UniqueConstraint,\ from passlib.apps import custom_app_context as pwd_context import kiskadee -Base = declarative_base() +import jwt +import os +import datetime + +TOKEN_SECRET_KEY = os.getenv('TOKEN_SECRET_KEY', 'default development key') +Base = declarative_base() class Package(Base): """Software packages abstraction. @@ -128,3 +133,12 @@ class User(Base): False if not. """ return pwd_context.verify(password, self.password_hash) + + def generate_token(self): + """Generates user auth token and returns it""" + token = jwt.encode({ + 'user_id': self.id, + 'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=30) + }, TOKEN_SECRET_KEY) + + return token.decode('UTF-8') \ No newline at end of file diff --git a/kiskadee/tests/test_api_users.py b/kiskadee/tests/test_api_users.py index 70e24c8..b8862a9 100644 --- a/kiskadee/tests/test_api_users.py +++ b/kiskadee/tests/test_api_users.py @@ -53,7 +53,11 @@ class ApiUsersTestCase(unittest.TestCase): kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session - response = self.app.get("/users") + 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() diff --git a/kiskadee/tests/test_model.py b/kiskadee/tests/test_model.py index 8615c88..0ea53f0 100644 --- a/kiskadee/tests/test_model.py +++ b/kiskadee/tests/test_model.py @@ -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 @@ -191,5 +192,20 @@ class ModelTestCase(unittest.TestCase): 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) + + self.assertGreaterEqual(len(token), 121) + self.assertEqual(decoded_token['user_id'], u.id) + if __name__ == '__main__': unittest.main() diff --git a/requirements.txt b/requirements.txt index e7bf7ff..a2431b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,5 @@ marshmallow alembic python-debian passlib -bcrypt \ No newline at end of file +bcrypt +pyjwt From 6ebef8edc48722d623969b337dc9aa14bba0877f Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Dec 19 2017 13:30:38 +0000 Subject: [PATCH 3/10] Add token_required to user routes Signed-off-by: Fabio Teixeira Signed-off-by: danmoura17 --- diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index 2064cc9..728d515 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -45,13 +45,9 @@ def token_required(fn): try: data = jwt.decode(token, TOKEN_SECRET_KEY) except jwt.ExpiredSignatureError: - return make_response(jsonify({ - 'error': 'Token expired' - }), 403) + return make_response(jsonify({'error': 'Token expired'}), 403) except jwt.InvalidTokenError: - return make_response(jsonify({ - 'error': 'Invalid token' - }), 403) + return make_response(jsonify({'error': 'Invalid token'}), 403) params = dict(kwargs, token_data=data) return fn(*args, **params) @@ -60,6 +56,14 @@ def token_required(fn): @kiskadee.route('/login', methods=['POST']) def login(): + """Token based login + + POST /login + + Possible status code: + - 200 Ok -> User token + - 401 Unauthorized -> Could not log user + """ json_data = request.get_json() email, password = [json_data.get('email'), json_data.get('password')] @@ -221,8 +225,9 @@ def create_user(): return make_response(jsonify({'user': result.data}), 201) -@kiskadee.route('/users/', methods=['GET']) -def get_user_data(user_id): +@kiskadee.route('/users/', methods=['GET']) +@token_required +def get_user_data(token_data, user_id): """Get the user data GET /users/:id @@ -243,8 +248,9 @@ def get_user_data(user_id): return make_response(jsonify({'user': result.data}), 200) -@kiskadee.route('/users/', methods=['PUT']) -def update_user(user_id): +@kiskadee.route('/users/', methods=['PUT']) +@token_required +def update_user(token_data, user_id): """Updates a user PUT /users/:id @@ -252,6 +258,7 @@ def update_user(user_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() @@ -260,6 +267,11 @@ def update_user(user_id): 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) @@ -286,14 +298,16 @@ def update_user(user_id): return make_response(jsonify({'user': result.data}), 200) -@kiskadee.route('/users/', methods=['DELETE']) -def delete_user(user_id): +@kiskadee.route('/users/', 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() @@ -302,6 +316,11 @@ def delete_user(user_id): 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() diff --git a/kiskadee/model.py b/kiskadee/model.py index b6cab39..c7bc9ca 100644 --- a/kiskadee/model.py +++ b/kiskadee/model.py @@ -132,7 +132,10 @@ class User(Base): True if the password is correct False if not. """ - return pwd_context.verify(password, self.password_hash) + try: + return pwd_context.verify(password, self.password_hash) + except ValueError: + return False def generate_token(self): """Generates user auth token and returns it""" diff --git a/kiskadee/tests/test_api_users.py b/kiskadee/tests/test_api_users.py index b8862a9..2188c22 100644 --- a/kiskadee/tests/test_api_users.py +++ b/kiskadee/tests/test_api_users.py @@ -46,6 +46,66 @@ class ApiUsersTestCase(unittest.TestCase): 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' + } + + # 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.assertEqual(200, 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' + } + + 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"], "Could not verify !") + self.assertEqual(401, response.status_code) + + # GET /users -> 200 ok def test_get_users(self): def mock_kiskadee_db_session(): @@ -160,8 +220,10 @@ class ApiUsersTestCase(unittest.TestCase): 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) + 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) @@ -179,7 +241,11 @@ class ApiUsersTestCase(unittest.TestCase): kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session - response = self.app.get("/users/%d" % 123456789) + 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) @@ -195,10 +261,13 @@ class ApiUsersTestCase(unittest.TestCase): 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') + content_type='application/json', + headers={'x-access-token': user_token}) data = json.loads(response.data.decode("utf-8")) @@ -222,9 +291,12 @@ class ApiUsersTestCase(unittest.TestCase): 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') + content_type='application/json', + headers={'x-access-token': user_token}) data = json.loads(response.data.decode("utf-8")) @@ -246,10 +318,13 @@ class ApiUsersTestCase(unittest.TestCase): 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') + content_type='application/json', + headers={'x-access-token': user_token}) data = json.loads(response.data.decode("utf-8")) @@ -266,6 +341,43 @@ class ApiUsersTestCase(unittest.TestCase): 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): @@ -274,9 +386,13 @@ class ApiUsersTestCase(unittest.TestCase): 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') + content_type='application/json', + headers={'x-access-token': user_token}) data = json.loads(response.data.decode("utf-8")) @@ -292,8 +408,10 @@ class ApiUsersTestCase(unittest.TestCase): 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)) + 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) @@ -302,6 +420,28 @@ class ApiUsersTestCase(unittest.TestCase): 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(): @@ -309,7 +449,11 @@ class ApiUsersTestCase(unittest.TestCase): kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session - response = self.app.delete("/users/{}".format(123456789)) + 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")) From 61b28461a7b4950d98595b0f6e9b85413518baf2 Mon Sep 17 00:00:00 2001 From: danmoura17 Date: Dec 19 2017 13:30:38 +0000 Subject: [PATCH 4/10] Set auth token to expire after 48 hours Signed-off-by: danmoura17 Signed-off-by: Fabio Teixeira --- diff --git a/kiskadee/model.py b/kiskadee/model.py index c7bc9ca..4e879ec 100644 --- a/kiskadee/model.py +++ b/kiskadee/model.py @@ -141,7 +141,7 @@ class User(Base): """Generates user auth token and returns it""" token = jwt.encode({ 'user_id': self.id, - 'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=30) + 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=48) }, TOKEN_SECRET_KEY) - return token.decode('UTF-8') \ No newline at end of file + return token.decode('UTF-8') From ad4cf6142f800cf10f88498803f16ff44d86f3fe Mon Sep 17 00:00:00 2001 From: danmoura17 Date: Dec 19 2017 13:30:38 +0000 Subject: [PATCH 5/10] Pass user_id on login together with token Signed-off-by: danmoura17 Signed-off-by: Fabio Teixeira --- diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index 728d515..711e81b 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -73,7 +73,8 @@ def login(): if user is not None and user.verify_password(password): token = user.generate_token() - return make_response(jsonify({'token': token}), 200) + response = {'token': token, 'user_id': user.id} + return make_response(jsonify(response), 200) return make_response(jsonify({'error': 'Could not verify !'}), 401) diff --git a/kiskadee/tests/test_api_users.py b/kiskadee/tests/test_api_users.py index 2188c22..9955be5 100644 --- a/kiskadee/tests/test_api_users.py +++ b/kiskadee/tests/test_api_users.py @@ -74,6 +74,8 @@ class ApiUsersTestCase(unittest.TestCase): data = json.loads(response.data.decode("utf-8")) self.assertIn("token", data) + self.assertIn("user_id", data) + self.assertEqual(user.id, data['user_id']) self.assertEqual(200, response.status_code) # POST /login -> 401 Unauthorized From 2dcbd731ec1db5752cc31abce9be004293cc1882 Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Dec 19 2017 13:30:38 +0000 Subject: [PATCH 6/10] Pass user token on creation --- diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index 711e81b..86d47ae 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -223,7 +223,9 @@ def create_user(): user_schema = UserSchema() result = user_schema.dump(user) - return make_response(jsonify({'user': result.data}), 201) + token = user.generate_token() + + return make_response(jsonify({'user': result.data, 'token': token}), 201) @kiskadee.route('/users/', methods=['GET']) diff --git a/kiskadee/tests/test_api_users.py b/kiskadee/tests/test_api_users.py index 9955be5..d0f0383 100644 --- a/kiskadee/tests/test_api_users.py +++ b/kiskadee/tests/test_api_users.py @@ -156,6 +156,7 @@ class ApiUsersTestCase(unittest.TestCase): 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) From 4b8303a5858a58a29c92f38574302173c9733b57 Mon Sep 17 00:00:00 2001 From: DaniloBarros Date: Dec 19 2017 13:30:38 +0000 Subject: [PATCH 7/10] Add fields in response of login post method - user id, user name and user email Signed-off-by: DaniloBarros Signed-off-by: thiagocmoreira --- diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index 86d47ae..cafd35c 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -73,7 +73,7 @@ def login(): if user is not None and user.verify_password(password): token = user.generate_token() - response = {'token': token, 'user_id': user.id} + response = {'token': token, 'user': {'id': user.id, 'name': user.name, 'email': user.email}} return make_response(jsonify(response), 200) return make_response(jsonify({'error': 'Could not verify !'}), 401) From 6a6454ab93ea14200128a0fae54982c6e76f703f Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Dec 19 2017 13:31:56 +0000 Subject: [PATCH 8/10] Send email confirmation --- diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index cafd35c..a18d7ba 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -6,9 +6,9 @@ from marshmallow.exceptions import ValidationError from kiskadee.database import Database from kiskadee.model import Package, Fetcher, Version, Analysis, User,\ - TOKEN_SECRET_KEY + TOKEN_SECRET_KEY from kiskadee.api.serializers import PackageSchema, FetcherSchema,\ - AnalysisSchema, UserSchema + AnalysisSchema, UserSchema import json from sqlalchemy.orm import eagerload @@ -17,6 +17,8 @@ from functools import wraps kiskadee = Flask(__name__) +from . import mail + CORS(kiskadee) @@ -54,6 +56,7 @@ def token_required(fn): return decorated + @kiskadee.route('/login', methods=['POST']) def login(): """Token based login @@ -73,7 +76,8 @@ def login(): if user is not None and user.verify_password(password): token = user.generate_token() - response = {'token': token, 'user': {'id': user.id, 'name': user.name, 'email': user.email}} + response = {'token': token, 'user': { + 'id': user.id, 'name': user.name, 'email': user.email}} return make_response(jsonify(response), 200) return make_response(jsonify({'error': 'Could not verify !'}), 401) @@ -203,7 +207,8 @@ def create_user(): # Verify is user already exists if data.get('email'): - user = db_session.query(User).filter_by(email=data.get('email')).first() + 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) @@ -273,7 +278,7 @@ def update_user(token_data, user_id): if token_data['user_id'] != user_id: return make_response(jsonify({ 'error': 'token user does not match to requested user' - }), 403) + }), 403) json_data = request.get_json() user_data = UserSchema().dump(user).data @@ -322,7 +327,7 @@ def delete_user(token_data, user_id): if token_data['user_id'] != user_id: return make_response(jsonify({ 'error': 'token user does not match to requested user' - }), 403) + }), 403) db_session.delete(user) db_session.commit() diff --git a/kiskadee/api/mail.py b/kiskadee/api/mail.py new file mode 100644 index 0000000..f5bd949 --- /dev/null +++ b/kiskadee/api/mail.py @@ -0,0 +1,54 @@ +import os +import datetime +import jwt +from threading import Thread + +from flask import make_response, jsonify, url_for +from flask_mail import Mail, Message + +from kiskadee import config +from kiskadee.api.app import kiskadee + +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 = Mail(kiskadee) + +@kiskadee.route('/users/send_email') +def send_email(): + token = jwt.encode({ + 'user_id': 123456, + 'exp': datetime.datetime.utcnow() + datetime.timedelta(seconds=60) + }, kiskadee.config['EMAIL_TOKEN_SECRET_KEY']) + + msg = Message("Sera ?", sender="fabio1079@gmail.com", + recipients=['fabio1079@gmail.com']) + link = url_for("confirm_email", token=token, _external=True) + msg.body = 'MAs sera memsmo ?. Link: {}'.format(link) + + mail.send(msg) + + return make_response(jsonify({'ok': True}), 200) + + +@kiskadee.route('/users/confirm_email/') +def confirm_email(token): + print("=" * 80) + print("Token: {}".format(token)) + print("=" * 80) + + try: + jwt.decode(token, kiskadee.config['EMAIL_TOKEN_SECRET_KEY']) + return make_response(jsonify({'ok': True}), 200) + except jwt.ExpiredSignatureError: + return make_response(jsonify({'error': 'Token expired'}), 403) + except jwt.InvalidTokenError: + return make_response(jsonify({'error': 'Invalid token'}), 403) diff --git a/requirements.txt b/requirements.txt index a2431b9..71de7a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,4 @@ python-debian passlib bcrypt pyjwt +Flask-Mail diff --git a/util/kiskadee.conf b/util/kiskadee.conf index c2dc351..ff89280 100644 --- a/util/kiskadee.conf +++ b/util/kiskadee.conf @@ -52,3 +52,10 @@ cppcheck = 1.0.0 flawfinder = 1.0.0 clanganalyzer = 1.0.0 frama_c = 1.0.0 + +[mail] +MAIL_ENABLED = True +MAIL_SERVER = smtp.googlemail.com +MAIL_PORT = 465 +MAIL_USE_TLS = False +MAIL_USE_SSL = True \ No newline at end of file From 7d8aa57c7e5ed0badd99c47616b29a5e809b7c90 Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Dec 19 2017 13:32:42 +0000 Subject: [PATCH 9/10] Send e-mail on user registration --- diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index a18d7ba..ec83266 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -5,16 +5,13 @@ from flask_cors import CORS from marshmallow.exceptions import ValidationError from kiskadee.database import Database -from kiskadee.model import Package, Fetcher, Version, Analysis, User,\ - TOKEN_SECRET_KEY +from kiskadee.model import Package, Fetcher, Version, Analysis, User from kiskadee.api.serializers import PackageSchema, FetcherSchema,\ AnalysisSchema, UserSchema +from kiskadee.api.token import token_required import json from sqlalchemy.orm import eagerload -import jwt -from functools import wraps - kiskadee = Flask(__name__) from . import mail @@ -22,41 +19,6 @@ from . import mail CORS(kiskadee) -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) - - try: - data = jwt.decode(token, TOKEN_SECRET_KEY) - except jwt.ExpiredSignatureError: - return make_response(jsonify({'error': 'Token expired'}), 403) - except jwt.InvalidTokenError: - return make_response(jsonify({'error': 'Invalid token'}), 403) - - params = dict(kwargs, token_data=data) - return fn(*args, **params) - - return decorated - - @kiskadee.route('/login', methods=['POST']) def login(): """Token based login @@ -76,8 +38,14 @@ def login(): if user is not None and user.verify_password(password): token = user.generate_token() - response = {'token': token, 'user': { - 'id': user.id, 'name': user.name, 'email': user.email}} + response = { + 'token': token, + 'user': { + 'id': user.id, + 'name': user.name, + 'email': user.email + } + } return make_response(jsonify(response), 200) return make_response(jsonify({'error': 'Could not verify !'}), 401) @@ -211,16 +179,20 @@ def create_user(): email=data.get('email')).first() if user is not None: - return make_response(jsonify({'error': 'user already exists'}), 403) + 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) + return make_response( + jsonify({ + 'error': 'Validation error', + 'validations': error.args[0] + }), 400) db_session.add(user) db_session.commit() @@ -230,6 +202,8 @@ def create_user(): token = user.generate_token() + mail.send_confirmation_email(user) + return make_response(jsonify({'user': result.data, 'token': token}), 201) @@ -276,9 +250,10 @@ def update_user(token_data, user_id): 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) + 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 @@ -287,10 +262,11 @@ def update_user(token_data, user_id): validation = UserSchema().load(user_data) if bool(validation.errors): - return make_response(jsonify({ - 'error': 'Validation error', - 'validations': validation.errors - }), 400) + return make_response( + jsonify({ + 'error': 'Validation error', + 'validations': validation.errors + }), 400) password = validation.data.get('validation') if password is not None: @@ -325,9 +301,10 @@ def delete_user(token_data, user_id): 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) + return make_response( + jsonify({ + 'error': 'token user does not match to requested user' + }), 403) db_session.delete(user) db_session.commit() diff --git a/kiskadee/api/mail.py b/kiskadee/api/mail.py index f5bd949..c024034 100644 --- a/kiskadee/api/mail.py +++ b/kiskadee/api/mail.py @@ -8,47 +8,67 @@ from flask_mail import Mail, Message from kiskadee import config 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_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 = Mail(kiskadee) -@kiskadee.route('/users/send_email') -def send_email(): - token = jwt.encode({ - 'user_id': 123456, - 'exp': datetime.datetime.utcnow() + datetime.timedelta(seconds=60) - }, kiskadee.config['EMAIL_TOKEN_SECRET_KEY']) - msg = Message("Sera ?", sender="fabio1079@gmail.com", - recipients=['fabio1079@gmail.com']) +def send_confirmation_email(user): + if not kiskadee.config['MAIL_ENABLED']: + return None + + token = jwt.encode( + { + 'user_id': user.id, + 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24) + }, + kiskadee.config['EMAIL_TOKEN_SECRET_KEY']) + + msg = Message( + "Kiskadee email confirmation", + sender=kiskadee.config['MAIL_USERNAME'], + recipients=[user.email]) + link = url_for("confirm_email", token=token, _external=True) - msg.body = 'MAs sera memsmo ?. Link: {}'.format(link) - mail.send(msg) + 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. - return make_response(jsonify({'ok': True}), 200) + 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/') def confirm_email(token): - print("=" * 80) - print("Token: {}".format(token)) - print("=" * 80) + vefirication = token_vefirication(token, kiskadee.config['EMAIL_TOKEN_SECRET_KEY']) - try: - jwt.decode(token, kiskadee.config['EMAIL_TOKEN_SECRET_KEY']) + if 'error' in vefirication: + return make_response(jsonify(vefirication), 403) + else: return make_response(jsonify({'ok': True}), 200) - except jwt.ExpiredSignatureError: - return make_response(jsonify({'error': 'Token expired'}), 403) - except jwt.InvalidTokenError: - return make_response(jsonify({'error': 'Invalid token'}), 403) diff --git a/kiskadee/api/token.py b/kiskadee/api/token.py new file mode 100644 index 0000000..0e4cfcd --- /dev/null +++ b/kiskadee/api/token.py @@ -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) + 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 From f85e55b8272cbcba030356d65c0aa588ffae4c23 Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Dec 22 2017 18:11:04 +0000 Subject: [PATCH 10/10] Add user activation --- diff --git a/alembic/versions/2dce48cbfea6_added_is_active_to_user_model.py b/alembic/versions/2dce48cbfea6_added_is_active_to_user_model.py new file mode 100644 index 0000000..f89c379 --- /dev/null +++ b/alembic/versions/2dce48cbfea6_added_is_active_to_user_model.py @@ -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 ### diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index ec83266..5340266 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -27,7 +27,8 @@ def login(): Possible status code: - 200 Ok -> User token - - 401 Unauthorized -> Could not log user + - 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')] @@ -36,19 +37,16 @@ def login(): 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': { - 'id': user.id, - 'name': user.name, - 'email': user.email - } - } + + response = {'token': token, 'user': UserSchema().dump(user).data} return make_response(jsonify(response), 200) - return make_response(jsonify({'error': 'Could not verify !'}), 401) + return make_response(jsonify({'error': 'Unauthorized, invalid user credentials'}), 401) @kiskadee.route('/fetchers') @@ -196,14 +194,13 @@ def create_user(): db_session.add(user) db_session.commit() + mail.send_confirmation_email(user) user_schema = UserSchema() result = user_schema.dump(user) token = user.generate_token() - mail.send_confirmation_email(user) - return make_response(jsonify({'user': result.data, 'token': token}), 201) @@ -319,4 +316,7 @@ def kiskadee_db_session(): def main(): """Initialize the kiskadee API.""" + cleaner = mail.UnconfirmedEmailsCleaner() + cleaner.start() + kiskadee.run('0.0.0.0') diff --git a/kiskadee/api/mail.py b/kiskadee/api/mail.py index c024034..da99a5c 100644 --- a/kiskadee/api/mail.py +++ b/kiskadee/api/mail.py @@ -1,12 +1,16 @@ 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 @@ -26,22 +30,41 @@ kiskadee.config.update({ 'MAIL_USE_SSL': config['mail']['MAIL_USE_SSL'] == 'True', 'EMAIL_TOKEN_SECRET_KEY': - os.environ.get('EMAIL_TOKEN_SECRET_KEY', 'dev email token') + 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 send_confirmation_email(user): - if not kiskadee.config['MAIL_ENABLED']: - return None - +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']) + 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", @@ -64,11 +87,54 @@ def send_confirmation_email(user): mail.send(msg) -@kiskadee.route('/users/confirm_email/') +@kiskadee.route('/users/confirm_email/', methods=['GET']) def confirm_email(token): - vefirication = token_vefirication(token, kiskadee.config['EMAIL_TOKEN_SECRET_KEY']) + """ + 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/ + + Possible status code: + - 200 Ok -> E-mail confirmed, go back to kiskadee: + - 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) - else: - return make_response(jsonify({'ok': True}), 200) + + 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: Kiskadee + '''.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() diff --git a/kiskadee/api/serializers.py b/kiskadee/api/serializers.py index 44e2f0a..12bbf13 100644 --- a/kiskadee/api/serializers.py +++ b/kiskadee/api/serializers.py @@ -101,6 +101,7 @@ class UserSchema(Schema): 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.""" diff --git a/kiskadee/api/token.py b/kiskadee/api/token.py index 0e4cfcd..ccbd653 100644 --- a/kiskadee/api/token.py +++ b/kiskadee/api/token.py @@ -21,7 +21,7 @@ def token_vefirication(token, verification_key): {'error': 'Invalid token'} """ try: - data = jwt.decode(token, verification_key) + data = jwt.decode(token, verification_key, algorithms=['HS256']) return {'data': data} except jwt.ExpiredSignatureError: return {'error': 'Token expired'} diff --git a/kiskadee/model.py b/kiskadee/model.py index 4e879ec..795b2b0 100644 --- a/kiskadee/model.py +++ b/kiskadee/model.py @@ -2,7 +2,8 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy import Column, Integer, UnicodeText, UniqueConstraint,\ - Sequence, Unicode, ForeignKey, orm, JSON, String + Sequence, Unicode, ForeignKey, orm, JSON, String,\ + Boolean from passlib.apps import custom_app_context as pwd_context import kiskadee @@ -14,6 +15,7 @@ TOKEN_SECRET_KEY = os.getenv('TOKEN_SECRET_KEY', 'default development key') Base = declarative_base() + class Package(Base): """Software packages abstraction. @@ -23,23 +25,21 @@ class Package(Base): """ __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) @@ -50,22 +50,20 @@ class Version(Base): """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') @@ -75,21 +73,21 @@ class Analysis(Base): """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') @@ -104,8 +102,8 @@ def create_analyzers(_session): """ 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 @@ -115,11 +113,12 @@ def create_analyzers(_session): class User(Base): __tablename__ = 'users' - id = Column(Integer, - Sequence('users_id_seq', optional=True), primary_key=True) + 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 @@ -139,9 +138,13 @@ class User(Base): 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) + 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') diff --git a/kiskadee/tests/test_api_users.py b/kiskadee/tests/test_api_users.py index d0f0383..051659a 100644 --- a/kiskadee/tests/test_api_users.py +++ b/kiskadee/tests/test_api_users.py @@ -9,6 +9,7 @@ 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. @@ -17,7 +18,6 @@ def mock_hash_password(self, password): class ApiUsersTestCase(unittest.TestCase): - def setUp(self): kiskadee.api.app.kiskadee.testing = True self.engine = kiskadee.database.Database('db_test').engine @@ -30,11 +30,22 @@ class ApiUsersTestCase(unittest.TestCase): @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'}, - {'name': 'test 2', 'email': 'test@user2.com', 'password': 'test'}, - {'name': 'test 3', 'email': 'test@user3.com', 'password': 'test'} - ] + 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) @@ -53,7 +64,8 @@ class ApiUsersTestCase(unittest.TestCase): user_data = { 'name': 'login', 'email': 'login@email.com', - 'password':'login' + 'password': 'login', + 'is_active': True } # Creating a user as user.verify_password inside login route @@ -67,17 +79,49 @@ class ApiUsersTestCase(unittest.TestCase): 'password': user_data['password'] } - response = self.app.post("/login", - data=json.dumps(login_data), - content_type='application/json') + 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_id", data) - self.assertEqual(user.id, data['user_id']) + 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 @@ -85,7 +129,8 @@ class ApiUsersTestCase(unittest.TestCase): user_data = { 'name': 'login', 'email': 'login@email.com', - 'password':'login' + 'password': 'login', + 'is_active': True } user = UserSchema.create(**user_data) @@ -97,17 +142,18 @@ class ApiUsersTestCase(unittest.TestCase): 'password': 'not my password' } - response = self.app.post("/login", - data=json.dumps(login_data), - content_type='application/json') + 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"], "Could not verify !") + 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(): @@ -118,8 +164,10 @@ class ApiUsersTestCase(unittest.TestCase): user = self.session.query(User).first() user_token = user.generate_token() - response = self.app.get("/users", - headers={'x-access-token': user_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() @@ -132,7 +180,6 @@ class ApiUsersTestCase(unittest.TestCase): 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): @@ -148,16 +195,18 @@ class ApiUsersTestCase(unittest.TestCase): } total_users_before_creation = self.session.query(User).count() - response = self.app.post("/users", - data=json.dumps(new_user_data), - content_type='application/json') + 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(total_users_after_creation, + total_users_before_creation + 1) self.assertEqual(data['user']['email'], new_user_data['email']) self.assertEqual(201, response.status_code) @@ -168,22 +217,21 @@ class ApiUsersTestCase(unittest.TestCase): kiskadee.api.app.kiskadee_db_session = mock_kiskadee_db_session - new_user_data = { - 'name': 'new user', - 'password': 'new user' - } + 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') + 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(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.') @@ -203,15 +251,17 @@ class ApiUsersTestCase(unittest.TestCase): } total_users_before_creation = self.session.query(User).count() - response = self.app.post("/users", - data=json.dumps(new_user_data), - content_type='application/json') + 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(total_users_after_creation, + total_users_before_creation) self.assertEqual(data['error'], 'user already exists') self.assertEqual(403, response.status_code) @@ -225,8 +275,10 @@ class ApiUsersTestCase(unittest.TestCase): 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}) + 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) @@ -247,8 +299,10 @@ class ApiUsersTestCase(unittest.TestCase): user = self.session.query(User).first() user_token = user.generate_token() - response = self.app.get("/users/%d" % 123456789, - headers={'x-access-token': user_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) @@ -266,11 +320,16 @@ class ApiUsersTestCase(unittest.TestCase): 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}) + 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")) @@ -296,10 +355,15 @@ class ApiUsersTestCase(unittest.TestCase): 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}) + 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")) @@ -323,11 +387,16 @@ class ApiUsersTestCase(unittest.TestCase): 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}) + 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")) @@ -351,10 +420,13 @@ class ApiUsersTestCase(unittest.TestCase): 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}) + 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 @@ -370,7 +442,7 @@ class ApiUsersTestCase(unittest.TestCase): self.assertIn("error", data) self.assertEqual(data['error'], - 'token user does not match to requested user') + 'token user does not match to requested user') self.assertEqual(403, response.status_code) token = user_to_update.generate_token() @@ -392,10 +464,15 @@ class ApiUsersTestCase(unittest.TestCase): 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}) + 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")) @@ -413,8 +490,11 @@ class ApiUsersTestCase(unittest.TestCase): 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}) + 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) @@ -435,14 +515,17 @@ class ApiUsersTestCase(unittest.TestCase): 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}) + 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') + 'token user does not match to requested user') self.assertEqual(403, response.status_code) # DELETE /users/:id -> 404 Not Found @@ -455,8 +538,11 @@ class ApiUsersTestCase(unittest.TestCase): user = self.session.query(User).first() user_token = user.generate_token() - response = self.app.delete("/users/{}".format(123456789), - headers={'x-access-token': user_token}) + response = self.app.delete( + "/users/{}".format(123456789), + headers={ + 'x-access-token': user_token + }) data = json.loads(response.data.decode("utf-8")) diff --git a/kiskadee/tests/test_model.py b/kiskadee/tests/test_model.py index 0ea53f0..f531614 100644 --- a/kiskadee/tests/test_model.py +++ b/kiskadee/tests/test_model.py @@ -202,7 +202,7 @@ class ModelTestCase(unittest.TestCase): 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) + decoded_token = jwt.decode(token, model.TOKEN_SECRET_KEY, algorithms=['HS256']) self.assertGreaterEqual(len(token), 121) self.assertEqual(decoded_token['user_id'], u.id) diff --git a/util/kiskadee.conf b/util/kiskadee.conf index ff89280..f21b9e0 100644 --- a/util/kiskadee.conf +++ b/util/kiskadee.conf @@ -54,8 +54,10 @@ clanganalyzer = 1.0.0 frama_c = 1.0.0 [mail] -MAIL_ENABLED = True +MAIL_ENABLED = False MAIL_SERVER = smtp.googlemail.com MAIL_PORT = 465 MAIL_USE_TLS = False -MAIL_USE_SSL = True \ No newline at end of file +MAIL_USE_SSL = True +MAIL_CONFIRM_REDIRECT = http://localhost:8080/ +MAIL_UNCONFIRMED_CLEANER_TIMER = 3600 \ No newline at end of file