Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fa77c53
feat: Implement client-side password encryption
google-labs-jules[bot] May 23, 2025
2d25b6b
Update admin/src/components/UserSettings/ChangePassword.tsx
kubbot May 23, 2025
e6c6e67
Update backend/app/tests/core/test_security.py
kubbot May 23, 2025
d3eb32d
Update frontend/app/customers/components/PasswordForm.tsx
kubbot May 23, 2025
dae88a6
Merge branch 'main' into feat/password-transit-encryption
cubxxw May 24, 2025
3a2677c
Merge branch 'main' into feat/password-transit-encryption
cubxxw May 24, 2025
cbd442c
Merge branch 'main' into feat/password-transit-encryption
cubxxw May 24, 2025
ab1a825
Merge branch 'main' into feat/password-transit-encryption
cubxxw May 25, 2025
3a0fb2a
Merge branch 'main' into feat/password-transit-encryption
cubxxw May 25, 2025
8016911
fix: actions error make all
cubxxw May 25, 2025
ae29a54
fix: actions error make all
cubxxw May 25, 2025
93c7077
Merge branch 'main' into feat/password-transit-encryption
cubxxw May 25, 2025
fad753b
feat: fix teset
cubxxw May 25, 2025
944b4bd
feat: fix teset
cubxxw May 25, 2025
c2783e5
feat: fix teset
cubxxw May 25, 2025
3625ecb
feat: fix teset
cubxxw May 25, 2025
a4037bb
feat: fix teset
cubxxw May 25, 2025
78b18dc
fix: actions error make all
cubxxw May 25, 2025
8b824a2
fix: actions error make all
cubxxw May 25, 2025
110c8fe
Resolved merge conflicts
cubxxw May 28, 2025
ddeec90
Resolved merge conflicts
cubxxw May 28, 2025
701bc1d
chore: update dependencies and environment configurations
cubxxw May 28, 2025
7e75f8e
refactor: streamline CORS configuration in main.py
cubxxw May 28, 2025
2962693
chore: comment out deployment steps in staging workflow
cubxxw May 28, 2025
9c4d04f
Update .github/workflows/generate-client.yml
cubxxw May 28, 2025
79c00c6
Update backend/app/core/security.py
cubxxw May 28, 2025
51ac12d
Update .env.example
cubxxw May 28, 2025
dc1e122
Enhance security logging and update OpenAPI specification
cubxxw May 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,15 @@ ACCESS_TOKEN_EXPIRE_MINUTES=14400

# First Superuser: Email address for the initial administrator account.
[email protected]

# First Superuser Password: Password for the initial administrator account.
# Choose a strong password.
# min_length=8
FIRST_SUPERUSER_PASSWORD='telepace'

# 用于密码加密和解密的对称密钥,必须是有效的 Fernet 密钥(32 字节的 URL 安全 base64 编码,末尾必须有 = 符号)
# 生成方法: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
APP_SYMMETRIC_ENCRYPTION_KEY='Buhzb09HgEg-4C7oUsZqykAH_-yfXEONu9sogno3a2s='

# -- Email (SMTP) Settings --
# Configuration for sending emails (e.g., password resets, notifications).

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/generate-client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ jobs:
POSTHOG_API_KEY: ""
POSTHOG_HOST: ""
VIRTUAL_ENV: .venv
APP_SYMMETRIC_ENCRYPTION_KEY: 'Buhzb09HgEg-4C7oUsZqykAH_-yfXEONu9sogno3a2s='

- name: Stage Generated Files
run: |
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ jobs:
- run: uv run bash scripts/generate-client.sh
env:
VIRTUAL_ENV: backend/.venv
APP_SYMMETRIC_ENCRYPTION_KEY: 'Buhzb09HgEg-4C7oUsZqykAH_-yfXEONu9sogno3a2s='
- name: Install Doppler CLI
uses: dopplerhq/cli-action@v3
- name: Setup Doppler and env
Expand Down
1 change: 1 addition & 0 deletions .windsurfrules
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1. The text of the reply is in Chinese, but the code is in English
Copy link
Preview

