From 515d8e87fe07c4307c1ba40d62a16c4b6b5a78e2 Mon Sep 17 00:00:00 2001 From: Kfir Dayan Date: Sun, 7 Jan 2024 13:28:49 +0200 Subject: [PATCH] add users table + authentication --- app.py | 51 ++++++++++++++---- config.py | 3 +- middlewares.py | 71 +++++++++++++++++++++++++ migrations/versions/9d6b0ea04d2c_.py | 46 ++++++++++++++++ migrations/versions/b962126d3578_.py | 36 +++++++++++++ models.py | 25 +++++++++ routes/__init__.py | 0 routes.py => routes/eventRoutes.py | 16 +++--- routes/userRoutes.py | 46 ++++++++++++++++ services.py => services/EventService.py | 1 + services/UserService.py | 33 ++++++++++++ 11 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 migrations/versions/9d6b0ea04d2c_.py create mode 100644 migrations/versions/b962126d3578_.py create mode 100644 routes/__init__.py rename routes.py => routes/eventRoutes.py (79%) create mode 100644 routes/userRoutes.py rename services.py => services/EventService.py (99%) create mode 100644 services/UserService.py diff --git a/app.py b/app.py index 553e20d..d5b6e9f 100644 --- a/app.py +++ b/app.py @@ -1,16 +1,49 @@ from flask import Flask from models import db from flask_migrate import Migrate -from routes import api +from routes.userRoutes import userRoutes +from routes.eventRoutes import eventRoutes import config +from flask_jwt_extended import JWTManager -app = Flask(__name__) -app.config.from_object(config.Config) -db.init_app(app) -migrate = Migrate(app, db) -app.register_blueprint(api) +class App: + def __init__(self): + self.app = Flask(__name__) + self.set_config() + self.set_up_db() + self.set_up_jwt() + self.register_blueprints() + + def set_config(self): + self.app.config.from_object(config.Config) + + def set_up_db(self): + db.init_app(self.app) + self.migrate = Migrate(self.app, db) + + def set_up_jwt(self): + self.jwt_manager = JWTManager(self.app) + self.app.config['JWT_TOKEN_LOCATION'] = ['cookies'] + self.app.config['JWT_COOKIE_NAME'] = 'access_token_cookie' + + + def register_blueprints(self): + self.app.register_blueprint(userRoutes, url_prefix='/user') + self.app.register_blueprint(eventRoutes, url_prefix='/event') + + def run(self): + with self.app.app_context(): + db.create_all() + self.app.run(debug=True) + + def print_endpoints(self): + print("Endpoints and their functions:") + for rule in self.app.url_map.iter_rules(): + print(f"Endpoint: {rule.endpoint}, Path: {rule}") + function_name = self.app.view_functions[rule.endpoint].__name__ + print(f" Function: {function_name}") if __name__ == '__main__': - with app.app_context(): - db.create_all() - app.run(debug=True) + app_instance = App() + app_instance.print_endpoints() + app_instance.run() diff --git a/config.py b/config.py index 6febdee..d05d99b 100644 --- a/config.py +++ b/config.py @@ -1,2 +1,3 @@ class Config: - SQLALCHEMY_DATABASE_URI = 'sqlite:///events.db' \ No newline at end of file + SQLALCHEMY_DATABASE_URI = 'sqlite:///events.db' + JWT_SECRET_KEY = 'your_jwt_secret_key' \ No newline at end of file diff --git a/middlewares.py b/middlewares.py index 9b54ba0..6f93fc6 100644 --- a/middlewares.py +++ b/middlewares.py @@ -1,6 +1,63 @@ from functools import wraps from flask import request, jsonify from datetime import datetime +from flask_jwt_extended import jwt_required, get_jwt_identity + + +def validate_user_post_request(f): + @wraps(f) + def decorated_function(*args, **kwargs): + data = request.get_json() + if not data: + return jsonify({"message": "No input data provided"}), 400 + + # Check required fields + required_fields = ['username', 'password', 'email', 'location'] + if not all(field in data for field in required_fields): + return jsonify({"message": "Please check your data, you missing some props; visit our docs https://git.dayanhub.com/kfir"}), 400 + + # Validate 'username' + if not isinstance(data['username'], str) or not data['username'].strip(): + return jsonify({"message": "Invalid username"}), 400 + + # Validate 'password' + if not isinstance(data['password'], str) or not data['password'].strip(): + return jsonify({"message": "Invalid password"}), 400 + + # Validate 'email' + if not isinstance(data['email'], str) or not data['email'].strip(): + return jsonify({"message": "Invalid email"}), 400 + + # Validate 'location' + if not isinstance(data['location'], str) or not data['location'].strip(): + return jsonify({"message": "Invalid location"}), 400 + + return f(*args, **kwargs) + return decorated_function + + +def validate_user_login_request(f): + @wraps(f) + def decorated_function(*args, **kwargs): + data = request.get_json() + if not data: + return jsonify({"message": "No input data provided"}), 400 + + # Check required fields + required_fields = ['email', 'password'] + if not all(field in data for field in required_fields): + return jsonify({"message": "Please check your data, you missing some props; visit our docs https://git.dayanhub.com/kfir"}), 400 + + # Validate 'email' + if not isinstance(data['email'], str) or not data['email'].strip(): + return jsonify({"message": "Invalid email"}), 400 + + # Validate 'password' + if not isinstance(data['password'], str) or not data['password'].strip(): + return jsonify({"message": "Invalid password"}), 400 + + return f(*args, **kwargs) + return decorated_function def validate_event_post_request(f): @wraps(f) @@ -34,3 +91,17 @@ def validate_event_post_request(f): return f(*args, **kwargs) return decorated_function + +def authenticate_user(f): + @wraps(f) + @jwt_required(locations=["cookies"]) # Specify to look for the token in cookies + def decorated_function(*args, **kwargs): + # Get user identity from JWT + user_id = get_jwt_identity() + if user_id: + request.user_id = user_id + else: + return jsonify({"error": "Invalid session token"}), 401 + + return f(*args, **kwargs) + return decorated_function \ No newline at end of file diff --git a/migrations/versions/9d6b0ea04d2c_.py b/migrations/versions/9d6b0ea04d2c_.py new file mode 100644 index 0000000..dd41d2c --- /dev/null +++ b/migrations/versions/9d6b0ea04d2c_.py @@ -0,0 +1,46 @@ +"""empty message + +Revision ID: 9d6b0ea04d2c +Revises: 5569d39a87cf +Create Date: 2024-01-07 11:34:58.903280 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9d6b0ea04d2c' +down_revision = '5569d39a87cf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('name', sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column('email', sa.String(length=120), nullable=False)) + batch_op.add_column(sa.Column('location', sa.String(length=100), nullable=True)) + batch_op.alter_column('id', + existing_type=sa.INTEGER(), + type_=sa.String(length=36), + existing_nullable=False) + batch_op.create_unique_constraint('uq_user_email', ['email']) # Added constraint name here + batch_op.drop_column('username') + # ### end Alembic commands ### + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('username', sa.VARCHAR(length=80), nullable=False)) + batch_op.drop_constraint('uq_user_email', type_='unique') # Updated constraint name here + batch_op.alter_column('id', + existing_type=sa.String(length=36), + type_=sa.INTEGER(), + existing_nullable=False) + batch_op.drop_column('location') + batch_op.drop_column('email') + batch_op.drop_column('name') + # ### end Alembic commands ### + diff --git a/migrations/versions/b962126d3578_.py b/migrations/versions/b962126d3578_.py new file mode 100644 index 0000000..bcba3c2 --- /dev/null +++ b/migrations/versions/b962126d3578_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: b962126d3578 +Revises: 9d6b0ea04d2c +Create Date: 2024-01-07 11:41:03.752411 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b962126d3578' +down_revision = '9d6b0ea04d2c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('location', + existing_type=sa.VARCHAR(length=100), + nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('location', + existing_type=sa.VARCHAR(length=100), + nullable=True) + + # ### end Alembic commands ### diff --git a/models.py b/models.py index 91da4eb..7fcb49e 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,9 @@ from flask_sqlalchemy import SQLAlchemy +from flask_bcrypt import Bcrypt +import uuid db = SQLAlchemy() +bcrypt = Bcrypt() class Event(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -20,3 +23,25 @@ class Event(db.Model): 'duedate': self.duedate.isoformat() if self.duedate else None, 'created_at': self.created_at.isoformat() } + +class User(db.Model): + id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name = db.Column(db.String(100)) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(128)) + location = db.Column(db.String(100), nullable=False) + + + def set_password(self, password): + self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8') + + def check_password(self, password): + return bcrypt.check_password_hash(self.password_hash, password) + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'email': self.email, + 'location': self.location + } \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes.py b/routes/eventRoutes.py similarity index 79% rename from routes.py rename to routes/eventRoutes.py index 749f49d..40b117d 100644 --- a/routes.py +++ b/routes/eventRoutes.py @@ -1,11 +1,12 @@ from flask import Blueprint, jsonify, request -from services import EventService +from services.EventService import EventService from middlewares import validate_event_post_request -api = Blueprint('api', __name__) +eventRoutes = Blueprint('eventRoutes', __name__) # Create new event -@api.route('/events', methods=['POST']) +@eventRoutes.route('/', methods=['POST']) +@eventRoutes.route('', methods=['POST']) @validate_event_post_request def create_event(): try: @@ -19,7 +20,8 @@ def create_event(): return jsonify({'error': str(e)}), 500 # Get All Events -@api.route('/events', methods=['GET']) +@eventRoutes.route('/', methods=['GET']) +@eventRoutes.route('', methods=['GET']) def get_events(): try: return {"events": EventService.get_all_events()}, 200 @@ -27,7 +29,7 @@ def get_events(): return {"error": str(e)}, 500 # Get Event by ID -@api.route('/events/', methods=['GET']) +@eventRoutes.route('/', methods=['GET']) def get_event(event_id): try: return {"event": EventService.get_event_by_id(event_id)}, 200 @@ -35,7 +37,7 @@ def get_event(event_id): return {"error": str(e)}, 500 # Update Event -@api.route('/events/', methods=['PUT']) +@eventRoutes.route('/', methods=['PUT']) @validate_event_post_request def update_event(event_id): try: @@ -49,7 +51,7 @@ def update_event(event_id): return jsonify({'error': str(e)}), 500 # DELETE Event -@api.route('/events/', methods=['DELETE']) +@eventRoutes.route('/', methods=['DELETE']) def delete_event(event_id): try: deleted_event = EventService.delete_event(event_id) diff --git a/routes/userRoutes.py b/routes/userRoutes.py new file mode 100644 index 0000000..3112394 --- /dev/null +++ b/routes/userRoutes.py @@ -0,0 +1,46 @@ +from flask import Blueprint, jsonify, request +from services.UserService import UserService +from flask_jwt_extended import JWTManager, jwt_required, create_access_token, get_jwt_identity +from middlewares import validate_user_post_request, validate_user_login_request, authenticate_user + +userRoutes = Blueprint('userRoutes', __name__) + + +@userRoutes.route('/', methods=['GET']) +@userRoutes.route('', methods=['GET']) +@authenticate_user +def allUsers(): + users = UserService.get_all_users() + return jsonify(users), 200 + +@userRoutes.route('/', methods=['POST']) +@userRoutes.route('', methods=['POST']) +@validate_user_post_request +def createNewUser(): + try: + data = request.json + if UserService.get_user_by_email(data['email']): + return jsonify({'error': 'User already exists'}), 400 + new_user = UserService.create_user(data) + if new_user: + return jsonify(new_user), 201 + else: + return jsonify({'error': 'Failed to create user'}), 400 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@userRoutes.route('/login', methods=['POST']) +@validate_user_login_request +def loginUser(): + try: + data = request.json + user = UserService.verify_user(data) + if user: + sessionToken = create_access_token(identity=user.id) + response = jsonify(user.to_dict()) + response.set_cookie('access_token_cookie', sessionToken, httponly=True, path='/') + return response, 200 + else: + return jsonify({'error': 'Invalid credentials'}), 400 + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/services.py b/services/EventService.py similarity index 99% rename from services.py rename to services/EventService.py index 9e6e5cb..1545c4d 100644 --- a/services.py +++ b/services/EventService.py @@ -22,6 +22,7 @@ class EventService: @staticmethod def get_event_by_id(event_id): return Event.query.filter_by(id=event_id, deleted=False).first().to_dict() + @staticmethod def update_event(event_id, data): event = Event.query.get(event_id) diff --git a/services/UserService.py b/services/UserService.py new file mode 100644 index 0000000..d043bf9 --- /dev/null +++ b/services/UserService.py @@ -0,0 +1,33 @@ +from flask_bcrypt import Bcrypt +from models import db, User + +bcrypt = Bcrypt() + +class UserService: + @staticmethod + def create_user(data): + new_user = User( + name=data['username'], + email=data['email'], + location=data['location'], + password_hash=bcrypt.generate_password_hash(data['password']).decode('utf-8') + ) + db.session.add(new_user) + db.session.commit() + return new_user + + @staticmethod + def get_all_users(): + users = User.query.all() + return [user.to_dict() for user in users] + + @staticmethod + def get_user_by_email(email): + return User.query.filter_by(email=email).first() + + @staticmethod + def verify_user(data): + user = UserService.get_user_by_email(data['email']) + if user and bcrypt.check_password_hash(user.password_hash, data['password']): + return user + return None