-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Implement client-side password encryption #67
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
fa77c53
2d25b6b
e6c6e67
d3eb32d
dae88a6
3a2677c
cbd442c
ab1a825
3a0fb2a
8016911
ae29a54
93c7077
fad753b
944b4bd
c2783e5
3625ecb
a4037bb
78b18dc
8b824a2
110c8fe
ddeec90
701bc1d
7e75f8e
2962693
9c4d04f
79c00c6
51ac12d
dc1e122
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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). | ||
|
||
|
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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
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" |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
onSuccess: () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
navigate({ to: "/login" }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const loginMutation = useMutation({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
mutationFn: login, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
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 | ||
kubbot marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// 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); | ||
}); | ||
}); |
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."); | ||
} | ||
}; |
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"] | ||
} |
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" |
Uh oh!
There was an error while loading. Please reload this page.