Files
worker/internal/webapp/handlers_auth.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

391 строка
13 KiB
Go

package webapp
import (
"fmt"
"net/http"
"strings"
"time"
)
// handleHealth returns 200 OK with a tiny body. Public endpoint so
// the operator's tooling (curl, monitoring) can probe the listener
// without going through the login form.
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeNoStore(w)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ok")
}
// handleLoginForm renders the login page. If the operator is already
// logged in, they are redirected to /overview.
//
// When WORKER_LOGIN / WORKER_PASSWORD are configured, the login form
// renders a username field and the explanatory copy tells the
// operator to use the env-var credentials. Otherwise the form is the
// plain first-run password entry (no username).
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, err := s.resolveSession(r)
if err == nil && sess != nil {
redirectTo(w, r, pathOverview)
return
}
data := loginPageData{
basePageData: s.newBasePage(r, "RSMon worker login", nil),
Error: strings.TrimSpace(r.URL.Query().Get("error")),
NextURL: strings.TrimSpace(r.URL.Query().Get("next")),
BasicAuth: s.BasicAuthEnabled(),
}
if err := s.templates.Execute(w, "login.html", data); err != nil {
s.deps.Logger.Printf("render login: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleLoginSubmit validates the credentials and starts a session.
// On failure: 401 with the login page re-rendered and an error message.
//
// When basic auth is configured the form must supply BOTH a username
// matching WORKER_LOGIN and a password matching WORKER_PASSWORD. The
// per-machine bcrypt user is bypassed in that mode (so the operator
// can rotate the basic-auth password without touching the bcrypt
// store). Local-only mode keeps the original first-run password flow.
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
plain := r.FormValue("password")
if plain == "" {
s.renderLoginError(w, r, "password is required", r.FormValue("next"))
return
}
next := r.FormValue("next")
if s.basicAuthOK {
s.handleBasicAuthLogin(w, r, plain, next)
return
}
s.handleLocalLogin(w, r, plain, next)
}
// handleBasicAuthLogin verifies the form-submitted password against
// WORKER_PASSWORD. The username field is checked against
// WORKER_LOGIN and the comparison is constant-time.
func (s *Server) handleBasicAuthLogin(w http.ResponseWriter, r *http.Request, password, next string) {
login := strings.TrimSpace(r.FormValue("login"))
if login == "" {
s.renderLoginError(w, r, "username is required", next)
return
}
if subtleEqual(login, s.cfg.BasicAuthLogin) != 1 ||
subtleEqual(password, s.cfg.BasicAuthPassword) != 1 {
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeBasic,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionLoginFail,
Target: auditTargetSelf,
})
s.renderLoginError(w, r, "invalid credentials", next)
return
}
// Mint a synthetic session backed by the local store but tagged
// with auth_mode=basic_auth so the audit log distinguishes the
// two paths. The bcrypt user is bypassed entirely.
if err := s.startSyntheticSession(w, r, "basic_auth"); err != nil {
s.deps.Logger.Printf("start synthetic session: %v", err)
http.Error(w, "session error", http.StatusInternalServerError)
return
}
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeBasic,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionLogin,
Target: auditTargetSelf,
})
if next == "" || !strings.HasPrefix(next, "/") {
next = pathOverview
}
redirectTo(w, r, next)
}
// handleLocalLogin is the legacy first-run bcrypt path. Kept as a
// separate function so handleLoginSubmit reads top-down without
// branching inside one long handler.
func (s *Server) handleLocalLogin(w http.ResponseWriter, r *http.Request, plain, next string) {
user, err := s.store.GetUser(r.Context())
if err != nil {
// No user provisioned yet => login is impossible. Surface as
// a generic error so we do not leak the "no user" state to
// a brute-force attacker.
s.renderLoginError(w, r, "invalid credentials", next)
return
}
if err := VerifyPassword(user.BcryptHash, plain); err != nil {
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeLocal,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionLoginFail,
Target: auditTargetSelf,
})
s.renderLoginError(w, r, "invalid credentials", next)
return
}
if err := s.startSession(w, r, user); err != nil {
s.deps.Logger.Printf("start session: %v", err)
http.Error(w, "session error", http.StatusInternalServerError)
return
}
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeLocal,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionLogin,
Target: auditTargetSelf,
})
// The requires_change flag is recorded on the user row for
// future hardening (a per-install "force rotation" toggle), but
// the login flow does not bounce operators to /web/change-password
// on first login any more. Frictionless first-login is the
// current default; the change-password page is still reachable
// from /settings.
if next == "" || !strings.HasPrefix(next, "/") {
next = pathOverview
}
redirectTo(w, r, next)
}
// startSyntheticSession mints a session row that is NOT bound to the
// bcrypt user. Used by the basic-auth login flow. The user_id is
// re-used (the row in webapp_users still exists for the local-mode
// fallback) so foreign-key-free audit inserts keep working.
func (s *Server) startSyntheticSession(w http.ResponseWriter, r *http.Request, _ string) error {
user, err := s.store.GetUser(r.Context())
if err != nil {
// No bcrypt user yet: synthesize an anonymous row so the
// session has a user_id to point at. The local-only path
// will eventually upgrade this to a real user on first
// basic-auth-less login.
if cerr := s.store.EnsureAnonymousUser(r.Context()); cerr != nil {
return cerr
}
user, err = s.store.GetUser(r.Context())
if err != nil {
return err
}
}
return s.startSession(w, r, user)
}
// subtleEqual wraps crypto/subtle.ConstantTimeCompare so the
// handler body stays free of import noise. Returns 1 on match.
func subtleEqual(a, b string) int {
return constantTimeEq(a, b)
}
// handleLogout deletes the session row and clears the cookies. The
// logout endpoint is a POST so a stray GET cannot end a session via
// link prefetch.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := s.resolveSession(r)
clearSessionCookie(w, r)
if sess != nil {
_ = s.store.DeleteSession(r.Context(), sess.ID)
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeLocal,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionLogout,
Target: auditTargetSelf,
})
}
redirectTo(w, r, pathLogin)
}
// handleChangePasswordForm renders the change-password page.
func (s *Server) handleChangePasswordForm(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, ok := sessionFromContext(r.Context())
if !ok {
redirectTo(w, r, pathLogin)
return
}
if !s.requireCSRF(sess, r) {
http.Error(w, "csrf token required", http.StatusForbidden)
return
}
data := changePasswordPageData{
basePageData: s.newBasePage(r, "Change password", sess),
MinStrength: minPasswordLength,
}
if err := s.templates.Execute(w, "change_password.html", data); err != nil {
s.deps.Logger.Printf("render change-password: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleChangePasswordSubmit rotates the user's bcrypt hash and
// clears the requires_change flag. On success, the operator lands on
// /overview. On any failure, the change-password page re-renders
// with an error message.
func (s *Server) handleChangePasswordSubmit(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, ok := sessionFromContext(r.Context())
if !ok {
redirectTo(w, r, pathLogin)
return
}
if !s.requireCSRF(sess, r) {
http.Error(w, "csrf token required", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
current := r.FormValue("current_password")
next := r.FormValue("new_password")
confirm := r.FormValue("new_password_confirm")
user, err := s.store.GetUser(r.Context())
if err != nil {
http.Error(w, "no user", http.StatusInternalServerError)
return
}
if err := VerifyPassword(user.BcryptHash, current); err != nil {
s.renderChangePasswordError(w, r, sess, "current password is incorrect")
return
}
if !validPasswordStrength(next) {
s.renderChangePasswordError(w, r, sess, fmt.Sprintf("new password must be at least %d characters", minPasswordLength))
return
}
if next != confirm {
s.renderChangePasswordError(w, r, sess, "new password and confirmation do not match")
return
}
newHash, err := HashPassword(next)
if err != nil {
http.Error(w, "hash error", http.StatusInternalServerError)
return
}
if err := s.store.UpdatePassword(r.Context(), user.ID, newHash); err != nil {
http.Error(w, "update error", http.StatusInternalServerError)
return
}
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeLocal,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionPassChange,
Target: fmt.Sprintf("user:%d", user.ID),
BeforeHash: user.BcryptHash,
AfterHash: newHash,
})
redirectTo(w, r, pathOverview)
}
// minPasswordLength matches the bcrypt minimum the worker enforces
// (bcrypt silently truncates after 72 bytes; the minimum is a UX
// floor so the operator does not pick "a").
const minPasswordLength = 8
func validPasswordStrength(p string) bool {
return len(p) >= minPasswordLength
}
// startSession creates a session row with a fresh id and CSRF token,
// persists it, and sets the cookies.
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *User) error {
id, err := newSessionID()
if err != nil {
return err
}
csrf, err := newSessionID()
if err != nil {
return err
}
now := time.Now().UTC()
sess := Session{
ID: id,
UserID: user.ID,
CSRFToken: csrf,
IP: clientIP(r),
UA: r.UserAgent(),
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(s.cfg.SessionAbs),
}
if err := s.store.CreateSession(r.Context(), &sess); err != nil {
return err
}
s.writeSessionCookie(w, r, &sess)
return nil
}
// renderLoginError renders the login page with an inline error
// message. We deliberately do NOT use http.StatusUnauthorized here;
// the status is 200 so an interactive operator gets the form back
// with the error visible, not a browser auth dialog.
func (s *Server) renderLoginError(w http.ResponseWriter, r *http.Request, msg, next string) {
data := loginPageData{
basePageData: s.newBasePage(r, "RSMon worker login", nil),
Error: msg,
NextURL: next,
BasicAuth: s.BasicAuthEnabled(),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err := s.templates.Execute(w, "login.html", data); err != nil {
s.deps.Logger.Printf("render login error: %v", err)
}
}
// renderChangePasswordError renders the change-password form with an
// inline error. The CSRF token is reused from the current session so
// the operator does not have to reload to retry.
func (s *Server) renderChangePasswordError(w http.ResponseWriter, r *http.Request, sess *Session, msg string) {
data := changePasswordPageData{
basePageData: s.newBasePage(r, "Change password", sess),
Error: msg,
MinStrength: minPasswordLength,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err := s.templates.Execute(w, "change_password.html", data); err != nil {
s.deps.Logger.Printf("render change-password error: %v", err)
}
}
// loginPageData is the input to the login.html template.
type loginPageData struct {
basePageData
Error string
NextURL string
BasicAuth bool // true when WORKER_LOGIN/WORKER_PASSWORD are configured; the form must show a username field
}
// changePasswordPageData is the input to the change_password.html
// template.
type changePasswordPageData struct {
basePageData
Error string
MinStrength int
}