Files
paste/internal/handler/auth.go
Blake Ridgway 6915cab5f3 first commit
2026-04-11 14:01:09 -05:00

121 lines
2.9 KiB
Go

package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const sessionCookie = "rs_paste_session"
const sessionDuration = 24 * time.Hour
// sessionSecret returns the HMAC signing key from env.
func sessionSecret() []byte {
s := os.Getenv("SESSION_SECRET")
if s == "" {
return []byte("change-me-in-production")
}
return []byte(s)
}
func adminPasswordHash() string {
return os.Getenv("ADMIN_PASSWORD_HASH")
}
func checkPassword(password string) bool {
hash := adminPasswordHash()
if hash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// signValue creates an HMAC-signed value: base64(payload)|base64(sig).
func signValue(payload string) string {
mac := hmac.New(sha256.New, sessionSecret())
mac.Write([]byte(payload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "|" + sig
}
// verifyValue checks the signature and returns the original payload.
func verifyValue(signed string) (string, bool) {
parts := strings.SplitN(signed, "|", 2)
if len(parts) != 2 {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
payload := string(payloadBytes)
expected := signValue(payload)
if !hmac.Equal([]byte(signed), []byte(expected)) {
return "", false
}
return payload, true
}
// setSession writes a signed session cookie valid across the whole site.
func setSession(w http.ResponseWriter) {
expiry := time.Now().Add(sessionDuration).Format(time.RFC3339)
value := signValue("admin|" + expiry)
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: value,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionDuration.Seconds()),
})
}
// clearSession deletes the session cookie.
func clearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
// isAuthenticated returns true if the request carries a valid session cookie.
func isAuthenticated(r *http.Request) bool {
c, err := r.Cookie(sessionCookie)
if err != nil {
return false
}
payload, ok := verifyValue(c.Value)
if !ok {
return false
}
parts := strings.SplitN(payload, "|", 2)
if len(parts) != 2 || parts[0] != "admin" {
return false
}
expiry, err := time.Parse(time.RFC3339, parts[1])
if err != nil {
return false
}
return time.Now().Before(expiry)
}
// RequireAuth is middleware that redirects to /admin/login if not authenticated.
func (h *Handler) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
next(w, r)
}
}