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
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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