session.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "net/http"
8 "time"
9
10 "hearthforge/internal/db"
11)
12
13const (
14 sessionCookie = "session"
15 sessionIDBytes = 32
16 sessionDuration = 30 * 24 * time.Hour
17 // reauthAfter is how old a session may be for changes that add a way
18 // into the account, so a stolen old cookie cannot make itself permanent.
19 reauthAfter = 15 * time.Minute
20)
21
22type ctxKey int
23
24const userKey ctxKey = 0
25
26// withSession resolves the session cookie and stores the user in the context.
27func (s *Server) withSession(next http.Handler) http.Handler {
28 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29 c, err := r.Cookie(sessionCookie)
30 if err != nil || c.Value == "" {
31 next.ServeHTTP(w, r)
32 return
33 }
34 u, err := s.DB.SessionUser(r.Context(), c.Value, db.NowISO())
35 if err != nil || u == nil {
36 next.ServeHTTP(w, r)
37 return
38 }
39 next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, u)))
40 })
41}
42
43// User returns the logged-in user or nil.
44func User(r *http.Request) *db.SessionUser {
45 u, _ := r.Context().Value(userKey).(*db.SessionUser)
46 return u
47}
48
49// requireAuth redirects anonymous requests to /login.
50func (s *Server) requireAuth(next http.Handler) http.Handler {
51 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52 if User(r) == nil {
53 http.Redirect(w, r, "/login", http.StatusFound)
54 return
55 }
56 next.ServeHTTP(w, r)
57 })
58}
59
60// requireAdmin redirects anonymous requests and rejects non-admins.
61func (s *Server) requireAdmin(next http.Handler) http.Handler {
62 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63 u := User(r)
64 if u == nil {
65 http.Redirect(w, r, "/login", http.StatusFound)
66 return
67 }
68 if !u.IsAdmin {
69 http.Error(w, "Forbidden", http.StatusForbidden)
70 return
71 }
72 next.ServeHTTP(w, r)
73 })
74}
75
76// newSession creates a session row and returns the cookie to set.
77func (s *Server) newSession(ctx context.Context, userID int64) (*http.Cookie, error) {
78 b := make([]byte, sessionIDBytes)
79 if _, err := rand.Read(b); err != nil {
80 return nil, err
81 }
82 id := hex.EncodeToString(b)
83 now := time.Now().UTC()
84 exp := now.Add(sessionDuration)
85 if err := s.DB.CreateSession(ctx, id, userID,
86 exp.Format(db.ISOLayout), now.Format(db.ISOLayout)); err != nil {
87 return nil, err
88 }
89 return s.sessionCookie(id, int(sessionDuration.Seconds())), nil
90}
91
92func (s *Server) sessionCookie(value string, maxAge int) *http.Cookie {
93 return &http.Cookie{
94 Name: sessionCookie,
95 Value: value,
96 Path: "/",
97 HttpOnly: true,
98 SameSite: http.SameSiteLaxMode,
99 Secure: s.Cfg.PublicHTTPS,
100 MaxAge: maxAge,
101 }
102}
103
104// clearSession deletes the session row and expires the cookie.
105func (s *Server) clearSession(w http.ResponseWriter, r *http.Request) {
106 if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
107 _ = s.DB.DeleteSession(r.Context(), c.Value)
108 }
109 http.SetCookie(w, s.sessionCookie("", -1))
110}
111
112// recentLogin reports whether the session signed in within reauthAfter.
113func recentLogin(u *db.SessionUser) bool {
114 t, err := time.Parse(db.ISOLayout, u.SessionCreatedAt)
115 return err == nil && time.Since(t) < reauthAfter
116}
117
118// reauthURL sends the user to sign in again and come back to next.
119func reauthURL(next string) string {
120 return "/login?next=" + queryEscape(next)
121}
122
123// dropOtherSessions signs the user out on every other device. Call it after
124// a change to the account's login methods.
125func (s *Server) dropOtherSessions(r *http.Request, userID int64) error {
126 keep := ""
127 if c, err := r.Cookie(sessionCookie); err == nil {
128 keep = c.Value
129 }
130 return s.DB.DeleteOtherSessions(r.Context(), userID, keep)
131}
132