diff options
| author | gramanas <grm@eyesin.space> | 2026-09-12 11:24:17 +0300 |
|---|---|---|
| committer | gramanas <grm@eyesin.space> | 2026-09-12 11:24:17 +0300 |
| commit | 3eb04b1a2bdf9e53231fe862cfd76327371a9741 (patch) | |
| tree | b38b2d82a47233fd8e0bb18c59e4a8f3dd2412d7 /internal/web/handlers_auth.go | |
| download | blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.gz blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.bz2 blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.zip | |
Initial multi-tenant blog host
Go + Postgres application serving a management dashboard on the base
domain and one public blog per subdomain. Markdown posts organised in
pages, form-based theme customisation, image uploads stored in Postgres,
JWT cookie sessions with CSRF, superadmin user management, RSS feeds.
Docker/compose deployment and a Makefile-driven dev environment with
seed data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/handlers_auth.go')
| -rw-r--r-- | internal/web/handlers_auth.go | 131 |
1 files changed, 131 insertions, 0 deletions
diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go new file mode 100644 index 0000000..4554d94 --- /dev/null +++ b/internal/web/handlers_auth.go @@ -0,0 +1,131 @@ +package web + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/gramanas/blogspace/internal/auth" + "github.com/gramanas/blogspace/internal/store" +) + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if currentUser(r) != nil { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { + if currentUser(r) != nil { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } + s.render(w, r, "auth/login.html", map[string]any{"next": safeNext(r.URL.Query().Get("next"))}) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + 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": "Wrong username or password.", "username": username, "next": next}) + } + 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 + fail() + return + } + if u.Disabled || !auth.CheckPassword(u.PasswordHash, password) { + 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) + if next == "" { + next = "/dashboard" + } + http.Redirect(w, r, next, http.StatusSeeOther) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + auth.ClearSessionCookie(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u.IsSuperadmin() { + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + blog, err := s.st.BlogByOwner(r.Context(), u.ID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + s.plainError(w, http.StatusNotFound, "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 = "Current password is wrong." + case len(pw) < 8: + msg = "New password must be at least 8 characters." + case pw != pw2: + msg = "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", "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 "" +} |
