d9de37d3d8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
134 lines
3.4 KiB
Go
134 lines
3.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const ctxUser contextKey = "user"
|
|
|
|
// User is the authenticated user stored in request context.
|
|
type User struct {
|
|
ID int64
|
|
Name string
|
|
}
|
|
|
|
// SQLiteStore implements scs.Store using a modernc.org/sqlite connection.
|
|
type SQLiteStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
|
|
|
|
func (s *SQLiteStore) Delete(token string) error {
|
|
_, err := s.db.Exec("DELETE FROM sessions WHERE token = ?", token)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteStore) Find(token string) ([]byte, bool, error) {
|
|
var data []byte
|
|
err := s.db.QueryRow(
|
|
"SELECT data FROM sessions WHERE token = ? AND expiry > ?",
|
|
token, time.Now().Unix(),
|
|
).Scan(&data)
|
|
if err == sql.ErrNoRows {
|
|
return nil, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return data, true, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) Commit(token string, b []byte, expiry time.Time) error {
|
|
_, err := s.db.Exec(
|
|
"INSERT OR REPLACE INTO sessions (token, data, expiry) VALUES (?, ?, ?)",
|
|
token, b, expiry.Unix(),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Manager wraps scs.SessionManager with application-level helpers.
|
|
type Manager struct {
|
|
SM *scs.SessionManager
|
|
}
|
|
|
|
func NewManager(db *sql.DB) *Manager {
|
|
sm := scs.New()
|
|
sm.Store = NewStore(db)
|
|
sm.Lifetime = 30 * 24 * time.Hour
|
|
sm.Cookie.HttpOnly = true
|
|
sm.Cookie.SameSite = http.SameSiteLaxMode
|
|
return &Manager{SM: sm}
|
|
}
|
|
|
|
// SetUser persists the authenticated user in the session.
|
|
func (m *Manager) SetUser(r *http.Request, id int64, name string) {
|
|
m.SM.Put(r.Context(), "userID", id)
|
|
m.SM.Put(r.Context(), "userName", name)
|
|
}
|
|
|
|
// ClearUser destroys the session (used on logout).
|
|
func (m *Manager) ClearUser(r *http.Request) error {
|
|
return m.SM.Destroy(r.Context())
|
|
}
|
|
|
|
// UserFromCtx returns the authenticated user from context, or nil.
|
|
func UserFromCtx(ctx context.Context) *User {
|
|
u, _ := ctx.Value(ctxUser).(*User)
|
|
return u
|
|
}
|
|
|
|
// RequireAuth redirects unauthenticated requests to /login.
|
|
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := m.SM.GetInt64(r.Context(), "userID")
|
|
if id == 0 {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
name := m.SM.GetString(r.Context(), "userName")
|
|
ctx := context.WithValue(r.Context(), ctxUser, &User{ID: id, Name: name})
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// CSRFToken returns the session's CSRF token, creating one if needed.
|
|
func (m *Manager) CSRFToken(r *http.Request) string {
|
|
tok := m.SM.GetString(r.Context(), "csrf")
|
|
if tok != "" {
|
|
return tok
|
|
}
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
tok = hex.EncodeToString(b)
|
|
m.SM.Put(r.Context(), "csrf", tok)
|
|
return tok
|
|
}
|
|
|
|
// CheckCSRF returns true if the form's csrf_token matches the session token.
|
|
func (m *Manager) CheckCSRF(r *http.Request) bool {
|
|
session := m.SM.GetString(r.Context(), "csrf")
|
|
form := r.FormValue("csrf_token")
|
|
return session != "" && subtle.ConstantTimeCompare([]byte(session), []byte(form)) == 1
|
|
}
|
|
|
|
// SetFlash stores a one-time flash message in the session.
|
|
func (m *Manager) SetFlash(r *http.Request, msg string) {
|
|
m.SM.Put(r.Context(), "flash", msg)
|
|
}
|
|
|
|
// PopFlash reads and removes the flash message.
|
|
func (m *Manager) PopFlash(r *http.Request) string {
|
|
return m.SM.PopString(r.Context(), "flash")
|
|
}
|