session.go
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "encoding/hex" |
| 7 | "net/http" |
| 8 | "time" |
| 9 | |
| 10 | "hearthforge/internal/db" |
| 11 | ) |
| 12 | |
| 13 | const ( |
| 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 | |
| 22 | type ctxKey int |
| 23 | |
| 24 | const userKey ctxKey = 0 |
| 25 | |
| 26 | // withSession resolves the session cookie and stores the user in the context. |
| 27 | func (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. |
| 44 | func 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. |
| 50 | func (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. |
| 61 | func (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. |
| 77 | func (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 | |
| 92 | func (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. |
| 105 | func (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. |
| 113 | func 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. |
| 119 | func 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. |
| 125 | func (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 |