From 9591c0a71a96e110f5485f46c6fe8b8373cdb671 Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Nov 21 2017 11:42:46 +0000 Subject: [PATCH 1/7] 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 4acdb24..2a1e9a9 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 kiskadee = Flask(__name__) @@ -64,6 +65,149 @@ def package_analysis(pkg_name, version): return jsonify({'analysis': results.data}) +@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 98e7835..5fc16f6 100644 --- a/kiskadee/api/serializers.py +++ b/kiskadee/api/serializers.py @@ -1,7 +1,7 @@ """Provide objects to serialize the kiskadee models.""" -from marshmallow import Schema, fields -from kiskadee.model import Package, Fetcher, Analysis, Version, Report +from marshmallow import Schema, fields, validate, exceptions +from kiskadee.model import Package, Fetcher, Analysis, Version, Report, User class ReportsSchema(Schema): @@ -72,3 +72,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 8303835..07d1751 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,6 @@ flask_cors Flask-Restless marshmallow alembic +sphinx-rtd-theme +passlib +bcrypt From bf14d7cc8c7a13322b8966a62f6414d264929b39 Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Nov 21 2017 11:42:46 +0000 Subject: [PATCH 2/7] Add token generation and verification --- diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index 2a1e9a9..80ae044 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -5,16 +5,74 @@ 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 +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.""" @@ -66,7 +124,8 @@ def package_analysis(pkg_name, version): @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 07d1751..d8056e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ alembic sphinx-rtd-theme passlib bcrypt +pyjwt \ No newline at end of file From 87324c44d1d2de1160d81c0328e3188c145963f6 Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Nov 21 2017 17:23:08 +0000 Subject: [PATCH 3/7] 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 80ae044..bd7f1ae 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -44,13 +44,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) @@ -59,6 +55,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')] @@ -180,8 +184,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 @@ -202,8 +207,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 @@ -211,6 +217,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() @@ -219,6 +226,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) @@ -245,14 +257,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() @@ -261,6 +275,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")) diff --git a/requirements.txt b/requirements.txt index d8056e4..b3c71ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,6 @@ flask_cors Flask-Restless marshmallow alembic -sphinx-rtd-theme passlib bcrypt pyjwt \ No newline at end of file From 6e8cb1c665d38736eefa80de6bc392ab765445d8 Mon Sep 17 00:00:00 2001 From: danmoura17 Date: Nov 21 2017 18:54:00 +0000 Subject: [PATCH 4/7] 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 e559e7fb2efcf85cace654a08f03c07c174299cb Mon Sep 17 00:00:00 2001 From: danmoura17 Date: Nov 21 2017 19:05:43 +0000 Subject: [PATCH 5/7] 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 bd7f1ae..e5ff6c3 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -72,7 +72,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 1f982a6b0883497180b3d7970a3ae9822e404302 Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Nov 23 2017 19:39:35 +0000 Subject: [PATCH 6/7] Pass user token on creation --- diff --git a/kiskadee/api/app.py b/kiskadee/api/app.py index e5ff6c3..ca2aeb2 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -182,7 +182,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 a733290598d7d03d4eb200ed8de61044264ae1b4 Mon Sep 17 00:00:00 2001 From: DaniloBarros Date: Nov 30 2017 21:08:33 +0000 Subject: [PATCH 7/7] 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 ca2aeb2..01b90cc 100644 --- a/kiskadee/api/app.py +++ b/kiskadee/api/app.py @@ -72,7 +72,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)