Copilot AI May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] This rule file appears unintended for the codebase and mixes Chinese/English. Remove or relocate it to avoid confusion.

Suggested change
1. The text of the reply is in Chinese, but the code is in English
[File removed]

Copilot uses AI. Check for mistakes.

2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ backend-restart:
.PHONY: backend-install
backend-install: check-uv
@echo "===========> Installing backend dependencies"
@cd $(BACKEND_DIR) && $(UV) sync
@cd $(BACKEND_DIR) && UV_HTTP_TIMEOUT=120 $(UV) sync

## backend-test: Run backend tests with coverage
.PHONY: backend-test
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,48 @@ git merge --continue

Built with ❤️ for AI entrepreneurs and freelancers. Happy coding!

## Enhanced Password Security

To provide an additional layer of protection for user credentials, especially during transit, this project implements client-side symmetric encryption for passwords on top of standard HTTPS.

### Mechanism Overview

1. **Client-Side Encryption:** Before a password is submitted during login, registration, or password update, it is encrypted directly in the user's browser (both in the main frontend and the admin panel) using AES (Advanced Encryption Standard).
2. **Secure Transmission:** This encrypted password, not the plain text password, is then sent to the backend over HTTPS.
3. **Backend Decryption:** The backend decrypts the received password using the shared symmetric key.
4. **Standard Hashing:** After decryption, the backend proceeds with the standard secure password hashing process (bcrypt) before storing or verifying the password.

This approach ensures that the plain text password is never transmitted directly from the client to the server, offering defense in depth.

### Key Management

The AES encryption and decryption process relies on a shared symmetric key. This key must be securely generated and kept secret. It is configured using environment variables across the different parts of the application:

* **Backend (Python/FastAPI):**
* Variable: `APP_SYMMETRIC_ENCRYPTION_KEY`
* Location: `backend/.env.example` (and your `.env` file)
* **Frontend (Next.js):**
* Variable: `NEXT_PUBLIC_APP_SYMMETRIC_ENCRYPTION_KEY`
* Location: `frontend/.env.example` (and your `.env` file)
* **Admin Panel (Vite):**
* Variable: `VITE_APP_SYMMETRIC_ENCRYPTION_KEY`
* Location: `admin/.env.example` (and your `.env` file)

**Important:**
* All three variables must hold the **same key value** for the encryption/decryption to work correctly.
* The key should be a strong, unique, and randomly generated value. For compatibility with the backend's Fernet library, it must be a URL-safe base64-encoded 32-byte key. You can generate such a key using Python:
```python
from cryptography.fernet import Fernet
key = Fernet.generate_key()
print(key.decode())
```
* Store these keys securely and never commit them directly to your version control system (except in the `.env.example` files as placeholders).

### Libraries Used

* **Frontend/Admin Panel:** `crypto-js` is utilized for AES encryption on the client-side.
* **Backend:** The `cryptography` library (specifically its Fernet implementation) is used for AES decryption.

## Development

See the [development guide](development.md) for instructions on setting up the development environment.
Expand Down
6 changes: 5 additions & 1 deletion admin/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
VITE_API_URL=http://localhost:8000
NODE_ENV=development
NODE_ENV=development

