diff --git a/main.ipynb b/main.ipynb index c13b577..8ec3546 100644 --- a/main.ipynb +++ b/main.ipynb @@ -1086,6 +1086,13 @@ "metadata": {}, "outputs": [], "source": [ + "import hashlib\n", + "\n", + "def sha256(text: str) -> str:\n", + " \"\"\"Server-side SHA-256 (hex)\"\"\"\n", + " return hashlib.sha256(text.encode(\"utf-8\")).hexdigest()\n", + "\n", + "\n", "@app.post(\"/api/login\")\n", "async def login(\n", " request: Request,\n", @@ -1096,57 +1103,86 @@ " await check_bruteforce(identifier)\n", "\n", " or_conditions = [\n", - " {\"email\": identifier},\n", - " {\"email\": identifier.lower()},\n", - " ]\n", - " \n", + " {\"email\": identifier},\n", + " {\"email\": identifier.lower()},\n", + " ]\n", + "\n", " try:\n", " normalized_phone = normalize_phone(identifier)\n", " if normalized_phone:\n", " or_conditions.append({\"phone\": normalized_phone})\n", - " except:\n", + " except Exception:\n", " pass\n", - " users = [doc async for doc in users_collection.find({\"$or\": or_conditions})]\n", "\n", + " users = [doc async for doc in users_collection.find({\"$or\": or_conditions})]\n", "\n", " if not users:\n", " await increment_failed_attempt(identifier)\n", - " raise HTTPException(401, \"Incorrect credentials\")\n", + " raise HTTPException(status_code=401, detail=\"Incorrect credentials\")\n", "\n", - " # Track if a valid password was found\n", - " valid_user_found = False\n", + " matched_user = None\n", + " new_hash = None\n", + " is_hash_login = login_data.client_id == \"web_hash\"\n", "\n", " for user in users:\n", " stored = user.get(\"hashed_password\")\n", - "\n", " if isinstance(stored, bytes):\n", " stored = stored.decode()\n", "\n", - " # Check if the password is hashed with Argon2\n", - " if stored.startswith(\"$argon2\"):\n", - " ok = verify_password(login_data.password, stored)\n", - " if ok:\n", - " valid_user_found = True\n", - " break # Password is valid, exit the loop\n", - " \n", - " # Check if the password is hashed with bcrypt\n", - " elif stored.startswith(\"$2y$\"):\n", - " stored = stored.replace(\"$2y$\", \"$2b$\", 1)\n", - " if bcrypt.checkpw(login_data.password.encode(), stored.encode()):\n", - " valid_user_found = True\n", - " new_hash = get_password_hash(login_data.password)\n", - " break # Password is valid, exit the loop\n", + " if not stored:\n", + " continue\n", "\n", - " # If no valid user was found, increment attempts and raise error\n", - " if not valid_user_found:\n", + " # ---------- 1. Client sent SHA-256 hash ----------\n", + " if is_hash_login:\n", + " # Stored value is expected to be Argon2(SHA256(password))\n", + " if stored.startswith(\"$argon2\"):\n", + " if verify_password(login_data.password, stored):\n", + " matched_user = user\n", + " break\n", + "\n", + " # ---------- 2. Client sent plaintext ----------\n", + " else:\n", + " # Modern: Argon2(SHA256(plain))\n", + " if stored.startswith(\"$argon2\"):\n", + " # Try verifying as if stored is Argon2(SHA256(plain))\n", + " hashed_input = sha256(login_data.password)\n", + " if verify_password(hashed_input, stored):\n", + " matched_user = user\n", + " break\n", + "\n", + " # Also try classic Argon2(plain) for very old accounts\n", + " if verify_password(login_data.password, stored):\n", + " matched_user = user\n", + " # Force migration to Argon2(SHA256)\n", + " new_hash = get_password_hash(sha256(login_data.password))\n", + " break\n", + "\n", + " # Legacy bcrypt(plain)\n", + " elif stored.startswith((\"$2y$\", \"$2b$\", \"$2a$\")):\n", + " stored_bcrypt = stored.replace(\"$2y$\", \"$2b$\", 1)\n", + " try:\n", + " if bcrypt.checkpw(\n", + " login_data.password.encode(\"utf-8\"),\n", + " stored_bcrypt.encode(\"utf-8\")\n", + " ):\n", + " matched_user = user\n", + " # Migrate to Argon2(SHA256(plain))\n", + " new_hash = get_password_hash(sha256(login_data.password))\n", + " break\n", + " except Exception:\n", + " continue\n", + "\n", + " if not matched_user:\n", " await increment_failed_attempt(identifier)\n", - " raise HTTPException(401, \"Incorrect credentials\")\n", + " raise HTTPException(status_code=401, detail=\"Incorrect credentials\")\n", "\n", - " # If we reach this point, authentication succeeded\n", - " # Proceed with further logic such as generating tokens or returning user details\n", + " # Apply migration if needed\n", + " if new_hash:\n", + " await users_collection.update_one(\n", + " {\"_id\": matched_user[\"_id\"]},\n", + " {\"$set\": {\"hashed_password\": new_hash}}\n", + " )\n", "\n", - " # await users_collection.update_one({\"_id\": user[\"_id\"]}, {\"$set\": {\"hashed_password\": new_hash}})\n", - " \n", " await reset_failed_attempts(identifier)\n", "\n", " user_id_str = str(user[\"_id\"])\n", @@ -1915,6 +1951,43 @@ " )\n", "\n", "\n", + "@app.post(\"/api/reset-hash-password\")\n", + "async def reset_hash_password_user(\n", + " request: Request,\n", + " response: Response,\n", + " data: ResetPasswordRequest = Body(...),\n", + " current_user: Dict = Security(get_current_user)\n", + "):\n", + " user_id_str = current_user[\"id\"]\n", + "\n", + " session_key = f\"user_sessions:{user_id_str}\"\n", + " tokens = await redis_client.smembers(session_key)\n", + " for t in tokens:\n", + " await redis_client.delete(t)\n", + " await redis_client.delete(session_key)\n", + "\n", + " for cookie_name in request.cookies.keys():\n", + " if cookie_name in ['zoom']:\n", + " continue\n", + "\n", + " for dom in [None,'.kultmost.ru','www.kultmost.ru','avtopost.kultmost.ru','avtopost.kultmost.ru','kultmost.ru']:\n", + " response.delete_cookie(\n", + " key=cookie_name,\n", + " domain=dom,\n", + " path=\"/\", \n", + " secure=False, \n", + " httponly=False,\n", + " )\n", + " hashed_password = get_password_hash(data.new_password)\n", + " \n", + " await users_collection.update_one(\n", + " {\"_id\": ObjectId(user_id_str)},\n", + " {\"$set\": {\"hashed_password\": hashed_password}}\n", + " )\n", + " \n", + " return {\"ok\": True, \"message\": \"Пароль успешно изменён\", \"next\": '/profile'}\n", + "\n", + "\n", "@router.post(\"/api/reset-password\")\n", "async def reset_password(\n", " data: ResetPasswordRequest = Body(...),\n", @@ -4146,6 +4219,30 @@ ], "display_name": ".venv" }, + { + "cell_type": "code", + "execution_count": null, + "id": "c7c3772b", + "metadata": {}, + "outputs": [], + "source": [ + "@app.post(\"/api/admin/delete-user\")\n", + "async def delete_user(\n", + " payload: DeleteUserRequest,\n", + " user: Dict = Security(get_current_user, scopes=[\"admin\"]),\n", + "):\n", + " try:\n", + " result = users_collection.delete_one({\"_id\": ObjectId(payload._id)})\n", + " except Exception:\n", + " raise HTTPException(status_code=400, detail=\"Неверный ID пользователя\")\n", + "\n", + " if result.deleted_count == 0:\n", + " raise HTTPException(status_code=404, detail=\"Пользователь не найден\")\n", + "\n", + " return {\"ok\": True}\n" + ], + "display_name": ".venv" + }, { "cell_type": "code", "execution_count": null, diff --git a/main.py b/main.py index ad3aa46..d1d4273 100644 --- a/main.py +++ b/main.py @@ -797,6 +797,13 @@ async def login_token(token: str, request: Request, response: Response): return RedirectResponse(next_url,headers=response.headers) +import hashlib + +def sha256(text: str) -> str: + """Server-side SHA-256 (hex)""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + @app.post("/api/login") async def login( request: Request, @@ -807,57 +814,86 @@ async def login( await check_bruteforce(identifier) or_conditions = [ - {"email": identifier}, - {"email": identifier.lower()}, - ] - + {"email": identifier}, + {"email": identifier.lower()}, + ] + try: normalized_phone = normalize_phone(identifier) if normalized_phone: or_conditions.append({"phone": normalized_phone}) - except: + except Exception: pass - users = [doc async for doc in users_collection.find({"$or": or_conditions})] + users = [doc async for doc in users_collection.find({"$or": or_conditions})] if not users: await increment_failed_attempt(identifier) - raise HTTPException(401, "Incorrect credentials") + raise HTTPException(status_code=401, detail="Incorrect credentials") - # Track if a valid password was found - valid_user_found = False + matched_user = None + new_hash = None + is_hash_login = login_data.client_id == "web_hash" for user in users: stored = user.get("hashed_password") - if isinstance(stored, bytes): stored = stored.decode() - # Check if the password is hashed with Argon2 - if stored.startswith("$argon2"): - ok = verify_password(login_data.password, stored) - if ok: - valid_user_found = True - break # Password is valid, exit the loop - - # Check if the password is hashed with bcrypt - elif stored.startswith("$2y$"): - stored = stored.replace("$2y$", "$2b$", 1) - if bcrypt.checkpw(login_data.password.encode(), stored.encode()): - valid_user_found = True - new_hash = get_password_hash(login_data.password) - break # Password is valid, exit the loop + if not stored: + continue - # If no valid user was found, increment attempts and raise error - if not valid_user_found: + # ---------- 1. Client sent SHA-256 hash ---------- + if is_hash_login: + # Stored value is expected to be Argon2(SHA256(password)) + if stored.startswith("$argon2"): + if verify_password(login_data.password, stored): + matched_user = user + break + + # ---------- 2. Client sent plaintext ---------- + else: + # Modern: Argon2(SHA256(plain)) + if stored.startswith("$argon2"): + # Try verifying as if stored is Argon2(SHA256(plain)) + hashed_input = sha256(login_data.password) + if verify_password(hashed_input, stored): + matched_user = user + break + + # Also try classic Argon2(plain) for very old accounts + if verify_password(login_data.password, stored): + matched_user = user + # Force migration to Argon2(SHA256) + new_hash = get_password_hash(sha256(login_data.password)) + break + + # Legacy bcrypt(plain) + elif stored.startswith(("$2y$", "$2b$", "$2a$")): + stored_bcrypt = stored.replace("$2y$", "$2b$", 1) + try: + if bcrypt.checkpw( + login_data.password.encode("utf-8"), + stored_bcrypt.encode("utf-8") + ): + matched_user = user + # Migrate to Argon2(SHA256(plain)) + new_hash = get_password_hash(sha256(login_data.password)) + break + except Exception: + continue + + if not matched_user: await increment_failed_attempt(identifier) - raise HTTPException(401, "Incorrect credentials") + raise HTTPException(status_code=401, detail="Incorrect credentials") - # If we reach this point, authentication succeeded - # Proceed with further logic such as generating tokens or returning user details + # Apply migration if needed + if new_hash: + await users_collection.update_one( + {"_id": matched_user["_id"]}, + {"$set": {"hashed_password": new_hash}} + ) - # await users_collection.update_one({"_id": user["_id"]}, {"$set": {"hashed_password": new_hash}}) - await reset_failed_attempts(identifier) user_id_str = str(user["_id"]) @@ -1432,6 +1468,43 @@ async def restore_page(request: Request, token: str): ) +@app.post("/api/reset-hash-password") +async def reset_hash_password_user( + request: Request, + response: Response, + data: ResetPasswordRequest = Body(...), + current_user: Dict = Security(get_current_user) +): + user_id_str = current_user["id"] + + session_key = f"user_sessions:{user_id_str}" + tokens = await redis_client.smembers(session_key) + for t in tokens: + await redis_client.delete(t) + await redis_client.delete(session_key) + + for cookie_name in request.cookies.keys(): + if cookie_name in ['zoom']: + continue + + for dom in [None,'.kultmost.ru','www.kultmost.ru','avtopost.kultmost.ru','avtopost.kultmost.ru','kultmost.ru']: + response.delete_cookie( + key=cookie_name, + domain=dom, + path="/", + secure=False, + httponly=False, + ) + hashed_password = get_password_hash(data.new_password) + + await users_collection.update_one( + {"_id": ObjectId(user_id_str)}, + {"$set": {"hashed_password": hashed_password}} + ) + + return {"ok": True, "message": "Пароль успешно изменён", "next": '/profile'} + + @router.post("/api/reset-password") async def reset_password( data: ResetPasswordRequest = Body(...), @@ -3178,6 +3251,22 @@ async def unsubscribe( """ +@app.post("/api/admin/delete-user") +async def delete_user( + payload: DeleteUserRequest, + user: Dict = Security(get_current_user, scopes=["admin"]), +): + try: + result = users_collection.delete_one({"_id": ObjectId(payload._id)}) + except Exception: + raise HTTPException(status_code=400, detail="Неверный ID пользователя") + + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Пользователь не найден") + + return {"ok": True} + + @app.get("/profile/admin/users", response_class=HTMLResponse) async def profile_admin_statistics( request: Request, diff --git a/models.py b/models.py index 09c00fe..a3e0e58 100644 --- a/models.py +++ b/models.py @@ -70,6 +70,10 @@ class UserLoginForm(OAuth2PasswordRequestForm): pass +class DeleteUserRequest(BaseModel): + _id: str + + class LoginRequest(BaseModel): identifier: str password: str diff --git a/templates/login.html b/templates/login.html index 0b8fb12..8179ad9 100644 --- a/templates/login.html +++ b/templates/login.html @@ -72,6 +72,24 @@ function sendToNextPage(e) { // @license-end + + {{ footer.section(user) }} {% endblock %} + {% block head %} {{ header.style() }} {{ profile_nav.style() }} + {{ footer.style() }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/registration.html b/templates/registration.html index 58b0526..580a9c1 100644 --- a/templates/registration.html +++ b/templates/registration.html @@ -143,6 +143,24 @@ document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelector(" +