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) }