package auth import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "net/http" "strconv" "strings" "time" "golang.org/x/crypto/bcrypt" ) const ( AdminCookie = "artifacto_admin" adminLifetime = 30 * 24 * time.Hour ) type ctxKey int const ctxAdmin ctxKey = 1 type Admin struct { passwordHash []byte secret []byte secure bool } func NewAdmin(password, secretHex string, secure bool) (*Admin, error) { if password == "" { return nil, errors.New("ADMIN_PASSWORD required") } if len(secretHex) < 32 { return nil, errors.New("SESSION_SECRET must be >= 32 chars") } h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } return &Admin{passwordHash: h, secret: []byte(secretHex), secure: secure}, nil } func (a *Admin) VerifyPassword(p string) bool { return bcrypt.CompareHashAndPassword(a.passwordHash, []byte(p)) == nil } // Issue returns a signed cookie value "issued_at.sig". func (a *Admin) Issue() string { issued := strconv.FormatInt(time.Now().Unix(), 10) return issued + "." + a.sign("admin|"+issued) } func (a *Admin) Verify(raw string) bool { parts := strings.SplitN(raw, ".", 2) if len(parts) != 2 { return false } issued, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { return false } if time.Since(time.Unix(issued, 0)) > adminLifetime { return false } expected := a.sign("admin|" + parts[0]) return hmac.Equal([]byte(expected), []byte(parts[1])) } func (a *Admin) sign(s string) string { m := hmac.New(sha256.New, a.secret) m.Write([]byte(s)) return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) } func (a *Admin) SetCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: AdminCookie, Value: a.Issue(), Path: "/", HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode, Expires: time.Now().Add(adminLifetime), }) } func (a *Admin) ClearCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: AdminCookie, Value: "", Path: "/", HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) } // Middleware allows the request through if the admin cookie is valid; otherwise // redirects to /login (for HTML nav) or returns 401 (for API/HTMX). func (a *Admin) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie(AdminCookie) if err != nil || !a.Verify(c.Value) { if isAPI(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } http.Redirect(w, r, "/login?next="+r.URL.RequestURI(), http.StatusSeeOther) return } ctx := context.WithValue(r.Context(), ctxAdmin, true) next.ServeHTTP(w, r.WithContext(ctx)) }) } func isAPI(r *http.Request) bool { if strings.HasPrefix(r.URL.Path, "/api/") { return true } if r.Header.Get("HX-Request") == "true" { return true } return false } func IsAdmin(ctx context.Context) bool { v, _ := ctx.Value(ctxAdmin).(bool) return v }