diff --git a/internal/server/db.go b/internal/server/db.go index 578206f..5741034 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -117,6 +117,7 @@ CREATE TABLE IF NOT EXISTS forward_rules ( `ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`, `ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`, } { if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") { return err @@ -246,8 +247,29 @@ func (db *DB) ListAdminKeys(adminID int64) ([]AdminKey, error) { return out, rows.Err() } -// CountAdminKeys is how many passkeys an admin has: with any, the password -// no longer signs them in. +// PasswordOff tells an admin who turned the password off: then only a +// passkey signs them in. It holds only while they have one. +func (db *DB) PasswordOff(adminID int64) bool { + var off int + _ = db.sql.QueryRow(`SELECT password_off FROM admins WHERE id = ?`, adminID).Scan(&off) + return off == 1 && db.CountAdminKeys(adminID) > 0 +} + +func (db *DB) SetAdminPassword(adminID int64, passHash string) error { + _, err := db.sql.Exec(`UPDATE admins SET pass_hash = ? WHERE id = ?`, passHash, adminID) + return err +} + +func (db *DB) SetPasswordOff(adminID int64, off bool) error { + v := 0 + if off { + v = 1 + } + _, err := db.sql.Exec(`UPDATE admins SET password_off = ? WHERE id = ?`, v, adminID) + return err +} + +// CountAdminKeys is how many passkeys an admin has. func (db *DB) CountAdminKeys(adminID int64) int { var n int _ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_keys WHERE admin_id = ?`, adminID).Scan(&n) @@ -271,14 +293,23 @@ func (db *DB) UseAdminKey(id int64, signCount uint32) error { return err } +// DeleteAdminKey removes a passkey; with the last one gone, the password +// signs the admin in again. 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 + if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ? AND id = ?`, adminID, id); err != nil { + return err + } + if db.CountAdminKeys(adminID) == 0 { + return db.SetPasswordOff(adminID, false) + } + return nil } func (db *DB) DeleteAdminKeys(adminID int64) error { - _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID) - return err + if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID); err != nil { + return err + } + return db.SetPasswordOff(adminID, false) } // --- admin sessions --- diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index 2b25934..fd93b85 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -90,8 +90,8 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { 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 { + // An admin who turned the password off signs in with a passkey only. + if s.db.PasswordOff(admin.ID) { s.render(w, "login.html", map[string]any{"Error": "Для этого аккаунта вход только по ключу доступа.", "Username": username}) return } @@ -378,9 +378,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{} + keyCounts, passOff := map[int64]int{}, map[int64]bool{} for _, a := range admins { keyCounts[a.ID] = s.db.CountAdminKeys(a.ID) + passOff[a.ID] = s.db.PasswordOff(a.ID) } host := s.db.HostAddress() secret := s.db.EnrollSecret() @@ -393,6 +394,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { "Secret": secret, "Admins": admins, "KeyCounts": keyCounts, + "PassOff": passOff, "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 index 5486f86..f313435 100644 --- a/internal/server/passkeys.go +++ b/internal/server/passkeys.go @@ -26,8 +26,8 @@ import ( ) // 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. +// fingerprint, the phone's lock, a hardware key - instead of a password. With +// a key in place, the admin can turn the password off in their account. // // 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. @@ -353,7 +353,73 @@ 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}) + writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID)}) +} + +// POST /api/panel/account/password/change - a new password, given the +// current one. +func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + var in struct { + Current string `json:"current"` + New string `json:"new"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) + return + } + ip := clientIP(r) + if s.limiter.blocked(ip) { + writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."}) + return + } + if !checkPassword(a.PassHash, in.Current) { + s.limiter.fin(ip, false) + writeJSON(w, map[string]any{"ok": false, "error": "текущий пароль не тот"}) + return + } + if len(in.New) < 8 { + writeJSON(w, map[string]any{"ok": false, "error": "новый пароль - не короче 8 символов"}) + return + } + hash, err := hashPassword(in.New) + if err == nil { + err = s.db.SetAdminPassword(a.ID, hash) + } + if err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + s.limiter.fin(ip, true) + s.db.Audit(a.ID, "", "password_change") + writeJSON(w, map[string]any{"ok": true}) +} + +// POST /api/panel/account/password {"off": bool} - turn signing in with the +// password off or on again. Off only with a passkey to sign in with. +func (s *Server) handlePasswordToggle(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + var in struct { + Off bool `json:"off"` + } + 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 in.Off && s.db.CountAdminKeys(a.ID) == 0 { + writeJSON(w, map[string]any{"ok": false, "error": "сначала добавьте ключ доступа"}) + return + } + if err := s.db.SetPasswordOff(a.ID, in.Off); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + action := "password_on" + if in.Off { + action = "password_off" + } + s.db.Audit(a.ID, "", action) + writeJSON(w, map[string]any{"ok": true}) } // POST /api/panel/account/keys/begin - options to make a new passkey with. diff --git a/internal/server/server.go b/internal/server/server.go index 03ab904..8a48478 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -237,6 +237,8 @@ func (s *Server) Handler() http.Handler { 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 /api/panel/account/password", s.requireAdmin(s.handlePasswordToggle)) + 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)) 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 index 358dc1b..9bbe772 100644 --- a/internal/server/web/static/account.js +++ b/internal/server/web/static/account.js @@ -1,6 +1,6 @@ // 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. +// passkeys (add one, remove one), the password switch and signing out. The +// password can be turned off once there is a passkey to sign in with. (function () { 'use strict'; var btn = document.getElementById('acct'); @@ -34,11 +34,20 @@ '
Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Пока у аккаунта есть хоть один ключ, войти по паролю нельзя - держите второй ключ или другого администратора, который сможет их сбросить.
' + + 'Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.
' + '