fix: harden login for MLPS requirements
This commit is contained in:
@@ -99,7 +99,7 @@ const enUS = {
|
||||
email: 'Email',
|
||||
loginIdentifier: 'Phone / Email',
|
||||
password: 'Password',
|
||||
rememberPassword: 'Remember password',
|
||||
rememberAccount: 'Remember account',
|
||||
forgotPassword: 'Forgot password',
|
||||
},
|
||||
membershipBlocked: {
|
||||
|
||||
@@ -101,7 +101,7 @@ const zhCN = {
|
||||
email: "邮箱",
|
||||
loginIdentifier: "手机号 / 邮箱",
|
||||
password: "密码",
|
||||
rememberPassword: "记住密码",
|
||||
rememberAccount: "记住账号",
|
||||
forgotPassword: "忘记密码",
|
||||
},
|
||||
membershipBlocked: {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user