spread

https://git.tonybtw.com/spread.git git://git.tonybtw.com/spread.git
3,554 bytes raw
1
package app
2
3
import (
4
	"context"
5
	"crypto/rand"
6
	"crypto/sha256"
7
	"encoding/base64"
8
	"encoding/hex"
9
	"net/http"
10
	"time"
11
)
12
13
const (
14
	cookieName = "spread_session"
15
	sessionTTL = 30 * 24 * time.Hour
16
)
17
18
type ctxKey int
19
20
const (
21
	userKey ctxKey = iota
22
	subKey
23
	csrfKey
24
)
25
26
func hashToken(raw string) string {
27
	sum := sha256.Sum256([]byte(raw))
28
	return hex.EncodeToString(sum[:])
29
}
30
31
// randomToken returns a 256-bit URL-safe random string.
32
func randomToken() string {
33
	buf := make([]byte, 32)
34
	_, _ = rand.Read(buf)
35
	return base64.RawURLEncoding.EncodeToString(buf)
36
}
37
38
func (a *App) createSession(ctx context.Context, userID int64) (string, error) {
39
	raw := randomToken()
40
	_, err := a.pool.Exec(ctx,
41
		`INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`,
42
		hashToken(raw), userID, time.Now().Add(sessionTTL))
43
	if err != nil {
44
		return "", err
45
	}
46
	return raw, nil
47
}
48
49
func (a *App) lookupSession(ctx context.Context, raw string) (User, Subscriber, error) {
50
	var u User
51
	err := a.pool.QueryRow(ctx,
52
		`SELECT u.id, u.subscriber_id, u.email, u.name
53
		   FROM sessions s JOIN users u ON u.id = s.user_id
54
		  WHERE s.token_hash = $1 AND s.expires_at > now()`,
55
		hashToken(raw)).Scan(&u.ID, &u.SubscriberID, &u.Email, &u.Name)
56
	if err != nil {
57
		return User{}, Subscriber{}, err
58
	}
59
	sub, err := a.subscriber(ctx, u.SubscriberID)
60
	return u, sub, err
61
}
62
63
func (a *App) destroySession(ctx context.Context, raw string) {
64
	_, _ = a.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hashToken(raw))
65
}
66
67
func (a *App) setSessionCookie(w http.ResponseWriter, raw string) {
68
	http.SetCookie(w, &http.Cookie{
69
		Name:     cookieName,
70
		Value:    raw,
71
		Path:     "/",
72
		HttpOnly: true,
73
		Secure:   a.secureCookie,
74
		SameSite: http.SameSiteLaxMode,
75
		Expires:  time.Now().Add(sessionTTL),
76
	})
77
}
78
79
func (a *App) clearSessionCookie(w http.ResponseWriter) {
80
	http.SetCookie(w, &http.Cookie{
81
		Name:     cookieName,
82
		Value:    "",
83
		Path:     "/",
84
		HttpOnly: true,
85
		Secure:   a.secureCookie,
86
		SameSite: http.SameSiteLaxMode,
87
		MaxAge:   -1,
88
	})
89
}
90
91
// current returns the logged-in user and subscriber from the request context.
92
func current(r *http.Request) (User, Subscriber, bool) {
93
	u, ok := r.Context().Value(userKey).(User)
94
	if !ok {
95
		return User{}, Subscriber{}, false
96
	}
97
	s, _ := r.Context().Value(subKey).(Subscriber)
98
	return u, s, true
99
}
100
101
// load attaches the session user/subscriber to the request context if a valid
102
// session cookie is present. It never redirects.
103
func (a *App) load(next http.Handler) http.Handler {
104
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105
		if c, err := r.Cookie(cookieName); err == nil && c.Value != "" {
106
			if u, s, err := a.lookupSession(r.Context(), c.Value); err == nil {
107
				ctx := context.WithValue(r.Context(), userKey, u)
108
				ctx = context.WithValue(ctx, subKey, s)
109
				r = r.WithContext(ctx)
110
			}
111
		}
112
		next.ServeHTTP(w, r)
113
	})
114
}
115
116
// requireAuth redirects to /login when there is no session.
117
func (a *App) requireAuth(fn http.HandlerFunc) http.HandlerFunc {
118
	return func(w http.ResponseWriter, r *http.Request) {
119
		if _, _, ok := current(r); !ok {
120
			http.Redirect(w, r, "/login", http.StatusSeeOther)
121
			return
122
		}
123
		fn(w, r)
124
	}
125
}
126
127
// requireInternal requires a session belonging to the internal subscriber.
128
func (a *App) requireInternal(fn http.HandlerFunc) http.HandlerFunc {
129
	return a.requireAuth(func(w http.ResponseWriter, r *http.Request) {
130
		if _, s, _ := current(r); !s.IsInternal {
131
			http.Redirect(w, r, "/app", http.StatusSeeOther)
132
			return
133
		}
134
		fn(w, r)
135
	})
136
}