From e7b5d28470c6f8544e6d0a5ccd11b9b7312c7fe1 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Wed, 14 Oct 2026 20:33:31 +0300 Subject: [PATCH] feat: sign in with passkeys, no password once an account has one; an account button and dialog in place of the sign-out button --- internal/server/db.go | 93 +++ internal/server/handlers_panel.go | 12 +- internal/server/passkeys.go | 565 +++++++++++++++++++ internal/server/server.go | 18 +- internal/server/web/static/account.js | 109 ++++ internal/server/web/static/app.css | 36 ++ internal/server/web/templates/dashboard.html | 5 +- internal/server/web/templates/login.html | 56 +- internal/server/web/templates/settings.html | 16 +- 9 files changed, 892 insertions(+), 18 deletions(-) create mode 100644 internal/server/passkeys.go create mode 100644 internal/server/web/static/account.js diff --git a/internal/server/db.go b/internal/server/db.go index ae1adec..578206f 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -85,6 +85,16 @@ CREATE TABLE IF NOT EXISTS ufw_rules ( created_at INTEGER NOT NULL, UNIQUE(port, proto) ); +CREATE TABLE IF NOT EXISTS admin_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, + cred_id BLOB UNIQUE NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + last_used INTEGER NOT NULL DEFAULT 0 +); CREATE TABLE IF NOT EXISTS forward_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, @@ -188,6 +198,89 @@ func (db *DB) DeleteAdmin(id int64) error { return err } +// AdminKey is a passkey an admin signs in with. +type AdminKey struct { + ID int64 + AdminID int64 + CredID []byte + PublicKey []byte // PKIX DER + SignCount uint32 + Name string + CreatedAt int64 + LastUsed int64 +} + +func (db *DB) AdminByID(id int64) (*Admin, error) { + a := &Admin{} + err := db.sql.QueryRow(`SELECT id, username, pass_hash FROM admins WHERE id = ?`, id). + Scan(&a.ID, &a.Username, &a.PassHash) + if err == sql.ErrNoRows { + return nil, nil + } + return a, err +} + +func (db *DB) AddAdminKey(k AdminKey) error { + _, err := db.sql.Exec( + `INSERT INTO admin_keys(admin_id, cred_id, public_key, sign_count, name, created_at) VALUES(?, ?, ?, ?, ?, ?)`, + k.AdminID, k.CredID, k.PublicKey, k.SignCount, k.Name, time.Now().Unix()) + return err +} + +func (db *DB) ListAdminKeys(adminID int64) ([]AdminKey, error) { + rows, err := db.sql.Query( + `SELECT id, admin_id, cred_id, public_key, sign_count, name, created_at, last_used + FROM admin_keys WHERE admin_id = ? ORDER BY id`, adminID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminKey + for rows.Next() { + var k AdminKey + if err := rows.Scan(&k.ID, &k.AdminID, &k.CredID, &k.PublicKey, &k.SignCount, &k.Name, &k.CreatedAt, &k.LastUsed); err != nil { + return nil, err + } + out = append(out, k) + } + return out, rows.Err() +} + +// CountAdminKeys is how many passkeys an admin has: with any, the password +// no longer signs them in. +func (db *DB) CountAdminKeys(adminID int64) int { + var n int + _ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_keys WHERE admin_id = ?`, adminID).Scan(&n) + return n +} + +func (db *DB) AdminKeyByCredID(credID []byte) (*AdminKey, error) { + k := &AdminKey{} + err := db.sql.QueryRow( + `SELECT id, admin_id, cred_id, public_key, sign_count, name, created_at, last_used + FROM admin_keys WHERE cred_id = ?`, credID). + Scan(&k.ID, &k.AdminID, &k.CredID, &k.PublicKey, &k.SignCount, &k.Name, &k.CreatedAt, &k.LastUsed) + if err == sql.ErrNoRows { + return nil, nil + } + return k, err +} + +func (db *DB) UseAdminKey(id int64, signCount uint32) error { + _, err := db.sql.Exec(`UPDATE admin_keys SET sign_count = ?, last_used = ? WHERE id = ?`, signCount, time.Now().Unix(), id) + return err +} + +func (db *DB) DeleteAdminKey(adminID, id int64) error { + _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ? AND id = ?`, adminID, id) + return err +} + +func (db *DB) DeleteAdminKeys(adminID int64) error { + _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID) + return err +} + // --- admin sessions --- func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error { diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index ba7aa57..b414672 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -87,7 +87,12 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { admin, _ := s.db.AdminByUsername(username) if admin == nil || !checkPassword(admin.PassHash, pw) { s.limiter.fin(ip, false) - s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль."}) + s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль.", "Username": username}) + return + } + // With a passkey on the account, the password alone does not do. + if s.db.CountAdminKeys(admin.ID) > 0 { + s.render(w, "login.html", map[string]any{"Error": "Для этого аккаунта вход только по ключу доступа.", "Username": username}) return } s.limiter.fin(ip, true) @@ -370,6 +375,10 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { admins, _ := s.db.ListAdmins() + keyCounts := map[int64]int{} + for _, a := range admins { + keyCounts[a.ID] = s.db.CountAdminKeys(a.ID) + } host := s.db.HostAddress() secret := s.db.EnrollSecret() code := s.db.InstallCode() @@ -380,6 +389,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { "Host": host, "Secret": secret, "Admins": admins, + "KeyCounts": keyCounts, "CSRF": csrfToken(r), "InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code), "InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret), diff --git a/internal/server/passkeys.go b/internal/server/passkeys.go new file mode 100644 index 0000000..5486f86 --- /dev/null +++ b/internal/server/passkeys.go @@ -0,0 +1,565 @@ +package server + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +// Passkeys (WebAuthn): an admin signs in with a key held by the device - a +// fingerprint, the phone's lock, a hardware key - instead of a password. Once +// an admin has a key, the password no longer signs them in. +// +// Attestation is not checked ("none"): the panel trusts any authenticator, +// it only needs the public key and, at sign-in, a signature made with it. + +var b64u = base64.RawURLEncoding + +// challenges are the ones handed out and not yet used, a few minutes each. +type challenges struct { + mu sync.Mutex + m map[string]challenge +} + +type challenge struct { + adminID int64 // the admin registering a key; 0 for a sign-in + expires time.Time +} + +func (c *challenges) issue(adminID int64) string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + id := b64u.EncodeToString(b) + c.mu.Lock() + defer c.mu.Unlock() + if c.m == nil { + c.m = map[string]challenge{} + } + for k, v := range c.m { + if time.Now().After(v.expires) { + delete(c.m, k) + } + } + c.m[id] = challenge{adminID: adminID, expires: time.Now().Add(5 * time.Minute)} + return id +} + +// take uses up a challenge: true when it was issued, for this admin, and is +// still fresh. +func (c *challenges) take(id string, adminID int64) bool { + c.mu.Lock() + defer c.mu.Unlock() + ch, ok := c.m[id] + delete(c.m, id) + return ok && ch.adminID == adminID && time.Now().Before(ch.expires) +} + +// rpID is the site the keys belong to: the host the browser is on. +func rpID(r *http.Request) string { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + return host +} + +// checkClientData verifies what the browser signed over: the kind of +// ceremony, our challenge and our origin. A proxy in front may speak plain +// http to us, so the origin is matched by host, and must be https unless the +// panel is on this machine. +func checkClientData(r *http.Request, raw []byte, typ string, adminID int64, ch *challenges) error { + var cd struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` + } + if err := json.Unmarshal(raw, &cd); err != nil { + return errors.New("ответ ключа не читается") + } + if cd.Type != typ { + return errors.New("ответ ключа не того вида") + } + if !ch.take(cd.Challenge, adminID) { + return errors.New("запрос устарел - попробуйте ещё раз") + } + u, err := url.Parse(cd.Origin) + if err != nil || u.Host != r.Host { + return errors.New("ключ подписал запрос для другого сайта") + } + local := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1" + if u.Scheme != "https" && !local { + return errors.New("ключи доступа работают только по https") + } + return nil +} + +// authData is the authenticator's part: rpIdHash, flags, counter, then the +// new credential at registration. +type authData struct { + rpIDHash []byte + flags byte + signCount uint32 + credID []byte + coseKey []byte +} + +func parseAuthData(b []byte, wantCred bool) (*authData, error) { + if len(b) < 37 { + return nil, errors.New("данные ключа слишком короткие") + } + ad := &authData{rpIDHash: b[:32], flags: b[32], signCount: binary.BigEndian.Uint32(b[33:37])} + if !wantCred { + return ad, nil + } + if ad.flags&0x40 == 0 || len(b) < 37+18 { + return nil, errors.New("ключ не прислал себя") + } + n := int(binary.BigEndian.Uint16(b[53:55])) + if len(b) < 55+n { + return nil, errors.New("данные ключа обрезаны") + } + ad.credID = b[55 : 55+n] + ad.coseKey = b[55+n:] + return ad, nil +} + +func (ad *authData) check(r *http.Request) error { + sum := sha256.Sum256([]byte(rpID(r))) + if !bytes.Equal(ad.rpIDHash, sum[:]) { + return errors.New("ключ создан для другого сайта") + } + if ad.flags&0x01 == 0 { + return errors.New("ключ не подтвердил присутствие") + } + return nil +} + +// coseToPKIX turns a COSE public key (ES256, RS256 or EdDSA) into PKIX DER. +func coseToPKIX(raw []byte) ([]byte, error) { + v, _, err := cborDecode(raw) + if err != nil { + return nil, err + } + m, ok := v.(map[any]any) + if !ok { + return nil, errors.New("ключ не в формате COSE") + } + num := func(k int64) int64 { n, _ := m[k].(int64); return n } + bin := func(k int64) []byte { b, _ := m[k].([]byte); return b } + var pub any + switch num(1) { // kty + case 2: // EC2 + x, y := bin(-2), bin(-3) + if num(-1) != 1 || len(x) != 32 || len(y) != 32 { + return nil, errors.New("поддерживается только кривая P-256") + } + k := &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)} + if !k.Curve.IsOnCurve(k.X, k.Y) { + return nil, errors.New("ключ не на кривой") + } + pub = k + case 3: // RSA + n, e := bin(-1), bin(-2) + if len(n) < 256 || len(e) == 0 || len(e) > 4 { + return nil, errors.New("неподходящий ключ RSA") + } + pub = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())} + case 1: // OKP + x := bin(-2) + if num(-1) != 6 || len(x) != ed25519.PublicKeySize { + return nil, errors.New("поддерживается только Ed25519") + } + pub = ed25519.PublicKey(x) + default: + return nil, errors.New("неизвестный вид ключа") + } + return x509.MarshalPKIXPublicKey(pub) +} + +func verifySig(der, data, sig []byte) bool { + pub, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return false + } + sum := sha256.Sum256(data) + switch k := pub.(type) { + case *ecdsa.PublicKey: + return ecdsa.VerifyASN1(k, sum[:], sig) + case *rsa.PublicKey: + return rsa.VerifyPKCS1v15(k, crypto.SHA256, sum[:], sig) == nil + case ed25519.PublicKey: + return ed25519.Verify(k, data, sig) + } + return false +} + +// ---------- CBOR, as much as WebAuthn needs ---------- + +func cborDecode(b []byte) (any, []byte, error) { + r := &cborReader{b: b} + v, err := r.item(0) + return v, r.b[r.i:], err +} + +type cborReader struct { + b []byte + i int +} + +var errCBOR = errors.New("данные ключа повреждены") + +func (r *cborReader) arg(info byte) (uint64, error) { + size := map[byte]int{24: 1, 25: 2, 26: 4, 27: 8} + if info < 24 { + return uint64(info), nil + } + n, ok := size[info] + if !ok || r.i+n > len(r.b) { + return 0, errCBOR + } + var v uint64 + for _, c := range r.b[r.i : r.i+n] { + v = v<<8 | uint64(c) + } + r.i += n + return v, nil +} + +func (r *cborReader) take(n uint64) ([]byte, error) { + if n > uint64(len(r.b)-r.i) { + return nil, errCBOR + } + out := r.b[r.i : r.i+int(n)] + r.i += int(n) + return out, nil +} + +func (r *cborReader) item(depth int) (any, error) { + if r.i >= len(r.b) || depth > 16 { + return nil, errCBOR + } + ib := r.b[r.i] + r.i++ + major, info := ib>>5, ib&31 + if major == 7 { + switch info { + case 20: + return false, nil + case 21: + return true, nil + case 22, 23: + return nil, nil + } + _, err := r.arg(info) // a float: not used, skipped + return nil, err + } + n, err := r.arg(info) + if err != nil { + return nil, err + } + switch major { + case 0: + return int64(n), nil + case 1: + return -1 - int64(n), nil + case 2: + return r.take(n) + case 3: + s, err := r.take(n) + return string(s), err + case 4: + if n > 1024 { + return nil, errCBOR + } + arr := make([]any, 0, n) + for k := uint64(0); k < n; k++ { + v, err := r.item(depth + 1) + if err != nil { + return nil, err + } + arr = append(arr, v) + } + return arr, nil + case 5: + if n > 1024 { + return nil, errCBOR + } + m := make(map[any]any, n) + for k := uint64(0); k < n; k++ { + key, err := r.item(depth + 1) + if err != nil { + return nil, err + } + v, err := r.item(depth + 1) + if err != nil { + return nil, err + } + switch key.(type) { + case int64, string: + m[key] = v + default: + return nil, errCBOR + } + } + return m, nil + case 6: + return r.item(depth + 1) // a tag: its content + } + return nil, errCBOR +} + +// ---------- handlers ---------- + +func credParams() []map[string]any { + return []map[string]any{ + {"type": "public-key", "alg": -7}, // ES256 + {"type": "public-key", "alg": -8}, // EdDSA + {"type": "public-key", "alg": -257}, // RS256 + } +} + +func userHandle(id int64) string { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, uint64(id)) + return b64u.EncodeToString(b) +} + +// GET /api/panel/account - who is signed in, and their passkeys. +func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + keys, _ := s.db.ListAdminKeys(a.ID) + list := []map[string]any{} + 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}) +} + +// POST /api/panel/account/keys/begin - options to make a new passkey with. +func (s *Server) handleKeyRegisterBegin(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + keys, _ := s.db.ListAdminKeys(a.ID) + exclude := []map[string]any{} + for _, k := range keys { + exclude = append(exclude, map[string]any{"type": "public-key", "id": b64u.EncodeToString(k.CredID)}) + } + writeJSON(w, map[string]any{"ok": true, "options": map[string]any{ + "challenge": s.challenges.issue(a.ID), + "rp": map[string]any{"id": rpID(r), "name": "LPRA"}, + "user": map[string]any{"id": userHandle(a.ID), "name": a.Username, "displayName": a.Username}, + "pubKeyCredParams": credParams(), + "excludeCredentials": exclude, + "authenticatorSelection": map[string]any{ + "residentKey": "preferred", "userVerification": "preferred", + }, + "attestation": "none", + "timeout": 120000, + }}) +} + +// POST /api/panel/account/keys/finish - keep the passkey the browser made. +func (s *Server) handleKeyRegisterFinish(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + var in struct { + Name string `json:"name"` + ClientDataJSON string `json:"clientDataJSON"` + AttestationObject string `json:"attestationObject"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) + return + } + fail := func(err error) { writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) } + cd, err1 := b64u.DecodeString(in.ClientDataJSON) + att, err2 := b64u.DecodeString(in.AttestationObject) + if err1 != nil || err2 != nil { + fail(errors.New("плохой запрос")) + return + } + if err := checkClientData(r, cd, "webauthn.create", a.ID, &s.challenges); err != nil { + fail(err) + return + } + v, _, err := cborDecode(att) + m, ok := v.(map[any]any) + if err != nil || !ok { + fail(errCBOR) + return + } + raw, _ := m["authData"].([]byte) + ad, err := parseAuthData(raw, true) + if err != nil { + fail(err) + return + } + if err := ad.check(r); err != nil { + fail(err) + return + } + der, err := coseToPKIX(ad.coseKey) + if err != nil { + fail(err) + return + } + if k, _ := s.db.AdminKeyByCredID(ad.credID); k != nil { + fail(errors.New("этот ключ уже добавлен")) + return + } + name := strings.TrimSpace(in.Name) + if name == "" { + name = "ключ " + time.Now().Format("02.01.2006") + } + if len(name) > 60 { + name = name[:60] + } + if err := s.db.AddAdminKey(AdminKey{AdminID: a.ID, CredID: ad.credID, PublicKey: der, SignCount: ad.signCount, Name: name}); err != nil { + fail(err) + return + } + s.db.Audit(a.ID, "", "passkey_add:"+name) + writeJSON(w, map[string]any{"ok": true}) +} + +// POST /api/panel/account/keys/{id}/delete +func (s *Server) handleKeyDelete(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := s.db.DeleteAdminKey(a.ID, id); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + s.db.Audit(a.ID, "", "passkey_delete:"+strconv.FormatInt(id, 10)) + writeJSON(w, map[string]any{"ok": true}) +} + +// POST /api/login/key/begin - options to sign in with a passkey. With a +// username, its keys are named, for keys that do not keep the account +// themselves. +func (s *Server) handleKeyLoginBegin(w http.ResponseWriter, r *http.Request) { + if s.limiter.blocked(clientIP(r)) { + writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."}) + return + } + var in struct { + Username string `json:"username"` + } + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in) + allow := []map[string]any{} + if u := strings.TrimSpace(in.Username); u != "" { + if a, _ := s.db.AdminByUsername(u); a != nil { + keys, _ := s.db.ListAdminKeys(a.ID) + for _, k := range keys { + allow = append(allow, map[string]any{"type": "public-key", "id": b64u.EncodeToString(k.CredID)}) + } + } + } + writeJSON(w, map[string]any{"ok": true, "options": map[string]any{ + "challenge": s.challenges.issue(0), + "rpId": rpID(r), + "allowCredentials": allow, + "userVerification": "preferred", + "timeout": 120000, + }}) +} + +// POST /api/login/key/finish - check the signature and sign the admin in. +func (s *Server) handleKeyLoginFinish(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + if s.limiter.blocked(ip) { + writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."}) + return + } + var in struct { + ID string `json:"id"` + ClientDataJSON string `json:"clientDataJSON"` + AuthenticatorData string `json:"authenticatorData"` + Signature string `json:"signature"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) + return + } + fail := func(msg string) { + s.limiter.fin(ip, false) + writeJSON(w, map[string]any{"ok": false, "error": msg}) + } + credID, e1 := b64u.DecodeString(in.ID) + cd, e2 := b64u.DecodeString(in.ClientDataJSON) + rawAD, e3 := b64u.DecodeString(in.AuthenticatorData) + sig, e4 := b64u.DecodeString(in.Signature) + if e1 != nil || e2 != nil || e3 != nil || e4 != nil { + fail("плохой запрос") + return + } + k, _ := s.db.AdminKeyByCredID(credID) + if k == nil { + fail("этот ключ здесь не привязан ни к одному аккаунту") + return + } + if err := checkClientData(r, cd, "webauthn.get", 0, &s.challenges); err != nil { + fail(err.Error()) + return + } + ad, err := parseAuthData(rawAD, false) + if err == nil { + err = ad.check(r) + } + if err != nil { + fail(err.Error()) + return + } + cdHash := sha256.Sum256(cd) + if !verifySig(k.PublicKey, append(append([]byte{}, rawAD...), cdHash[:]...), sig) { + fail("подпись ключа не сошлась") + return + } + // A counter that goes back means a copy of the key is in use. + if ad.signCount != 0 && k.SignCount != 0 && ad.signCount <= k.SignCount { + fail("ключ похож на копию: счётчик не вырос") + return + } + _ = s.db.UseAdminKey(k.ID, ad.signCount) + s.limiter.fin(ip, true) + if err := s.setSession(w, k.AdminID); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "внутренняя ошибка"}) + return + } + s.db.Audit(k.AdminID, "", "login_passkey:"+k.Name) + writeJSON(w, map[string]any{"ok": true}) +} + +// POST /settings/admins/keys/reset - drop all passkeys of an admin, who signs +// in with the password again: for one who lost theirs. +func (s *Server) handleResetAdminKeys(w http.ResponseWriter, r *http.Request) { + if !checkCSRF(r) { + http.Error(w, "bad csrf", http.StatusForbidden) + return + } + id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) + _ = s.db.DeleteAdminKeys(id) + if a := s.currentAdmin(r); a != nil { + s.db.Audit(a.ID, "", fmt.Sprintf("passkeys_reset:%d", id)) + } + http.Redirect(w, r, "/settings", http.StatusSeeOther) +} diff --git a/internal/server/server.go b/internal/server/server.go index b59592d..03ab904 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -55,11 +55,12 @@ type Server struct { tunnelMu sync.Mutex // serializes port changes tunnelPort atomic.Int64 - stats statCache // last load sample per agent, for the status page - relays speedRelays // speed tests that run through the browser - meters speedMeters // byte counts of the speed tests under way - xfers xfers // files on their way between a browser and an agent - assets map[string]string // static file -> hash of its content + stats statCache // last load sample per agent, for the status page + relays speedRelays // speed tests that run through the browser + meters speedMeters // byte counts of the speed tests under way + xfers xfers // files on their way between a browser and an agent + challenges challenges // passkey challenges handed out + assets map[string]string // static file -> hash of its content } // New builds the server, parsing embedded templates. @@ -210,6 +211,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /login", s.handleLoginForm) mux.HandleFunc("POST /login", s.handleLoginSubmit) mux.HandleFunc("POST /logout", s.handleLogout) + mux.HandleFunc("POST /api/login/key/begin", s.handleKeyLoginBegin) + mux.HandleFunc("POST /api/login/key/finish", s.handleKeyLoginFinish) // Panel (admin-only). mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard)) @@ -229,6 +232,11 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret)) mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin)) mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin)) + mux.Handle("POST /settings/admins/keys/reset", s.requireAdmin(s.handleResetAdminKeys)) + mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount)) + mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) + 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 /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle)) mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd)) mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke)) diff --git a/internal/server/web/static/account.js b/internal/server/web/static/account.js new file mode 100644 index 0000000..358dc1b --- /dev/null +++ b/internal/server/web/static/account.js @@ -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 = ''; + var KEY = ''; + + var dlg = document.createElement('dialog'); + dlg.id = 'acctdlg'; + dlg.tabIndex = -1; + dlg.innerHTML = + '
Аккаунт' + + '
' + + '
' + + '
' + + '

