fix: harden login for MLPS requirements
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user