From 879677516f4f446facd1cead11f066f5c165db4e Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 14 May 2026 11:05:20 +0800 Subject: [PATCH] fix: harden login for MLPS requirements --- apps/admin-web/src/i18n/messages/en-US.ts | 2 +- apps/admin-web/src/i18n/messages/zh-CN.ts | 2 +- apps/admin-web/src/lib/api.ts | 4 + apps/admin-web/src/lib/password-cipher.ts | 59 ++++++++ apps/admin-web/src/stores/auth.ts | 24 +++- apps/admin-web/src/views/LoginView.vue | 9 +- .../renderer/composables/useDesktopSession.ts | 25 +++- .../src/renderer/lib/password-cipher.ts | 59 ++++++++ .../src/renderer/views/LoginView.vue | 15 +- apps/ops-web/src/lib/password-cipher.ts | 63 +++++++++ apps/ops-web/src/stores/auth.ts | 34 ++++- apps/ops-web/src/views/LoginView.vue | 3 +- packages/shared-types/src/index.ts | 10 +- server/cmd/ops-api/main.go | 16 ++- server/configs/config.local.yaml.example | 15 ++ server/configs/config.yaml | 10 ++ server/configs/ops-config.yaml | 12 ++ server/internal/bootstrap/bootstrap.go | 31 ++++- server/internal/ops/app/auth.go | 55 +++++++- server/internal/ops/app/auth_test.go | 60 ++++++++ server/internal/ops/config/config.go | 43 ++++++ server/internal/ops/transport/auth_handler.go | 29 +++- server/internal/ops/transport/router.go | 19 ++- .../internal/shared/auth/password_cipher.go | 128 ++++++++++++++++++ .../shared/auth/password_cipher_test.go | 64 +++++++++ server/internal/shared/config/config.go | 46 ++++++- server/internal/shared/config/config_test.go | 54 ++++++++ server/internal/shared/middleware/cors.go | 14 +- server/internal/shared/middleware/logger.go | 31 ++++- .../internal/shared/middleware/logger_test.go | 33 +++++ .../shared/middleware/security_headers.go | 119 ++++++++++++++++ .../middleware/security_headers_test.go | 75 ++++++++++ .../internal/shared/swagger/descriptions.go | 9 +- server/internal/shared/swagger/swagger.go | 3 +- server/internal/tenant/app/auth_service.go | 53 +++++++- .../internal/tenant/app/auth_service_test.go | 60 ++++++++ .../internal/tenant/transport/auth_handler.go | 11 +- server/internal/tenant/transport/router.go | 1 + .../internal/tenant/transport/router_test.go | 1 + 39 files changed, 1230 insertions(+), 71 deletions(-) create mode 100644 apps/admin-web/src/lib/password-cipher.ts create mode 100644 apps/desktop-client/src/renderer/lib/password-cipher.ts create mode 100644 apps/ops-web/src/lib/password-cipher.ts create mode 100644 server/internal/ops/app/auth_test.go create mode 100644 server/internal/shared/auth/password_cipher.go create mode 100644 server/internal/shared/auth/password_cipher_test.go create mode 100644 server/internal/shared/middleware/security_headers.go create mode 100644 server/internal/shared/middleware/security_headers_test.go create mode 100644 server/internal/tenant/app/auth_service_test.go diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 7e42a9f..6d42ac1 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -99,7 +99,7 @@ const enUS = { email: 'Email', loginIdentifier: 'Phone / Email', password: 'Password', - rememberPassword: 'Remember password', + rememberAccount: 'Remember account', forgotPassword: 'Forgot password', }, membershipBlocked: { diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index e97a4fc..840efc4 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -101,7 +101,7 @@ const zhCN = { email: "邮箱", loginIdentifier: "手机号 / 邮箱", password: "密码", - rememberPassword: "记住密码", + rememberAccount: "记住账号", forgotPassword: "忘记密码", }, membershipBlocked: { diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 1a0202c..aa67547 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -80,6 +80,7 @@ import type { MonitoringDashboardCompositeResponse, MonitoringQuestionDetailResponse, PlatformAccount, + PasswordPublicKeyResponse, PromptRule, PromptRuleGroup, PromptRuleGroupRequest, @@ -336,6 +337,9 @@ apiClient.raw.interceptors.response.use( ) export const authApi = { + passwordPublicKey() { + return publicClient.get('/api/auth/password-key') + }, login(payload: LoginRequest) { return publicClient.post('/api/auth/login', payload) }, diff --git a/apps/admin-web/src/lib/password-cipher.ts b/apps/admin-web/src/lib/password-cipher.ts new file mode 100644 index 0000000..2cfcf81 --- /dev/null +++ b/apps/admin-web/src/lib/password-cipher.ts @@ -0,0 +1,59 @@ +import type { PasswordPublicKeyResponse } from '@geo/shared-types' + +const textEncoder = new TextEncoder() + +export function browserSupportsPasswordCipher(): boolean { + return typeof window !== 'undefined' && Boolean(window.crypto?.subtle) +} + +export async function encryptPasswordForLogin( + password: string, + key: PasswordPublicKeyResponse, +): Promise<{ encrypted_password: string; password_key_id: string }> { + const cryptoKey = await importRSAOAEPKey(key.public_key) + const encrypted = await window.crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + cryptoKey, + textEncoder.encode(password), + ) + return { + encrypted_password: arrayBufferToBase64(encrypted), + password_key_id: key.key_id, + } +} + +async function importRSAOAEPKey(publicKeyPEM: string): Promise { + const binaryDER = pemToArrayBuffer(publicKeyPEM) + return window.crypto.subtle.importKey( + 'spki', + binaryDER, + { + name: 'RSA-OAEP', + hash: 'SHA-256', + }, + false, + ['encrypt'], + ) +} + +function pemToArrayBuffer(pem: string): ArrayBuffer { + const base64 = pem + .replace(/-----BEGIN PUBLIC KEY-----/g, '') + .replace(/-----END PUBLIC KEY-----/g, '') + .replace(/\s/g, '') + const binary = window.atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return bytes.buffer +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return window.btoa(binary) +} diff --git a/apps/admin-web/src/stores/auth.ts b/apps/admin-web/src/stores/auth.ts index 2eb34cb..00d306b 100644 --- a/apps/admin-web/src/stores/auth.ts +++ b/apps/admin-web/src/stores/auth.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' +import { ApiClientError } from '@geo/http-client' import type { LoginRequest, UserInfo } from '@geo/shared-types' import { @@ -9,6 +10,7 @@ import { refreshStoredAuthSession, shouldRefreshAccessToken, } from '@/lib/api' +import { browserSupportsPasswordCipher, encryptPasswordForLogin } from '@/lib/password-cipher' import { clearStoredSession, readStoredSession, @@ -147,7 +149,7 @@ export const useAuthStore = defineStore('auth', () => { return loginPromise } - const loginPayload = { ...payload } + const loginPayload = await buildSecureLoginPayload(payload) loginPromise = authApi .login(loginPayload) .then((response) => { @@ -166,6 +168,26 @@ export const useAuthStore = defineStore('auth', () => { return loginPromise } + async function buildSecureLoginPayload(payload: LoginRequest): Promise { + if (!payload.password || !browserSupportsPasswordCipher()) { + return { ...payload } + } + try { + const key = await authApi.passwordPublicKey() + const encrypted = await encryptPasswordForLogin(payload.password, key) + return { + identifier: payload.identifier, + email: payload.email, + ...encrypted, + } + } catch (error) { + if (error instanceof ApiClientError && error.status === 404) { + return { ...payload } + } + throw error + } + } + async function logout(): Promise { try { if (accessToken.value) { diff --git a/apps/admin-web/src/views/LoginView.vue b/apps/admin-web/src/views/LoginView.vue index 9bea141..6cc4224 100644 --- a/apps/admin-web/src/views/LoginView.vue +++ b/apps/admin-web/src/views/LoginView.vue @@ -43,7 +43,7 @@ v-model="formState.identifier" type="text" placeholder="账户" - autocomplete="off" + autocomplete="username" required @focus="isTyping = true" @blur="isTyping = false" @@ -68,7 +68,7 @@
{{ t('auth.forgotPassword') }}
@@ -127,9 +127,8 @@ onMounted(() => { return } const parsed = JSON.parse(raw) as { identifier?: string; password?: string } - if (parsed && typeof parsed.identifier === 'string' && typeof parsed.password === 'string') { + if (parsed && typeof parsed.identifier === 'string') { formState.identifier = parsed.identifier - formState.password = parsed.password remember.value = true } } catch { @@ -144,7 +143,7 @@ function persistRememberedCredentials(): void { if (remember.value) { window.localStorage.setItem( REMEMBER_STORAGE_KEY, - JSON.stringify({ identifier: formState.identifier, password: formState.password }), + JSON.stringify({ identifier: formState.identifier }), ) } else { window.localStorage.removeItem(REMEMBER_STORAGE_KEY) diff --git a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts index c7311a9..9b06f84 100644 --- a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts +++ b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts @@ -10,11 +10,13 @@ import type { DesktopRuntimeSessionSyncRequest, LoginRequest, LoginResponse, + PasswordPublicKeyResponse, RefreshResponse, UserInfo, } from '@geo/shared-types' import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url' +import { browserSupportsPasswordCipher, encryptPasswordForLogin } from '../lib/password-cipher' type SessionMode = 'authenticated' | 'preview' @@ -596,9 +598,10 @@ async function performLogin(payload: LoginRequest): Promise { error.value = null try { + const loginPayload = await buildSecureLoginPayload(payload) const authData = await createPublicClient().post( '/api/auth/login', - payload, + loginPayload, ) persistSession({ @@ -633,6 +636,26 @@ async function performLogin(payload: LoginRequest): Promise { } } +async function buildSecureLoginPayload(payload: LoginRequest): Promise { + if (!payload.password || !browserSupportsPasswordCipher()) { + return { ...payload } + } + try { + const key = await createPublicClient().get('/api/auth/password-key') + const encrypted = await encryptPasswordForLogin(payload.password, key) + return { + identifier: payload.identifier, + email: payload.email, + ...encrypted, + } + } catch (err) { + if (err instanceof ApiClientError && err.status === 404) { + return { ...payload } + } + throw err + } +} + async function login(payload: LoginRequest): Promise { if (loginPromise) { return loginPromise diff --git a/apps/desktop-client/src/renderer/lib/password-cipher.ts b/apps/desktop-client/src/renderer/lib/password-cipher.ts new file mode 100644 index 0000000..2cfcf81 --- /dev/null +++ b/apps/desktop-client/src/renderer/lib/password-cipher.ts @@ -0,0 +1,59 @@ +import type { PasswordPublicKeyResponse } from '@geo/shared-types' + +const textEncoder = new TextEncoder() + +export function browserSupportsPasswordCipher(): boolean { + return typeof window !== 'undefined' && Boolean(window.crypto?.subtle) +} + +export async function encryptPasswordForLogin( + password: string, + key: PasswordPublicKeyResponse, +): Promise<{ encrypted_password: string; password_key_id: string }> { + const cryptoKey = await importRSAOAEPKey(key.public_key) + const encrypted = await window.crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + cryptoKey, + textEncoder.encode(password), + ) + return { + encrypted_password: arrayBufferToBase64(encrypted), + password_key_id: key.key_id, + } +} + +async function importRSAOAEPKey(publicKeyPEM: string): Promise { + const binaryDER = pemToArrayBuffer(publicKeyPEM) + return window.crypto.subtle.importKey( + 'spki', + binaryDER, + { + name: 'RSA-OAEP', + hash: 'SHA-256', + }, + false, + ['encrypt'], + ) +} + +function pemToArrayBuffer(pem: string): ArrayBuffer { + const base64 = pem + .replace(/-----BEGIN PUBLIC KEY-----/g, '') + .replace(/-----END PUBLIC KEY-----/g, '') + .replace(/\s/g, '') + const binary = window.atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return bytes.buffer +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return window.btoa(binary) +} diff --git a/apps/desktop-client/src/renderer/views/LoginView.vue b/apps/desktop-client/src/renderer/views/LoginView.vue index 3a5fc00..95a8c2b 100644 --- a/apps/desktop-client/src/renderer/views/LoginView.vue +++ b/apps/desktop-client/src/renderer/views/LoginView.vue @@ -7,7 +7,7 @@ const { apiBaseURL, error, pending, setApiBaseURL, login } = useDesktopSession() const menuOpen = ref(false) const showServerDialog = ref(false) const showPassword = ref(false) -const rememberPassword = ref(!!localStorage.getItem('geo_rankly_saved_password')) +const rememberAccount = ref(!!localStorage.getItem('geo_rankly_saved_identifier')) const savedIdentifier = localStorage.getItem('geo_rankly_saved_identifier') ?? localStorage.getItem('geo_rankly_saved_email') ?? @@ -16,7 +16,7 @@ const savedIdentifier = const form = reactive({ apiBaseURL: apiBaseURL.value, identifier: savedIdentifier, - password: localStorage.getItem('geo_rankly_saved_password') || '', + password: '', }) const menuRef = ref(null) @@ -26,7 +26,7 @@ function toggleMenu() { } function onRememberChange(event: Event) { - rememberPassword.value = (event.target as HTMLInputElement).checked + rememberAccount.value = (event.target as HTMLInputElement).checked } function handleOutsideClick(event: MouseEvent) { @@ -53,14 +53,13 @@ function saveServerSettings() { } async function submitLogin() { - if (rememberPassword.value) { + localStorage.removeItem('geo_rankly_saved_password') + if (rememberAccount.value) { localStorage.setItem('geo_rankly_saved_identifier', form.identifier) localStorage.removeItem('geo_rankly_saved_email') - localStorage.setItem('geo_rankly_saved_password', form.password) } else { localStorage.removeItem('geo_rankly_saved_identifier') localStorage.removeItem('geo_rankly_saved_email') - localStorage.removeItem('geo_rankly_saved_password') } await login({ @@ -179,8 +178,8 @@ onBeforeUnmount(() => {
diff --git a/apps/ops-web/src/lib/password-cipher.ts b/apps/ops-web/src/lib/password-cipher.ts new file mode 100644 index 0000000..1409ac1 --- /dev/null +++ b/apps/ops-web/src/lib/password-cipher.ts @@ -0,0 +1,63 @@ +interface PasswordPublicKeyResponse { + key_id: string + algorithm: 'RSA-OAEP-256' + public_key: string +} + +const textEncoder = new TextEncoder() + +export function browserSupportsPasswordCipher(): boolean { + return typeof window !== 'undefined' && Boolean(window.crypto?.subtle) +} + +export async function encryptPasswordForLogin( + password: string, + key: PasswordPublicKeyResponse, +): Promise<{ encrypted_password: string; password_key_id: string }> { + const cryptoKey = await importRSAOAEPKey(key.public_key) + const encrypted = await window.crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + cryptoKey, + textEncoder.encode(password), + ) + return { + encrypted_password: arrayBufferToBase64(encrypted), + password_key_id: key.key_id, + } +} + +async function importRSAOAEPKey(publicKeyPEM: string): Promise { + const binaryDER = pemToArrayBuffer(publicKeyPEM) + return window.crypto.subtle.importKey( + 'spki', + binaryDER, + { + name: 'RSA-OAEP', + hash: 'SHA-256', + }, + false, + ['encrypt'], + ) +} + +function pemToArrayBuffer(pem: string): ArrayBuffer { + const base64 = pem + .replace(/-----BEGIN PUBLIC KEY-----/g, '') + .replace(/-----END PUBLIC KEY-----/g, '') + .replace(/\s/g, '') + const binary = window.atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return bytes.buffer +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return window.btoa(binary) +} diff --git a/apps/ops-web/src/stores/auth.ts b/apps/ops-web/src/stores/auth.ts index 899989d..9d2dc04 100644 --- a/apps/ops-web/src/stores/auth.ts +++ b/apps/ops-web/src/stores/auth.ts @@ -1,7 +1,8 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' -import { http, resetUnauthorizedHandling } from '@/lib/http' +import { http, OpsApiError, resetUnauthorizedHandling } from '@/lib/http' +import { browserSupportsPasswordCipher, encryptPasswordForLogin } from '@/lib/password-cipher' import { type OperatorView, clearStoredSession, @@ -17,6 +18,12 @@ interface LoginResponse { operator: OperatorView } +interface PasswordPublicKeyResponse { + key_id: string + algorithm: 'RSA-OAEP-256' + public_key: string +} + export const useAuthStore = defineStore('ops-auth', () => { const accessToken = ref(null) const expiresAt = ref(null) @@ -41,11 +48,9 @@ export const useAuthStore = defineStore('ops-auth', () => { } const payload = { username, password, remember } + const loginPayload = await buildSecureLoginPayload(username, password) loginPromise = http - .post('/auth/login', { - username: payload.username, - password: payload.password, - }) + .post('/auth/login', loginPayload) .then((result) => { resetUnauthorizedHandling() accessToken.value = result.access_token @@ -69,6 +74,25 @@ export const useAuthStore = defineStore('ops-auth', () => { return loginPromise } + async function buildSecureLoginPayload( + username: string, + password: string, + ): Promise<{ username: string; password?: string; encrypted_password?: string; password_key_id?: string }> { + if (!browserSupportsPasswordCipher()) { + return { username, password } + } + try { + const key = await http.get('/auth/password-key') + const encrypted = await encryptPasswordForLogin(password, key) + return { username, ...encrypted } + } catch (error) { + if (error instanceof OpsApiError && error.status === 404) { + return { username, password } + } + throw error + } + } + async function refreshSelf(): Promise { if (!accessToken.value) return try { diff --git a/apps/ops-web/src/views/LoginView.vue b/apps/ops-web/src/views/LoginView.vue index 7fa06f7..c2e5555 100644 --- a/apps/ops-web/src/views/LoginView.vue +++ b/apps/ops-web/src/views/LoginView.vue @@ -44,7 +44,7 @@ v-model="formState.username" type="text" placeholder="请输入管理员账号" - autocomplete="off" + autocomplete="username" required @focus="isTyping = true" @blur="isTyping = false" @@ -58,6 +58,7 @@ v-model="formState.password" :type="showPassword ? 'text' : 'password'" placeholder="请输入密码" + autocomplete="current-password" required />