# This key needs to be a securely generated, preferably 32-byte (256-bit) random string,
# often represented in base64. For Fernet, it must be a URL-safe base64-encoded 32-byte key.
VITE_APP_SYMMETRIC_ENCRYPTION_KEY="your_strong_symmetric_encryption_key_here"
2 changes: 2 additions & 0 deletions admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@tanstack/react-query-devtools": "^5.28.14",
"@tanstack/react-router": "1.19.1",
"axios": "1.7.4",
"crypto-js": "^4.2.0",
"form-data": "4.0.0",
"next-themes": "^0.4.4",
"react": "^18.2.0",
Expand All @@ -36,6 +37,7 @@
"@playwright/test": "^1.45.2",
"@tanstack/router-devtools": "1.19.1",
"@tanstack/router-vite-plugin": "1.19.0",
"@types/crypto-js": "^4.2.2",
"@types/node": "^20.10.5",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
Expand Down
16 changes: 16 additions & 0 deletions admin/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 21 additions & 7 deletions admin/src/components/UserSettings/ChangePassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import {
OpenAPI,
type UpdatePassword,
UsersService,
} from "@/client"
import { request } from "@/client/core/request"
import useCustomToast from "@/hooks/useCustomToast"
import { confirmPasswordRules, handleError, passwordRules } from "@/utils"
import { PasswordInput } from "../ui/password-input"
} from "@/client";
import { request } from "@/client/core/request";
import useCustomToast from "@/hooks/useCustomToast";
import { confirmPasswordRules, handleError, passwordRules } from "@/utils";
import { PasswordInput } from "../ui/password-input";
import { encryptPassword } from "@/utils/encryption"; // Added import

interface UpdatePasswordForm extends UpdatePassword {
confirm_password: string
Expand Down Expand Up @@ -71,8 +72,20 @@ const ChangePassword = () => {
})

const onSubmit: SubmitHandler<UpdatePasswordForm> = async (data) => {
mutation.mutate(data)
}
try {
// Encrypt passwords before sending to backend
const encryptedCurrentPassword = encryptPassword(data.current_password);
const encryptedNewPassword = encryptPassword(data.new_password);

mutation.mutate({
current_password: encryptedCurrentPassword,
new_password: encryptedNewPassword,
});
} catch (error) {
console.error("Password encryption failed:", error);
showErrorToast("Failed to encrypt password. Please try again.");
}
};

return (
<>
Expand Down Expand Up @@ -118,4 +131,5 @@ const ChangePassword = () => {
</>
)
}

export default ChangePassword
32 changes: 22 additions & 10 deletions admin/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import {
type UserPublic,
type UserRegister,
UsersService,
} from "@/client"
import { request } from "@/client/core/request"
import { handleError } from "@/utils"
} from "@/client";
import { request } from "@/client/core/request";
import { handleError } from "@/utils";
import { encryptPassword } from "@/utils/encryption"; // Added import

const isLoggedIn = () => {
return localStorage.getItem("access_token") !== null
Expand Down Expand Up @@ -45,8 +46,15 @@ const useAuth = () => {
})

