Merge branch 'feat/encrypt_login' into develop

This commit is contained in:
2026-06-23 16:02:39 +08:00
42 changed files with 2175 additions and 127 deletions

View File

@@ -1,4 +1,5 @@
import { baseRequestClient, requestClient } from '#/api/request';
import {baseRequestClient, requestClient} from '#/api/request';
import {type CredentialKeyMaterial, encryptCredentialPayload,} from '#/utils/credential-encryption';
export namespace AuthApi {
/** 登录接口参数 */
@@ -25,11 +26,27 @@ export namespace AuthApi {
}
}
/**
* 获取登录加密公钥
*/
export async function getCredentialKeyApi() {
return requestClient.get<CredentialKeyMaterial>(
'/api/v1/auth/credential-key',
);
}
/**
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('/api/v1/auth/login', data);
const encryptedPayload = await encryptCredentialPayload(
getCredentialKeyApi,
data,
);
return requestClient.post<AuthApi.LoginResult>(
'/api/v1/auth/login',
encryptedPayload,
);
}
/**

View File

@@ -0,0 +1,234 @@
interface CredentialKeyMaterial {
algorithm: string;
expiresAt: string;
fingerprint: string;
keyId: string;
nonce: string;
publicKey: string;
transitions?: CredentialKeyTransition[];
}
interface CredentialKeyPin {
fingerprint: string;
keyId: string;
publicKey: string;
}
interface CredentialKeyTransition {
fromKeyId: string;
payload: string;
signature: string;
toKeyId: string;
}
interface TransitionPayload {
newFingerprint: string;
newKeyId: string;
newPublicKey: string;
oldKeyId: string;
}
const PIN_STORAGE_KEY = 'easyflow:credential-key-pin:v1';
const SECURITY_ERROR = '登录安全校验失败,请刷新或联系管理员';
const UNSUPPORTED_ERROR = '当前浏览器不支持登录安全加密,请升级浏览器';
/**
* 加密密码类请求载荷。
* @param loadMaterial 获取后端公钥材料的方法
* @param payload 待加密载荷
* @returns 加密信封
*/
export async function encryptCredentialPayload(
loadMaterial: () => Promise<CredentialKeyMaterial>,
payload: Record<string, any>,
) {
ensureWebCrypto();
const material = await loadMaterial();
await verifyAndPersistPin(material);
const aesKey = await window.crypto.subtle.generateKey(
{ length: 256, name: 'AES-GCM' },
true,
['encrypt'],
);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode(
JSON.stringify({
...payload,
nonce: material.nonce,
timestamp: Date.now(),
}),
);
const ciphertext = await window.crypto.subtle.encrypt(
{ iv, name: 'AES-GCM' },
aesKey,
plaintext,
);
const rawAesKey = await window.crypto.subtle.exportKey('raw', aesKey);
const publicKey = await importRsaPublicKey(material.publicKey, ['encrypt']);
const encryptedKey = await window.crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
rawAesKey,
);
return {
ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
encryptedKey: bytesToBase64(new Uint8Array(encryptedKey)),
iv: bytesToBase64(iv),
keyId: material.keyId,
nonce: material.nonce,
};
}
async function verifyAndPersistPin(material: CredentialKeyMaterial) {
const current = readPin();
if (!current) {
writePin(material);
return;
}
if (current.fingerprint === material.fingerprint) {
writePin(material);
return;
}
const nextPin = await verifyTransitionChain(current, material);
if (!nextPin || nextPin.fingerprint !== material.fingerprint) {
throw new Error(SECURITY_ERROR);
}
writePin(material);
}
async function verifyTransitionChain(
current: CredentialKeyPin,
material: CredentialKeyMaterial,
) {
let cursor = { ...current };
const transitions = material.transitions || [];
const visited = new Set<string>();
while (cursor.fingerprint !== material.fingerprint) {
if (visited.has(cursor.keyId)) {
return null;
}
visited.add(cursor.keyId);
const transition = transitions.find((item) => item.fromKeyId === cursor.keyId);
if (!transition) {
return null;
}
const payload = decodeTransitionPayload(transition.payload);
if (
payload.oldKeyId !== cursor.keyId ||
payload.newKeyId !== transition.toKeyId
) {
return null;
}
const verified = await verifyTransition(cursor.publicKey, transition);
if (!verified) {
return null;
}
cursor = {
fingerprint: payload.newFingerprint,
keyId: payload.newKeyId,
publicKey: payload.newPublicKey,
};
}
return cursor;
}
async function verifyTransition(
publicKeyPem: string,
transition: CredentialKeyTransition,
) {
const publicKey = await importSignPublicKey(publicKeyPem);
return window.crypto.subtle.verify(
{ name: 'RSASSA-PKCS1-v1_5' },
publicKey,
base64ToBytes(transition.signature),
new TextEncoder().encode(transition.payload),
);
}
function decodeTransitionPayload(payload: string): TransitionPayload {
const json = new TextDecoder().decode(base64UrlToBytes(payload));
return JSON.parse(json) as TransitionPayload;
}
async function importRsaPublicKey(publicKeyPem: string, usages: KeyUsage[]) {
return window.crypto.subtle.importKey(
'spki',
pemToBytes(publicKeyPem),
{ hash: 'SHA-256', name: 'RSA-OAEP' },
false,
usages,
);
}
async function importSignPublicKey(publicKeyPem: string) {
return window.crypto.subtle.importKey(
'spki',
pemToBytes(publicKeyPem),
{ hash: 'SHA-256', name: 'RSASSA-PKCS1-v1_5' },
false,
['verify'],
);
}
function ensureWebCrypto() {
if (!window.crypto?.subtle || !window.crypto.getRandomValues) {
throw new Error(UNSUPPORTED_ERROR);
}
}
function readPin(): CredentialKeyPin | null {
const raw = window.localStorage.getItem(PIN_STORAGE_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as CredentialKeyPin;
} catch {
window.localStorage.removeItem(PIN_STORAGE_KEY);
return null;
}
}
function writePin(material: CredentialKeyMaterial) {
window.localStorage.setItem(
PIN_STORAGE_KEY,
JSON.stringify({
fingerprint: material.fingerprint,
keyId: material.keyId,
publicKey: material.publicKey,
}),
);
}
function pemToBytes(pem: string) {
const base64 = pem
.replace('-----BEGIN PUBLIC KEY-----', '')
.replace('-----END PUBLIC KEY-----', '')
.replace(/\s/g, '');
return base64ToBytes(base64);
}
function base64ToBytes(value: string) {
const binary = window.atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function base64UrlToBytes(value: string) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
return base64ToBytes(padded);
}
function bytesToBase64(bytes: Uint8Array) {
let binary = '';
bytes.forEach((item) => {
binary += String.fromCharCode(item);
});
return window.btoa(binary);
}
export type { CredentialKeyMaterial };

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import type { EasyFlowFormSchema } from '#/adapter/form';
import type {EasyFlowFormSchema} from '#/adapter/form';
import { computed, markRaw, onMounted, ref } from 'vue';
import {computed, markRaw, nextTick, onMounted, ref} from 'vue';
import { ProfileBaseSetting } from '@easyflow/common-ui';
import {ProfileBaseSetting} from '@easyflow/common-ui';
import { ElMessage } from 'element-plus';
import {ElMessage} from 'element-plus';
import { api } from '#/api/request';
import {api} from '#/api/request';
import Cropper from '#/components/upload/Cropper.vue';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import {$t} from '#/locales';
import {useAuthStore} from '#/store';
const { fetchUserInfo } = useAuthStore();
const profileBaseSettingRef = ref();
@@ -48,9 +48,16 @@ onMounted(async () => {
});
async function getInfo() {
loading.value = true;
const data = await fetchUserInfo();
await profileBaseSettingRef.value.getFormApi().setValues(data);
loading.value = false;
try {
const data = await fetchUserInfo();
await nextTick();
const formApi = profileBaseSettingRef.value?.getFormApi?.();
if (formApi) {
await formApi.setValues(data);
}
} finally {
loading.value = false;
}
}
const loading = ref(false);
const updateLoading = ref(false);

