diff --git a/internal/server/db.go b/internal/server/db.go index b8326b7..b5c58a0 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -119,6 +119,7 @@ CREATE TABLE IF NOT EXISTS forward_rules ( `ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`, `ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE agents ADD COLUMN transport TEXT NOT NULL DEFAULT 'auto'`, + `ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`, } { if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") { return err @@ -256,6 +257,18 @@ func (db *DB) PasswordOff(adminID int64) bool { return off == 1 && db.CountAdminKeys(adminID) > 0 } +// AdminPin is the hash of the admin's PIN, "" when there is none. +func (db *DB) AdminPin(adminID int64) string { + var h string + _ = db.sql.QueryRow(`SELECT pin_hash FROM admins WHERE id = ?`, adminID).Scan(&h) + return h +} + +func (db *DB) SetAdminPin(adminID int64, pinHash string) error { + _, err := db.sql.Exec(`UPDATE admins SET pin_hash = ? WHERE id = ?`, pinHash, adminID) + return err +} + func (db *DB) SetAdminPassword(adminID int64, passHash string) error { _, err := db.sql.Exec(`UPDATE admins SET pass_hash = ? WHERE id = ?`, passHash, adminID) return err diff --git a/internal/server/passkeys.go b/internal/server/passkeys.go index f313435..2df064e 100644 --- a/internal/server/passkeys.go +++ b/internal/server/passkeys.go @@ -353,7 +353,7 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) { for _, k := range keys { list = append(list, map[string]any{"id": k.ID, "name": k.Name, "created": k.CreatedAt, "last_used": k.LastUsed}) } - writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID)}) + writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID), "pin": s.db.AdminPin(a.ID) != ""}) } // POST /api/panel/account/password/change - a new password, given the diff --git a/internal/server/pin.go b/internal/server/pin.go new file mode 100644 index 0000000..a6b5e89 --- /dev/null +++ b/internal/server/pin.go @@ -0,0 +1,108 @@ +package server + +import ( + "encoding/json" + "net/http" + "regexp" + "sync" +) + +// The PIN: four digits an admin can set, asked for by the page in every new +// browser tab (a reload of the same tab does not ask again). The server +// checks it; five wrong ones in a row end the session. + +var pinRe = regexp.MustCompile(`^[0-9]{4}$`) + +// pinMisses counts wrong PINs per session. +type pinMisses struct { + mu sync.Mutex + m map[string]int +} + +func (p *pinMisses) miss(sid string) int { + p.mu.Lock() + defer p.mu.Unlock() + if p.m == nil { + p.m = map[string]int{} + } + p.m[sid]++ + return p.m[sid] +} + +func (p *pinMisses) clear(sid string) { + p.mu.Lock() + delete(p.m, sid) + p.mu.Unlock() +} + +// GET /api/panel/pin - whether the admin has a PIN to ask for. +func (s *Server) handlePinState(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, map[string]any{"pin": s.db.AdminPin(a.ID) != ""}) +} + +// POST /api/panel/pin/check {"pin"} - the PIN typed in a new tab. +func (s *Server) handlePinCheck(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + var in struct { + Pin string `json:"pin"` + } + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in) + h := s.db.AdminPin(a.ID) + c, _ := r.Cookie(sessionCookie) + if h == "" || (pinRe.MatchString(in.Pin) && checkPassword(h, in.Pin)) { + s.pinMisses.clear(c.Value) + writeJSON(w, map[string]any{"ok": true}) + return + } + if n := s.pinMisses.miss(c.Value); n >= 5 { + s.pinMisses.clear(c.Value) + s.clearSession(w, r) + s.db.Audit(a.ID, "", "pin_lockout") + writeJSON(w, map[string]any{"ok": false, "out": true, "error": "слишком много неверных попыток"}) + return + } else { + writeJSON(w, map[string]any{"ok": false, "left": 5 - n, "error": "неверный PIN"}) + } +} + +// POST /api/panel/account/pin {"pin", "current"} - set or change the PIN; +// with pin empty, remove it. A PIN in place is needed to change or drop it. +func (s *Server) handlePinSet(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + var in struct { + Pin string `json:"pin"` + Current string `json:"current"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) + return + } + if h := s.db.AdminPin(a.ID); h != "" && !checkPassword(h, in.Current) { + writeJSON(w, map[string]any{"ok": false, "error": "текущий PIN не тот"}) + return + } + hash := "" + if in.Pin != "" { + if !pinRe.MatchString(in.Pin) { + writeJSON(w, map[string]any{"ok": false, "error": "PIN - ровно 4 цифры"}) + return + } + var err error + if hash, err = hashPassword(in.Pin); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + } + if err := s.db.SetAdminPin(a.ID, hash); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + action := "pin_set" + if hash == "" { + action = "pin_remove" + } + s.db.Audit(a.ID, "", action) + writeJSON(w, map[string]any{"ok": true}) +} diff --git a/internal/server/server.go b/internal/server/server.go index 7c49c4e..287d110 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -61,6 +61,7 @@ type Server struct { xfers xfers // files on their way between a browser and an agent challenges challenges // passkey challenges handed out wsAgents wsAgents // agents on their WebSocket now + pinMisses pinMisses // wrong PINs per session assets map[string]string // static file -> hash of its content } @@ -241,6 +242,9 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete)) mux.Handle("POST /api/panel/account/password", s.requireAdmin(s.handlePasswordToggle)) + mux.Handle("POST /api/panel/account/pin", s.requireAdmin(s.handlePinSet)) + mux.Handle("GET /api/panel/pin", s.requireAdmin(s.handlePinState)) + mux.Handle("POST /api/panel/pin/check", s.requireAdmin(s.handlePinCheck)) mux.Handle("POST /api/panel/account/password/change", s.requireAdmin(s.handlePasswordChange)) mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle)) mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd)) diff --git a/internal/server/web/static/account.js b/internal/server/web/static/account.js index 9bbe772..6995a56 100644 --- a/internal/server/web/static/account.js +++ b/internal/server/web/static/account.js @@ -48,6 +48,13 @@ ' ' + ' ' + ' ' + + '
PIN-код ' + + '
' + + ' ' + + ' ' + + ' ' + + '
' + + '

Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.

' + '
' + ''; document.body.appendChild(dlg); @@ -64,6 +71,11 @@ return fetch('/api/panel/account', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) { $('.acct-name').textContent = d.username; dlg.querySelector('.acct-pwform [name=username]').value = d.username; + const pf = dlg.querySelector('.acct-pinform'); + dlg.querySelector('.acct-pinstate').textContent = d.pin ? '· установлен' : '· не задан'; + pf.current.hidden = !d.pin; + pf.querySelector('.acct-pindel').hidden = !d.pin; + pf.querySelector('.acct-pinsave').textContent = d.pin ? 'Сменить' : 'Установить'; $('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase(); $('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю'; const sw = $('.acct-pw .sw'); @@ -134,6 +146,35 @@ .catch(function () { msg('ошибка сети', true); }) .then(function () { f.querySelector('button').disabled = false; }); }; + function pinMsg(text, bad) { + $('.acct-pinmsg').textContent = text || ''; + $('.acct-pinmsg').className = 'acct-pinmsg' + (bad ? ' bad' : ''); + } + function savePin(pin) { + const f = $('.acct-pinform'); + return fetch('/api/panel/account/pin', {method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({pin: pin, current: f.current.value})}) + .then(function (r) { return r.json(); }) + .then(function (r) { + if (!r.ok) { pinMsg(r.error, true); return; } + f.reset(); + // This tab stays unlocked: the PIN is for tabs opened from now on. + try { sessionStorage.setItem('lpra.pin.ok', '1'); } catch (e) { /* asked next load */ } + pinMsg(pin ? 'PIN сохранён' : 'PIN убран'); + load(); + }) + .catch(function () { pinMsg('ошибка сети', true); }); + } + $('.acct-pinform').onsubmit = function (ev) { + ev.preventDefault(); + const f = ev.target; + if (!/^[0-9]{4}$/.test(f.pin.value)) { pinMsg('PIN - ровно 4 цифры', true); return; } + if (f.pin.value !== f.again.value) { pinMsg('новые PIN не совпадают', true); return; } + savePin(f.pin.value); + }; + $('.acct-pindel').onclick = function () { + if (confirm('Убрать PIN-код?')) savePin(''); + }; $('.acct-pw .sw').onchange = function (ev) { const off = !ev.target.checked; if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) { @@ -146,6 +187,7 @@ }; btn.onclick = function () { msg(''); + pinMsg(''); load().then(function () { dlg.showModal(); dlg.focus(); }); }; })(); diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 0bb295a..1e38921 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -107,6 +107,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); } +/* The PIN lock of a new tab: the page stays hidden until it is unlocked. */ +html.pin-wait body > :not(.pinlock) { visibility: hidden; } +.pinlock { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px; + background: rgba(10, 13, 17, .92); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); + opacity: 0; transition: opacity .3s ease; } +.pinlock.on { opacity: 1; } +.pinlock.done { opacity: 0; pointer-events: none; } +.pin-card { width: 100%; max-width: 300px; display: flex; flex-direction: column; align-items: center; text-align: center; + transform: translateY(8px) scale(.98); transition: transform .3s ease; } +.pinlock.on .pin-card { transform: none; } +.pinlock.done .pin-card { transform: scale(1.03); } +.pin-ico { width: 54px; height: 54px; border-radius: 50%; display: flex; align-items: center; justify-content: center; + background: #12202f; border: 1px solid var(--border); color: var(--accent); } +.pin-title { margin: 14px 0 18px; font-size: 18px; font-weight: 600; } +.pin-dots { display: flex; gap: 16px; } +.pin-dots i { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--muted); transition: background .15s, border-color .15s, transform .15s; } +.pin-dots i.on { background: var(--accent); border-color: var(--accent); transform: scale(1.1); } +.pin-card.bad .pin-dots i.on { background: var(--danger); border-color: var(--danger); } +.pin-card.bad .pin-dots { animation: pin-shake .38s ease; } +@keyframes pin-shake { 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } } +.pin-msg { min-height: 20px; margin: 14px 0 6px; font-size: 13px; color: var(--danger); } +.pin-pad { display: grid; grid-template-columns: repeat(3, 72px); gap: 14px; justify-content: center; } +.pin-pad button { width: 72px; height: 72px; border-radius: 50%; padding: 0; display: inline-flex; align-items: center; justify-content: center; + background: #151a21; border: 1px solid var(--border); color: var(--text); font-size: 26px; font-weight: 400; + -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background .12s, transform .08s; } +.pin-pad button:hover { background: #1b222c; } +.pin-pad button:active { background: #12202f; transform: scale(.94); } +.pin-pad button[data-k="back"] { background: transparent; border-color: transparent; color: var(--muted); } +.pin-out { margin-top: 18px; } +.pin-out button { background: transparent; color: var(--muted); padding: 6px 12px; font-size: 14px; } +.pin-out button:hover { background: transparent; color: var(--text); } +@media (max-height: 560px) { .pin-pad { grid-template-columns: repeat(3, 58px); gap: 10px; } .pin-pad button { width: 58px; height: 58px; font-size: 22px; } } + /* 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; } @@ -150,6 +183,14 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; } .acct-chpw summary { cursor: pointer; font-size: 14px; color: var(--text); } .acct-pwform { display: grid; gap: 8px; margin-top: 10px; } .acct-pwform button { justify-self: end; } +.acct-pin { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } +.acct-pin summary { cursor: pointer; font-size: 14px; color: var(--text); } +.acct-pin small { color: var(--muted); font-size: 12px; } +.acct-pinform { display: grid; gap: 8px; margin-top: 10px; } +.acct-pinform input:not(:placeholder-shown) { letter-spacing: .4em; font-family: ui-monospace, Menlo, monospace; } +.acct-pinform .row { justify-content: flex-end; } +.acct-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; } +.acct-pinmsg.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; } diff --git a/internal/server/web/static/pin.js b/internal/server/web/static/pin.js new file mode 100644 index 0000000..cc922d9 --- /dev/null +++ b/internal/server/web/static/pin.js @@ -0,0 +1,100 @@ +// The PIN lock: in a new browser tab the panel asks for the admin's PIN +// before showing anything. The tab remembers it was unlocked (sessionStorage +// lives as long as the tab, through reloads, and a new tab starts empty), so +// a reload does not ask again. Loaded in , before the page shows. +(function () { + 'use strict'; + var KEY = 'lpra.pin.ok'; + var ok = false; + try { ok = sessionStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: ask */ } + if (ok) return; + var root = document.documentElement; + root.classList.add('pin-wait'); + function release() { root.classList.remove('pin-wait'); } + function remember() { try { sessionStorage.setItem(KEY, '1'); } catch (e) { /* asked again next load */ } } + + fetch('/api/panel/pin', {cache: 'no-store'}) + .then(function (r) { return r.ok ? r.json() : {pin: false}; }) + .then(function (d) { + if (!d.pin) { remember(); release(); return; } + if (document.body) lock(); else document.addEventListener('DOMContentLoaded', lock); + }) + .catch(release); + + function lock() { + var LOCK = ''; + var BACK = ''; + var box = document.createElement('div'); + box.className = 'pinlock'; + box.setAttribute('role', 'dialog'); + box.setAttribute('aria-modal', 'true'); + box.setAttribute('aria-label', 'Ввод PIN-кода'); + var keys = ''; + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'back'].forEach(function (k) { + if (!k) { keys += ''; return; } + keys += ''; + }); + box.innerHTML = + '
' + + '
' + LOCK + '
' + + '
Введите PIN-код
' + + '
' + + '
' + + '
' + keys + '
' + + '
' + + '
'; + document.body.appendChild(box); + requestAnimationFrame(function () { box.classList.add('on'); }); + var dots = box.querySelectorAll('.pin-dots i'), msg = box.querySelector('.pin-msg'), card = box.querySelector('.pin-card'); + var code = '', busy = false; + function show() { + for (var i = 0; i < 4; i++) dots[i].classList.toggle('on', i < code.length); + } + function press(k) { + if (busy) return; + if (k === 'back') { code = code.slice(0, -1); show(); return; } + if (code.length >= 4) return; + code += k; + msg.textContent = ''; + card.classList.remove('bad'); + show(); + if (code.length === 4) check(); + } + function check() { + busy = true; + fetch('/api/panel/pin/check', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({pin: code})}) + .then(function (r) { return r.json(); }) + .then(function (r) { + if (r.ok) { + remember(); + box.classList.add('done'); + release(); + setTimeout(function () { box.remove(); }, 320); + document.removeEventListener('keydown', onKey, true); + return; + } + if (r.out) { location.href = '/login'; return; } + card.classList.remove('bad'); + void card.offsetWidth; // restart the shake + card.classList.add('bad'); + msg.textContent = r.error + (r.left ? ' - осталось попыток: ' + r.left : ''); + setTimeout(function () { code = ''; show(); busy = false; }, 380); + }) + .catch(function () { msg.textContent = 'нет связи с сервером'; code = ''; show(); busy = false; }); + } + box.querySelector('.pin-pad').addEventListener('click', function (ev) { + var b = ev.target.closest('button'); + if (b) press(b.dataset.k); + }); + // Typed digits, ahead of anything on the page (the terminal included). + function onKey(ev) { + if (/^[0-9]$/.test(ev.key)) press(ev.key); + else if (ev.key === 'Backspace') press('back'); + else if (ev.key !== 'Tab') return; + ev.preventDefault(); + ev.stopPropagation(); + } + document.addEventListener('keydown', onKey, true); + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); + } +})(); diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html index 121ddec..347f115 100644 --- a/internal/server/web/templates/dashboard.html +++ b/internal/server/web/templates/dashboard.html @@ -5,6 +5,7 @@ Машины - LPRA +
diff --git a/internal/server/web/templates/edit.html b/internal/server/web/templates/edit.html index ac25e00..292099c 100644 --- a/internal/server/web/templates/edit.html +++ b/internal/server/web/templates/edit.html @@ -5,6 +5,7 @@ Изменить машину - LPRA +
diff --git a/internal/server/web/templates/files.html b/internal/server/web/templates/files.html index 2dcb9d1..57d1a23 100644 --- a/internal/server/web/templates/files.html +++ b/internal/server/web/templates/files.html @@ -5,6 +5,7 @@ Файлы · {{.Agent.Name}} - LPRA +