const signUpMutation = useMutation({
mutationFn: (data: UserRegister) =>
UsersService.registerUser({ requestBody: data }),
mutationFn: (data: UserRegister) => { // data is UserRegister
const encryptedPassword = encryptPassword(data.password);
return UsersService.registerUser({
requestBody: {
...data,
password: encryptedPassword, // Override with encrypted password
},
});
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for password encryption in signup

The current implementation doesn't handle potential encryption errors specifically, which could lead to confusing error messages for users.

mutationFn: (data: UserRegister) => { // data is UserRegister
-  const encryptedPassword = encryptPassword(data.password);
-  return UsersService.registerUser({
-    requestBody: {
-      ...data,
-      password: encryptedPassword, // Override with encrypted password
-    },
-  });
+  try {
+    if (!data.password) {
+      throw new Error("Password is required");
+    }
+    const encryptedPassword = encryptPassword(data.password);
+    return UsersService.registerUser({
+      requestBody: {
+        ...data,
+        password: encryptedPassword, // Override with encrypted password
+      },
+    });
+  } catch (error) {
+    console.error("Password encryption failed:", error);
+    throw new Error("Failed to encrypt password. Please try again.");
+  }
},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mutationFn: (data: UserRegister) => { // data is UserRegister
const encryptedPassword = encryptPassword(data.password);
return UsersService.registerUser({
requestBody: {
...data,
password: encryptedPassword, // Override with encrypted password
},
});
},
mutationFn: (data: UserRegister) => { // data is UserRegister
try {
if (!data.password) {
throw new Error("Password is required");
}
const encryptedPassword = encryptPassword(data.password);
return UsersService.registerUser({
requestBody: {
...data,
password: encryptedPassword, // Override with encrypted password
},
});
} catch (error) {
console.error("Password encryption failed:", error);
throw new Error("Failed to encrypt password. Please try again.");
}
},
🤖 Prompt for AI Agents
In admin/src/hooks/useAuth.ts around lines 49 to 57, the password encryption
step lacks error handling, which may cause unclear errors during signup. Wrap
the encryption call in a try-catch block to catch any errors thrown by
encryptPassword, and handle them appropriately by returning or throwing a clear,
user-friendly error message before proceeding with user registration.


onSuccess: () => {
navigate({ to: "/login" })
Expand All @@ -59,12 +67,16 @@ const useAuth = () => {
},
})

const login = async (data: AccessToken) => {
const login = async (data: AccessToken) => { // AccessToken is Body_login_login_access_token
const encryptedPassword = encryptPassword(data.password); // data.password should exist
const response = await LoginService.loginAccessToken({
formData: data,
})
localStorage.setItem("access_token", response.access_token)
}
formData: {
...data, // Spread other potential fields like username, grant_type etc.
password: encryptedPassword, // Override with encrypted password
},
});
localStorage.setItem("access_token", response.access_token);
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for password encryption in login

Similar to the signup flow, the login function lacks specific error handling for password encryption failures.

const login = async (data: AccessToken) => { // AccessToken is Body_login_login_access_token
-  const encryptedPassword = encryptPassword(data.password); // data.password should exist
-  const response = await LoginService.loginAccessToken({
-    formData: {
-      ...data, // Spread other potential fields like username, grant_type etc.
-      password: encryptedPassword, // Override with encrypted password
-    },
-  });
-  localStorage.setItem("access_token", response.access_token);
+  try {
+    if (!data.password) {
+      throw new Error("Password is required");
+    }
+    const encryptedPassword = encryptPassword(data.password);
+    const response = await LoginService.loginAccessToken({
+      formData: {
+        ...data,
+        password: encryptedPassword,
+      },
+    });
+    localStorage.setItem("access_token", response.access_token);
+  } catch (error) {
+    console.error("Login process failed:", error);
+    if (error instanceof Error && error.message.includes("encrypt")) {
+      throw new Error("Failed to secure your password. Please try again.");
+    }
+    throw error; // Re-throw other errors to be handled by the mutation
+  }
};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const login = async (data: AccessToken) => { // AccessToken is Body_login_login_access_token
const encryptedPassword = encryptPassword(data.password); // data.password should exist
const response = await LoginService.loginAccessToken({
formData: data,
})
localStorage.setItem("access_token", response.access_token)
}
formData: {
...data, // Spread other potential fields like username, grant_type etc.
password: encryptedPassword, // Override with encrypted password
},
});
localStorage.setItem("access_token", response.access_token);
};
const login = async (data: AccessToken) => { // AccessToken is Body_login_login_access_token
try {
if (!data.password) {
throw new Error("Password is required");
}
const encryptedPassword = encryptPassword(data.password);
const response = await LoginService.loginAccessToken({
formData: {
...data,
password: encryptedPassword,
},
});
localStorage.setItem("access_token", response.access_token);
} catch (error) {
console.error("Login process failed:", error);
if (error instanceof Error && error.message.includes("encrypt")) {
throw new Error("Failed to secure your password. Please try again.");
}
throw error; // Re-throw other errors to be handled by the mutation
}
};
🤖 Prompt for AI Agents
In admin/src/hooks/useAuth.ts around lines 70 to 79, the login function does not
handle errors that may occur during password encryption. Wrap the
encryptPassword call in a try-catch block to catch any encryption errors, and
handle them appropriately by either logging the error or returning a failure
response to prevent the function from proceeding with invalid data.


