391 строка
13 KiB
Go
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
|
|
}
|