aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/handlers_auth.go
diff options
context:
space:
mode:
authorgramanas <grm@eyesin.space>2026-09-12 11:24:17 +0300
committergramanas <grm@eyesin.space>2026-09-12 11:24:17 +0300
commit3eb04b1a2bdf9e53231fe862cfd76327371a9741 (patch)
treeb38b2d82a47233fd8e0bb18c59e4a8f3dd2412d7 /internal/web/handlers_auth.go
downloadblogspace-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.go131
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 ""
+}