fix: harden login for MLPS requirements

This commit is contained in:
2026-05-14 11:05:20 +08:00
parent ecf3ee1ef0
commit 879677516f
39 changed files with 1230 additions and 71 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ const enUS = {
email: 'Email',
loginIdentifier: 'Phone / Email',
password: 'Password',
rememberPassword: 'Remember password',
rememberAccount: 'Remember account',
forgotPassword: 'Forgot password',
},
membershipBlocked: {
+1 -1
View File
@@ -101,7 +101,7 @@ const zhCN = {
email: "邮箱",
loginIdentifier: "手机号 / 邮箱",
password: "密码",
rememberPassword: "记住密码",
rememberAccount: "记住账号",
forgotPassword: "忘记密码",
},
membershipBlocked: {
+4
View File
@@ -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<PasswordPublicKeyResponse>('/api/auth/password-key')
},
login(payload: LoginRequest) {
return publicClient.post<LoginResponse, LoginRequest>('/api/auth/login', payload)
},
+59
View File
@@ -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<CryptoKey> {
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)
}
+23 -1
View File
@@ -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<LoginRequest> {
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<void> {
try {
if (accessToken.value) {
+4 -5
View File
@@ -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 @@
<div class="options">
<label class="remember">
<input v-model="remember" type="checkbox" />
{{ t('auth.rememberPassword') }}
{{ t('auth.rememberAccount') }}
</label>
<a href="#" class="forgot">{{ t('auth.forgotPassword') }}</a>
</div>
@@ -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)
@@ -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<void> {
error.value = null
try {
const loginPayload = await buildSecureLoginPayload(payload)
const authData = await createPublicClient().post<LoginResponse, LoginRequest>(
'/api/auth/login',
payload,
loginPayload,
)
persistSession({
@@ -633,6 +636,26 @@ async function performLogin(payload: LoginRequest): Promise<void> {
}
}
async function buildSecureLoginPayload(payload: LoginRequest): Promise<LoginRequest> {
if (!payload.password || !browserSupportsPasswordCipher()) {
return { ...payload }
}
try {
const key = await createPublicClient().get<PasswordPublicKeyResponse>('/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<void> {
if (loginPromise) {
return loginPromise
@@ -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<CryptoKey> {
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)
}
@@ -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<HTMLElement | null>(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(() => {
<div class="options-row">
<label class="check-label">
<input type="checkbox" :checked="rememberPassword" @change="onRememberChange" />
<span>记住密码</span>
<input type="checkbox" :checked="rememberAccount" @change="onRememberChange" />
<span>记住账号</span>
</label>
</div>
+63
View File
@@ -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<CryptoKey> {
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)
}
+29 -5
View File
@@ -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<string | null>(null)
const expiresAt = ref<number | null>(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<LoginResponse>('/auth/login', {
username: payload.username,
password: payload.password,
})
.post<LoginResponse>('/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<PasswordPublicKeyResponse>('/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<void> {
if (!accessToken.value) return
try {
+2 -1
View File
@@ -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
/>
<button type="button" class="eye-btn" @click="showPassword = !showPassword">