feat: sign in with passkeys, no password once an account has one; an account button and dialog in place of the sign-out button
This commit is contained in:
1 parent
d2d6fe0100
commit
e7b5d28470
9 files changed
+892
-18
No files matched your search
@@ -0,0 +1,109 @@
|
||||
// The account button in the top bar: a dialog with who is signed in, their
|
||||
// passkeys (add one, remove one) and signing out. Once there is a passkey,
|
||||
// the password no longer signs this account in.
|
||||
(function () {
|
||||
'use strict';
|
||||
var btn = document.getElementById('acct');
|
||||
if (!btn) return;
|
||||
|
||||
var b64u = {
|
||||
enc: function (buf) {
|
||||
var s = '', b = new Uint8Array(buf);
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
},
|
||||
dec: function (str) {
|
||||
var s = atob(str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4));
|
||||
var b = new Uint8Array(s.length);
|
||||
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
|
||||
return b.buffer;
|
||||
}
|
||||
};
|
||||
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
|
||||
var when = function (t) { return t ? new Date(t * 1000).toLocaleString() : 'ещё не использовался'; };
|
||||
var csrf = (document.cookie.match(/(?:^|; )lpra_csrf=([^;]*)/) || [])[1] || '';
|
||||
var X = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||
var KEY = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
|
||||
|
||||
var dlg = document.createElement('dialog');
|
||||
dlg.id = 'acctdlg';
|
||||
dlg.tabIndex = -1;
|
||||
dlg.innerHTML =
|
||||
'<div class="dlg-h"><strong style="flex:1">Аккаунт</strong>' +
|
||||
'<button type="button" class="btn ghost small icon close x" data-close title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
|
||||
'<div class="dlg-b acct">' +
|
||||
' <div class="acct-who"><span class="acct-ava"></span><div><div class="acct-name"></div><div class="muted acct-mode"></div></div></div>' +
|
||||
' <h3>Ключи доступа</h3>' +
|
||||
' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Пока у аккаунта есть хоть один ключ, войти по паролю нельзя - держите второй ключ или другого администратора, который сможет их сбросить.</p>' +
|
||||
' <ul class="acct-keys"></ul>' +
|
||||
' <div class="acct-add"><input type="text" class="acct-keyname" maxlength="60" placeholder="название, например «телефон»">' +
|
||||
' <button type="button" class="acct-addbtn">Добавить ключ</button></div>' +
|
||||
' <p class="acct-msg"></p>' +
|
||||
' <form method="post" action="/logout" class="acct-out"><input type="hidden" name="csrf"><button type="submit" class="btn ghost">Выйти</button></form>' +
|
||||
'</div>';
|
||||
document.body.appendChild(dlg);
|
||||
dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf);
|
||||
var $ = function (sel) { return dlg.querySelector(sel); };
|
||||
$('[data-close]').onclick = function () { dlg.close(); };
|
||||
var supported = !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
|
||||
|
||||
function msg(text, bad) {
|
||||
$('.acct-msg').textContent = text || '';
|
||||
$('.acct-msg').className = 'acct-msg' + (bad ? ' bad' : '');
|
||||
}
|
||||
function load() {
|
||||
return fetch('/api/panel/account', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
$('.acct-name').textContent = d.username;
|
||||
$('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase();
|
||||
$('.acct-mode').textContent = d.keys.length ? 'вход только по ключу доступа' : 'вход по паролю';
|
||||
var ul = $('.acct-keys');
|
||||
ul.innerHTML = d.keys.length ? '' : '<li class="muted">Ключей пока нет.</li>';
|
||||
d.keys.forEach(function (k) {
|
||||
var li = document.createElement('li');
|
||||
li.innerHTML = '<span class="acct-kicon">' + KEY + '</span><div class="acct-kinfo"><div>' + esc(k.name) + '</div>' +
|
||||
'<div class="muted">добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '</div></div>' +
|
||||
'<button type="button" class="btn danger small" title="Удалить ключ">Удалить</button>';
|
||||
li.querySelector('button').onclick = function () {
|
||||
var last = d.keys.length === 1;
|
||||
if (!confirm('Удалить ключ «' + k.name + '»?' + (last ? ' Это последний ключ: вход по паролю снова станет возможен.' : ''))) return;
|
||||
fetch('/api/panel/account/keys/' + k.id + '/delete', {method: 'POST'}).then(function (r) { return r.json(); })
|
||||
.then(function (r) { if (!r.ok) msg(r.error, true); else { msg('ключ удалён'); load(); } });
|
||||
};
|
||||
ul.appendChild(li);
|
||||
});
|
||||
});
|
||||
}
|
||||
$('.acct-addbtn').onclick = function () {
|
||||
if (!supported) { msg('этот браузер не умеет ключи доступа, или страница открыта не по https', true); return; }
|
||||
var add = $('.acct-addbtn');
|
||||
add.disabled = true;
|
||||
msg('подтвердите на устройстве…');
|
||||
fetch('/api/panel/account/keys/begin', {method: 'POST'}).then(function (r) { return r.json(); }).then(function (r) {
|
||||
var o = r.options;
|
||||
o.challenge = b64u.dec(o.challenge);
|
||||
o.user.id = b64u.dec(o.user.id);
|
||||
o.excludeCredentials.forEach(function (c) { c.id = b64u.dec(c.id); });
|
||||
return navigator.credentials.create({publicKey: o});
|
||||
}).then(function (cred) {
|
||||
return fetch('/api/panel/account/keys/finish', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: $('.acct-keyname').value,
|
||||
clientDataJSON: b64u.enc(cred.response.clientDataJSON),
|
||||
attestationObject: b64u.enc(cred.response.attestationObject)
|
||||
})
|
||||
}).then(function (r) { return r.json(); });
|
||||
}).then(function (r) {
|
||||
if (!r.ok) { msg(r.error, true); return; }
|
||||
$('.acct-keyname').value = '';
|
||||
msg('ключ добавлен - теперь вход по нему');
|
||||
load();
|
||||
}).catch(function (e) {
|
||||
msg(e && e.name === 'NotAllowedError' ? 'отменено' : e && e.name === 'InvalidStateError' ? 'этот ключ уже добавлен' : 'не вышло: ' + (e && e.message || e), true);
|
||||
}).then(function () { add.disabled = false; });
|
||||
};
|
||||
btn.onclick = function () {
|
||||
msg('');
|
||||
load().then(function () { dlg.showModal(); dlg.focus(); });
|
||||
};
|
||||
})();
|
||||
@@ -102,6 +102,39 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
#terminal { width: 100%; height: calc(100vh - 150px); }
|
||||
/* Dialogs shared by the pages. */
|
||||
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 0; max-width: 900px; width: 92vw; }
|
||||
dialog::backdrop { background: rgba(0, 0, 0, .6); }
|
||||
dialog:focus { outline: none; }
|
||||
.dlg-h { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
|
||||
.dlg-b { padding: 16px 18px; }
|
||||
/* The account button in the top bar, and its dialog. */
|
||||
header.topbar nav .acct-btn { margin-left: 22px; width: 34px; height: 34px; padding: 0; vertical-align: middle; }
|
||||
#acctdlg { max-width: 480px; }
|
||||
.acct-who { display: flex; align-items: center; gap: 12px; }
|
||||
.acct-ava { width: 40px; height: 40px; border-radius: 50%; background: #12202f; border: 1px solid var(--border);
|
||||
display: inline-flex; align-items: center; justify-content: center; font-weight: 600; color: var(--accent); flex: none; }
|
||||
.acct-name { font-weight: 600; }
|
||||
.acct-mode { font-size: 13px; }
|
||||
.acct h3 { font-size: 15px; margin: 18px 0 4px; }
|
||||
.acct-hint { font-size: 13px; margin: 0 0 10px; }
|
||||
.acct-keys { list-style: none; margin: 0; padding: 0; }
|
||||
.acct-keys li { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.acct-keys li .muted { font-size: 12px; }
|
||||
.acct-kicon { color: var(--accent); display: inline-flex; flex: none; }
|
||||
.acct-kinfo { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.acct-add { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.acct-add input { flex: 1; min-width: 0; }
|
||||
.acct-add button { flex: none; }
|
||||
.acct-msg { min-height: 20px; margin: 8px 0 0; font-size: 13px; color: var(--ok); }
|
||||
.acct-msg.bad { color: var(--danger); }
|
||||
.acct-out { margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; }
|
||||
/* Sign-in page. */
|
||||
.or { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; margin: 18px 0 4px; }
|
||||
.or::before, .or::after { content: ""; flex: 1; border-top: 1px solid var(--border); }
|
||||
.keybtn { width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
|
||||
/* Phones. A table marked .stack turns each row into a block of
|
||||
"label: value" lines, the labels coming from data-l on the cells; a cell
|
||||
marked .lead goes first, as the row's title. */
|
||||
@@ -112,6 +145,9 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
|
||||
overflow-x: auto; white-space: nowrap; scrollbar-width: none; }
|
||||
header.topbar nav::-webkit-scrollbar { display: none; }
|
||||
header.topbar nav > * { margin-left: 0 !important; flex: none; }
|
||||
.acct-add { flex-wrap: wrap; }
|
||||
.acct-add button { width: 100%; }
|
||||
.acct-keys li { flex-wrap: wrap; }
|
||||
.wrap { padding: 16px 12px; }
|
||||
.card { padding: 16px; border-radius: 8px; }
|
||||
h1 { font-size: 20px; }
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
<nav>
|
||||
<a href="/">Машины</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
||||
<button class="btn ghost small" type="submit">Выйти</button>
|
||||
</form>
|
||||
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
@@ -100,5 +98,6 @@
|
||||
if (Object.keys(rows).length) schedule();
|
||||
})();
|
||||
</script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,15 +10,61 @@
|
||||
<div class="center">
|
||||
<div class="card">
|
||||
<h1>Вход в панель</h1>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
{{if .Error}}<div class="err" id="err">{{.Error}}</div>{{else}}<div class="err" id="err" hidden></div>{{end}}
|
||||
<button type="button" class="keybtn" id="keylogin" hidden><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>Войти с ключом доступа</button>
|
||||
<div class="or" id="or" hidden>или по паролю</div>
|
||||
<form method="post" action="/login">
|
||||
<label>Имя пользователя</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>Пароль</label>
|
||||
<input type="password" name="password" required>
|
||||
<label for="u">Имя пользователя</label>
|
||||
<input type="text" id="u" name="username" value="{{.Username}}" autocomplete="username webauthn" autofocus required>
|
||||
<label for="pw">Пароль</label>
|
||||
<input type="password" id="pw" name="password" autocomplete="current-password" required>
|
||||
<div style="margin-top:18px"><button type="submit">Войти</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Sign in with a passkey: with the name typed, its keys are offered; without
|
||||
// it, the device offers the keys it keeps for this site.
|
||||
(function () {
|
||||
if (!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext)) return;
|
||||
var btn = document.getElementById('keylogin'), err = document.getElementById('err');
|
||||
btn.hidden = false;
|
||||
document.getElementById('or').hidden = false;
|
||||
var enc = function (buf) {
|
||||
var s = '', b = new Uint8Array(buf);
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
};
|
||||
var dec = function (str) {
|
||||
var s = atob(str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4)), b = new Uint8Array(s.length);
|
||||
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
|
||||
return b.buffer;
|
||||
};
|
||||
function show(t) { err.textContent = t; err.hidden = !t; }
|
||||
btn.onclick = function () {
|
||||
btn.disabled = true;
|
||||
show('');
|
||||
fetch('/api/login/key/begin', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: document.getElementById('u').value})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
var o = r.options;
|
||||
o.challenge = dec(o.challenge);
|
||||
o.allowCredentials.forEach(function (c) { c.id = dec(c.id); });
|
||||
return navigator.credentials.get({publicKey: o});
|
||||
})
|
||||
.then(function (c) {
|
||||
return fetch('/api/login/key/finish', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: enc(c.rawId), clientDataJSON: enc(c.response.clientDataJSON),
|
||||
authenticatorData: enc(c.response.authenticatorData), signature: enc(c.response.signature)})})
|
||||
.then(function (r) { return r.json(); });
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error(r.error); location.href = '/'; })
|
||||
.catch(function (e) { show(e && e.name === 'NotAllowedError' ? 'Вход по ключу отменён.' : (e && e.message) || 'Не вышло.'); })
|
||||
.then(function () { btn.disabled = false; });
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -20,9 +20,7 @@
|
||||
<nav>
|
||||
<a href="/">Машины</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
||||
<button class="btn ghost small" type="submit">Выйти</button>
|
||||
</form>
|
||||
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
@@ -98,7 +96,16 @@
|
||||
{{range .Admins}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td style="text-align:right">
|
||||
<td class="muted">{{with index $.KeyCounts .ID}}ключей доступа: {{.}}{{else}}по паролю{{end}}</td>
|
||||
<td style="text-align:right" class="nowrap">
|
||||
{{if index $.KeyCounts .ID}}
|
||||
<form method="post" action="/settings/admins/keys/reset" style="display:inline"
|
||||
onsubmit="return confirm('Сбросить все ключи доступа {{.Username}}? Вход по паролю снова станет возможен.')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn ghost small" type="submit">Сбросить ключи</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/settings/admins/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить администратора {{.Username}}?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
@@ -210,5 +217,6 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user