Merge pull request #4 from ai5590/codex/connect-ui-client-to-server-for-authentication-xqvn1u
Add AuthService + crypto/key-vault + WS client and integrate real auth/session flows into UI
This commit is contained in:
commit
52fa631733
@ -74,7 +74,7 @@ export function render({ navigate }) {
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const result = await authService.login(state.loginDraft.login, state.loginDraft.password);
|
||||
const result = await authService.createSessionForExistingUser(state.loginDraft.login, state.loginDraft.password);
|
||||
authorizeSession(result);
|
||||
await refreshSessions();
|
||||
setAuthInfo('Успешный вход выполнен.');
|
||||
|
||||
@ -1,12 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260327192619';
|
||||
import {
|
||||
authService,
|
||||
clearAuthMessages,
|
||||
setAuthBusy,
|
||||
setAuthError,
|
||||
setAuthInfo,
|
||||
state,
|
||||
} from '../state.js?v=20260327192619';
|
||||
import { authService, state } from '../state.js?v=20260327192619';
|
||||
|
||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||
|
||||
@ -40,20 +33,6 @@ export function render({ navigate }) {
|
||||
checkButton.type = 'button';
|
||||
checkButton.textContent = 'Проверить логин';
|
||||
|
||||
const saveRootRow = document.createElement('label');
|
||||
saveRootRow.className = 'checkbox-row';
|
||||
saveRootRow.innerHTML = `<input type="checkbox" ${state.keyStorage.saveRoot ? 'checked' : ''} /> <span>Сохранить root key</span>`;
|
||||
const saveRootInput = saveRootRow.querySelector('input');
|
||||
|
||||
const saveBchRow = document.createElement('label');
|
||||
saveBchRow.className = 'checkbox-row';
|
||||
saveBchRow.innerHTML = `<input type="checkbox" ${state.keyStorage.saveBlockchain ? 'checked' : ''} /> <span>Сохранить blockchain key</span>`;
|
||||
const saveBchInput = saveBchRow.querySelector('input');
|
||||
|
||||
const saveDevRow = document.createElement('label');
|
||||
saveDevRow.className = 'checkbox-row';
|
||||
saveDevRow.innerHTML = '<input type="checkbox" checked disabled /> <span>device key сохраняется всегда</span>';
|
||||
|
||||
async function runAvailabilityCheck() {
|
||||
const login = loginInput.value.trim();
|
||||
if (!login) {
|
||||
@ -87,7 +66,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
form.children[0].append(loginInput);
|
||||
form.children[1].append(passwordInput);
|
||||
form.append(checkButton, statusText, saveRootRow, saveBchRow, saveDevRow);
|
||||
form.append(checkButton, statusText);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
@ -105,49 +84,19 @@ export function render({ navigate }) {
|
||||
nextButton.addEventListener('click', async () => {
|
||||
const isFree = await runAvailabilityCheck();
|
||||
if (!isFree) {
|
||||
setAuthError('Выберите свободный логин');
|
||||
window.alert('Выберите свободный логин');
|
||||
return;
|
||||
}
|
||||
|
||||
state.registrationDraft.login = loginInput.value.trim();
|
||||
state.registrationDraft.password = passwordInput.value;
|
||||
state.keyStorage.saveRoot = saveRootInput.checked;
|
||||
state.keyStorage.saveBlockchain = saveBchInput.checked;
|
||||
state.keyStorage.saveDevice = true;
|
||||
|
||||
if (!state.registrationDraft.password) {
|
||||
window.alert('Введите пароль');
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthBusy(true);
|
||||
nextButton.disabled = true;
|
||||
nextButton.textContent = 'Создание...';
|
||||
setAuthError('');
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const result = await authService.register(
|
||||
state.registrationDraft.login,
|
||||
state.registrationDraft.password,
|
||||
{
|
||||
saveRoot: state.keyStorage.saveRoot,
|
||||
saveBlockchain: state.keyStorage.saveBlockchain,
|
||||
saveDevice: true,
|
||||
},
|
||||
);
|
||||
state.registrationDraft.sessionId = result.sessionId;
|
||||
state.registrationDraft.storagePwd = result.storagePwd;
|
||||
setAuthInfo(`Пользователь ${result.login} зарегистрирован`);
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
nextButton.disabled = false;
|
||||
nextButton.textContent = 'Далее';
|
||||
}
|
||||
navigate('registration-payment-view');
|
||||
});
|
||||
|
||||
actions.append(backButton, nextButton);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260327192619';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
refreshSessions,
|
||||
setAuthError,
|
||||
@ -21,11 +22,24 @@ export function render({ navigate }) {
|
||||
|
||||
const title = document.createElement('p');
|
||||
title.className = 'auth-copy';
|
||||
title.textContent = `Поздравляю, логин ${displayLogin} зарегистрирован.`;
|
||||
title.textContent = `Отлично, логин ${displayLogin} зарегистрирован.`;
|
||||
|
||||
const question = document.createElement('p');
|
||||
question.className = 'auth-copy';
|
||||
question.textContent = 'Ключи считаются из пароля (SHA-256 + суффиксы root.key/dev.key/bch.key). В IndexedDB сохраняются только выбранные ключи и всегда device key.';
|
||||
question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?';
|
||||
|
||||
const rootToggle = document.createElement('input');
|
||||
rootToggle.type = 'checkbox';
|
||||
rootToggle.checked = state.keyStorage.saveRoot;
|
||||
|
||||
const blockchainToggle = document.createElement('input');
|
||||
blockchainToggle.type = 'checkbox';
|
||||
blockchainToggle.checked = state.keyStorage.saveBlockchain;
|
||||
|
||||
const deviceToggle = document.createElement('input');
|
||||
deviceToggle.type = 'checkbox';
|
||||
deviceToggle.checked = true;
|
||||
deviceToggle.disabled = true;
|
||||
|
||||
const rootRow = document.createElement('label');
|
||||
rootRow.className = 'checkbox-row';
|
||||
@ -37,7 +51,7 @@ export function render({ navigate }) {
|
||||
|
||||
const deviceRow = document.createElement('label');
|
||||
deviceRow.className = 'checkbox-row';
|
||||
deviceRow.innerHTML = '<input type="checkbox" checked disabled /> <span>device key</span>';
|
||||
deviceRow.append(deviceToggle, document.createTextNode('device key (всегда)'));
|
||||
|
||||
card.append(title, question, rootRow, deviceRow, blockchainRow);
|
||||
|
||||
@ -56,13 +70,34 @@ export function render({ navigate }) {
|
||||
okButton.textContent = 'OK';
|
||||
okButton.addEventListener('click', async () => {
|
||||
try {
|
||||
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
|
||||
throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
|
||||
}
|
||||
|
||||
state.keyStorage.saveRoot = rootToggle.checked;
|
||||
state.keyStorage.saveBlockchain = blockchainToggle.checked;
|
||||
|
||||
await authService.persistSelectedKeys(
|
||||
state.registrationDraft.login,
|
||||
state.registrationDraft.storagePwd,
|
||||
state.registrationDraft.pendingKeyBundle,
|
||||
{
|
||||
saveRoot: state.keyStorage.saveRoot,
|
||||
saveBlockchain: state.keyStorage.saveBlockchain,
|
||||
},
|
||||
);
|
||||
await authService.persistSessionMaterial(
|
||||
state.registrationDraft.login,
|
||||
state.registrationDraft.pendingSessionMaterial,
|
||||
);
|
||||
|
||||
authorizeSession({
|
||||
login: state.registrationDraft.login,
|
||||
sessionId: state.registrationDraft.sessionId,
|
||||
storagePwd: state.registrationDraft.storagePwd,
|
||||
});
|
||||
await refreshSessions();
|
||||
setAuthInfo('Регистрация завершена, список сессий загружен.');
|
||||
setAuthInfo('Ключи сохранены, регистрация завершена.');
|
||||
navigate('profile-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260327192619';
|
||||
import { refreshRegistrationBalance, state } from '../state.js?v=20260327192619';
|
||||
import {
|
||||
authService,
|
||||
refreshRegistrationBalance,
|
||||
setAuthError,
|
||||
setAuthInfo,
|
||||
state,
|
||||
} from '../state.js?v=20260327192619';
|
||||
|
||||
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
||||
|
||||
@ -66,8 +72,28 @@ export function render({ navigate }) {
|
||||
submitButton.className = 'primary-btn';
|
||||
submitButton.type = 'button';
|
||||
submitButton.textContent = 'Зарегистрироваться';
|
||||
submitButton.addEventListener('click', () => {
|
||||
navigate('registration-keys-view');
|
||||
submitButton.addEventListener('click', async () => {
|
||||
try {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Регистрация...';
|
||||
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password);
|
||||
state.registrationDraft.sessionId = result.sessionId;
|
||||
state.registrationDraft.storagePwd = result.storagePwd;
|
||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||
|
||||
setAuthInfo(`Отлично, вы зарегистрировались: ${result.login}`);
|
||||
window.alert('Отлично, вы зарегистрировались');
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Зарегистрироваться';
|
||||
}
|
||||
});
|
||||
|
||||
card.innerHTML = `
|
||||
|
||||
@ -4,16 +4,10 @@ import {
|
||||
exportEd25519PublicKeyB64,
|
||||
exportPkcs8B64,
|
||||
generateEd25519Pair,
|
||||
importPkcs8Ed25519,
|
||||
randomBase64,
|
||||
signBase64,
|
||||
} from './crypto-utils.js?v=20260327192619';
|
||||
import {
|
||||
loadEncryptedUserSecrets,
|
||||
loadSessionMaterial,
|
||||
saveEncryptedUserSecrets,
|
||||
saveSessionMaterial,
|
||||
} from './key-vault.js?v=20260327192619';
|
||||
import { saveEncryptedUserSecrets, saveSessionMaterial } from './key-vault.js?v=20260327192619';
|
||||
|
||||
const BCH_SUFFIX = '001';
|
||||
|
||||
@ -63,33 +57,22 @@ export class AuthService {
|
||||
return payload.exists !== true;
|
||||
}
|
||||
|
||||
async register(login, password, saveOptions = { saveRoot: true, saveBlockchain: true, saveDevice: true }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
async derivePasswordKeyBundle(password) {
|
||||
if (!password) throw new Error('Введите пароль');
|
||||
|
||||
const isFree = await this.ensureLoginFree(cleanLogin);
|
||||
if (!isFree) throw new Error('Этот логин уже занят');
|
||||
|
||||
const storagePwd = randomBase64(32);
|
||||
|
||||
const rootPair = await deriveEd25519FromPassword(password, 'root.key');
|
||||
const blockchainPair = await deriveEd25519FromPassword(password, 'bch.key');
|
||||
const devicePair = await deriveEd25519FromPassword(password, 'dev.key');
|
||||
return { rootPair, blockchainPair, devicePair };
|
||||
}
|
||||
|
||||
async createAuthSession(login, keyBundle) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
|
||||
const sessionPair = await generateEd25519Pair();
|
||||
const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey);
|
||||
const sessionKey = `ed25519/${sessionKeyPub}`;
|
||||
|
||||
const addResp = await this.ws.request('AddUser', {
|
||||
login: cleanLogin,
|
||||
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
|
||||
solanaKey: rootPair.publicKeyB64,
|
||||
blockchainKey: blockchainPair.publicKeyB64,
|
||||
deviceKey: devicePair.publicKeyB64,
|
||||
bchLimit: 1000000,
|
||||
});
|
||||
if (addResp.status !== 200) throw opError('AddUser', addResp);
|
||||
const storagePwd = randomBase64(32);
|
||||
|
||||
const challengeResp = await this.ws.request('AuthChallenge', { login: cleanLogin });
|
||||
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
|
||||
@ -99,7 +82,7 @@ export class AuthService {
|
||||
|
||||
const timeMs = Date.now();
|
||||
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
|
||||
const signatureB64 = await signBase64(devicePair.privateKey, preimage);
|
||||
const signatureB64 = await signBase64(keyBundle.devicePair.privateKey, preimage);
|
||||
|
||||
const createResp = await this.ws.request('CreateAuthSession', {
|
||||
login: cleanLogin,
|
||||
@ -107,43 +90,52 @@ export class AuthService {
|
||||
sessionKey,
|
||||
timeMs,
|
||||
authNonce,
|
||||
deviceKey: devicePair.publicKeyB64,
|
||||
deviceKey: keyBundle.devicePair.publicKeyB64,
|
||||
signatureB64,
|
||||
clientInfo: makeClientInfo(),
|
||||
});
|
||||
|
||||
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
|
||||
|
||||
const sessionId = createResp?.payload?.sessionId;
|
||||
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
||||
|
||||
const secrets = {
|
||||
deviceKey: devicePair.privatePkcs8B64,
|
||||
};
|
||||
|
||||
if (saveOptions.saveRoot) {
|
||||
secrets.rootKey = rootPair.privatePkcs8B64;
|
||||
}
|
||||
if (saveOptions.saveBlockchain) {
|
||||
secrets.blockchainKey = blockchainPair.privatePkcs8B64;
|
||||
}
|
||||
|
||||
await saveEncryptedUserSecrets(cleanLogin, storagePwd, secrets);
|
||||
|
||||
await saveSessionMaterial(cleanLogin, {
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
|
||||
});
|
||||
|
||||
return {
|
||||
login: cleanLogin,
|
||||
sessionId,
|
||||
storagePwd,
|
||||
sessionMaterial: {
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async login(login, password) {
|
||||
async registerUser(login, password) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
if (!password) throw new Error('Введите пароль');
|
||||
|
||||
const isFree = await this.ensureLoginFree(cleanLogin);
|
||||
if (!isFree) throw new Error('Этот логин уже занят');
|
||||
|
||||
const keyBundle = await this.derivePasswordKeyBundle(password);
|
||||
|
||||
const addResp = await this.ws.request('AddUser', {
|
||||
login: cleanLogin,
|
||||
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
|
||||
solanaKey: keyBundle.rootPair.publicKeyB64,
|
||||
blockchainKey: keyBundle.blockchainPair.publicKeyB64,
|
||||
deviceKey: keyBundle.devicePair.publicKeyB64,
|
||||
bchLimit: 1000000,
|
||||
});
|
||||
if (addResp.status !== 200) throw opError('AddUser', addResp);
|
||||
|
||||
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
||||
return { ...session, keyBundle };
|
||||
}
|
||||
|
||||
async createSessionForExistingUser(login, password) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
if (!password) throw new Error('Введите пароль');
|
||||
@ -151,45 +143,19 @@ export class AuthService {
|
||||
const user = await this.getUser(cleanLogin);
|
||||
if (!user.exists) throw new Error('Пользователь не найден');
|
||||
|
||||
const sessionMaterial = await loadSessionMaterial(cleanLogin);
|
||||
if (!sessionMaterial?.sessionId || !sessionMaterial?.sessionKey || !sessionMaterial?.sessionPrivPkcs8) {
|
||||
throw new Error('На устройстве отсутствует ключ сессии. Нужна регистрация на этом устройстве.');
|
||||
}
|
||||
const keyBundle = await this.derivePasswordKeyBundle(password);
|
||||
return this.createAuthSession(cleanLogin, keyBundle);
|
||||
}
|
||||
|
||||
await deriveEd25519FromPassword(password, 'root.key');
|
||||
await deriveEd25519FromPassword(password, 'bch.key');
|
||||
await deriveEd25519FromPassword(password, 'dev.key');
|
||||
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
|
||||
const secrets = { deviceKey: keyBundle.devicePair.privatePkcs8B64 };
|
||||
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
|
||||
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
|
||||
await saveEncryptedUserSecrets(login, storagePwd, secrets);
|
||||
}
|
||||
|
||||
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
||||
|
||||
const challengeResp = await this.ws.request('SessionChallenge', { sessionId: sessionMaterial.sessionId });
|
||||
if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp);
|
||||
const nonce = challengeResp?.payload?.nonce;
|
||||
if (!nonce) throw new Error('SessionChallenge: не вернулся nonce');
|
||||
|
||||
const timeMs = Date.now();
|
||||
const preimage = `SESSION_LOGIN:${sessionMaterial.sessionId}:${timeMs}:${nonce}`;
|
||||
const signatureB64 = await signBase64(privateKey, preimage);
|
||||
|
||||
const loginResp = await this.ws.request('SessionLogin', {
|
||||
sessionId: sessionMaterial.sessionId,
|
||||
sessionKey: sessionMaterial.sessionKey,
|
||||
timeMs,
|
||||
signatureB64,
|
||||
clientInfo: makeClientInfo(),
|
||||
});
|
||||
|
||||
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);
|
||||
const storagePwd = loginResp?.payload?.storagePwd;
|
||||
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
|
||||
|
||||
await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
|
||||
return {
|
||||
login: cleanLogin,
|
||||
sessionId: sessionMaterial.sessionId,
|
||||
storagePwd,
|
||||
};
|
||||
async persistSessionMaterial(login, sessionMaterial) {
|
||||
await saveSessionMaterial(login, sessionMaterial);
|
||||
}
|
||||
|
||||
async listSessions() {
|
||||
|
||||
@ -37,7 +37,7 @@ export const state = {
|
||||
notificationsTab: 'replies',
|
||||
pageLabelCollapsed: false,
|
||||
session: {
|
||||
isAuthorized: Boolean(storedSession?.isAuthorized),
|
||||
isAuthorized: false,
|
||||
login: storedSession?.login || '',
|
||||
sessionId: storedSession?.sessionId || '',
|
||||
storagePwdInMemory: '',
|
||||
@ -59,9 +59,11 @@ export const state = {
|
||||
password: '',
|
||||
sessionId: '',
|
||||
storagePwd: '',
|
||||
pendingKeyBundle: null,
|
||||
pendingSessionMaterial: null,
|
||||
},
|
||||
loginDraft: {
|
||||
login: '',
|
||||
login: storedSession?.login || '',
|
||||
password: '',
|
||||
},
|
||||
registrationPayment: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user