From 5f49b45eed75da24d4052d91581976be0db5f4c0 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 30 Oct 2025 22:36:23 +0300 Subject: [PATCH] Implement hashed password transfer, change password, redesign the settings UI --- backend/app.py | 5 +- backend/dependencies.py | 26 ++++ backend/models.py | 36 ++++++ backend/requirements.txt | 1 + backend/routes/account.py | 90 ++++++++++++- backend/routes/devices.py | 88 +++++++++++++ backend/utils.py | 3 +- frontend/src/core/api/authApi.ts | 14 +++ frontend/src/core/api/devicesApi.ts | 36 ++++++ frontend/src/core/api/securityApi.ts | 25 ++++ frontend/src/pages/auth/LoginPage.tsx | 5 +- frontend/src/pages/auth/RegisterPage.tsx | 7 +- .../chat/ui/left/settings/SettingsDialog.tsx | 119 +++++++++--------- 13 files changed, 381 insertions(+), 74 deletions(-) create mode 100644 backend/routes/devices.py create mode 100644 frontend/src/core/api/devicesApi.ts create mode 100644 frontend/src/core/api/securityApi.ts diff --git a/backend/app.py b/backend/app.py index 16462c7..23c30c2 100644 --- a/backend/app.py +++ b/backend/app.py @@ -5,7 +5,7 @@ import subprocess import sys import os from constants import DATABASE_URL -from routes import account, messaging, profile, push, webrtc +from routes import account, messaging, profile, push, webrtc, devices import logging from models import User from constants import OWNER_USERNAME @@ -88,4 +88,5 @@ app.include_router(account.router) app.include_router(messaging.router) app.include_router(profile.router) app.include_router(push.router, prefix="/push") -app.include_router(webrtc.router, prefix="/webrtc") \ No newline at end of file +app.include_router(webrtc.router, prefix="/webrtc") +app.include_router(devices.router, prefix="/devices") \ No newline at end of file diff --git a/backend/dependencies.py b/backend/dependencies.py index 5a40ea2..3178e80 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -36,6 +36,32 @@ def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) + # Validate device session from JWT + session_id = payload.get("session_id") + if not session_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid session", + headers={"WWW-Authenticate": "Bearer"}, + ) + + device_session = ( + db.query(DeviceSession) + .filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id) + .first() + ) + + if not device_session or device_session.revoked: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Session revoked or not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Touch last_seen on valid session + device_session.last_seen = datetime.now() + db.commit() + # Check if user is suspended if user.suspended: raise HTTPException( diff --git a/backend/models.py b/backend/models.py index 5e4533e..b1dcb90 100644 --- a/backend/models.py +++ b/backend/models.py @@ -146,6 +146,36 @@ class DMReaction(Base): __table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),) +# Tracks authenticated device sessions per user +class DeviceSession(Base): + __tablename__ = "device_session" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) + + # Raw User-Agent for reference/debugging + raw_user_agent = Column(Text, nullable=True) + + # Parsed fields + device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown + os_name = Column(String(64), nullable=True) + os_version = Column(String(64), nullable=True) + browser_name = Column(String(64), nullable=True) + browser_version = Column(String(64), nullable=True) + brand = Column(String(64), nullable=True) + model = Column(String(64), nullable=True) + + # Session identity embedded into JWTs + session_id = Column(String(64), unique=True, nullable=False, index=True) + + # Lifecycle + created_at = Column(DateTime, default=datetime.now) + last_seen = Column(DateTime, default=datetime.now) + revoked = Column(Boolean, default=False) + + # Relationship back to user (optional lazy to avoid heavy loads) + user = relationship("User", lazy="select") + # Pydantic модели class LoginRequest(BaseModel): username: str @@ -159,6 +189,12 @@ class RegisterRequest(BaseModel): confirm_password: str +class ChangePasswordRequest(BaseModel): + currentPasswordDerived: str + newPasswordDerived: str + logoutAllExceptCurrent: bool = False + + class SendMessageRequest(BaseModel): content: str reply_to_id: int | None = None diff --git a/backend/requirements.txt b/backend/requirements.txt index 27fbad9..3cf107f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,4 @@ pywebpush>=1.14.0 cryptography>=41.0.0 alembic>=1.13.2 better-profanity>=0.7.0 +user-agents>=2.2.0 diff --git a/backend/routes/account.py b/backend/routes/account.py index 607c805..de70625 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -1,10 +1,13 @@ from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session +import uuid +from user_agents import parse as parse_ua +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from constants import OWNER_USERNAME from dependencies import get_current_user, get_db -from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup +from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession from utils import create_token, get_password_hash, verify_password from validation import is_valid_password, is_valid_username, is_valid_display_name @@ -37,7 +40,7 @@ def check_auth(current_user: User = Depends(get_current_user)): @router.post("/login") -def login(request: LoginRequest, db: Session = Depends(get_db)): +def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = None): user = db.query(User).filter(User.username == request.username.strip()).first() if not user or not verify_password(request.password.strip(), user.password_hash): @@ -46,11 +49,33 @@ def login(request: LoginRequest, db: Session = Depends(get_db)): detail="Неверное имя пользователя или пароль" ) + # Create device session and embed into JWT + raw_ua = http.headers.get("user-agent") if http else None + ua = parse_ua(raw_ua or "") + session_id = uuid.uuid4().hex + + device = DeviceSession( + user_id=user.id, + raw_user_agent=raw_ua, + 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() - token = create_token(user.id, user.username) + token = create_token(user.id, user.username, session_id) return { "status": "success", @@ -61,7 +86,7 @@ def login(request: LoginRequest, db: Session = Depends(get_db)): @router.post("/register") -def register(request: RegisterRequest, db: Session = Depends(get_db)): +def register(request: RegisterRequest, db: Session = Depends(get_db), http: Request = None): username = request.username.strip() display_name = request.display_name.strip() password = request.password.strip() @@ -134,7 +159,29 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)): db.commit() db.refresh(new_user) - token = create_token(new_user.id, new_user.username) + # Create initial device session + raw_ua = http.headers.get("user-agent") if http else None + ua = parse_ua(raw_ua or "") + session_id = uuid.uuid4().hex + device = DeviceSession( + user_id=new_user.id, + raw_user_agent=raw_ua, + 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) return { "status": "success", @@ -227,6 +274,37 @@ def logout( } +@router.post("/change-password") +def change_password( + request: ChangePasswordRequest, + credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Verify current derived password against stored hash + if not verify_password(request.currentPasswordDerived.strip(), current_user.password_hash): + raise HTTPException(status_code=401, detail="Текущий пароль неверный") + + # Update password hash to hash of new derived password + current_user.password_hash = get_password_hash(request.newPasswordDerived.strip()) + db.commit() + + # Optionally revoke all other sessions, keeping the current one + if request.logoutAllExceptCurrent: + from 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() + + return {"status": "success"} + + @router.get("/users") def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): users = db.query(User).order_by(User.username.asc()).all() diff --git a/backend/routes/devices.py b/backend/routes/devices.py new file mode 100644 index 0000000..144ffb6 --- /dev/null +++ b/backend/routes/devices.py @@ -0,0 +1,88 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from dependencies import get_current_user, get_db +from models import User, DeviceSession +from utils import verify_token +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +router = APIRouter() +security = HTTPBearer() + + +def _get_current_session_id(credentials: HTTPAuthorizationCredentials) -> str: + token = credentials.credentials + payload = verify_token(token) + if not payload or "session_id" not in payload: + raise HTTPException(status_code=401, detail="Invalid session") + return payload["session_id"] + + +@router.get("") +def list_devices( + credentials: HTTPAuthorizationCredentials = Depends(security), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + current_session_id = _get_current_session_id(credentials) + sessions = ( + db.query(DeviceSession) + .filter(DeviceSession.user_id == current_user.id) + .order_by(DeviceSession.last_seen.desc()) + .all() + ) + return { + "devices": [ + { + "session_id": s.session_id, + "device_type": s.device_type, + "os_name": s.os_name, + "os_version": s.os_version, + "browser_name": s.browser_name, + "browser_version": s.browser_version, + "brand": s.brand, + "model": s.model, + "created_at": s.created_at.isoformat() if s.created_at else None, + "last_seen": s.last_seen.isoformat() if s.last_seen else None, + "revoked": s.revoked, + "current": s.session_id == current_session_id, + } + for s in sessions + ] + } + + +@router.delete("/{session_id}") +def revoke_device( + session_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + s = ( + db.query(DeviceSession) + .filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id) + .first() + ) + if not s: + raise HTTPException(status_code=404, detail="Device session not found") + s.revoked = True + db.commit() + return {"status": "success"} + + +@router.post("/logout-all") +def logout_all_except_current( + credentials: HTTPAuthorizationCredentials = Depends(security), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + current_session_id = _get_current_session_id(credentials) + db.query(DeviceSession).filter( + DeviceSession.user_id == current_user.id, + DeviceSession.session_id != current_session_id, + ).update({DeviceSession.revoked: True}) + db.commit() + return {"status": "success"} + + diff --git a/backend/utils.py b/backend/utils.py index 437a324..09db130 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -6,11 +6,12 @@ import bcrypt from constants import * # JWT Helper Functions -def create_token(user_id: int, username: str) -> str: +def create_token(user_id: int, username: str, session_id: str) -> str: expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) payload = { "user_id": user_id, "username": username, + "session_id": session_id, "exp": expire } return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) diff --git a/frontend/src/core/api/authApi.ts b/frontend/src/core/api/authApi.ts index 033f15b..78edac5 100644 --- a/frontend/src/core/api/authApi.ts +++ b/frontend/src/core/api/authApi.ts @@ -3,6 +3,7 @@ import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup"; import { b64, ub64 } from "@/utils/utils"; import { API_BASE_URL } from "@/core/config"; +import { importPassword, hkdfExtractAndExpand } from "@/utils/crypto/kdf"; /** * Generates authentication headers for API requests @@ -143,4 +144,17 @@ export function restoreKeys() { export function getAuthToken(): string | null { return localStorage.getItem("authToken"); +} + +/** + * Derive a client-side authentication secret so the raw password never leaves the client. + * Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64. + */ +export async function deriveAuthSecret(username: string, password: string): Promise { + const key = await importPassword(password); + // Use per-user salt derived from username; in future we can fetch a server-provided salt + const salt = new TextEncoder().encode(`fromchat.user:${username}`); + // Derive 32 bytes using HKDF; PBKDF2 already used within importPassword + const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32); + return b64(derived); } \ No newline at end of file diff --git a/frontend/src/core/api/devicesApi.ts b/frontend/src/core/api/devicesApi.ts new file mode 100644 index 0000000..a6a48da --- /dev/null +++ b/frontend/src/core/api/devicesApi.ts @@ -0,0 +1,36 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "@/core/api/authApi"; + +export interface DeviceInfo { + session_id: string; + device_type?: string; + os_name?: string; + os_version?: string; + browser_name?: string; + browser_version?: string; + brand?: string; + model?: string; + created_at?: string; + last_seen?: string; + revoked?: boolean; + current?: boolean; +} + +export async function listDevices(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token) }); + if (!res.ok) throw new Error("Failed to fetch devices"); + const data = await res.json(); + return data.devices as DeviceInfo[]; +} + +export async function revokeDevice(token: string, sessionId: string): Promise { + const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token) }); + if (!res.ok) throw new Error("Failed to revoke device"); +} + +export async function logoutAllOtherDevices(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token) }); + if (!res.ok) throw new Error("Failed to logout all devices"); +} + + diff --git a/frontend/src/core/api/securityApi.ts b/frontend/src/core/api/securityApi.ts new file mode 100644 index 0000000..9244c83 --- /dev/null +++ b/frontend/src/core/api/securityApi.ts @@ -0,0 +1,25 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders, deriveAuthSecret } from "@/core/api/authApi"; + +export async function changePassword( + token: string, + username: string, + currentPassword: string, + newPassword: string, + logoutAllExceptCurrent: boolean +): Promise { + const currentDerived = await deriveAuthSecret(username, currentPassword); + const newDerived = await deriveAuthSecret(username, newPassword); + const res = await fetch(`${API_BASE_URL}/change-password`, { + method: "POST", + headers: getAuthHeaders(token), + body: JSON.stringify({ + currentPasswordDerived: currentDerived, + newPasswordDerived: newDerived, + logoutAllExceptCurrent + }) + }); + if (!res.ok) throw new Error("Failed to change password"); +} + + diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index 01537ba..7111273 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -2,7 +2,7 @@ import { useImmer } from "use-immer"; import { AlertsContainer, type Alert, type AlertType } from "./Auth"; import { AuthContainer, AuthHeader } from "./Auth"; import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types"; -import { ensureKeysOnLogin } from "@/core/api/authApi"; +import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; import { API_BASE_URL } from "@/core/config"; import { useRef } from "react"; import type { TextField } from "mdui/components/text-field"; @@ -47,9 +47,10 @@ export default function LoginPage() { } try { + const derived = await deriveAuthSecret(username, password); const request: LoginRequest = { username: username, - password: password + password: derived } const response = await fetch(`${API_BASE_URL}/login`, { diff --git a/frontend/src/pages/auth/RegisterPage.tsx b/frontend/src/pages/auth/RegisterPage.tsx index 453b174..28b31d3 100644 --- a/frontend/src/pages/auth/RegisterPage.tsx +++ b/frontend/src/pages/auth/RegisterPage.tsx @@ -7,7 +7,7 @@ import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types import { API_BASE_URL } from "@/core/config"; import { useAppState } from "@/pages/chat/state"; import { MaterialTextField } from "@/core/components/MaterialTextField"; -import { ensureKeysOnLogin } from "@/core/api/authApi"; +import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; import { useNavigate } from "react-router-dom"; import "./auth.scss"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; @@ -74,11 +74,12 @@ export default function RegisterPage() { } try { + const derived = await deriveAuthSecret(username, password); const request: RegisterRequest = { display_name: displayName, username: username, - password: password, - confirm_password: confirmPassword + password: derived, + confirm_password: derived } const response = await fetch(`${API_BASE_URL}/register`, { diff --git a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx index 4713646..e7e61a9 100644 --- a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx @@ -7,12 +7,21 @@ import { isElectron } from "@/core/electron/electron"; import { useAppState } from "@/pages/chat/state"; import type { Switch } from "mdui/components/switch"; import { getAuthHeaders } from "@/core/api/authApi"; +import { changePassword } from "@/core/api/securityApi"; +import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi"; +import { useImmer } from "use-immer"; export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { const [activePanel, setActivePanel] = useState("notifications-settings"); const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false); const [pushSupported, setPushSupported] = useState(false); const user = useAppState(state => state.user); + const logout = useAppState(state => state.logout); + const [devices, updateDevices] = useImmer([]); + const [cpCurrent, setCpCurrent] = useState(""); + const [cpNext, setCpNext] = useState(""); + const [cpConfirm, setCpConfirm] = useState(""); + const [cpLogoutAll, setCpLogoutAll] = useState(true); useEffect(() => { setPushSupported(isSupported()); @@ -21,6 +30,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { setPushNotificationsEnabled(isSupported()); }, []); + useEffect(() => { + if (activePanel === "devices-settings" && user.authToken) { + listDevices(user.authToken) + .then(list => updateDevices(() => list)) + .catch(() => {}); + } + }, [activePanel, user.authToken, updateDevices]); + const handlePanelChange = (panelId: string) => { setActivePanel(panelId); }; @@ -79,15 +96,6 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { > Уведомления - handlePanelChange("appearance-settings")} - style={{ cursor: "pointer" }} - > - Внешний вид - handlePanelChange("language-settings")} + active={activePanel === "devices-settings"} + onClick={() => handlePanelChange("devices-settings")} style={{ cursor: "pointer" }} > - Язык - - handlePanelChange("storage-settings")} - style={{ cursor: "pointer" }} - > - Хранилище - - handlePanelChange("help-settings")} - style={{ cursor: "pointer" }} - > - Помощь + Устройства )} - Новые сообщения - Звуковые уведомления - Уведомления о статусе - Email уведомления -
-

