base dashboard and login
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Middleware wraps an http.Handler.
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
|
||||
// Chain applies middlewares in order (first = outermost).
|
||||
func Chain(mws ...Middleware) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
for i := len(mws) - 1; i >= 0; i-- {
|
||||
next = mws[i](next)
|
||||
}
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Context keys
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
sessionContextKey contextKey = iota
|
||||
csrfContextKey
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Session constants
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
sessionCookieName = "cs_session"
|
||||
sessionTTL = 8 * time.Hour
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SessionAuth middleware
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// SessionAuth validates the session cookie on every request.
|
||||
// /login and /static/ are exempt — all other paths redirect to /login when unauthenticated.
|
||||
// On success, the CSRF token is stored in the request context.
|
||||
func SessionAuth(secret string) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/login" || strings.HasPrefix(r.URL.Path, "/static/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
username, ok := validateSessionValue(secret, cookie.Value)
|
||||
if !ok {
|
||||
http.SetCookie(w, expiredCookie())
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
csrf := deriveCSRF(secret, cookie.Value)
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey, username)
|
||||
ctx = context.WithValue(ctx, csrfContextKey, csrf)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cookie helpers (exported for use in auth handler)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// NewSessionCookie returns a signed, HttpOnly session cookie.
|
||||
func NewSessionCookie(secret, username string) *http.Cookie {
|
||||
exp := time.Now().Add(sessionTTL).Unix()
|
||||
payload := fmt.Sprintf("%d.%s", exp, username)
|
||||
mac := computeHMAC(secret, payload)
|
||||
return &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: payload + "." + mac,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
}
|
||||
}
|
||||
|
||||
// ClearSessionCookie returns an expired cookie that clears the session.
|
||||
func ClearSessionCookie() *http.Cookie {
|
||||
return expiredCookie()
|
||||
}
|
||||
|
||||
func expiredCookie() *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Context accessors (exported for handlers)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// CSRFFromContext returns the CSRF token stored by SessionAuth, or "".
|
||||
func CSRFFromContext(r *http.Request) string {
|
||||
v, _ := r.Context().Value(csrfContextKey).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// validateSessionValue parses and verifies a session cookie value.
|
||||
// Format: "<exp_unix>.<username>.<hmac-hex>"
|
||||
// HMAC covers "<exp_unix>.<username>".
|
||||
func validateSessionValue(secret, value string) (username string, ok bool) {
|
||||
lastDot := strings.LastIndex(value, ".")
|
||||
if lastDot < 0 {
|
||||
return "", false
|
||||
}
|
||||
payload := value[:lastDot]
|
||||
mac := value[lastDot+1:]
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(mac), []byte(computeHMAC(secret, payload))) != 1 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
firstDot := strings.Index(payload, ".")
|
||||
if firstDot < 0 {
|
||||
return "", false
|
||||
}
|
||||
expStr := payload[:firstDot]
|
||||
username = payload[firstDot+1:]
|
||||
|
||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||
if err != nil || time.Now().Unix() > exp || username == "" {
|
||||
return "", false
|
||||
}
|
||||
return username, true
|
||||
}
|
||||
|
||||
func deriveCSRF(secret, sessionValue string) string {
|
||||
h := computeHMAC(secret, "csrf:"+sessionValue)
|
||||
if len(h) > 32 {
|
||||
return h[:32]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func computeHMAC(secret, data string) string {
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write([]byte(data))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Logger
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func Logger() Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rw, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.status, time.Since(start))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SecureHeaders
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func SecureHeaders() Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("X-XSS-Protection", "1; mode=block")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; "+
|
||||
"style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; "+
|
||||
"font-src 'self' https://fonts.gstatic.com; "+
|
||||
"img-src 'self' data:; "+
|
||||
"connect-src 'self'; "+
|
||||
"frame-ancestors 'none'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Recovery
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func Recovery() Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("panic: %v\n%s", rec, debug.Stack())
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// responseWriter — captures status code for logging
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
written bool
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
if !rw.written {
|
||||
rw.status = code
|
||||
rw.written = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
if !rw.written {
|
||||
rw.written = true
|
||||
}
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
Reference in New Issue
Block a user