Skip to content

Commit 6df91dd

Browse files
adnaanclaude
andcommitted
feat(auth): add pkg/cookie and pkg/flash utilities
Extract common auth patterns into reusable packages: pkg/cookie: - Set/SetSecure for setting cookies with secure defaults - Clear/ClearSecure for clearing cookies - ClearLiveTemplateSession for clearing LiveTemplate state - SetSession for session cookies with day-based expiry - Get for reading cookie values pkg/flash: - Set/Get for generic flash messages - Error/Success convenience methods - GetError/GetSuccess/GetAll for reading messages - Pending marker for one-time display tracking - RedirectWithError/RedirectWithSuccess helpers - Messages struct for template data pkg/token: - Added context constants (session, magic, reset, confirm) Updated handler.go.tmpl to use these packages, reducing generated code complexity and improving maintainability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fa25417 commit 6df91dd

6 files changed

Lines changed: 705 additions & 118 deletions

File tree

internal/kits/system/multi/templates/auth/handler.go.tmpl

Lines changed: 33 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,21 @@ package auth
3333

3434
import (
3535
"context"
36-
"crypto/rand"
3736
"database/sql"
38-
"encoding/base64"
3937
"fmt"
4038
"log"
4139
"net/http"
42-
"net/url"
4340
"os"
4441
"time"
4542

4643
"{{.ModuleName}}/database/models"
44+
"github.com/livetemplate/lvt/pkg/cookie"
4745
"github.com/livetemplate/lvt/pkg/email"
46+
"github.com/livetemplate/lvt/pkg/flash"
4847
{{- if .EnablePassword }}
4948
"github.com/livetemplate/lvt/pkg/password"
5049
{{- end }}
50+
"github.com/livetemplate/lvt/pkg/token"
5151
"github.com/go-playground/validator/v10"
5252
"github.com/google/uuid"
5353
"github.com/livetemplate/livetemplate"
@@ -630,58 +630,32 @@ func (c *{{.StructName}}Controller) HandlePasswordLogin(w http.ResponseWriter, r
630630
}
631631

632632
// Create session token
633-
token, err := c.generateToken(user.ID, "session", 30*24*time.Hour)
633+
tok, err := c.generateToken(user.ID, "session", 30*24*time.Hour)
634634
if err != nil {
635635
log.Printf("Generate session token error: %v", err)
636636
http.Redirect(w, r, "/auth?error=login_failed", http.StatusSeeOther)
637637
return
638638
}
639639

640-
// Set session cookie
641-
http.SetCookie(w, &http.Cookie{
642-
Name: "{{.TableName}}_token",
643-
Value: token,
644-
Path: "/",
645-
MaxAge: 30 * 24 * 60 * 60,
646-
HttpOnly: true,
647-
Secure: false, // Set to true in production with HTTPS
648-
SameSite: http.SameSiteLaxMode,
649-
})
640+
// Set session cookie (30 days)
641+
cookie.SetSession(w, "{{.TableName}}_token", tok, 30)
650642

651643
// Redirect to home
652644
http.Redirect(w, r, "/", http.StatusSeeOther)
653645
}
654646

