From 6c523a6c618d2914677b877d871a8a6477b842e2 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 7 Jan 2026 23:09:10 +0300 Subject: [PATCH] Try fixing login/register --- backend/app.py | 1 - backend/migration.py | 6 +- backend/routes/account.py | 125 ++++++++++---------------- backend/shared/models.py | 4 +- deployment/db-init/01-init-roles.sql | 18 ++-- deployment/db-init/02-init-tables.sql | 35 +++++++- deployment/docker-compose.yml | 49 +++++----- deployment/frontend/server.ts | 24 +++-- scripts/generate:env.sh | 2 + 9 files changed, 137 insertions(+), 127 deletions(-) diff --git a/backend/app.py b/backend/app.py index 0e3b662..7861102 100644 --- a/backend/app.py +++ b/backend/app.py @@ -156,7 +156,6 @@ async def proxy_moderation(path: str, request: Request): """Proxy moderation service requests.""" return await _proxy_to_service("moderation", path, request) - async def _proxy_to_service(service: str, path: str, request: Request): """Helper function to proxy requests to microservices.""" from fastapi.responses import Response diff --git a/backend/migration.py b/backend/migration.py index 1e00f0b..99f2534 100644 --- a/backend/migration.py +++ b/backend/migration.py @@ -147,7 +147,11 @@ def run_migrations(): command.upgrade(alembic_cfg, "head") logger.info("Database migrations completed successfully.") except Exception as upgrade_error: - if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error): + error_msg = str(upgrade_error) + # Handle PostgreSQL "already exists" errors gracefully + if "already exists" in error_msg.lower() or "relation" in error_msg.lower() and "exists" in error_msg.lower(): + pass + elif "Can't locate revision identified by 'direct_creation'" in error_msg: logger.info("Found 'direct_creation' revision - resetting migration state...") # Clear the alembic_version table and start fresh engine = create_engine(DATABASE_URL) diff --git a/backend/routes/account.py b/backend/routes/account.py index ef81545..f69d3ad 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -10,7 +10,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from backend.shared.constants import OWNER_USERNAME from backend.shared.dependencies import get_current_user, get_db -from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession +from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup from backend.shared.utils import create_token, get_password_hash, verify_password, get_client_ip from backend.shared.validation import is_valid_password, is_valid_username, is_valid_display_name import os @@ -47,16 +47,16 @@ def convert_user(user: User) -> dict: "id": user.id, "created_at": user.created_at.isoformat(), "last_seen": user.last_seen.isoformat(), - "online": user.online, + "online": user.is_online, "username": user.username, "display_name": user.display_name, - "profile_picture": user.profile_picture, + "profile_picture": user.avatar_url, "bio": user.bio, "admin": _is_admin(user), "verified": user.verified, - "suspended": user.suspended or False, + "suspended": user.suspended, "suspension_reason": user.suspension_reason, - "deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted + "deleted": user.deleted } @router.get("/check_auth") @@ -77,7 +77,7 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g user = db.query(User).filter(User.username == username).first() - if not user or not verify_password(login_request.password.strip(), user.password_hash): + if not user or not verify_password(login_request.password.strip(), user.hashed_password): log_security( "login_failed", severity="warning", @@ -112,30 +112,9 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g detail="Неверное имя пользователя или пароль" ) - # Create device session and embed into JWT - raw_ua = request.headers.get("user-agent") - device_name = request.headers.get("x-device-name") - ua = parse_ua(raw_ua or "") + # Generate session ID for JWT (device session will be created on first device service access) session_id = uuid.uuid4().hex - device = DeviceSession( - user_id=user.id, - raw_user_agent=raw_ua, - device_name=device_name, - device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"), - os_name=(ua.os.family or None), - os_version=(ua.os.version_string or None), - browser_name=(ua.browser.family or None), - browser_version=(ua.browser.version_string or None), - brand=(ua.device.brand or None), - model=(ua.device.model or None), - session_id=session_id, - created_at=datetime.now(), - last_seen=datetime.now(), - revoked=False, - ) - db.add(device) - user.online = True user.last_seen = datetime.now() db.commit() @@ -148,15 +127,19 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g for identifier in identifiers: _reset_failed_logins(identifier) + # Parse user agent for logging + ua = parse_ua(raw_ua or "") + device_type = "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop" + log_security( "login_success", username=user.username, user_id=user.id, ip=client_ip, session_id=session_id, - device=device.device_type, - os=device.os_name, - browser=device.browser_name, + device=device_type, + os=ua.os.family, + browser=ua.browser.family, ) return { @@ -230,8 +213,9 @@ def register(request: Request, register_request: RegisterRequest, db: Session = new_user = User( username=username, display_name=display_name, - password_hash=hashed_password, - online=True, + hashed_password=hashed_password, + salt="", # Not used since bcrypt includes salt in hash + is_online=True, last_seen=datetime.now(), verified=is_owner ) @@ -240,32 +224,13 @@ def register(request: Request, register_request: RegisterRequest, db: Session = db.commit() db.refresh(new_user) - # Create initial device session - raw_ua = request.headers.get("user-agent") - device_name = request.headers.get("x-device-name") - ua = parse_ua(raw_ua or "") + # Generate a temporary session ID for the token (device session will be created on first device service access) session_id = uuid.uuid4().hex - device = DeviceSession( - user_id=new_user.id, - raw_user_agent=raw_ua, - device_name=device_name, - device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"), - os_name=(ua.os.family or None), - os_version=(ua.os.version_string or None), - browser_name=(ua.browser.family or None), - browser_version=(ua.browser.version_string or None), - brand=(ua.device.brand or None), - model=(ua.device.model or None), - session_id=session_id, - created_at=datetime.now(), - last_seen=datetime.now(), - revoked=False, - ) - db.add(device) - db.commit() - token = create_token(new_user.id, new_user.username, session_id) + # Parse user agent for logging + raw_ua = request.headers.get("user-agent") + ua = parse_ua(raw_ua or "") os_name = ua.os.family or "Unknown OS" if ua.os.version_string: os_name = f"{os_name} {ua.os.version_string}" @@ -380,14 +345,14 @@ def logout( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): - # Revoke current session - from backend.shared.utils import verify_token as _verify_token - payload = _verify_token(credentials.credentials) - if payload and payload.get("session_id"): - db.query(DeviceSession).filter( - DeviceSession.user_id == current_user.id, - DeviceSession.session_id == payload["session_id"], - ).update({DeviceSession.revoked: True}) + # Revoke current session - TODO: Move to device service + # from backend.shared.utils import verify_token as _verify_token + # payload = _verify_token(credentials.credentials) + # if payload and payload.get("session_id"): + # db.query(DeviceSession).filter( + # DeviceSession.user_id == current_user.id, + # DeviceSession.session_id == payload["session_id"], + # ).update({DeviceSession.revoked: True}) current_user.online = False current_user.last_seen = datetime.now() @@ -399,7 +364,7 @@ def logout( username=current_user.username, user_id=current_user.id, ip=client_ip, - session_id=payload.get("session_id") if payload else None, + session_id=None, # TODO: Get session_id from device service ) return { @@ -418,25 +383,25 @@ def change_password( db: Session = Depends(get_db) ): # Verify current derived password against stored hash - if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash): + if not verify_password(password_request.currentPasswordDerived.strip(), current_user.hashed_password): raise HTTPException(status_code=401, detail="Текущий пароль неверный") # Update password hash to hash of new derived password - current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip()) + current_user.hashed_password = get_password_hash(password_request.newPasswordDerived.strip()) db.commit() - # Optionally revoke all other sessions, keeping the current one - if password_request.logoutAllExceptCurrent: - from backend.shared.utils import verify_token as _verify_token - payload = _verify_token(credentials.credentials) - if not payload: - raise HTTPException(status_code=401, detail="Invalid token") - current_session_id = payload.get("session_id") - db.query(DeviceSession).filter( - DeviceSession.user_id == current_user.id, - DeviceSession.session_id != current_session_id, - ).update({DeviceSession.revoked: True}) - db.commit() + # Optionally revoke all other sessions, keeping the current one - TODO: Move to device service + # if password_request.logoutAllExceptCurrent: + # from backend.shared.utils import verify_token as _verify_token + # payload = _verify_token(credentials.credentials) + # if not payload: + # raise HTTPException(status_code=401, detail="Invalid token") + # current_session_id = payload.get("session_id") + # db.query(DeviceSession).filter( + # DeviceSession.user_id == current_user.id, + # DeviceSession.session_id != current_session_id, + # ).update({DeviceSession.revoked: True}) + # db.commit() client_ip = get_client_ip(request) log_security( @@ -496,7 +461,7 @@ async def _delete_user_data(user: User, db: Session): user.deleted = True user.display_name = f"Deleted User #{user_id}" user.bio = None - user.password_hash = "" + user.hashed_password = "" user.username = f"deleted_{user_id}" user.profile_picture = None user.last_seen = None # Clear last seen timestamp diff --git a/backend/shared/models.py b/backend/shared/models.py index 8e7e2c2..0969374 100644 --- a/backend/shared/models.py +++ b/backend/shared/models.py @@ -13,7 +13,6 @@ class User(Base): id = Column(BigInteger, primary_key=True, index=True) username = Column(String(50), unique=True, index=True, nullable=False) - email = Column(String(100), unique=True, index=True, nullable=False) hashed_password = Column(String(255), nullable=False) salt = Column(String(64), nullable=False) display_name = Column(String(100), nullable=True) @@ -34,6 +33,9 @@ class User(Base): public_key = Column(Text, nullable=True) private_key = Column(Text, nullable=True) encryption_enabled = Column(Boolean, default=False) + suspended = Column(Boolean, default=False) + suspension_reason = Column(Text, nullable=True) + deleted = Column(Boolean, default=False) # Relationships messages = relationship("Message", back_populates="sender", cascade="all, delete-orphan") diff --git a/deployment/db-init/01-init-roles.sql b/deployment/db-init/01-init-roles.sql index 60a522b..db01eb0 100644 --- a/deployment/db-init/01-init-roles.sql +++ b/deployment/db-init/01-init-roles.sql @@ -2,15 +2,15 @@ -- This script creates dedicated users with limited privileges for each service -- Create service-specific database roles with limited privileges --- All services use the same password from DB_PASSWORD environment variable -CREATE ROLE account_service_user LOGIN PASSWORD '${DB_PASSWORD}'; -CREATE ROLE profile_service_user LOGIN PASSWORD '${DB_PASSWORD}'; -CREATE ROLE device_service_user LOGIN PASSWORD '${DB_PASSWORD}'; -CREATE ROLE messaging_service_user LOGIN PASSWORD '${DB_PASSWORD}'; -CREATE ROLE push_service_user LOGIN PASSWORD '${DB_PASSWORD}'; -CREATE ROLE webrtc_service_user LOGIN PASSWORD '${DB_PASSWORD}'; -CREATE ROLE moderation_service_user LOGIN PASSWORD '${DB_PASSWORD}'; -CREATE ROLE gateway_user LOGIN PASSWORD '${DB_PASSWORD}'; +-- All services use the same password +CREATE ROLE account_service_user LOGIN PASSWORD 'development'; +CREATE ROLE profile_service_user LOGIN PASSWORD 'development'; +CREATE ROLE device_service_user LOGIN PASSWORD 'development'; +CREATE ROLE messaging_service_user LOGIN PASSWORD 'development'; +CREATE ROLE push_service_user LOGIN PASSWORD 'development'; +CREATE ROLE webrtc_service_user LOGIN PASSWORD 'development'; +CREATE ROLE moderation_service_user LOGIN PASSWORD 'development'; +CREATE ROLE gateway_user LOGIN PASSWORD 'development'; -- Create dedicated schemas for each service CREATE SCHEMA IF NOT EXISTS account_schema AUTHORIZATION account_service_user; diff --git a/deployment/db-init/02-init-tables.sql b/deployment/db-init/02-init-tables.sql index acef401..d5a80be 100644 --- a/deployment/db-init/02-init-tables.sql +++ b/deployment/db-init/02-init-tables.sql @@ -8,7 +8,6 @@ CREATE TABLE IF NOT EXISTS account_schema.users ( id BIGSERIAL PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, - email VARCHAR(100) UNIQUE NOT NULL, hashed_password VARCHAR(255) NOT NULL, salt VARCHAR(64) NOT NULL, display_name VARCHAR(100), @@ -28,7 +27,10 @@ CREATE TABLE IF NOT EXISTS account_schema.users ( locked_until TIMESTAMP, public_key TEXT, private_key TEXT, - encryption_enabled BOOLEAN DEFAULT FALSE + encryption_enabled BOOLEAN DEFAULT FALSE, + suspended BOOLEAN DEFAULT FALSE, + suspension_reason TEXT, + deleted BOOLEAN DEFAULT FALSE ); -- Profile schema tables (references account_schema.users) @@ -54,6 +56,24 @@ CREATE TABLE IF NOT EXISTS device_schema.devices ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS device_schema.device_session ( + id SERIAL PRIMARY KEY, + user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE, + raw_user_agent TEXT, + device_name VARCHAR(128), + device_type VARCHAR(32), + os_name VARCHAR(64), + os_version VARCHAR(64), + browser_name VARCHAR(64), + browser_version VARCHAR(64), + brand VARCHAR(64), + model VARCHAR(64), + session_id VARCHAR(64) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + revoked BOOLEAN DEFAULT FALSE +); + -- Messaging schema tables CREATE TABLE IF NOT EXISTS messaging_schema.messages ( id BIGSERIAL PRIMARY KEY, @@ -125,3 +145,14 @@ CREATE TABLE IF NOT EXISTS moderation_schema.moderation_actions ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP ); + +-- Grant permissions on sequences (after all tables are created) +GRANT USAGE ON SEQUENCE account_schema.users_id_seq TO account_service_user; +GRANT USAGE ON SEQUENCE device_schema.devices_id_seq TO device_service_user; +GRANT USAGE ON SEQUENCE device_schema.device_session_id_seq TO account_service_user; +GRANT USAGE ON SEQUENCE messaging_schema.messages_id_seq TO messaging_service_user; +GRANT USAGE ON SEQUENCE messaging_schema.message_recipients_id_seq TO messaging_service_user; +GRANT USAGE ON SEQUENCE messaging_schema.message_reactions_id_seq TO messaging_service_user; +GRANT USAGE ON SEQUENCE push_schema.push_subscriptions_id_seq TO push_service_user; +GRANT USAGE ON SEQUENCE webrtc_schema.webrtc_sessions_id_seq TO webrtc_service_user; +GRANT USAGE ON SEQUENCE moderation_schema.moderation_actions_id_seq TO moderation_service_user; diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index c76082c..2436013 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -5,7 +5,7 @@ services: environment: POSTGRES_DB: fromchat POSTGRES_USER: fromchat_admin - POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme} + POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - database:/var/lib/postgresql/data - ./db-init:/docker-entrypoint-initdb.d @@ -25,8 +25,8 @@ services: dockerfile: docker/Dockerfile.multi target: migration_runner environment: - DATABASE_URL: postgresql://fromchat_admin:${DB_PASSWORD:-changeme}@database:5432/fromchat - JWT_SECRET: ${JWT_SECRET:-changeme} + DATABASE_URL: postgresql://fromchat_admin:${DB_PASSWORD}@database:5432/fromchat + JWT_SECRET: ${JWT_SECRET} depends_on: database: condition: service_healthy @@ -58,7 +58,7 @@ services: target: gateway ports: ["8300:8300"] environment: - DATABASE_URL: postgresql://gateway_user:${DB_PASSWORD:-changeme}@database:5432/fromchat + DATABASE_URL: postgresql://gateway_user:${DB_PASSWORD}@database:5432/fromchat PORT: 8300 ACCOUNT_SERVICE_URL: http://account_service:8302 PROFILE_SERVICE_URL: http://profile_service:8303 @@ -102,8 +102,8 @@ services: dockerfile: docker/Dockerfile.multi target: account_service environment: - DATABASE_URL: postgresql://account_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat - JWT_SECRET: ${JWT_SECRET:-changeme} + DATABASE_URL: postgresql://account_service_user:${DB_PASSWORD}@database:5432/fromchat + JWT_SECRET: ${JWT_SECRET} PORT: 8302 depends_on: migration_runner: @@ -130,11 +130,11 @@ services: dockerfile: docker/Dockerfile.multi target: profile_service environment: - DATABASE_URL: postgresql://profile_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat - FIREBASE_CERT: ${FIREBASE_CERT:-} - VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} - VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} - VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com} + DATABASE_URL: postgresql://profile_service_user:${DB_PASSWORD}@database:5432/fromchat + FIREBASE_CERT: ${FIREBASE_CERT} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} + VAPID_SUBJECT: ${VAPID_SUBJECT} MESSAGING_SERVICE_URL: http://messaging_service:8305 PORT: 8303 depends_on: @@ -162,7 +162,7 @@ services: dockerfile: docker/Dockerfile.multi target: device_service environment: - DATABASE_URL: postgresql://device_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat + DATABASE_URL: postgresql://device_service_user:${DB_PASSWORD}@database:5432/fromchat PORT: 8304 depends_on: migration_runner: @@ -191,11 +191,11 @@ services: ports: - "8305:8305" environment: - DATABASE_URL: postgresql://messaging_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat - FIREBASE_CERT: ${FIREBASE_CERT:-} - VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} - VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} - VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com} + DATABASE_URL: postgresql://messaging_service_user:${DB_PASSWORD}@database:5432/fromchat + FIREBASE_CERT: ${FIREBASE_CERT} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} + VAPID_SUBJECT: ${VAPID_SUBJECT} PUSH_SERVICE_URL: http://push_service:8306 PORT: 8305 depends_on: @@ -226,11 +226,11 @@ services: dockerfile: docker/Dockerfile.multi target: push_service environment: - DATABASE_URL: postgresql://push_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat - FIREBASE_CERT: ${FIREBASE_CERT:-} - VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} - VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} - VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com} + DATABASE_URL: postgresql://push_service_user:${DB_PASSWORD}@database:5432/fromchat + FIREBASE_CERT: ${FIREBASE_CERT} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} + VAPID_SUBJECT: ${VAPID_SUBJECT} PORT: 8306 depends_on: migration_runner: @@ -260,7 +260,7 @@ services: dockerfile: docker/Dockerfile.multi target: webrtc_service environment: - DATABASE_URL: postgresql://webrtc_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat + DATABASE_URL: postgresql://webrtc_service_user:${DB_PASSWORD}@database:5432/fromchat PORT: 8307 depends_on: migration_runner: @@ -287,7 +287,7 @@ services: dockerfile: docker/Dockerfile.multi target: moderation_service environment: - DATABASE_URL: postgresql://moderation_service_user:${DB_PASSWORD:-changeme}@database:5432/fromchat + DATABASE_URL: postgresql://moderation_service_user:${DB_PASSWORD}@database:5432/fromchat PORT: 8308 depends_on: migration_runner: @@ -348,6 +348,7 @@ services: restart: unless-stopped networks: - fromchat_external + - fromchat_internal volumes: database: diff --git a/deployment/frontend/server.ts b/deployment/frontend/server.ts index ce7b655..cffa7fd 100644 --- a/deployment/frontend/server.ts +++ b/deployment/frontend/server.ts @@ -8,15 +8,7 @@ const port = Number(process.env.PORT) || 8301; const backendHost = process.env.BACKEND_HOST || "http://localhost:8300"; const filePath = process.env.STATIC_FILE_PATH || "."; -// API proxy middleware -app.use('/api', createProxyMiddleware({ - target: backendHost, - changeOrigin: true, - pathRewrite: { '^/api': '' }, - ws: true -})); - -// Direct WebSocket proxy for chat - bypass gateway +// Direct WebSocket proxy for chat - bypass gateway (must come before general API proxy) app.use('/api/chat/ws', createProxyMiddleware({ target: 'http://messaging_service:8305', changeOrigin: true, @@ -24,6 +16,20 @@ app.use('/api/chat/ws', createProxyMiddleware({ ws: true })); +// API proxy middleware (exclude WebSocket paths) +app.use('/api', (req, res, next) => { + // Skip WebSocket upgrade requests - let them be handled by specific proxies + if (req.headers.upgrade === 'websocket') { + return next(); + } + createProxyMiddleware({ + target: backendHost, + changeOrigin: true, + pathRewrite: { '^/api': '' }, + ws: true + })(req, res, next); +}); + // Serve static files app.use(express.static(resolve(filePath))); diff --git a/scripts/generate:env.sh b/scripts/generate:env.sh index 0f1df8e..84c5593 100755 --- a/scripts/generate:env.sh +++ b/scripts/generate:env.sh @@ -5,9 +5,11 @@ echo > deployment/.env ./.venv/bin/python3 backend/generate_vapid_keys.py >> deployment/.env cat >> deployment/.env < TURN_SECRET= DEPLOYMENT_SERVER= FIREBASE_CERT= +DB_PASSWORD=development EOF