feat: optional 4-digit PIN asked in every new tab (not on reload), on-screen keypad for phones; set, change or remove it in the account; five misses sign out
This commit is contained in:
1 parent
29353e5e6a
commit
119b524825
14 files changed
+316
-1
No files matched your search
@@ -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})
|
||||
}
|
||||
Reference in new issue
Block a user