Files
backup-master/internal/server/auth.go
T
mrleo1nidandClaude Opus 5.5 1d44d067e9 backup-master: сервер бэкапов, агент и восстановление VPS
Один бинарник backupctl:
- server: API для агентов и restore, веб-морда (один админ), SQLite,
  ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh;
- backup/plan: сбор по каталогу категорий (files/systemd/xui/compose),
  горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома,
  digest образов, проверка crontab, потоковая отправка tar.zst;
- restore: провижининг на основе vps_installer с теми же версиями пакетов,
  recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose
  с заливкой дампов и закреплением образов по digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:52:40 +03:00

170 lines
4.7 KiB
Go

package server
import (
"context"
"crypto/subtle"
"net"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "bm_session"
sessionTTL = 7 * 24 * time.Hour
csrfKey ctxKey = 2
)
// SetAdmin задаёт логин и пароль единственного администратора и сбрасывает сессии.
func (s *Store) SetAdmin(user, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := s.SetSetting(settingAdminUser, user); err != nil {
return err
}
if err := s.SetSetting(settingAdminHash, string(hash)); err != nil {
return err
}
return s.DeleteAllSessions()
}
func (s *Store) CheckAdmin(user, password string) bool {
hash := s.Setting(settingAdminHash)
if hash == "" {
return false
}
userOK := subtle.ConstantTimeCompare([]byte(user), []byte(s.Setting(settingAdminUser))) == 1
passOK := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
return userOK && passOK
}
// admin пропускает только авторизованного админа; на POST дополнительно проверяет CSRF.
func (s *Server) admin(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ck, err := r.Cookie(sessionCookie)
var csrf string
if err == nil {
csrf, err = s.st.SessionCSRF(ck.Value)
}
if err != nil {
if r.Method == http.MethodGet {
http.Redirect(w, r, "/login", http.StatusSeeOther)
} else {
http.Error(w, "нужна авторизация", http.StatusUnauthorized)
}
return
}
if r.Method == http.MethodPost {
if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrf)) != 1 {
http.Error(w, "неверный CSRF-токен, обновите страницу", http.StatusForbidden)
return
}
}
h(w, r.WithContext(context.WithValue(r.Context(), csrfKey, csrf)))
}
}
func csrfFrom(r *http.Request) string {
v, _ := r.Context().Value(csrfKey).(string)
return v
}
func (s *Server) secureCookies() bool { return strings.HasPrefix(s.cfg.PublicURL, "https://") }
func (s *Server) webLoginPage(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "login", map[string]any{"NoAdmin": s.st.Setting(settingAdminHash) == ""})
}
func (s *Server) webLogin(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if !s.login.allowed(ip) {
s.render(w, r, "login", map[string]any{"Error": "Слишком много попыток, подождите несколько минут"})
return
}
if !s.st.CheckAdmin(r.FormValue("user"), r.FormValue("password")) {
s.login.fail(ip)
time.Sleep(700 * time.Millisecond)
s.render(w, r, "login", map[string]any{"Error": "Неверный логин или пароль"})
return
}
s.login.reset(ip)
tok := newToken()
if err := s.st.CreateSession(tok, newToken(), sessionTTL); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: tok, Path: "/", HttpOnly: true, Secure: s.secureCookies(),
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) webLogout(w http.ResponseWriter, r *http.Request) {
if ck, err := r.Cookie(sessionCookie); err == nil {
_ = s.st.DeleteSession(ck.Value)
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if s.cfg.TrustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[len(parts)-1])
}
}
return host
}
// loginLimiter: после 5 неудач подряд вход с IP блокируется на 10 минут.
type loginLimiter struct {
mu sync.Mutex
fails map[string]*loginState
}
type loginState struct {
n int
until time.Time
}
func newLoginLimiter() *loginLimiter { return &loginLimiter{fails: map[string]*loginState{}} }
func (l *loginLimiter) allowed(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
st := l.fails[ip]
return st == nil || time.Now().After(st.until)
}
func (l *loginLimiter) fail(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
st := l.fails[ip]
if st == nil {
st = &loginState{}
l.fails[ip] = st
}
st.n++
if st.n >= 5 {
st.n = 0
st.until = time.Now().Add(10 * time.Minute)
}
}
func (l *loginLimiter) reset(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.fails, ip)
}