Ключи доступа

' + + '

Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Пока у аккаунта есть хоть один ключ, войти по паролю нельзя - держите второй ключ или другого администратора, который сможет их сбросить.

' + + ' ' + + '
' + + '
' + + '

' + + '
' + + '
'; + 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 ? '' : '
  • Ключей пока нет.
  • '; + d.keys.forEach(function (k) { + var li = document.createElement('li'); + li.innerHTML = '' + KEY + '
    ' + esc(k.name) + '
    ' + + '
    добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '
    ' + + ''; + 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(); }); + }; +})(); diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index b09f924..423c35d 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -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; } diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html index fa496ef..121ddec 100644 --- a/internal/server/web/templates/dashboard.html +++ b/internal/server/web/templates/dashboard.html @@ -12,9 +12,7 @@
    @@ -100,5 +98,6 @@ if (Object.keys(rows).length) schedule(); })(); + diff --git a/internal/server/web/templates/login.html b/internal/server/web/templates/login.html index fc226e6..1961dd8 100644 --- a/internal/server/web/templates/login.html +++ b/internal/server/web/templates/login.html @@ -10,15 +10,61 @@

    Вход в панель

    - {{if .Error}}
    {{.Error}}
    {{end}} + {{if .Error}}
    {{.Error}}
    {{else}}{{end}} + +
    - - - - + + + +
    + diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index 66d9363..3b10f4f 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -20,9 +20,7 @@
    @@ -98,7 +96,16 @@ {{range .Admins}} {{.Username}} - + {{with index $.KeyCounts .ID}}ключей доступа: {{.}}{{else}}по паролю{{end}} + + {{if index $.KeyCounts .ID}} +
    + + + +
    + {{end}}
    @@ -210,5 +217,6 @@ }); })(); +