const loginMutation = useMutation({
mutationFn: login,
Expand Down
78 changes: 78 additions & 0 deletions admin/src/utils/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { encryptPassword } from './encryption';
import CryptoJS from 'crypto-js';

describe('Encryption Utility (Admin Panel)', () => {
const MOCK_KEY = 'testadminmockkey98765432109876543210'; // Example key

// Store original import.meta.env
const originalImportMetaEnv = import.meta.env;

beforeEach(() => {
// Mock import.meta.env
// @ts-ignore - We are intentionally modifying import.meta.env for tests
import.meta.env = {
...originalImportMetaEnv,
VITE_APP_SYMMETRIC_ENCRYPTION_KEY: MOCK_KEY,
};
});

afterEach(() => {
// Restore original import.meta.env
// @ts-ignore
import.meta.env = originalImportMetaEnv;
// It's good practice to reset modules if they cache environment variables,
// but for this simple case, direct manipulation of import.meta.env
// (when possible in test environment like Vitest/Jest with proper setup) is shown.
// If issues arise, module reset (jest.resetModules()) might be needed.
});

it('should encrypt a password successfully', () => {
const plainPassword = 'myAdminSecretPassword';
const encrypted = encryptPassword(plainPassword);

expect(encrypted).toBeDefined();
expect(encrypted).not.toBe(plainPassword);

// Verify basic crypto integrity: decrypt with the same key
const decryptedBytes = CryptoJS.AES.decrypt(encrypted, MOCK_KEY);
const decryptedPassword = decryptedBytes.toString(CryptoJS.enc.Utf8);
expect(decryptedPassword).toBe(plainPassword);
});

it('should produce a base64 string', () => {
const plainPassword = 'testAdminBase64Output';
const encrypted = encryptPassword(plainPassword);
expect(typeof encrypted).toBe('string');
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
expect(base64Regex.test(encrypted)).toBe(true);
});

it('should throw an error if encryption key is not defined', () => {
// @ts-ignore
delete import.meta.env.VITE_APP_SYMMETRIC_ENCRYPTION_KEY;

const plainPassword = 'testAdminPassword';

expect(() => encryptPassword(plainPassword)).toThrow('Encryption key is not defined.');
});

it('should encrypt different passwords to different ciphertexts', () => {
const passwordA = 'adminPass123';
const passwordB = 'AdminPass123'; // Different case

const encryptedA = encryptPassword(passwordA);
const encryptedB = encryptPassword(passwordB);

expect(encryptedA).not.toBe(encryptedB);
});

it('should encrypt an empty string successfully', () => {
const plainPassword = '';
const encrypted = encryptPassword(plainPassword);
expect(encrypted).toBeDefined();

const decryptedBytes = CryptoJS.AES.decrypt(encrypted, MOCK_KEY);
const decryptedPassword = decryptedBytes.toString(CryptoJS.enc.Utf8);
expect(decryptedPassword).toBe(plainPassword);
});
});
22 changes: 22 additions & 0 deletions admin/src/utils/encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// In admin/src/utils/encryption.ts
import CryptoJS from 'crypto-js';

const getEncryptionKey = (): string => {
const key = import.meta.env.VITE_APP_SYMMETRIC_ENCRYPTION_KEY;
if (!key) {
console.error("Encryption key is not defined. Please check VITE_APP_SYMMETRIC_ENCRYPTION_KEY environment variable.");
throw new Error("Encryption key is not defined.");
}
return key;
};

export const encryptPassword = (plainPassword: string): string => {
const key = getEncryptionKey();
try {
const encrypted = CryptoJS.AES.encrypt(plainPassword, key).toString();
return encrypted;
} catch (error) {
console.error("Password encryption failed:", error);
throw new Error("Could not encrypt password.");
}
};
2 changes: 1 addition & 1 deletion admin/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["tests/**/*.ts"]
"exclude": ["tests/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts"]
}
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# This key needs to be a securely generated, preferably 32-byte (256-bit) random string,
# often represented in base64. For Fernet, it must be a URL-safe base64-encoded 32-byte key.
APP_SYMMETRIC_ENCRYPTION_KEY="your_strong_symmetric_encryption_key_here"
Loading
Loading