From aa9c10f01f1bb789c0645fcab664a79554df5fbb Mon Sep 17 00:00:00 2001 From: git2060 Date: Fri, 24 Jul 2026 08:24:40 +0530 Subject: [PATCH 1/4] inital commit --- core/api/auth/auth_helper.py | 2 ++ models/signin_attempt.py | 44 ++++++++++++++++++++++++++ schemas/signin_attempt_schema.py | 9 ++++++ utils.py | 54 +++++++++++++++++++++++++++++++- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 models/signin_attempt.py create mode 100644 schemas/signin_attempt_schema.py diff --git a/core/api/auth/auth_helper.py b/core/api/auth/auth_helper.py index 0e29e85..131694e 100644 --- a/core/api/auth/auth_helper.py +++ b/core/api/auth/auth_helper.py @@ -5,6 +5,7 @@ ) from core import socketio from core.api.bookings import messages +from utils import generate_device_hash from functools import wraps from flask import ( @@ -185,6 +186,7 @@ def wrapped(*args, **kwargs): try: token = request.headers['access-token'] uid = verify_token(token) + generate_device_hash() print(uid) logger.debug("user with data: {} still has access".format(uid)) user = User.query.filter_by(user_id=uid).first() diff --git a/models/signin_attempt.py b/models/signin_attempt.py new file mode 100644 index 0000000..f08e9c0 --- /dev/null +++ b/models/signin_attempt.py @@ -0,0 +1,44 @@ +from core import db +from .base import TimestampMixin + + +class SignInAttempts(TimestampMixin, db.Model): + + __tablename__= "signin_attempts" + + id = db.Column( + db.Integer, + primary_key=True + autoincrement=True + ) + + user_id = db.Column( + db.Integer, + db.ForeignKey("users.id"), + nullable=False + ) + + created_at = db.Column( + db.DateTime, + nullable=False, + default=db.func.current_timestamp() + ) + + device_os = db.Column( + db.String(50), + nullable=True + ) + + ip_address = db.Column( + db.String(45), + nullable=True + ) + + user = db.relationship( + "User", + back_populates="signin_attempts") + + estimated_location = db.Column( + db.JSON, + nullable=True + ) diff --git a/schemas/signin_attempt_schema.py b/schemas/signin_attempt_schema.py new file mode 100644 index 0000000..2bb85e6 --- /dev/null +++ b/schemas/signin_attempt_schema.py @@ -0,0 +1,9 @@ +from models.sigin_attempt import SignInAttempt +from .base import BaseSQLAlchemyAutoSchema + +class SignInAttemptSchema(BaseSQLAlchemyAutoSchema): + class Meta: + model = SignInAttempt + include_fk = True + include_relationships = True + load_instance = True \ No newline at end of file diff --git a/utils.py b/utils.py index 8581b9e..de9b9fa 100644 --- a/utils.py +++ b/utils.py @@ -1,5 +1,6 @@ import os import json +from urllib import response import zlib import pprint import base64 @@ -7,13 +8,25 @@ import requests import mimetypes import subprocess +import hashlib from loguru import logger -from flask import jsonify +from flask import jsonify, request from google.cloud import storage from firebase_admin import messaging from google.oauth2 import service_account from werkzeug.http import HTTP_STATUS_CODES +from user_agents import parse + +from models.signin_attempt import SignInAttempt +from models.user_models import User +from core import db +from add_extensions import ( + redis_, + redis_2, + redis_4, + redis_7 +) def is_serializable(obj): @@ -327,6 +340,45 @@ def decode_id(val): return int(res) +def generate_device_hash(): + raw_string = f"{device_os}:{ip_address}" + + # Generate a hash using SHA256 + hash_object = hashlib.sha256(raw_string.encode()) + device_hash = hash_object.hexdigest() + + redis_key = f"device_hash:{device_hash}" + + user_agent = parse(request.headers.get("User-Agent")) + + device_os = user_agent.os.family + + ip_address = request.headers.get( + "X-Forwarded-For", + request.remote_addr + ) + + response = requests.get(f"http://ip-api.com/json/{ip_address}") + data = response.json() + estimated_location = ( + f"{data['city']}, " + f"{data['regionName']}, " + f"{data['country']}" + ) + + if redis_.exists(redis_key): + logger.debug(f"Device hash {device_hash}is Known.") + else: + # send notification to user about new device login + logger.debug(f"Device hash {device_hash} is Unknown. Storing in Redis.") + + + + + + + + # --- Example Usage --- # my_number = 1000000000 # encoded_number = base62_encode(my_number) From 6422afd0cd4770f4e3adfc5959ea48b413cf91aa Mon Sep 17 00:00:00 2001 From: git2060 Date: Wed, 5 Aug 2026 20:03:26 +0530 Subject: [PATCH 2/4] added security login --- build/cloudbuild.yaml | 4 +- core/api/auth/auth_helper.py | 5 +- core/api/bookings/events/artisan.py | 9 +- core/api/bookings/events/customer.py | 2 +- core/api/bookings/views.py | 3 - core/api/user/views.py | 27 +++- core/api/views.py | 4 +- devices.py | 183 +++++++++++++++++++++++++++ models/signin_attempt.py | 53 +++----- models/user_models.py | 12 +- run.sh | 2 +- sample.py | 6 +- schemas/artisan.py | 1 + schemas/generic.py | 9 -- schemas/signin_attempt_schema.py | 4 +- schemas/user_schemas.py | 18 ++- supervisord-dev.conf | 2 +- utils.py | 126 +++++++++--------- 18 files changed, 329 insertions(+), 141 deletions(-) create mode 100644 devices.py diff --git a/build/cloudbuild.yaml b/build/cloudbuild.yaml index 4247764..141bb66 100644 --- a/build/cloudbuild.yaml +++ b/build/cloudbuild.yaml @@ -9,9 +9,9 @@ steps: - '--build-arg' - 'COCKROACH_DB_CERT_URL=${_COCKROACH_DB_CERT_URL}' - '-t' - - 'africa-south1-docker.pkg.dev/$PROJECT_ID/handees-dev/handees-backend:1.1' + - 'africa-south1-docker.pkg.dev/$PROJECT_ID/handees-dev/handees-backend:1.2' - '-f' - 'docker/Dockerfile' - '.' images: - - 'africa-south1-docker.pkg.dev/$PROJECT_ID/handees-dev/handees-backend:1.1' \ No newline at end of file + - 'africa-south1-docker.pkg.dev/$PROJECT_ID/handees-dev/handees-backend:1.2' \ No newline at end of file diff --git a/core/api/auth/auth_helper.py b/core/api/auth/auth_helper.py index 131694e..c0f1744 100644 --- a/core/api/auth/auth_helper.py +++ b/core/api/auth/auth_helper.py @@ -5,7 +5,7 @@ ) from core import socketio from core.api.bookings import messages -from utils import generate_device_hash +from devices import check_device_hash from functools import wraps from flask import ( @@ -186,8 +186,9 @@ def wrapped(*args, **kwargs): try: token = request.headers['access-token'] uid = verify_token(token) - generate_device_hash() print(uid) + user = User.query.filter_by(user_id=uid).first() + check_device_hash(user) logger.debug("user with data: {} still has access".format(uid)) user = User.query.filter_by(user_id=uid).first() except (Exception or Exception in excs or auth.ExpiredIdTokenError) as e: diff --git a/core/api/bookings/events/artisan.py b/core/api/bookings/events/artisan.py index b68c6e9..ef3bdd1 100644 --- a/core/api/bookings/events/artisan.py +++ b/core/api/bookings/events/artisan.py @@ -137,7 +137,7 @@ def on_connect(auth): @socketio.on('disconnect', namespace='/artisan') -def on_disconnect(): +def on_disconnect(reason=None): dropping_sid = request.sid print(f"==== Disconnecting: {dropping_sid} ====") if redis_4.exists(dropping_sid): @@ -159,7 +159,7 @@ def on_disconnect(): @parse_event_data @valid_auth_required def update_location(uid, data): - print("EXECUTING ... for {}".format(uid)) + print("EXECUTING ... for {}".format(uid), flush=True) # add coords to redis redis_5.geoadd( name=data['job_category'], @@ -293,7 +293,7 @@ def accept_offer(uid, data): room = data['booking_id'] data['uid'] = uid lock_key = f"lock:booking:{room}" - with redis_.lock(lock_key, blocking_timeout=1): + with redis_.lock(lock_key, ttl=5000): if redis_.exists(room): # read and remove from queue bk_info = parse_str_data(redis_.get(room)) @@ -317,7 +317,6 @@ def accept_offer(uid, data): only=( 'created_at', 'user_profile', - 'rating', 'job_category', 'jobs_completed', 'hourly_rate' @@ -490,7 +489,7 @@ def handle_location_arrival(uid, data): ) payload = { - 'payload': messages.ARTISAN_ARRIVES, + 'payload': {'message': messages.ARTISAN_ARRIVES}, 'recipient': redis_4.hget( 'booking_id_to_uid', data['booking_id'] diff --git a/core/api/bookings/events/customer.py b/core/api/bookings/events/customer.py index f733e76..008b97e 100644 --- a/core/api/bookings/events/customer.py +++ b/core/api/bookings/events/customer.py @@ -85,7 +85,7 @@ def enter_chat_namespace(uid): @socketio.on('disconnect', namespace='/customer') -def disconnect(): +def disconnect(reason=None): if redis_4.exists(request.sid): redis_4.delete(request.sid) sid_all = redis_4.hgetall("sid_to_user") diff --git a/core/api/bookings/views.py b/core/api/bookings/views.py index 6fd79f6..dac3c8f 100644 --- a/core/api/bookings/views.py +++ b/core/api/bookings/views.py @@ -83,9 +83,6 @@ def create_booking(current_user): to_be_uploaded = [{**_base_img, **img} for img in images['files']] images_schema = BlobSchema(uid=current_user.id, many=True) images = images_schema.load(to_be_uploaded) - for img in images: - img.blob_id = uuid.uuid4().hex - sess.add_all(images) sess.commit() diff --git a/core/api/user/views.py b/core/api/user/views.py index 799822f..33bdeb3 100644 --- a/core/api/user/views.py +++ b/core/api/user/views.py @@ -4,6 +4,7 @@ from loguru import logger import sys +from devices import save_device_data from . import user from core import db from utils import decode_id @@ -16,7 +17,8 @@ from utils import ( gen_response, error_response, - setLogger + setLogger, + paginate ) from schemas.user_schemas import ( AddNewUserSchema, @@ -35,6 +37,7 @@ permission_required ) + logger.remove() setLogger() @@ -57,6 +60,7 @@ def create_new_user(): try: db.session.add(new_user) db.session.commit() + save_device_data(new_user) return gen_response( 201, data=schema.dump(new_user), @@ -183,9 +187,15 @@ def fetch_user(current_user): @permission_required(Permission.service_request) def fetch_bookings_for_user(current_user): """ fetch all bookings made by a user """ - bookings = current_user.bookings.order_by( + query = current_user.bookings.order_by( desc(Booking.created_at) - ).all() + ) + + pagination = paginate( + query=query, + page=request.args.get("page", 1, type=int), + per_page=request.args.get("per_page", 10, type=int), + ) msg = 'fetched top recent bookings successfully' schema = BookingSchema( @@ -205,8 +215,15 @@ def fetch_bookings_for_user(current_user): return gen_response( 200, - data=schema.dump(bookings), - message=msg + data={ + "bookings": schema.dump(pagination.items), + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages + }, + message=msg, + ) diff --git a/core/api/views.py b/core/api/views.py index e6dd456..caac3d4 100644 --- a/core/api/views.py +++ b/core/api/views.py @@ -61,12 +61,12 @@ def request_download_urls(current_user): imgs = ImageFileSchema(action='download').load(data) except Exception as e: return error_response(status_code=400, message=str(e)) - + to_be_downloaded = [] for img in imgs['images']: # Fetch the exact blob using the primary key blob = sess.get(Blob, img['blob_id']) - + # Security check: ensure the current user actually owns this blob if not blob or blob.user_id != current_user.user_id: return error_response( diff --git a/devices.py b/devices.py new file mode 100644 index 0000000..5d69003 --- /dev/null +++ b/devices.py @@ -0,0 +1,183 @@ +import datetime +import requests +import hashlib +import hmac +import os + +from models.signin_attempt import SignInAttempts +from flask import request +from loguru import logger +from user_agents import parse +from core import db +from add_extensions import ( + redis_, + redis_2, + redis_4, + redis_7 +) + + +def generate_device_hash(): + try: + user_agent = parse(request.headers.get("User-Agent", "")) + + device_uuid = ( + request.headers.get("X-Device-UUID", "") + .strip() + .lower() + ) + print("Device UUID:", device_uuid, flush=True) + + device_model = ( + request.headers.get("X-Device-Model", "") + .strip() + .lower() + ) + print("Device Model:", device_model, flush=True) + + device_os = ( + request.headers.get("X-Device-OS") + or user_agent.os.family + ).strip().lower() + print("Device OS:", device_os, flush=True) + + server_secret = os.getenv("DEVICE_HASH_SECRET") + + if not server_secret: + raise RuntimeError("DEVICE_HASH_SECRET is not configured.") + + raw = f"{device_uuid}|{device_model}|{device_os}" + + device_hash = hmac.new( + server_secret.encode(), + raw.encode(), + hashlib.sha256 + ).hexdigest() + + device_data = { + "device_uuid": device_uuid, + "device_model": device_model, + "device_os": device_os + } + + return device_hash, device_data + + except Exception: + logger.exception("Failed to generate device hash") + raise + + +def check_device_hash(user): + try: + + device_hash, _ = generate_device_hash() + + user_id = user.user_id if hasattr(user, "user_id") else user + + pattern = f"device_hash:{user_id}:*" + + keys = redis_.keys(pattern) + + print("Generated hash:", device_hash) + print("Redis keys:", keys) + + for key in keys: + saved_hash = key.split(":")[-1] + + if saved_hash == device_hash: + print("Hash matched!") + return True + + return False + + except Exception: + logger.exception("Device hash check failed") + return False + + +def save_device_data(new_user): + try: + device_hash, device_data = generate_device_hash() + + redis_key = f"device_hash:{new_user.user_id}:{device_hash}" + print("Saving device hash in Redis with key:", redis_key, flush=True) + + try: + redis_.set( + redis_key, + "1", + ex=60 * 60 * 24 * 30 + ) + except Exception: + logger.exception("Failed to save device hash in Redis") + + signin_data = SignInAttempts( + user_id=new_user.user_id, + created_at=datetime.datetime.utcnow(), + device_uuid=device_data["device_uuid"], + device_model=device_data["device_model"], + device_os=device_data["device_os"], + ) + + db.session.add(signin_data) + db.session.flush() + + new_user.last_seen_id = signin_data.id + new_user.device_hash = device_hash + + db.session.commit() + + logger.info( + "Device saved successfully for user_id={}", + new_user.user_id + ) + + return True + + except Exception: + db.session.rollback() + logger.exception( + "Failed to save device for user_id={}", + getattr(new_user, "user_id", None) + ) + return False + + +# def get_client_ip(): +# try: +# forwarded = request.headers.get("X-Forwarded-For") + +# if forwarded: +# return forwarded.split(",")[0].strip() + +# return request.remote_addr + +# except Exception as e: +# logger.exception("Failed to get client IP: %s", e) +# return None + + +def get_estimated_location(ip): + try: + response = requests.get( + f"http://ip-api.com/json/{ip}", + timeout=3 + ) + + response.raise_for_status() + + data = response.json() + + return { + "country": data.get("country"), + "state": data.get("regionName"), + "city": data.get("city") + } + + except requests.exceptions.RequestException as e: + logger.exception("Failed to fetch estimated location: %s", e) + return {} + + except Exception as e: + logger.exception("Unexpected error while fetching estimated location: %s", e) + return {} \ No newline at end of file diff --git a/models/signin_attempt.py b/models/signin_attempt.py index f08e9c0..c53edfa 100644 --- a/models/signin_attempt.py +++ b/models/signin_attempt.py @@ -3,42 +3,25 @@ class SignInAttempts(TimestampMixin, db.Model): + __tablename__ = "signin_attempts" - __tablename__= "signin_attempts" + id = db.Column(db.Integer, primary_key=True) - id = db.Column( - db.Integer, - primary_key=True - autoincrement=True - ) - user_id = db.Column( - db.Integer, - db.ForeignKey("users.id"), - nullable=False - ) - - created_at = db.Column( - db.DateTime, - nullable=False, - default=db.func.current_timestamp() - ) - - device_os = db.Column( - db.String(50), - nullable=True - ) - - ip_address = db.Column( - db.String(45), - nullable=True - ) - + db.String(50), + db.ForeignKey("user.user_id") + ) + user = db.relationship( - "User", - back_populates="signin_attempts") - - estimated_location = db.Column( - db.JSON, - nullable=True - ) + "User", + back_populates="signin_attempts", + foreign_keys=[user_id] + ) + + ip_address = db.Column(db.String(45)) + device_uuid = db.Column(db.String(128)) + device_model = db.Column(db.String(128)) + device_os = db.Column(db.String(50)) + estimated_location = db.Column(db.JSON) + + \ No newline at end of file diff --git a/models/user_models.py b/models/user_models.py index 392d099..f41a7b2 100644 --- a/models/user_models.py +++ b/models/user_models.py @@ -203,10 +203,10 @@ def calculate_dynamic_request_ttl(self, c=3.75): def fetch_active_bookings(cls, user_id, session): subq = ( select( - Booking.booking_id, Booking.status, cls.first_name, - cls.last_name, cls.profile_picture, + Booking.booking_id, Booking.status, Booking.created_at, + cls.first_name, cls.last_name, cls.profile_picture, Booking.settlement_type, Artisan.artisan_id, - BookingCategory.name + BookingCategory.name, cls.telephone ) .join(Artisan, Booking.artisan_id == Artisan.artisan_id) .join(cls, Artisan.user_id == cls.user_id) @@ -226,10 +226,12 @@ def fetch_active_bookings(cls, user_id, session): 'name', func.concat_ws(' ', subq.c.first_name, subq.c.last_name), 'settlement_type', subq.c.settlement_type, 'id', subq.c.artisan_id, - 'profile_picture', subq.c.profile_picture + 'profile_picture', subq.c.profile_picture, + 'phone_number', subq.c.telephone ), 'id', subq.c.booking_id, 'status', subq.c.status, - 'category', subq.c.name + 'category', subq.c.name, + 'created_at', subq.c.created_at ) ) ) diff --git a/run.sh b/run.sh index b1b5c41..104aa67 100644 --- a/run.sh +++ b/run.sh @@ -1 +1 @@ -docker rm handees-backend && docker run -p 5000:5000 --name handees-backend handees-backend:1.1 \ No newline at end of file +docker stop handees-backend && docker rm handees-backend && docker run -p 5000:5000 --name handees-backend handees-backend:1.1 diff --git a/sample.py b/sample.py index 797a473..ec0c68d 100644 --- a/sample.py +++ b/sample.py @@ -217,7 +217,7 @@ def upload_file_with_presigned_url(presigned_url: str, file_path: str): # and match it when uploading. content_type, _ = mimetypes.guess_type(file_path) file_size = os.path.getsize(file_path) - print("file size is::", file_size) + print("file size is::", file_size, content_type) if content_type is None: content_type = 'application/octet-stream' @@ -252,10 +252,10 @@ def upload_file_with_presigned_url(presigned_url: str, file_path: str): if __name__ == "__main__": - test_presigned_url = "https://storage.googleapis.com/handees_service_request_images_dev/cat.png?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=firebase-adminsdk-fbsvc%40handees-dev.iam.gserviceaccount.com%2F20250708%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20250708T005612Z&X-Goog-Expires=900&X-Goog-SignedHeaders=content-type%3Bhost&X-Goog-Signature=44eb06c9dba5738fab7051745607992b80bbb13ca3a88dce37170f97427e9f1cb732417e84d6e79526b6d98b3d417d568410753f04436f128adf4acafaac19e1e21bb3d915cd308de5dcc24c4df30b52d4ca2fcab624d8617de79b5cb2e45e9050d3765e44d3616ce83efe6e839a79caead86e9276ba67bc83fd84c586056ece38f24b60bfb92ac7ff9dcecae0ba683a312ba60440dc91d7a4d378570e1c73613c6174f5545056b2c64ed2f6fe456cc8f6f3fcc1162689c085e5827448366c40fa07d811de318b73b1e39001b5ab41f60fd1d6478bee2c9691c6fa99e736c91a539035f76588db9cbebff5d5c10ff4091a82f6c0b3f75b16d4b4e8e058df3fdc" + test_presigned_url = "https://storage.googleapis.com/handees_service_request_images_dev/uploads/YVFr9oGC9CPpwBriJ3QbIW1PcO23/cd7c9b6ffe384feeb097b1af5dfbd73b?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=firebase-adminsdk-fbsvc%40handees-dev.iam.gserviceaccount.com%2F20260728%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20260728T151122Z&X-Goog-Expires=900&X-Goog-SignedHeaders=content-type%3Bhost&X-Goog-Signature=65a9fe8e9e41418d60be431db1212c4d78e1d7efe417fb71c90cbe4238a69aa0142f7316fd8ba1fb37ae4cf0a1b9a20bb19729978d691233aab80d89fa7a1c60808754f91a26d1ac5da38343b97808471e0b698538cc57e1a30d9abbe39966dac6521e75d3b43182b4363682208ddbc2ec68159f64b56cfa03fb28bdf19a60c4948b3830243f743bd12661ed5ecefa19bd9ad85b5bf5998277cec5b56731e71f668ce0ef2b47716da36607e654dce143f1d8788cdb342a34b2f8a8a36b38eb38b86a6f3a09dc361c3e24354d4b327bd225d4cee8259639ee06df0b1e05efc968337c16e71fa4bf9db9a920822dc4d8f982bc1f7fa995e7f68528dabed5c35e2f" # 2. Replace with the path to a local file you want to upload - test_file_path = "cat.png" + test_file_path = "frame.png" # Create a dummy file for testing if it doesn't exist if not os.path.exists(test_file_path): diff --git a/schemas/artisan.py b/schemas/artisan.py index 0dedf0f..a130f57 100644 --- a/schemas/artisan.py +++ b/schemas/artisan.py @@ -54,6 +54,7 @@ class Meta: ) reviews = fields.Nested('ReviewSchema', only=('weight', 'comment',)) metrics = fields.Method(serialize='get_metrics') + rating = fields.Method(serialize='get_artisan_rating', dump_only=True) @pre_load def preformat_data(self, data, *args, **kwargs): diff --git a/schemas/generic.py b/schemas/generic.py index 661f47d..c0aa33d 100644 --- a/schemas/generic.py +++ b/schemas/generic.py @@ -52,12 +52,3 @@ def get_url(self, obj): return obj.upload_url return obj.download_url - @pre_load - def add_img_id(self, obj, *args, **kwargs): - if self.uid: - obj['img_id'] = generate_unique_file_id( - user_id=self.uid, - filename=obj['filename'], - blob_type=int(BlobTypes[obj['blob_type'].name].value) - ) - return obj diff --git a/schemas/signin_attempt_schema.py b/schemas/signin_attempt_schema.py index 2bb85e6..4105254 100644 --- a/schemas/signin_attempt_schema.py +++ b/schemas/signin_attempt_schema.py @@ -1,9 +1,9 @@ -from models.sigin_attempt import SignInAttempt +from models.signin_attempt import SignInAttempts from .base import BaseSQLAlchemyAutoSchema class SignInAttemptSchema(BaseSQLAlchemyAutoSchema): class Meta: - model = SignInAttempt + model = SignInAttempts include_fk = True include_relationships = True load_instance = True \ No newline at end of file diff --git a/schemas/user_schemas.py b/schemas/user_schemas.py index dbac5e9..29e6c8b 100644 --- a/schemas/user_schemas.py +++ b/schemas/user_schemas.py @@ -54,12 +54,14 @@ class Meta: rating = fields.Method(serialize='get_user_rating') active_bookings = fields.Method(serialize='get_active_bookings') - def get_profile_url(self, obj): - blob_id = obj.profile_picture.split('/')[-1] - print(blob_id) + def _get_profile_url(self, blob_id): profile_picture_blob = Blob.get_by_id(blob_id, session=db.session()) return profile_picture_blob.download_url + def get_profile_url(self, obj): + blob_id = obj.profile_picture.split('/')[-1] + return self._get_profile_url(blob_id) + def set_profile_url(self, value): BUCKET_NAME = os.getenv('BUCKET_NAME') FILENAME = value['filename'] @@ -74,7 +76,6 @@ def set_profile_url(self, value): url = f"https://storage.googleapis.com/{BUCKET_NAME}/{new_blob.blob_id}" # save presigned url to schema instance for use in response to api self.upload_url = new_blob.upload_url - print("URL GENERATED!!", self.upload_url, new_blob.upload_url) return url def get_user_rating(self, obj): @@ -86,8 +87,13 @@ def get_user_rating(self, obj): def get_active_bookings(self, obj): uid = obj.user_id - return User.fetch_active_bookings(uid, session=self.session) - + active_bks = User.fetch_active_bookings(uid, session=self.session) + if not active_bks: + return [] + for bk in active_bks: + blob_id = bk['matched_artisan']['profile_picture'].split('/')[-1] + bk['matched_artisan']['profile_picture'] = self._get_profile_url(blob_id) + return active_bks # load_instance = True # transient = True diff --git a/supervisord-dev.conf b/supervisord-dev.conf index 8e00781..3952f15 100644 --- a/supervisord-dev.conf +++ b/supervisord-dev.conf @@ -42,7 +42,7 @@ stdout_logfile_maxbytes=0 stdout_logfile_backups=0 stderr_logfile_maxbytes=0 stderr_logfile_backups=0 -environment=HOME="/home/handeesofficial" +environment=HOME="/home/handeesofficial",PYTHONUNBUFFERED=1 # [program:handees2] diff --git a/utils.py b/utils.py index de9b9fa..9e369b8 100644 --- a/utils.py +++ b/utils.py @@ -1,6 +1,5 @@ import os import json -from urllib import response import zlib import pprint import base64 @@ -8,25 +7,17 @@ import requests import mimetypes import subprocess -import hashlib +from math import ceil +from flask import request +from sqlalchemy import select, func from loguru import logger -from flask import jsonify, request +from flask import jsonify from google.cloud import storage from firebase_admin import messaging from google.oauth2 import service_account from werkzeug.http import HTTP_STATUS_CODES -from user_agents import parse - -from models.signin_attempt import SignInAttempt -from models.user_models import User -from core import db -from add_extensions import ( - redis_, - redis_2, - redis_4, - redis_7 -) +from firebase_admin._messaging_utils import UnregisteredError def is_serializable(obj): @@ -323,12 +314,17 @@ def decode_file_id(encoded_id: str): def send_notification(data, token, app=None, notification_object=None): print("FCM TOKEN IS::", token) print("Input data", data) - push_notification = messaging.Message( - data=data, - token=token, - notification=messaging.Notification(**notification_object), - ) - response = messaging.send(push_notification, app=app) + try: + push_notification = messaging.Message( + data=data, + token=token, + notification=messaging.Notification(**notification_object), + ) + response = messaging.send(push_notification, app=app) + except UnregisteredError: + logger.warning(f"FCM token stale/unregistered, skipping and removing") + # TODO: mark this token invalid in your DB so you stop sending to it + return return response @@ -340,45 +336,6 @@ def decode_id(val): return int(res) -def generate_device_hash(): - raw_string = f"{device_os}:{ip_address}" - - # Generate a hash using SHA256 - hash_object = hashlib.sha256(raw_string.encode()) - device_hash = hash_object.hexdigest() - - redis_key = f"device_hash:{device_hash}" - - user_agent = parse(request.headers.get("User-Agent")) - - device_os = user_agent.os.family - - ip_address = request.headers.get( - "X-Forwarded-For", - request.remote_addr - ) - - response = requests.get(f"http://ip-api.com/json/{ip_address}") - data = response.json() - estimated_location = ( - f"{data['city']}, " - f"{data['regionName']}, " - f"{data['country']}" - ) - - if redis_.exists(redis_key): - logger.debug(f"Device hash {device_hash}is Known.") - else: - # send notification to user about new device login - logger.debug(f"Device hash {device_hash} is Unknown. Storing in Redis.") - - - - - - - - # --- Example Usage --- # my_number = 1000000000 # encoded_number = base62_encode(my_number) @@ -438,3 +395,54 @@ def generate_device_hash(): # response = requests.post(url, data=payload, headers=headers) # print(response.text) + + +def paginate(query, page=1, per_page=10, sort_key=None, session=None): + """ + Paginate a SQLAlchemy query. + + :param query: The query to paginate. + :param page: The page number to retrieve. + :param per_page: The number of items per page. + :return: A paginated query object. + """ + if session: + total = session.scalar(select(func.count()).select_from(query.subquery())) + else: + total = query.count() + + page = max(1, request.args.get("page", page, type=int)) + per_page = max(1, request.args.get("per_page", per_page, type=int)) + + pages = ceil(total / per_page) if total else 1 + + if sort_key: + paginated_query = query.order_by( + sort_key + ).limit(per_page).offset((page - 1) * per_page) + else: + paginated_query = query.limit(per_page).offset((page - 1) * per_page) + return PaginatedQuery(paginated_query, page, per_page, total, pages) + + +class PaginatedQuery: + """ + A paginated SQLAlchemy query object. + """ + + def __init__(self, query, page, per_page, total, pages): + self.query = query + self.page = page + self.per_page = per_page + self.total = total + self.pages = pages + + @property + def items(self): + """ + Get the data for the current page. + """ + return self.query.all() + + def scalars(self, session): + return session.scalars(self.query) \ No newline at end of file From c133d856d6a7f737ff56088521f9bdcfc77cb079 Mon Sep 17 00:00:00 2001 From: git2060 Date: Mon, 10 Aug 2026 23:24:17 +0530 Subject: [PATCH 3/4] Update device security and authentication --- core/api/auth/auth_helper.py | 2 - core/api/user/views.py | 8 +- devices.py | 160 +++++++++++++++++++++++++---------- models/signin_attempt.py | 10 +-- models/user_models.py | 19 +++++ requirements.txt | 2 + 6 files changed, 144 insertions(+), 57 deletions(-) diff --git a/core/api/auth/auth_helper.py b/core/api/auth/auth_helper.py index c0f1744..6133aba 100644 --- a/core/api/auth/auth_helper.py +++ b/core/api/auth/auth_helper.py @@ -5,7 +5,6 @@ ) from core import socketio from core.api.bookings import messages -from devices import check_device_hash from functools import wraps from flask import ( @@ -188,7 +187,6 @@ def wrapped(*args, **kwargs): uid = verify_token(token) print(uid) user = User.query.filter_by(user_id=uid).first() - check_device_hash(user) logger.debug("user with data: {} still has access".format(uid)) user = User.query.filter_by(user_id=uid).first() except (Exception or Exception in excs or auth.ExpiredIdTokenError) as e: diff --git a/core/api/user/views.py b/core/api/user/views.py index 33bdeb3..4eaa05b 100644 --- a/core/api/user/views.py +++ b/core/api/user/views.py @@ -4,7 +4,7 @@ from loguru import logger import sys -from devices import save_device_data + from . import user from core import db from utils import decode_id @@ -36,7 +36,7 @@ login_required, permission_required ) - +from devices import save_device_hash, check_device_hash logger.remove() setLogger() @@ -60,7 +60,7 @@ def create_new_user(): try: db.session.add(new_user) db.session.commit() - save_device_data(new_user) + save_device_hash(new_user) return gen_response( 201, data=schema.dump(new_user), @@ -174,6 +174,8 @@ def add_app_token(current_user): @login_required def fetch_user(current_user): """ checks if uid exists """ + user = User.query.filter_by(user_id=current_user.user_id).first() + check_device_hash(user) with db.session() as sess: schema = UserSchema(session=sess) return gen_response( diff --git a/devices.py b/devices.py index 5d69003..3f7f3db 100644 --- a/devices.py +++ b/devices.py @@ -3,6 +3,7 @@ import hashlib import hmac import os +import geoip2.database from models.signin_attempt import SignInAttempts from flask import request @@ -17,28 +18,41 @@ ) +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + + def generate_device_hash(): try: user_agent = parse(request.headers.get("User-Agent", "")) - device_uuid = ( - request.headers.get("X-Device-UUID", "") + device_mac = ( + request.headers.get("X-Device-ID", "") .strip() .lower() ) - print("Device UUID:", device_uuid, flush=True) + + print("Device MAC:", device_mac, flush=True) - device_model = ( + device_name = ( request.headers.get("X-Device-Model", "") .strip() .lower() ) - print("Device Model:", device_model, flush=True) + device_name = device_name if device_name else user_agent.device.family.strip().lower() + print("Device Name:", device_name, flush=True) device_os = ( - request.headers.get("X-Device-OS") + request.headers.get("X-OS-Family") or user_agent.os.family ).strip().lower() + + if device_os.startswith("android"): + device_os = "android" + elif device_os.startswith("ios"): + device_os = "ios" + print("Device OS:", device_os, flush=True) server_secret = os.getenv("DEVICE_HASH_SECRET") @@ -46,7 +60,7 @@ def generate_device_hash(): if not server_secret: raise RuntimeError("DEVICE_HASH_SECRET is not configured.") - raw = f"{device_uuid}|{device_model}|{device_os}" + raw = f"{device_mac}|{device_name}|{device_os}" device_hash = hmac.new( server_secret.encode(), @@ -55,8 +69,8 @@ def generate_device_hash(): ).hexdigest() device_data = { - "device_uuid": device_uuid, - "device_model": device_model, + "device_uuid": device_mac, + "device_model": device_name, "device_os": device_os } @@ -69,54 +83,77 @@ def generate_device_hash(): def check_device_hash(user): try: + matched = False - device_hash, _ = generate_device_hash() + device_hash, device_data = generate_device_hash() user_id = user.user_id if hasattr(user, "user_id") else user + print(user_id, flush=True) + pattern = f"device_hash:{user_id}:*" + print("Redis key pattern:", pattern, flush=True) + print(redis_.connection_pool.connection_kwargs["db"], flush=True) + print(redis_.connection_pool.connection_kwargs, flush=True) + + keys = redis_.keys() + print(keys, flush=True) keys = redis_.keys(pattern) print("Generated hash:", device_hash) print("Redis keys:", keys) for key in keys: + print("Checking key:", key, flush=True) saved_hash = key.split(":")[-1] if saved_hash == device_hash: + matched = True print("Hash matched!") return True - - return False + + print(user.email, flush=True) + + if not matched: + print("Device hash mismatch. Sending verification email.", flush=True) + try: + send_emails( + user.email, + "Unrecognized device detected", + "Please change your password if this wasn't you." + ) + except Exception: + logger.exception("Failed to send verification email") + return { + "message": "Unrecognized device. Verification email sent." + }, 403 except Exception: logger.exception("Device hash check failed") return False -def save_device_data(new_user): +def save_device_hash(new_user): try: device_hash, device_data = generate_device_hash() redis_key = f"device_hash:{new_user.user_id}:{device_hash}" + print("Saving device hash in Redis with key:", redis_key, flush=True) try: - redis_.set( - redis_key, - "1", - ex=60 * 60 * 24 * 30 - ) + redis_.set(redis_key, 1) except Exception: logger.exception("Failed to save device hash in Redis") signin_data = SignInAttempts( user_id=new_user.user_id, created_at=datetime.datetime.utcnow(), - device_uuid=device_data["device_uuid"], - device_model=device_data["device_model"], + device_mac=device_data["device_uuid"], + device_name=device_data["device_model"], device_os=device_data["device_os"], + estimated_location = get_estimated_location(), ) db.session.add(signin_data) @@ -142,42 +179,73 @@ def save_device_data(new_user): ) return False - -# def get_client_ip(): -# try: -# forwarded = request.headers.get("X-Forwarded-For") - -# if forwarded: -# return forwarded.split(",")[0].strip() - -# return request.remote_addr - -# except Exception as e: -# logger.exception("Failed to get client IP: %s", e) -# return None + +def get_location(): + # Get IP address + if request.headers.get('X-Forwarded-For'): + ip_address = request.headers.get('X-Forwarded-For').split(',')[0] + else: + ip_address = request.remote_addr + + # Lookup location (path to your local MaxMind database) + try: + with geoip2.database.Reader('GeoLite2-City.mmdb') as reader: + response = reader.city(ip_address) + city = response.city.name + country = response.country.name + return {'ip': ip_address, 'city': city, 'country': country} + except Exception: + return {'ip': ip_address, 'error': 'Location not found'} -def get_estimated_location(ip): +def get_estimated_location(): try: - response = requests.get( - f"http://ip-api.com/json/{ip}", - timeout=3 - ) - response.raise_for_status() + data = get_location() - data = response.json() + print("Location data:", data, flush=True) return { + "ip": data.get("ip"), + "city": data.get("city"), "country": data.get("country"), - "state": data.get("regionName"), - "city": data.get("city") } - except requests.exceptions.RequestException as e: - logger.exception("Failed to fetch estimated location: %s", e) + except requests.RequestException as e: + logger.exception( + "Failed to fetch estimated location: %s", + e + ) return {} except Exception as e: - logger.exception("Unexpected error while fetching estimated location: %s", e) - return {} \ No newline at end of file + logger.exception( + "Unexpected error while fetching estimated location: %s", + e + ) + return {} + + + +def send_emails(to_email, subject, body): + try: + + sender_email = "shyamgundetin@gmail.com" + sender_password = "qfdgagqxljuoascx" + + message = MIMEMultipart() + + message["From"] = sender_email + message["To"] = to_email + message["Subject"] = subject + + message.attach(MIMEText(body, "plain")) + + with smtplib.SMTP("smtp.gmail.com", 587) as server: + server.starttls() + server.login(sender_email, sender_password) + server.send_message(message) + + except Exception as e: + logger.exception("Failed to send email: %s", e) + diff --git a/models/signin_attempt.py b/models/signin_attempt.py index c53edfa..4f08c0e 100644 --- a/models/signin_attempt.py +++ b/models/signin_attempt.py @@ -1,3 +1,5 @@ +from ctypes.wintypes import INT + from core import db from .base import TimestampMixin @@ -17,11 +19,7 @@ class SignInAttempts(TimestampMixin, db.Model): back_populates="signin_attempts", foreign_keys=[user_id] ) - - ip_address = db.Column(db.String(45)) - device_uuid = db.Column(db.String(128)) - device_model = db.Column(db.String(128)) + device_mac = db.Column(db.String(128)) + device_name = db.Column(db.String(128)) device_os = db.Column(db.String(50)) estimated_location = db.Column(db.JSON) - - \ No newline at end of file diff --git a/models/user_models.py b/models/user_models.py index f41a7b2..59000a6 100644 --- a/models/user_models.py +++ b/models/user_models.py @@ -5,6 +5,7 @@ from flask import current_app from loguru import logger +from models.signin_attempt import SignInAttempts from core import db from models.bookings import Booking, BookingStatusEnum, BookingCategory from models.payments import Payment @@ -135,6 +136,24 @@ class User(TimestampMixin, db.Model): role_id = db.Column(db.Integer, db.ForeignKey('role.id'), nullable=False) cards = db.relationship('CardAuth', backref='user') payments = db.relationship('Payment', backref='user') + device_hash = db.Column(db.String(100)) + + signin_attempts = db.relationship( + "SignInAttempts", + back_populates="user", + foreign_keys="SignInAttempts.user_id" + ) + + last_seen_id = db.Column( + db.Integer, + db.ForeignKey("signin_attempts.id"), + nullable=True + ) + + last_seen = db.relationship( + "SignInAttempts", + foreign_keys=[last_seen_id] + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/requirements.txt b/requirements.txt index 0ea69de..92c3218 100644 --- a/requirements.txt +++ b/requirements.txt @@ -240,3 +240,5 @@ zipp # importlib-resources sqlalchemy-cockroachdb passlib[argon2] +user-agents +geoip2 From 8154919feb7f32f03a3deb99dc5451f948ced3eb Mon Sep 17 00:00:00 2001 From: git2060 Date: Mon, 10 Aug 2026 23:44:38 +0530 Subject: [PATCH 4/4] devices.py updated --- devices.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devices.py b/devices.py index 3f7f3db..52160b6 100644 --- a/devices.py +++ b/devices.py @@ -230,8 +230,8 @@ def get_estimated_location(): def send_emails(to_email, subject, body): try: - sender_email = "shyamgundetin@gmail.com" - sender_password = "qfdgagqxljuoascx" + sender_email = os.getenv("EMAIL_ADDRESS") + sender_password = os.getenv("EMAIL_PASSWORD") message = MIMEMultipart() @@ -241,7 +241,7 @@ def send_emails(to_email, subject, body): message.attach(MIMEText(body, "plain")) - with smtplib.SMTP("smtp.gmail.com", 587) as server: + with smtplib.SMTP(os.getenv("smtp.gmail.com"), 587) as server: server.starttls() server.login(sender_email, sender_password) server.send_message(message)