package app import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "net/http" "strconv" "strings" "time" ) const ( adminSessionCookieName = "sub2api_crm_admin_session" defaultAdminUsername = "admin" defaultAdminSessionTTL = 12 * time.Hour ) type AdminAuthConfig struct { Token string Username string Password string SessionTTL time.Duration Now func() time.Time } type adminLoginRequest struct { Username string `json:"username"` Password string `json:"password"` } type adminSessionInfo struct { Username string ExpiresAt time.Time } func (c AdminAuthConfig) normalized() AdminAuthConfig { c.Token = strings.TrimSpace(c.Token) c.Username = strings.TrimSpace(c.Username) c.Password = strings.TrimSpace(c.Password) if c.Username == "" { c.Username = defaultAdminUsername } if c.Password == "" { c.Password = c.Token } if c.SessionTTL <= 0 { c.SessionTTL = defaultAdminSessionTTL } if c.Now == nil { c.Now = time.Now } return c } func (c AdminAuthConfig) loginEnabled() bool { cfg := c.normalized() return cfg.Token != "" && cfg.Password != "" } func (c AdminAuthConfig) now() time.Time { return c.normalized().Now() } func (c AdminAuthConfig) sessionCookie(r *http.Request) (*http.Cookie, *adminSessionInfo, bool) { cfg := c.normalized() cookie, err := r.Cookie(adminSessionCookieName) if err != nil || cookie == nil || strings.TrimSpace(cookie.Value) == "" { return nil, nil, false } info, ok := verifyAdminSessionValue(cfg.Token, cookie.Value, cfg.now()) if !ok { return cookie, nil, false } return cookie, info, true } func (c AdminAuthConfig) requestAuthorized(r *http.Request) bool { cfg := c.normalized() if secureCompare(bearerToken(r), cfg.Token) { return true } _, _, ok := cfg.sessionCookie(r) return ok } func secureCompare(left, right string) bool { if left == "" || right == "" { return false } return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1 } func signAdminSessionValue(secret, username string, expiresAt time.Time) string { payload := strings.TrimSpace(username) + "|" + strconv.FormatInt(expiresAt.Unix(), 10) mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(payload)) signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + signature } func verifyAdminSessionValue(secret, raw string, now time.Time) (*adminSessionInfo, bool) { if strings.TrimSpace(secret) == "" || strings.TrimSpace(raw) == "" { return nil, false } parts := strings.Split(raw, ".") if len(parts) != 2 { return nil, false } payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, false } payload := string(payloadBytes) mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(payload)) expectedSignature := mac.Sum(nil) signatureBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, false } if subtle.ConstantTimeCompare(signatureBytes, expectedSignature) != 1 { return nil, false } fields := strings.Split(payload, "|") if len(fields) != 2 { return nil, false } username := strings.TrimSpace(fields[0]) if username == "" { return nil, false } unixSeconds, err := strconv.ParseInt(fields[1], 10, 64) if err != nil { return nil, false } expiresAt := time.Unix(unixSeconds, 0) if !expiresAt.After(now) { return nil, false } return &adminSessionInfo{Username: username, ExpiresAt: expiresAt}, true } func issueAdminSessionCookie(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig, username string) *adminSessionInfo { cfg = cfg.normalized() expiresAt := cfg.now().Add(cfg.SessionTTL) http.SetCookie(w, &http.Cookie{ Name: adminSessionCookieName, Value: signAdminSessionValue(cfg.Token, username, expiresAt), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: requestUsesHTTPS(r), Expires: expiresAt, MaxAge: int(cfg.SessionTTL.Seconds()), }) return &adminSessionInfo{Username: username, ExpiresAt: expiresAt} } func clearAdminSessionCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: adminSessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: requestUsesHTTPS(r), Expires: time.Unix(0, 0), MaxAge: -1, }) } func requestUsesHTTPS(r *http.Request) bool { if r != nil && r.TLS != nil { return true } return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") } func handleAdminSessionState(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig) { cfg = cfg.normalized() payload := map[string]any{ "authenticated": false, "login_enabled": cfg.loginEnabled(), "username": cfg.Username, } if _, session, ok := cfg.sessionCookie(r); ok { payload["authenticated"] = true payload["username"] = session.Username payload["expires_at"] = session.ExpiresAt.Format(time.RFC3339) } writeJSON(w, http.StatusOK, payload) } func handleAdminSessionLogin(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig) { cfg = cfg.normalized() if cfg.Token == "" { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"}) return } if !cfg.loginEnabled() { writeHTTPError(w, &httpError{StatusCode: http.StatusServiceUnavailable, Code: "login_disabled", Message: "admin login is not enabled"}) return } var req adminLoginRequest if err := decodeJSON(r, &req); err != nil { writeHTTPError(w, err) return } if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" { writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "username and password are required"}) return } if !secureCompare(strings.TrimSpace(req.Username), cfg.Username) || !secureCompare(strings.TrimSpace(req.Password), cfg.Password) { writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "invalid admin credentials"}) return } session := issueAdminSessionCookie(w, r, cfg, cfg.Username) writeJSON(w, http.StatusOK, map[string]any{ "authenticated": true, "username": session.Username, "expires_at": session.ExpiresAt.Format(time.RFC3339), }) } func handleAdminSessionLogout(w http.ResponseWriter, r *http.Request) { clearAdminSessionCookie(w, r) w.WriteHeader(http.StatusNoContent) } func requireAdminAccess(cfg AdminAuthConfig, next http.Handler) http.Handler { cfg = cfg.normalized() if cfg.Token == "" { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"}) }) } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !cfg.requestAuthorized(r) { writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "missing or invalid admin credentials"}) return } next.ServeHTTP(w, r) }) } func adminSessionDebugValue(secret, username string, expiresAt time.Time) string { return hex.EncodeToString([]byte(signAdminSessionValue(secret, username, expiresAt))) } func adminSessionPayload(raw string) map[string]any { payload := map[string]any{"raw": raw} parts := strings.Split(raw, ".") if len(parts) != 2 { return payload } body, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return payload } fields := strings.Split(string(body), "|") payload["payload"] = string(body) if len(fields) == 2 { payload["username"] = fields[0] payload["expires_unix"] = fields[1] } return payload } func marshalAdminSessionPayload(raw string) string { body, _ := json.Marshal(adminSessionPayload(raw)) return string(body) }