645 lines
19 KiB
Go
645 lines
19 KiB
Go
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. 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.
|
|
|
|
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)
|
|
}
|
|
|
|
// mainHost is the main address's host name, set when the server starts.
|
|
var mainHost = func() string { return "" }
|
|
|
|
// rpID is the site the keys belong to: the host the browser is on, or the
|
|
// main address's host when the browser is on it or under it - so the keys
|
|
// made on lpra.example.com also sign in on direct.lpra.example.com.
|
|
func rpID(r *http.Request) string {
|
|
host := r.Host
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
if m := mainHost(); m != "" && strings.HasSuffix(host, "."+m) {
|
|
return m
|
|
}
|
|
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, "password_off": s.db.PasswordOff(a.ID), "pin": s.db.AdminPin(a.ID) != "",
|
|
"pw_locked": a.PwLocked, "admin": a.IsAdmin()})
|
|
}
|
|
|
|
// 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 a.PwLocked {
|
|
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.
|
|
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)
|
|
}
|