f2fc07fafa
Resolves plan item A.1 (option б): login/setup rate-limiting used RemoteAddr only, which behind the default reverse proxy is the proxy's own address, making the limiter effectively global and enabling a lockout-DoS. Now, when the request's direct peer matches the new TRUSTED_PROXY_CIDR list (comma-separated CIDRs, env, empty by default), the last X-Forwarded-For entry is used instead, giving a real per-client limit. Unset behaviour is unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
114 lines
3.2 KiB
Go
114 lines
3.2 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"codeberg.org/mix/selfpost/internal/store"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// sessionCookie is the name of the panel session cookie.
|
|
const sessionCookie = "selfpost_session"
|
|
|
|
// handleLogin serves the login form (GET) and authenticates (POST). Until an
|
|
// administrator exists there is nobody to log in, so it points at setup.
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
exists, err := s.store.AdminExists()
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !exists {
|
|
// No admin yet: login is meaningless. Send a clear message rather than
|
|
// a failing form.
|
|
s.render(w, http.StatusOK, "login", map[string]any{
|
|
"Title": "SelfPost — Sign in",
|
|
"SetupHint": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.renderLogin(w, http.StatusOK, "")
|
|
case http.MethodPost:
|
|
s.submitLogin(w, r)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) {
|
|
s.render(w, status, "login", map[string]any{
|
|
"Title": "SelfPost — Sign in",
|
|
"Error": formErr,
|
|
})
|
|
}
|
|
|
|
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
|
|
// Brute-force throttle by client IP (spec 7.6.5).
|
|
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
|
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
|
|
return
|
|
}
|
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
password := r.PostFormValue("password")
|
|
|
|
admin, err := s.store.GetAdmin()
|
|
if err != nil {
|
|
if !errors.Is(err, store.ErrNoAdmin) {
|
|
logf("panel: login: get admin failed: %v", err)
|
|
}
|
|
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
|
return
|
|
}
|
|
|
|
// Always run bcrypt so timing does not distinguish "wrong user" from
|
|
// "wrong password", and compare the username too.
|
|
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
|
|
if username != admin.Username || pwErr != nil {
|
|
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
|
return
|
|
}
|
|
|
|
token := s.sessions.Create(admin.Username)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: s.cfg.CookieSecure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// handleLogout destroys the session and clears the cookie.
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if c, err := r.Cookie(sessionCookie); err == nil {
|
|
s.sessions.Destroy(c.Value)
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: s.cfg.CookieSecure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|