View File

@@ -1,19 +1,21 @@
<script setup lang="ts">
import type { EasyFlowFormSchema } from '#/adapter/form';
import type {EasyFlowFormSchema} from '#/adapter/form';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {computed, ref} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
import { preferences } from '@easyflow/preferences';
import { useUserStore } from '@easyflow/stores';
import {ProfilePasswordSetting, z} from '@easyflow/common-ui';
import {preferences} from '@easyflow/preferences';
import {useUserStore} from '@easyflow/stores';
import { ElMessage } from 'element-plus';
import {ElMessage} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import { isStrongPassword } from '#/utils/password-policy';
import {getCredentialKeyApi} from '#/api';
import {api} from '#/api/request';
import {$t} from '#/locales';
import {useAuthStore} from '#/store';
import {encryptCredentialPayload} from '#/utils/credential-encryption';
import {isStrongPassword} from '#/utils/password-policy';
const profilePasswordSettingRef = ref();
const authStore = useAuthStore();
@@ -87,7 +89,14 @@ const updateLoading = ref(false);
async function handleSubmit(values: any) {
updateLoading.value = true;
try {
const res = await api.post('/api/v1/sysAccount/updatePassword', values);
const encryptedPayload = await encryptCredentialPayload(
getCredentialKeyApi,
values,
);
const res = await api.post(
'/api/v1/sysAccount/updatePassword',
encryptedPayload,
);
if (res.errorCode === 0) {
ElMessage.success($t('message.success'));
const userInfo = await authStore.fetchUserInfo();

View File

@@ -1,18 +1,19 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type {FormInstance} from 'element-plus';
import {ElForm, ElFormItem, ElInput, ElMessage} from 'element-plus';
import { onMounted, ref, watch } from 'vue';
import {onMounted, ref, watch} from 'vue';
import { EasyFlowFormModal, EasyFlowInputPassword } from '@easyflow/common-ui';
import {EasyFlowFormModal, EasyFlowInputPassword} from '@easyflow/common-ui';
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import {getCredentialKeyApi} from '#/api';
import {api} from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
// import Cropper from '#/components/upload/Cropper.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
import { isStrongPassword } from '#/utils/password-policy';
import {$t} from '#/locales';
import {encryptCredentialPayload} from '#/utils/credential-encryption';
import {isStrongPassword} from '#/utils/password-policy';
const emit = defineEmits(['reload']);
// vue
@@ -107,26 +108,33 @@ function openDialog(row: any) {
dialogVisible.value = true;
}
function save() {
saveForm.value?.validate((valid) => {
saveForm.value?.validate(async (valid) => {
if (valid) {
btnLoading.value = true;
const { confirmPassword: _confirmPassword, ...payload } = entity.value;
api
.post(
try {
const {
confirmPassword: _confirmPassword,
password,
...payload
} = entity.value;
if (isAdd.value) {
payload.passwordCredential = await encryptCredentialPayload(
getCredentialKeyApi,
{ password },
);
}
const res = await api.post(
isAdd.value ? 'api/v1/sysAccount/save' : 'api/v1/sysAccount/update',
payload,
)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
})
.catch(() => {
btnLoading.value = false;
});
);
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
} finally {
btnLoading.value = false;
}
}
});
}