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 }