package web import ( "errors" "net/http" "strings" "time" "github.com/gramanas/blogspace/internal/auth" "github.com/gramanas/blogspace/internal/store" ) func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { next := safeNext(r.URL.Query().Get("next")) if u := currentUser(r); u != nil { http.Redirect(w, r, s.landing(r, u, next), http.StatusSeeOther) return } s.render(w, r, "auth/login.html", map[string]any{"next": next}) } // landing is where a user goes once logged in: next, unless that is another // blogger's dashboard — someone typing /webadmin on a blog that is not theirs // gets their own dashboard, not a 403. func (s *Server) landing(r *http.Request, u *store.User, next string) string { if next == "" { return "/dashboard" } if rest, ok := strings.CutPrefix(next, "/b/"); ok && !u.IsSuperadmin() { sub, _, _ := strings.Cut(rest, "/") sub, _, _ = strings.Cut(sub, "?") blog, err := s.st.BlogBySubdomain(r.Context(), sub) if err != nil || blog.OwnerID != u.ID { return "/dashboard" } } return next } // handleWebadminRedirect serves /webadmin on a blog's own host: the login page // lives on the root domain, so bounce there and come back to this blog's dashboard. func (s *Server) handleWebadminRedirect(w http.ResponseWriter, r *http.Request) { sub, _ := r.Context().Value(ctxHostSub).(string) http.Redirect(w, r, s.cfg.RootURL()+"/webadmin?next="+urlQuery("/b/"+sub+"/"), http.StatusSeeOther) } // maxLoginBody is all a login form needs; it is the one POST guardPOST does // not cap, so it caps itself. const maxLoginBody = 64 << 10 func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody) if err := r.ParseForm(); err != nil { s.plainError(w, http.StatusBadRequest, s.tr(r, "Could not read the form.")) return } username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") next := safeNext(r.FormValue("next")) fail := func() { s.renderStatus(w, r, http.StatusUnauthorized, "auth/login.html", map[string]any{"error": s.tr(r, "Wrong username or password."), "username": username, "next": next}) } // Both buckets must have a token: one address guessing many accounts and // many addresses guessing one account are throttled alike. if !s.throttle(w, r, s.loginLimit, "ip:"+clientIP(r)) || !s.throttle(w, r, s.loginLimit, "user:"+strings.ToLower(username)) { return } u, err := s.st.UserByUsername(r.Context(), username) if err != nil { if !errors.Is(err, store.ErrNotFound) { s.serverError(w, err) return } auth.CheckPassword("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinval", password) // constant-ish time logf("login failed for %q from %s", username, clientIP(r)) fail() return } if u.Disabled || !auth.CheckPassword(u.PasswordHash, password) { logf("login failed for %q from %s", username, clientIP(r)) fail() return } tok, err := auth.IssueToken(s.cfg.JWTSecret, u.ID, u.TokenVersion, time.Now()) if err != nil { s.serverError(w, err) return } auth.SetSessionCookie(w, tok) http.Redirect(w, r, s.landing(r, u, next), http.StatusSeeOther) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { auth.ClearSessionCookie(w) http.Redirect(w, r, "/webadmin", http.StatusSeeOther) } func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { u := currentUser(r) blog, err := s.st.BlogByOwner(r.Context(), u.ID) if err != nil { if errors.Is(err, store.ErrNotFound) { if u.IsSuperadmin() { http.Redirect(w, r, "/admin/", http.StatusSeeOther) return } s.plainError(w, http.StatusNotFound, s.tr(r, "You have no blog yet. Ask the administrator to create one.")) return } s.serverError(w, err) return } http.Redirect(w, r, "/b/"+blog.Subdomain+"/", http.StatusSeeOther) } func (s *Server) handlePasswordForm(w http.ResponseWriter, r *http.Request) { s.render(w, r, "dashboard/password.html", nil) } func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) { u := currentUser(r) cur, pw, pw2 := r.FormValue("current"), r.FormValue("password"), r.FormValue("password2") var msg string switch { case !auth.CheckPassword(u.PasswordHash, cur): msg = s.tr(r, "Current password is wrong.") case len(pw) < 8: msg = s.tr(r, "New password must be at least 8 characters.") case pw != pw2: msg = s.tr(r, "New passwords do not match.") } if msg != "" { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/password.html", map[string]any{"error": msg}) return } hash, err := auth.HashPassword(pw) if err != nil { s.serverError(w, err) return } if err := s.st.SetPassword(r.Context(), u.ID, hash); err != nil { s.serverError(w, err) return } // token_version changed, so re-issue the session instead of logging the user out tok, err := auth.IssueToken(s.cfg.JWTSecret, u.ID, u.TokenVersion+1, time.Now()) if err != nil { s.serverError(w, err) return } auth.SetSessionCookie(w, tok) redirectOK(w, r, "/dashboard", s.tr(r, "Password changed.")) } // safeNext only allows local paths as post-login redirect targets. func safeNext(n string) string { if strings.HasPrefix(n, "/") && !strings.HasPrefix(n, "//") { return n } return "" }