655647
// HandleLogout handles user logout (HTTP-only, no LiveTemplate)
656648
func (c *{{.StructName}}Controller) HandleLogout(w http.ResponseWriter, r *http.Request) {
657-
// Get session token from cookie
658-
cookie, err := r.Cookie("{{.TableName}}_token")
659-
if err == nil {
660-
// Delete the token from database
661-
c.queries.Delete{{.StructName}}Token(context.Background(), cookie.Value)
649+
// Get session token from cookie and delete from database
650+
if tok := cookie.Get(r, "{{.TableName}}_token"); tok != "" {
651+
c.queries.Delete{{.StructName}}Token(context.Background(), tok)
662652
}
663653

664654
// Clear auth cookie
665-
http.SetCookie(w, &http.Cookie{
666-
Name: "{{.TableName}}_token",
667-
Value: "",
668-
Path: "/",
669-
MaxAge: -1,
670-
HttpOnly: true,
671-
Secure: true,
672-
SameSite: http.SameSiteStrictMode,
673-
})
655+
cookie.ClearSecure(w, "{{.TableName}}_token")
674656

675657
// Clear LiveTemplate session cookie to force fresh state on home page
676-
// This ensures the home page gets a new session with IsLoggedIn=false
677-
http.SetCookie(w, &http.Cookie{
678-
Name: "livetemplate-id",
679-
Value: "",
680-
Path: "/",
681-
MaxAge: -1,
682-
HttpOnly: true,
683-
SameSite: http.SameSiteLaxMode,
684-
})
658+
cookie.ClearLiveTemplateSession(w)
685659

686660
// Redirect to home
687661
http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -690,21 +664,20 @@ func (c *{{.StructName}}Controller) HandleLogout(w http.ResponseWriter, r *http.
690664
// generateToken creates a random token and stores it
691665
func (c *{{.StructName}}Controller) generateToken(userID, tokenContext string, duration time.Duration) (string, error) {
692666
// Generate random token
693-
b := make([]byte, 32)
694-
if _, err := rand.Read(b); err != nil {
667+
tok, err := token.Generate()
668+
if err != nil {
695669
return "", err
696670
}
697-
token := base64.URLEncoding.EncodeToString(b)
698671

699672
// Store token
700673
tokenID := uuid.New().String()
701674
now := time.Now()
702675
expiresAt := sql.NullTime{Time: now.Add(duration), Valid: true}
703676

704-
_, err := c.queries.Create{{.StructName}}Token(context.Background(), models.Create{{.StructName}}TokenParams{
677+
_, err = c.queries.Create{{.StructName}}Token(context.Background(), models.Create{{.StructName}}TokenParams{
705678
ID: tokenID,
706679
{{.StructName}}ID: userID,
707-
Token: token,
680+
Token: tok,
708681
Context: tokenContext,
709682
CreatedAt: now,
710683
ExpiresAt: expiresAt,
@@ -713,18 +686,18 @@ func (c *{{.StructName}}Controller) generateToken(userID, tokenContext string, d
713686
return "", err
714687
}
715688

716-
return token, nil
689+
return tok, nil
717690
}
718691

719692
// GetCurrentUser returns the authenticated user or nil
720693
func (c *{{.StructName}}Controller) GetCurrentUser(r *http.Request) (*models.{{.StructName}}, error) {
721-
cookie, err := r.Cookie("{{.TableName}}_token")
722-
if err != nil {
723-
return nil, err
694+
tok := cookie.Get(r, "{{.TableName}}_token")
695+
if tok == "" {
696+
return nil, http.ErrNoCookie
724697
}
725698

726699
userToken, err := c.queries.Get{{.StructName}}Token(context.Background(), models.Get{{.StructName}}TokenParams{
727-
Token: cookie.Value,
700+
Token: tok,
728701
ExpiresAt: sql.NullTime{Time: time.Now(), Valid: true},
729702
})
730703
if err != nil {
@@ -771,46 +744,22 @@ func Handler(queries *models.Queries) http.Handler {
771744

772745
if errCode != "" || successCode != "" {
773746
// Clear livetemplate-id to force new session on next request
774-
// This ensures the flash message will be properly displayed
775-
http.SetCookie(w, &http.Cookie{
776-
Name: "livetemplate-id",
777-
Value: "",
778-
Path: "/",
779-
MaxAge: -1,
780-
})
781-
782-
// Set flash message in cookie (URL-encoded to avoid quoting issues)
747+
cookie.ClearLiveTemplateSession(w)
748+
749+
// Set flash message from error/success code
783750
if errCode != "" {
784751
if msg, ok := errorMessages[errCode]; ok {
785-
http.SetCookie(w, &http.Cookie{
786-
Name: "flash_error",
787-
Value: url.QueryEscape(msg),
788-
Path: "/",
789-
MaxAge: 10, // Short-lived
790-
HttpOnly: true,
791-
})
752+
flash.Error(w, msg)
792753
}
793754
}
794755
if successCode != "" {
795756
if msg, ok := successMessages[successCode]; ok {
796-
http.SetCookie(w, &http.Cookie{
797-
Name: "flash_success",
798-
Value: url.QueryEscape(msg),
799-
Path: "/",
800-
MaxAge: 10,
801-
HttpOnly: true,
802-
})
757+
flash.Success(w, msg)
803758
}
804759
}
805760

806761
// Set marker so we know flash was triggered (for one-time display)
807-
http.SetCookie(w, &http.Cookie{
808-
Name: "flash_pending",
809-
Value: "1",
810-
Path: "/",
811-
MaxAge: 10,
812-
HttpOnly: true,
813-
})
762+
flash.SetPending(w)
814763

815764
// Redirect to clean URL
816765
http.Redirect(w, r, "/auth", http.StatusSeeOther)
@@ -826,60 +775,26 @@ func Handler(queries *models.Queries) http.Handler {
826775

827776
// Only process flash logic for GET requests (not HEAD/WebSocket preflight)
828777
if r.Method == "GET" {
829-
// Check if this is a fresh flash display or a reload
830-
_, errFlashPending := r.Cookie("flash_pending")
831-
_, errSession := r.Cookie("livetemplate-id")
832-
833-
hasFlashPending := errFlashPending == nil // true if cookie exists
834-
hasSession := errSession == nil // true if cookie exists
778+
hasFlashPending := flash.IsPending(r)
779+
hasSession := cookie.Get(r, "livetemplate-id") != ""
835780

836781
// If we have a session but no flash_pending marker, this is a reload
837782
// Force new session to clear any stale flash from previous session
838783
if hasSession && !hasFlashPending {
839-
http.SetCookie(w, &http.Cookie{
840-
Name: "livetemplate-id",
841-
Value: "",
842-
Path: "/",
843-
MaxAge: -1,
844-
})
784+
cookie.ClearLiveTemplateSession(w)
845785
}
846786

847787
// Clear the flash_pending marker after first use
848788
if hasFlashPending {
849-
http.SetCookie(w, &http.Cookie{
850-
Name: "flash_pending",
851-
Value: "",
852-
Path: "/",
853-
MaxAge: -1,
854-
})
789+
flash.ClearPending(w)
855790
}
856791
}
857792

858793
// Read and clear flash cookies, set in initial state
859794
state := New{{.StructName}}State()
860-
861-
if cookie, err := r.Cookie("flash_error"); err == nil && cookie.Value != "" {
862-
if msg, err := url.QueryUnescape(cookie.Value); err == nil {
863-
state.FlashError = msg
864-
}
865-
http.SetCookie(w, &http.Cookie{
866-
Name: "flash_error",
867-
Value: "",
868-
Path: "/",
869-
MaxAge: -1,
870-
})
871-
}
872-
if cookie, err := r.Cookie("flash_success"); err == nil && cookie.Value != "" {
873-
if msg, err := url.QueryUnescape(cookie.Value); err == nil {
874-
state.FlashSuccess = msg
875-
}
876-
http.SetCookie(w, &http.Cookie{
877-
Name: "flash_success",
878-
Value: "",
879-
Path: "/",
880-
MaxAge: -1,
881-
})
882-
}
795+
msgs := flash.GetAll(r, w)
796+
state.FlashError = msgs.Error
797+
state.FlashSuccess = msgs.Success
883798

884799
tmpl.Handle(controller, livetemplate.AsState(state)).ServeHTTP(w, r)
885800
})

pkg/cookie/cookie.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Package cookie provides utilities for setting and clearing HTTP cookies
2+
// with secure defaults appropriate for authentication and session management.
3+
package cookie
4+
5+
import "net/http"
6+
7+
// Set sets a cookie with secure defaults.
8+
// Uses HttpOnly and SameSite=Lax for security.
9+
//
10+
// Example:
11+
//
12+
// cookie.Set(w, "preference", "dark", 86400) // 1 day
13+
func Set(w http.ResponseWriter, name, value string, maxAge int) {
14+
http.SetCookie(w, &http.Cookie{
15+
Name: name,
16+
Value: value,
17+
Path: "/",
18+
MaxAge: maxAge,
19+
HttpOnly: true,
20+
SameSite: http.SameSiteLaxMode,
21+
})
22+
}
23+
24+
// SetSecure sets a cookie with strict security settings.
25+
// Uses HttpOnly, Secure, and SameSite=Strict.
26+
// Use this for sensitive cookies like session tokens.
27+
//
28+
// Example:
29+
//
30+
// cookie.SetSecure(w, "session", token, 30*24*60*60) // 30 days
31+
func SetSecure(w http.ResponseWriter, name, value string, maxAge int) {
32+
http.SetCookie(w, &http.Cookie{
33+
Name: name,
34+
Value: value,
35+
Path: "/",
36+
MaxAge: maxAge,
37+
HttpOnly: true,
38+
Secure: true,
39+
SameSite: http.SameSiteStrictMode,
40+
})
41+
}
42+
43+
// Clear clears a cookie by setting MaxAge to -1.
44+
//
45+
// Example:
46+
//
47+
// cookie.Clear(w, "session")
48+
func Clear(w http.ResponseWriter, name string) {
49+
http.SetCookie(w, &http.Cookie{
50+
Name: name,
51+
Value: "",
52+
Path: "/",
53+
MaxAge: -1,
54+
HttpOnly: true,
55+
SameSite: http.SameSiteLaxMode,
56+
})
57+
}
58+
59+
// ClearSecure clears a secure cookie by setting MaxAge to -1.
60+
// Matches the attributes used by SetSecure.
61+
//
62+
// Example:
63+
//
64+
// cookie.ClearSecure(w, "session")
65+
func ClearSecure(w http.ResponseWriter, name string) {
66+
http.SetCookie(w, &http.Cookie{
67+
Name: name,
68+
Value: "",
69+
Path: "/",
70+
MaxAge: -1,
71+
HttpOnly: true,
72+
Secure: true,
73+
SameSite: http.SameSiteStrictMode,
74+
})
75+
}
76+
77+
// ClearLiveTemplateSession clears the LiveTemplate session cookie.
78+
// This forces a fresh session state on the next page load, which is
79+
// necessary after logout to ensure the home page shows the correct
80+
// logged-out state.
81+
//
82+
// Example:
83+
//
84+
// // In logout handler
85+
// cookie.Clear(w, "users_token")
86+
// cookie.ClearLiveTemplateSession(w)
87+
// http.Redirect(w, r, "/", http.StatusSeeOther)
88+
func ClearLiveTemplateSession(w http.ResponseWriter) {
89+
http.SetCookie(w, &http.Cookie{
90+
Name: "livetemplate-id",
91+
Value: "",
92+
Path: "/",
93+
MaxAge: -1,
94+
HttpOnly: true,
95+
SameSite: http.SameSiteLaxMode,
96+
})
97+
}
98+
99+
// SetSession sets a session cookie with appropriate security settings.
100+
// The maxAgeDays parameter specifies how long the session should last.
101+
//
102+
// Example:
103+
//
104+
// cookie.SetSession(w, "users_token", token, 30) // 30 days
105+
func SetSession(w http.ResponseWriter, name, value string, maxAgeDays int) {
106+
http.SetCookie(w, &http.Cookie{
107+
Name: name,
108+
Value: value,
109+
Path: "/",
110+
MaxAge: maxAgeDays * 24 * 60 * 60,
111+
HttpOnly: true,
112+
SameSite: http.SameSiteLaxMode,
113+
})
114+
}
115+
116+
// Get retrieves a cookie value by name.
117+
// Returns empty string if cookie doesn't exist.
118+
//
119+
// Example:
120+
//
121+
// token := cookie.Get(r, "session")
122+
// if token == "" {
123+
// // Not logged in
124+
// }
125+
func Get(r *http.Request, name string) string {
126+
c, err := r.Cookie(name)
127+
if err != nil {
128+
return ""
129+
}
130+
return c.Value
131+
}

0 commit comments

Comments
 (0)