Внешний вид

- - Тёмная - Светлая - Авто - - - Маленький - Средний - Большой - -

Безопасность

- Изменить пароль - Двухфакторная аутентификация - Автоматический выход +
{ + e.preventDefault(); + if (!user.authToken || !user.username) return; + if (!cpCurrent || !cpNext || cpNext !== cpConfirm) return; + try { + await changePassword(user.authToken, user.username, cpCurrent, cpNext, cpLogoutAll); + setCpCurrent(""); + setCpNext(""); + setCpConfirm(""); + } catch (err) { + console.error(err); + } + }}> + setCpCurrent(e.target.value)} variant="outlined" toggle-password> + setCpNext(e.target.value)} variant="outlined" toggle-password> + setCpConfirm(e.target.value)} variant="outlined" toggle-password> +
+ setCpLogoutAll(e.target.checked)}>Выйти на всех устройствах (кроме текущего) +
+ Сохранить +
+
@@ -188,19 +180,26 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { Очистить кэш
-
-

Помощь

- Руководство пользователя - Связаться с поддержкой - FAQ +
+

Устройства

+
+ { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах + { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве +
+ + {devices.map((d) => ( + { if (!user.authToken || d.current) return; await revokeDevice(user.authToken, d.session_id); const list = await listDevices(user.authToken); updateDevices(() => list); }}> +
{d.browser_name || "Браузер"} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}
+
Последняя активность: {d.last_seen || "—"}
+
+ ))} +

О приложении

-

Версия: 1.0.0

-

© 2025 {PRODUCT_NAME}. Все права защищены.

- Политика конфиденциальности - Условия использования +

100% open source. Репозиторий на GitHub.

+

{PRODUCT_NAME}