Phase 1.2: session login + empty HTMX dashboard
- Session login for the single user Sam: server-side session store with an HMAC-signed session cookie, POST /login, POST /logout, and double-submit CSRF on every form post. Unauthenticated requests to / and /partials/* redirect to /login?next=... - Empty dashboard shell in the Family Home Lab design system: left nav (category and folder placeholders), centre list, right detail pane. - Copy tokens.css and the self-hosted Inter fonts from the family-home-lab design system into web/static; vendor htmx 2.0.4. No cross-project imports at runtime. - Dark mode via prefers-color-scheme plus a persisted toggle; phone-first layout with 44px touch targets, hairline borders, 8px grid, pill CTAs. - Config: EMAIL_AGENT_PASSWORD for the login password, falling back to EMAIL_AGENT_SESSION_SECRET so one configured secret is enough. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
284
internal/server/auth.go
Normal file
284
internal/server/auth.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
// csrfCookieName holds the double-submit CSRF token. It is independent of
|
||||
// the session so the login form (which has no session yet) is protected too.
|
||||
csrfCookieName = "email_agent_csrf"
|
||||
sessionTTL = session.DefaultTTL
|
||||
)
|
||||
|
||||
// ctxKey is the private context key type for request-scoped values.
|
||||
type ctxKey int
|
||||
|
||||
const sessionCtxKey ctxKey = iota
|
||||
|
||||
// requireAuth redirects unauthenticated requests to the login screen and
|
||||
// otherwise puts the session on the request context.
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, ok := s.currentSession(r)
|
||||
if !ok {
|
||||
target := "/login?next=" + url.QueryEscape(r.URL.RequestURI())
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), sessionCtxKey, sess)))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) currentSession(r *http.Request) (session.Session, bool) {
|
||||
c, err := r.Cookie(session.CookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return session.Session{}, false
|
||||
}
|
||||
return s.sessions.Lookup(c.Value)
|
||||
}
|
||||
|
||||
func sessionFrom(r *http.Request) session.Session {
|
||||
sess, _ := r.Context().Value(sessionCtxKey).(session.Session)
|
||||
return sess
|
||||
}
|
||||
|
||||
// loginSecret is the password the single user must supply. EMAIL_AGENT_PASSWORD
|
||||
// wins; otherwise the session secret doubles as the password so a single
|
||||
// configured secret is enough.
|
||||
func (s *Server) loginSecret() string {
|
||||
if s.cfg.Password != "" {
|
||||
return s.cfg.Password
|
||||
}
|
||||
return s.cfg.SessionSecret
|
||||
}
|
||||
|
||||
// csrfToken returns the current double-submit token, minting one when absent.
|
||||
func (s *Server) csrfToken(w http.ResponseWriter, r *http.Request) string {
|
||||
if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
}
|
||||
tok, err := session.RandomToken(32)
|
||||
if err != nil {
|
||||
// crypto/rand failing is fatal for security; surface it rather than
|
||||
// issuing an unprotected form.
|
||||
s.logger.Error("csrf token generation failed", "error", err)
|
||||
return ""
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: tok,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.cfg.IsProduction(),
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
return tok
|
||||
}
|
||||
|
||||
// csrfOK verifies the double-submit token from the form or the X-CSRF-Token
|
||||
// header.
|
||||
func (s *Server) csrfOK(r *http.Request) bool {
|
||||
c, err := r.Cookie(csrfCookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return false
|
||||
}
|
||||
tok := r.FormValue("csrf")
|
||||
if tok == "" {
|
||||
tok = r.Header.Get("X-CSRF-Token")
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(tok), []byte(c.Value)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) setSessionCookie(w http.ResponseWriter, value string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: session.CookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.cfg.IsProduction(),
|
||||
Expires: expires,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: session.CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.cfg.IsProduction(),
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
type loginData struct {
|
||||
PartOfDay string
|
||||
Error string
|
||||
Next string
|
||||
CSRFToken string
|
||||
Configured bool
|
||||
}
|
||||
|
||||
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, code int, errMsg, next string) {
|
||||
// Mint the CSRF cookie before the status line, or Set-Cookie is dropped.
|
||||
csrf := s.csrfToken(w, r)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
data := loginData{
|
||||
PartOfDay: partOfDay(time.Now()),
|
||||
Error: errMsg,
|
||||
Next: next,
|
||||
CSRFToken: csrf,
|
||||
Configured: s.loginSecret() != "",
|
||||
}
|
||||
if err := s.tmpl.ExecuteTemplate(w, "login.html", data); err != nil {
|
||||
s.logger.Error("render login", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLoginGet renders the sign-in form.
|
||||
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.currentSession(r); ok {
|
||||
http.Redirect(w, r, cleanNext(r.URL.Query().Get("next")), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderLogin(w, r, http.StatusOK, "", cleanNext(r.URL.Query().Get("next")))
|
||||
}
|
||||
|
||||
// handleLoginPost validates the CSRF token and password, then starts a session.
|
||||
// POST (not GET) means the password is never placed in the URL or browser
|
||||
// history.
|
||||
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderLogin(w, r, http.StatusBadRequest, "Could not read the sign-in form.", "/")
|
||||
return
|
||||
}
|
||||
next := cleanNext(r.FormValue("next"))
|
||||
|
||||
if !s.csrfOK(r) {
|
||||
s.renderLogin(w, r, http.StatusForbidden, "Your sign-in page expired. Please try again.", next)
|
||||
return
|
||||
}
|
||||
|
||||
secret := s.loginSecret()
|
||||
if secret == "" {
|
||||
s.renderLogin(w, r, http.StatusServiceUnavailable, "Login is not configured yet.", next)
|
||||
return
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(r.FormValue("password")), []byte(secret)) != 1 {
|
||||
s.logger.Warn("login failed", "remote", r.RemoteAddr)
|
||||
s.renderLogin(w, r, http.StatusUnauthorized, "Incorrect password.", next)
|
||||
return
|
||||
}
|
||||
|
||||
sess, cookie, err := s.sessions.New("Sam")
|
||||
if err != nil {
|
||||
s.logger.Error("create session", "error", err)
|
||||
http.Error(w, "could not start a session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, cookie, sess.ExpiresAt)
|
||||
s.logger.Info("login ok", "user", sess.User, "remote", r.RemoteAddr)
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleLogout destroys the session and returns to the login screen.
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if !s.csrfOK(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if c, err := r.Cookie(session.CookieName); err == nil && c.Value != "" {
|
||||
s.sessions.Destroy(c.Value)
|
||||
}
|
||||
s.clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type navItem struct {
|
||||
Name string
|
||||
Color string
|
||||
}
|
||||
|
||||
type dashboardData struct {
|
||||
Greeting string
|
||||
User string
|
||||
CSRFToken string
|
||||
Version string
|
||||
Categories []navItem
|
||||
Folders []navItem
|
||||
}
|
||||
|
||||
// handleDashboard renders the empty inbox shell.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFrom(r)
|
||||
data := dashboardData{
|
||||
Greeting: partOfDay(time.Now()),
|
||||
User: sess.User,
|
||||
CSRFToken: s.csrfToken(w, r),
|
||||
Version: version.Version,
|
||||
// Placeholders until FR-14 (editable categories) and FR-9 (folders)
|
||||
// land. Colours are the design system's decorative sticker palette.
|
||||
Categories: []navItem{
|
||||
{Name: "Action", Color: "var(--primary)"},
|
||||
{Name: "Important", Color: "var(--sticker-orange)"},
|
||||
{Name: "Info", Color: "var(--sticker-sky)"},
|
||||
{Name: "Spam", Color: "var(--sticker-red)"},
|
||||
{Name: "Newsletter", Color: "var(--sticker-purple)"},
|
||||
{Name: "Auto-reply", Color: "var(--sticker-teal)"},
|
||||
},
|
||||
Folders: []navItem{
|
||||
{Name: "Replied-to", Color: "var(--sticker-green)"},
|
||||
{Name: "Delete", Color: "var(--sticker-red)"},
|
||||
{Name: "Saved", Color: "var(--sticker-teal)"},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {
|
||||
s.logger.Error("render dashboard", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleInboxPartial is the HTMX fragment for the centre list. Phase 1.2 has no
|
||||
// mail yet, so it renders the empty state.
|
||||
func (s *Server) handleInboxPartial(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "inbox.html", nil); err != nil {
|
||||
s.logger.Error("render inbox partial", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanNext only allows same-site, absolute-path redirects.
|
||||
func cleanNext(next string) string {
|
||||
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
|
||||
return "/"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// partOfDay returns the time-aware greeting prefix.
|
||||
func partOfDay(t time.Time) string {
|
||||
switch h := t.Hour(); {
|
||||
case h < 12:
|
||||
return "Good morning"
|
||||
case h < 18:
|
||||
return "Good afternoon"
|
||||
default:
|
||||
return "Good evening"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user