mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
feat: cloud hardening and security follow-ups (PLAN-503)
Address 11 issues identified during the PLAN-427 security review: Critical/High: - Stripe customer-to-user mapping with indexed lookup (TASK-505) - OAuth provider linking with explicit consent model (TASK-504) - CSRF tokens on admin console mutations (TASK-506) - Rate limiting on cloud admin and OAuth endpoints (TASK-507) Medium: - __Host- cookie prefix for subdomain protection (TASK-510) - Billing portal verifies customer ownership server-side (TASK-515) - Transactional account deletion with rollback (TASK-509) - Streaming data export with 60s timeout (TASK-508) - Migration registration for new columns (TASK-514) Low: - Billing page fetches actual plan limits from API (TASK-511) - Admin user search/filter pushed into SQL with pagination (TASK-512)
This commit is contained in:
+27
-1
@@ -1,6 +1,9 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents a registered user in the system.
|
||||
type User struct {
|
||||
@@ -18,10 +21,33 @@ type User struct {
|
||||
PlanExpiresAt string `json:"plan_expires_at,omitempty"`
|
||||
StripeCustomerID string `json:"-"` // Never serialized
|
||||
PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits
|
||||
OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"]
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// GetOAuthProviders parses the JSON oauth_providers field into a string slice.
|
||||
func (u *User) GetOAuthProviders() []string {
|
||||
if u.OAuthProviders == "" {
|
||||
return nil
|
||||
}
|
||||
var providers []string
|
||||
if err := json.Unmarshal([]byte(u.OAuthProviders), &providers); err != nil {
|
||||
return nil
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
// HasOAuthProvider returns true if the user has linked the given provider.
|
||||
func (u *User) HasOAuthProvider(provider string) bool {
|
||||
for _, p := range u.GetOAuthProviders() {
|
||||
if p == provider {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UserCreate is the input for registering a new user.
|
||||
type UserCreate struct {
|
||||
Email string `json:"email"`
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -55,32 +57,31 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all owned workspaces
|
||||
// Delete all owned workspaces, sessions, and the user atomically.
|
||||
// If any workspace deletion fails, the entire operation is aborted.
|
||||
workspaces, err := s.store.GetUserWorkspaces(user.ID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var ownedSlugs []string
|
||||
for _, ws := range workspaces {
|
||||
if ws.OwnerID == user.ID {
|
||||
if err := s.store.DeleteWorkspace(ws.Slug); err != nil {
|
||||
slog.Error("delete account: failed to delete workspace", "workspace", ws.Slug, "error", err)
|
||||
}
|
||||
ownedSlugs = append(ownedSlugs, ws.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke all sessions
|
||||
_ = s.store.DeleteUserSessions(user.ID)
|
||||
|
||||
// Delete the user
|
||||
if err := s.store.DeleteUser(user.ID); err != nil {
|
||||
writeInternalError(w, err)
|
||||
if err := s.store.DeleteAccountAtomic(user.ID, ownedSlugs); err != nil {
|
||||
slog.Error("delete account: atomic deletion failed", "user_id", user.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error",
|
||||
"Account deletion failed. No data was removed. Please try again or contact support.")
|
||||
return
|
||||
}
|
||||
|
||||
// Clear session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Name: sessionCookieName(s.secureCookies),
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
@@ -101,7 +102,8 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
// --- Data Export (GDPR Article 20 — Right to Portability) ---
|
||||
|
||||
// handleExportAccount handles GET /api/v1/auth/export.
|
||||
// Returns all user data as a JSON object.
|
||||
// Streams user data as JSON, processing one workspace at a time to avoid
|
||||
// loading everything into memory. Enforces a 60-second timeout.
|
||||
func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil {
|
||||
@@ -112,30 +114,51 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Collect all user data
|
||||
export := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"plan": user.Plan,
|
||||
"totp_enabled": user.TOTPEnabled,
|
||||
"created_at": user.CreatedAt,
|
||||
"updated_at": user.UpdatedAt,
|
||||
},
|
||||
}
|
||||
// Enforce a 60-second timeout for the entire export
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Export owned workspaces with all items
|
||||
// Prefetch workspace list (small) before starting the streaming response
|
||||
workspaces, err := s.store.GetUserWorkspaces(user.ID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var wsExports []interface{}
|
||||
for _, ws := range workspaces {
|
||||
// Stream the response — once we start writing, we can't send error status codes
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"pad-export.json\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
|
||||
// Write opening structure
|
||||
w.Write([]byte("{\n \"user\": "))
|
||||
enc.Encode(map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"plan": user.Plan,
|
||||
"totp_enabled": user.TOTPEnabled,
|
||||
"created_at": user.CreatedAt,
|
||||
"updated_at": user.UpdatedAt,
|
||||
})
|
||||
|
||||
w.Write([]byte(",\n \"workspaces\": [\n"))
|
||||
|
||||
for i, ws := range workspaces {
|
||||
// Check timeout between workspaces
|
||||
if ctx.Err() != nil {
|
||||
slog.Warn("export timeout", "user_id", user.ID, "workspaces_exported", i)
|
||||
break
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
w.Write([]byte(",\n"))
|
||||
}
|
||||
|
||||
wsData := map[string]interface{}{
|
||||
"id": ws.ID,
|
||||
"name": ws.Name,
|
||||
@@ -146,23 +169,32 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Only export full data for owned workspaces
|
||||
if ws.OwnerID == user.ID {
|
||||
// Get collections
|
||||
collections, _ := s.store.ListCollections(ws.ID)
|
||||
wsData["collections"] = collections
|
||||
|
||||
// Get all items
|
||||
items, _ := s.store.ListItems(ws.ID, models.ItemListParams{IncludeArchived: true})
|
||||
wsData["items"] = items
|
||||
// Stream items per workspace (each workspace loaded individually, then GC'd)
|
||||
items, err := s.store.ListItems(ws.ID, models.ItemListParams{IncludeArchived: true})
|
||||
if err != nil {
|
||||
slog.Error("export: failed to list items", "workspace", ws.Slug, "error", err)
|
||||
wsData["items"] = []interface{}{}
|
||||
wsData["export_error"] = "failed to export items"
|
||||
} else {
|
||||
wsData["items"] = items
|
||||
}
|
||||
}
|
||||
|
||||
wsExports = append(wsExports, wsData)
|
||||
}
|
||||
export["workspaces"] = wsExports
|
||||
w.Write([]byte(" "))
|
||||
enc.Encode(wsData)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"pad-export.json\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(export)
|
||||
// Flush after each workspace to free memory and show progress
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
w.Write([]byte("\n ]\n}\n"))
|
||||
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
@@ -20,44 +19,47 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
users, err := s.store.ListUsers()
|
||||
limit := 50
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
offset := 0
|
||||
if v := r.URL.Query().Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
|
||||
result, err := s.store.SearchUsers(store.AdminUserSearchParams{
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Plan: r.URL.Query().Get("plan"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter by plan if specified
|
||||
planFilter := r.URL.Query().Get("plan")
|
||||
searchQuery := strings.ToLower(r.URL.Query().Get("q"))
|
||||
|
||||
type adminUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Plan string `json:"plan"`
|
||||
PlanExpiresAt string `json:"plan_expires_at,omitempty"`
|
||||
PlanOverrides string `json:"plan_overrides,omitempty"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Plan string `json:"plan"`
|
||||
PlanExpiresAt string `json:"plan_expires_at,omitempty"`
|
||||
PlanOverrides string `json:"plan_overrides,omitempty"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
var result []adminUser
|
||||
for _, u := range users {
|
||||
// Filter
|
||||
if planFilter != "" && u.Plan != planFilter {
|
||||
continue
|
||||
}
|
||||
if searchQuery != "" &&
|
||||
!strings.Contains(strings.ToLower(u.Email), searchQuery) &&
|
||||
!strings.Contains(strings.ToLower(u.Name), searchQuery) &&
|
||||
!strings.Contains(strings.ToLower(u.Username), searchQuery) {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, adminUser{
|
||||
users := make([]adminUser, 0, len(result.Users))
|
||||
for _, u := range result.Users {
|
||||
users = append(users, adminUser{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Username: u.Username,
|
||||
@@ -72,11 +74,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
result = []adminUser{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// handleAdminGetUser returns a single user with full detail.
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "pad_session"
|
||||
webSessionTTL = 7 * 24 * time.Hour // 7 days for web sessions
|
||||
cliSessionTTL = 30 * 24 * time.Hour // 30 days for CLI tokens
|
||||
|
||||
@@ -27,6 +26,25 @@ const (
|
||||
|
||||
var emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// sessionCookieName returns the session cookie name. When running over TLS
|
||||
// (secureCookies=true), the __Host- prefix is used to prevent subdomain
|
||||
// cookie injection attacks.
|
||||
func sessionCookieName(secure bool) string {
|
||||
if secure {
|
||||
return "__Host-pad_session"
|
||||
}
|
||||
return "pad_session"
|
||||
}
|
||||
|
||||
// csrfCookieName returns the CSRF cookie name. Uses the same __Host- prefix
|
||||
// strategy as the session cookie.
|
||||
func csrfCookieName(secure bool) string {
|
||||
if secure {
|
||||
return "__Host-pad_csrf"
|
||||
}
|
||||
return "pad_csrf"
|
||||
}
|
||||
|
||||
func sessionUserPayload(user *models.User) map[string]interface{} {
|
||||
if user == nil {
|
||||
return nil
|
||||
@@ -128,9 +146,13 @@ func requestIsLoopback(r *http.Request) bool {
|
||||
// (User-Agent check). Returns the user if valid, nil otherwise. This must be
|
||||
// used instead of calling ValidateSession directly to ensure binding is enforced.
|
||||
func (s *Server) validateSessionCookie(r *http.Request) *models.User {
|
||||
cookie, err := r.Cookie(sessionCookie)
|
||||
cookie, err := r.Cookie(sessionCookieName(s.secureCookies))
|
||||
if err != nil {
|
||||
return nil
|
||||
// Fallback: check the unprefixed name for sessions created before the upgrade
|
||||
cookie, err = r.Cookie("pad_session")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
session, _ := s.store.ValidateSession(cookie.Value)
|
||||
if session == nil || session.User == nil {
|
||||
@@ -151,7 +173,7 @@ func (s *Server) createAuthSession(w http.ResponseWriter, r *http.Request, user
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Name: sessionCookieName(s.secureCookies),
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
@@ -507,7 +529,7 @@ func (s *Server) handleSessionCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// It handles both cookie-based sessions (web) and Bearer token sessions (CLI).
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
// Revoke cookie-based session
|
||||
if cookie, err := r.Cookie(sessionCookie); err == nil {
|
||||
if cookie, err := r.Cookie(sessionCookieName(s.secureCookies)); err == nil {
|
||||
_ = s.store.DeleteSession(cookie.Value)
|
||||
}
|
||||
|
||||
@@ -520,7 +542,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Name: sessionCookieName(s.secureCookies),
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
@@ -551,7 +573,7 @@ func (s *Server) handleGetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
resp := map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
@@ -560,7 +582,23 @@ func (s *Server) handleGetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
"avatar_url": user.AvatarURL,
|
||||
"created_at": user.CreatedAt,
|
||||
"updated_at": user.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Include Stripe customer ID when present (used by pad-cloud sidecar
|
||||
// to create billing portal sessions without accepting customer_id from
|
||||
// the client, preventing users from accessing other users' portals).
|
||||
if user.StripeCustomerID != "" {
|
||||
resp["stripe_customer_id"] = user.StripeCustomerID
|
||||
}
|
||||
|
||||
// Include linked OAuth providers (used by settings UI for link/unlink)
|
||||
if providers := user.GetOAuthProviders(); len(providers) > 0 {
|
||||
resp["oauth_providers"] = providers
|
||||
} else {
|
||||
resp["oauth_providers"] = []string{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleUpdateCurrentUser updates the authenticated user's profile.
|
||||
@@ -786,7 +824,7 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Name: sessionCookieName(s.secureCookies),
|
||||
Value: sessionToken,
|
||||
Path: "/",
|
||||
MaxAge: int(webSessionTTL.Seconds()),
|
||||
|
||||
@@ -133,15 +133,21 @@ func (s *Server) handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
isNewUser = true
|
||||
|
||||
// Auto-link the provider for new OAuth users
|
||||
if err := s.store.AddOAuthProvider(user.ID, input.Provider); err != nil {
|
||||
slog.Error("oauth-login: failed to link provider", "error", err, "user_id", user.ID)
|
||||
}
|
||||
|
||||
slog.Info("oauth-login: created new user", "provider", input.Provider, "email", input.Email, "user_id", user.ID)
|
||||
|
||||
// Auto-create default workspace for new OAuth users
|
||||
s.autoCreateWorkspace(user)
|
||||
} else {
|
||||
// Existing user — implicit account link.
|
||||
// Block OAuth login if the user has 2FA enabled — OAuth must not bypass 2FA.
|
||||
if user.TOTPEnabled {
|
||||
slog.Warn("oauth-login: blocked — existing user has 2FA enabled",
|
||||
// Existing user — require explicit provider linking.
|
||||
// The user must have previously linked this provider from their settings.
|
||||
if !user.HasOAuthProvider(input.Provider) {
|
||||
slog.Warn("oauth-login: rejected — provider not linked",
|
||||
"provider", input.Provider,
|
||||
"email", input.Email,
|
||||
"user_id", user.ID,
|
||||
@@ -149,20 +155,13 @@ func (s *Server) handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
s.logAuditEventForUser(models.ActionOAuthLoginFailed, r, user.ID, auditMeta(map[string]string{
|
||||
"provider": input.Provider,
|
||||
"email": input.Email,
|
||||
"reason": "2fa_enabled",
|
||||
"reason": "provider_not_linked",
|
||||
}))
|
||||
writeError(w, http.StatusForbidden, "forbidden",
|
||||
"This account has two-factor authentication enabled. Please sign in with your password and 2FA code, then link your OAuth provider in account settings.")
|
||||
writeError(w, http.StatusForbidden, "oauth_provider_not_linked",
|
||||
"An account with this email already exists. Sign in with your password and link "+input.Provider+" from account settings.")
|
||||
return
|
||||
}
|
||||
|
||||
// Log the implicit link for audit
|
||||
slog.Info("oauth-login: existing user (account link)",
|
||||
"provider", input.Provider,
|
||||
"email", input.Email,
|
||||
"user_id", user.ID,
|
||||
)
|
||||
|
||||
// Update avatar if they don't have one
|
||||
if user.AvatarURL == "" && input.AvatarURL != "" {
|
||||
avatar := input.AvatarURL
|
||||
@@ -275,6 +274,324 @@ func (s *Server) handleSetPlan(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// --- OAuth Provider Linking (TASK-504) ---
|
||||
|
||||
// handleOAuthLink handles POST /api/v1/auth/oauth-link.
|
||||
// Called by the pad-cloud sidecar after an OAuth flow initiated from account settings.
|
||||
// Requires an active session (the user must be logged in) and links the provider.
|
||||
func (s *Server) handleOAuthLink(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Provider string `json:"provider"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
CloudSecret string `json:"cloud_secret"`
|
||||
}
|
||||
if err := decodeJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Validate cloud secret
|
||||
if !s.validateCloudSecret(input.CloudSecret, w) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Validate provider
|
||||
if input.Provider != "github" && input.Provider != "google" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "provider must be 'github' or 'google'")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Require verified email
|
||||
if !input.EmailVerified {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Only verified email addresses are accepted")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Find user by email (the sidecar passes the OAuth email)
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
user, err := s.store.GetUserByEmail(input.Email)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "No account found with that email")
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Check if already linked
|
||||
if user.HasOAuthProvider(input.Provider) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"provider": input.Provider,
|
||||
"message": "Provider already linked",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Link the provider
|
||||
if err := s.store.AddOAuthProvider(user.ID, input.Provider); err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Audit log
|
||||
s.logAuditEventForUser(models.ActionOAuthLogin, r, user.ID, auditMeta(map[string]string{
|
||||
"provider": input.Provider,
|
||||
"email": input.Email,
|
||||
"action": "link_provider",
|
||||
}))
|
||||
|
||||
slog.Info("oauth-link: provider linked", "provider", input.Provider, "user_id", user.ID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"provider": input.Provider,
|
||||
})
|
||||
}
|
||||
|
||||
// handleOAuthUnlink handles POST /api/v1/auth/oauth-unlink.
|
||||
// Removes a linked OAuth provider. Requires the user to have a usable password
|
||||
// (to prevent locking themselves out).
|
||||
func (s *Server) handleOAuthUnlink(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err := decodeJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if input.Provider != "github" && input.Provider != "google" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "provider must be 'github' or 'google'")
|
||||
return
|
||||
}
|
||||
|
||||
if !user.HasOAuthProvider(input.Provider) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Provider not linked")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure user won't be locked out: they must have a usable password
|
||||
// or another linked provider remaining.
|
||||
providers := user.GetOAuthProviders()
|
||||
hasOtherProvider := false
|
||||
for _, p := range providers {
|
||||
if p != input.Provider {
|
||||
hasOtherProvider = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// A user has a "usable" password if they set one explicitly.
|
||||
// OAuth-created users have a random unusable password but may have
|
||||
// set one later via the password reset flow.
|
||||
// We can't easily distinguish, so we require at least one other auth method.
|
||||
if !hasOtherProvider && user.PasswordHash == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"Cannot unlink your only authentication method. Set a password first.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.RemoveOAuthProvider(user.ID, input.Provider); err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.logAuditEventForUser(models.ActionOAuthLogin, r, user.ID, auditMeta(map[string]string{
|
||||
"provider": input.Provider,
|
||||
"action": "unlink_provider",
|
||||
}))
|
||||
|
||||
slog.Info("oauth-unlink: provider unlinked", "provider", input.Provider, "user_id", user.ID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"provider": input.Provider,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Stripe Customer ID (TASK-505) ---
|
||||
|
||||
// handleSetStripeCustomerID handles POST /api/v1/admin/stripe-customer-id.
|
||||
// Called by the pad-cloud sidecar after a Stripe checkout.completed event
|
||||
// to associate a Stripe customer ID with a Pad user.
|
||||
func (s *Server) handleSetStripeCustomerID(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
UserID string `json:"user_id"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
CloudSecret string `json:"cloud_secret"`
|
||||
}
|
||||
if err := decodeJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Validate cloud secret (or admin auth)
|
||||
user := currentUser(r)
|
||||
isAdmin := user != nil && user.Role == "admin"
|
||||
if !isAdmin {
|
||||
if !s.validateCloudSecret(input.CloudSecret, w) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Validate inputs
|
||||
if input.UserID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "user_id is required")
|
||||
return
|
||||
}
|
||||
if input.CustomerID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "customer_id is required")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(input.CustomerID, "cus_") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "customer_id must start with 'cus_'")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Verify user exists
|
||||
targetUser, err := s.store.GetUser(input.UserID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
if targetUser == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "User not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Store the Stripe customer ID
|
||||
if err := s.store.SetUserStripeCustomerID(input.UserID, input.CustomerID); err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Audit log
|
||||
actorID := ""
|
||||
if user != nil {
|
||||
actorID = user.ID
|
||||
}
|
||||
s.logAuditEventForUser(models.ActionPlanChanged, r, actorID, auditMeta(map[string]string{
|
||||
"target_user_id": input.UserID,
|
||||
"stripe_customer_id": input.CustomerID,
|
||||
"action": "set_stripe_customer_id",
|
||||
}))
|
||||
|
||||
slog.Info("stripe customer ID set", "user_id", input.UserID, "customer_id", input.CustomerID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user_id": input.UserID,
|
||||
"customer_id": input.CustomerID,
|
||||
"ok": true,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetUserByCustomerID handles GET /api/v1/admin/user-by-customer?customer_id=cus_xxx.
|
||||
// Called by the pad-cloud sidecar during Stripe subscription webhook processing
|
||||
// to resolve a Stripe customer back to a Pad user.
|
||||
func (s *Server) handleGetUserByCustomerID(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Validate cloud secret (via header or query param) or admin auth
|
||||
user := currentUser(r)
|
||||
isAdmin := user != nil && user.Role == "admin"
|
||||
if !isAdmin {
|
||||
secret := r.Header.Get("X-Cloud-Secret")
|
||||
if secret == "" {
|
||||
secret = r.URL.Query().Get("cloud_secret")
|
||||
}
|
||||
if !s.validateCloudSecret(secret, w) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Validate customer_id
|
||||
customerID := r.URL.Query().Get("customer_id")
|
||||
if customerID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "customer_id query parameter is required")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(customerID, "cus_") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "customer_id must start with 'cus_'")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Look up user
|
||||
targetUser, err := s.store.GetUserByStripeCustomerID(customerID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
if targetUser == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "No user found with that Stripe customer ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Return minimal user info (only what the sidecar needs)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user_id": targetUser.ID,
|
||||
"email": targetUser.Email,
|
||||
"plan": targetUser.Plan,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Public Plan Limits (TASK-511) ---
|
||||
|
||||
// handleGetPlanLimits returns the configured plan limits for free and pro tiers.
|
||||
// GET /api/v1/plan-limits — public endpoint, no auth required.
|
||||
// Used by the billing page to show actual limits instead of hardcoded values.
|
||||
func (s *Server) handleGetPlanLimits(w http.ResponseWriter, r *http.Request) {
|
||||
result := map[string]interface{}{
|
||||
"free": store.DefaultFreeLimits,
|
||||
"pro": store.DefaultProLimits,
|
||||
}
|
||||
|
||||
// Override with DB-stored limits if available
|
||||
features := []string{
|
||||
"workspaces", "items_per_workspace", "members_per_workspace",
|
||||
"api_tokens", "storage_bytes", "webhooks", "automated_backups",
|
||||
}
|
||||
for _, plan := range []string{"free", "pro"} {
|
||||
overrides := make(map[string]int)
|
||||
for _, feature := range features {
|
||||
key := "plan_limits_" + plan + "_" + feature
|
||||
val, err := s.store.GetPlatformSetting(key)
|
||||
if err != nil || val == "" {
|
||||
continue
|
||||
}
|
||||
v, _ := strconv.Atoi(val)
|
||||
overrides[feature] = v
|
||||
}
|
||||
if len(overrides) > 0 {
|
||||
// Merge overrides onto defaults
|
||||
defaults := store.DefaultFreeLimits
|
||||
if plan == "pro" {
|
||||
defaults = store.DefaultProLimits
|
||||
}
|
||||
merged := map[string]int{
|
||||
"workspaces": defaults.Workspaces,
|
||||
"items_per_workspace": defaults.ItemsPerWorkspace,
|
||||
"members_per_workspace": defaults.MembersPerWorkspace,
|
||||
"api_tokens": defaults.APITokens,
|
||||
"storage_bytes": defaults.StorageBytes,
|
||||
"webhooks": defaults.Webhooks,
|
||||
"automated_backups": defaults.AutomatedBackups,
|
||||
}
|
||||
for k, v := range overrides {
|
||||
merged[k] = v
|
||||
}
|
||||
result[plan] = merged
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// --- Plan Limit Enforcement ---
|
||||
|
||||
// enforcePlanLimit checks a workspace-scoped plan limit and writes a 403
|
||||
|
||||
@@ -132,7 +132,7 @@ func (s *Server) SessionAuth(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Try session cookie
|
||||
cookie, err := r.Cookie(sessionCookie)
|
||||
cookie, err := r.Cookie(sessionCookieName(s.secureCookies))
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -157,7 +157,7 @@ func (s *Server) SessionAuth(next http.Handler) http.Handler {
|
||||
// This can happen when cookies expire at different times or are selectively cleared.
|
||||
// Skip for auth endpoints — they manage their own CSRF cookies (login sets, logout clears).
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/auth/") {
|
||||
if _, csrfErr := r.Cookie(csrfCookie); csrfErr != nil {
|
||||
if _, csrfErr := r.Cookie(csrfCookieName(s.secureCookies)); csrfErr != nil {
|
||||
setCSRFCookie(w, 7*24*60*60, s.secureCookies)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
csrfCookie = "pad_csrf"
|
||||
csrfHeader = "X-CSRF-Token"
|
||||
csrfHeader = "X-CSRF-Token"
|
||||
csrfTokenLen = 32 // 32 bytes = 64 hex chars
|
||||
)
|
||||
|
||||
@@ -61,7 +60,7 @@ func (s *Server) CSRFProtect(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Cookie-based session: require CSRF token
|
||||
cookie, err := r.Cookie(csrfCookie)
|
||||
cookie, err := r.Cookie(csrfCookieName(s.secureCookies))
|
||||
if err != nil || cookie.Value == "" {
|
||||
writeError(w, http.StatusForbidden, "csrf_error", "Missing CSRF token")
|
||||
return
|
||||
@@ -87,7 +86,7 @@ func (s *Server) CSRFProtect(next http.Handler) http.Handler {
|
||||
func setCSRFCookie(w http.ResponseWriter, ttl int, secure bool) {
|
||||
token := generateCSRFToken()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookie,
|
||||
Name: csrfCookieName(secure),
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: ttl,
|
||||
@@ -98,15 +97,18 @@ func setCSRFCookie(w http.ResponseWriter, ttl int, secure bool) {
|
||||
}
|
||||
|
||||
// clearCSRFCookie removes the CSRF cookie (e.g. on logout).
|
||||
// Must clear both prefixed and unprefixed names to handle upgrades cleanly.
|
||||
func clearCSRFCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
for _, name := range []string{"pad_csrf", "__Host-pad_csrf"} {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// generateCSRFToken returns a cryptographically random hex string.
|
||||
|
||||
@@ -80,6 +80,8 @@ type RateLimiters struct {
|
||||
Register *ipRateLimiter
|
||||
// OAuth login: per-IP (higher limit since pad-cloud sidecar calls this)
|
||||
OAuthLogin *ipRateLimiter
|
||||
// Cloud admin: per-IP for sidecar-to-pad admin endpoints (plan, stripe, user lookup)
|
||||
CloudAdmin *ipRateLimiter
|
||||
// API: per-user (authenticated)
|
||||
API *ipRateLimiter
|
||||
// Search: per-user or per-IP
|
||||
@@ -104,11 +106,17 @@ func NewRateLimiters() *RateLimiters {
|
||||
Rate: rate.Limit(5.0 / 3600.0),
|
||||
Burst: 5,
|
||||
}),
|
||||
// OAuth login: 20 per minute per IP (sidecar calls this — higher than regular auth)
|
||||
// OAuth login/link: 20 per minute per IP (sidecar calls this — higher than regular auth)
|
||||
OAuthLogin: newIPRateLimiter(rateLimitConfig{
|
||||
Rate: rate.Limit(20.0 / 60.0),
|
||||
Burst: 20,
|
||||
}),
|
||||
// Cloud admin: 30 per minute per IP for sidecar admin calls (plan changes, Stripe mapping)
|
||||
// These are cloud-secret gated but rate-limited for defense in depth.
|
||||
CloudAdmin: newIPRateLimiter(rateLimitConfig{
|
||||
Rate: rate.Limit(30.0 / 60.0),
|
||||
Burst: 10,
|
||||
}),
|
||||
// API: 600 requests per minute per user/IP (= 10 per second, burst 60)
|
||||
// Local-first tool with SSE-driven UI needs headroom for cascading refreshes.
|
||||
API: newIPRateLimiter(rateLimitConfig{
|
||||
@@ -152,8 +160,10 @@ func (s *Server) RateLimit(next http.Handler) http.Handler {
|
||||
limiter = s.rateLimiters.PasswordReset
|
||||
case path == "/api/v1/auth/register":
|
||||
limiter = s.rateLimiters.Register
|
||||
case path == "/api/v1/auth/oauth-login":
|
||||
case path == "/api/v1/auth/oauth-login" || path == "/api/v1/auth/oauth-link":
|
||||
limiter = s.rateLimiters.OAuthLogin
|
||||
case path == "/api/v1/auth/oauth-unlink":
|
||||
limiter = s.rateLimiters.Auth // Same as login — 5/min, user-initiated
|
||||
default:
|
||||
// Other auth endpoints (session check, logout) — use general API limit
|
||||
limiter = s.rateLimiters.API
|
||||
@@ -171,6 +181,20 @@ func (s *Server) RateLimit(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Cloud admin endpoints (sidecar → pad): plan changes, Stripe mapping, user lookup
|
||||
if strings.HasPrefix(path, "/api/v1/admin/") {
|
||||
switch path {
|
||||
case "/api/v1/admin/plan", "/api/v1/admin/stripe-customer-id", "/api/v1/admin/user-by-customer":
|
||||
l := s.rateLimiters.CloudAdmin.getLimiter(ip)
|
||||
if !l.Allow() {
|
||||
slog.Warn("rate limited", "ip", ip, "path", path, "limiter", "cloud_admin")
|
||||
writeRateLimitResponse(w, s.rateLimiters.CloudAdmin.config)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Other admin endpoints fall through to general API limit below
|
||||
}
|
||||
|
||||
// Search endpoint
|
||||
if path == "/api/v1/search" {
|
||||
key := rateLimitKey(r, ip)
|
||||
|
||||
@@ -249,6 +249,7 @@ func (s *Server) setupRouter() {
|
||||
r.Get("/health", s.handleHealth)
|
||||
r.Get("/health/live", s.handleHealthLive)
|
||||
r.Get("/health/ready", s.handleHealthReady)
|
||||
r.Get("/plan-limits", s.handleGetPlanLimits) // Public: billing page reads plan limits
|
||||
|
||||
// Auth endpoints (exempt from auth middleware)
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
@@ -281,8 +282,10 @@ func (s *Server) setupRouter() {
|
||||
r.Delete("/tokens/{tokenID}", s.handleDeleteUserToken)
|
||||
r.Post("/tokens/{tokenID}/rotate", s.handleRotateUserToken)
|
||||
|
||||
// Cloud: OAuth login (called by pad-cloud sidecar, protected by cloud secret)
|
||||
// Cloud: OAuth login/linking (called by pad-cloud sidecar, protected by cloud secret)
|
||||
r.Post("/oauth-login", s.handleOAuthLogin)
|
||||
r.Post("/oauth-link", s.handleOAuthLink)
|
||||
r.Post("/oauth-unlink", s.handleOAuthUnlink)
|
||||
})
|
||||
|
||||
// Admin endpoints (admin-only, handlers check role internally)
|
||||
@@ -290,7 +293,9 @@ func (s *Server) setupRouter() {
|
||||
r.Get("/settings", s.handleGetPlatformSettings)
|
||||
r.Patch("/settings", s.handleUpdatePlatformSettings)
|
||||
r.Post("/test-email", s.handleTestEmail)
|
||||
r.Post("/plan", s.handleSetPlan) // Cloud: sidecar sets user plans; also accessible to admins
|
||||
r.Post("/plan", s.handleSetPlan) // Cloud: sidecar sets user plans; also accessible to admins
|
||||
r.Post("/stripe-customer-id", s.handleSetStripeCustomerID) // Cloud: sidecar stores Stripe customer ID after checkout
|
||||
r.Get("/user-by-customer", s.handleGetUserByCustomerID) // Cloud: sidecar looks up user by Stripe customer ID
|
||||
|
||||
// User management
|
||||
r.Get("/users", s.handleAdminListUsers)
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
)
|
||||
|
||||
// PlanLimits defines the limits for a billing plan tier.
|
||||
@@ -328,3 +331,20 @@ func (s *Store) SetUserStripeCustomerID(userID, customerID string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByStripeCustomerID retrieves a user by their Stripe customer ID.
|
||||
// Returns nil if no user is found with the given customer ID.
|
||||
func (s *Store) GetUserByStripeCustomerID(customerID string) (*models.User, error) {
|
||||
customerID = strings.TrimSpace(customerID)
|
||||
if customerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
u, err := scanUser(s.db.QueryRow(s.q(`SELECT `+userColumns+` FROM users WHERE stripe_customer_id = ?`), customerID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user by stripe customer id: %w", err)
|
||||
}
|
||||
if err := s.decryptUserTOTP(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Index stripe_customer_id for fast lookups during Stripe webhook processing.
|
||||
-- Only non-empty values need to be indexed (most users won't have a Stripe customer ID).
|
||||
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id) WHERE stripe_customer_id != '';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Track which OAuth providers a user has explicitly linked.
|
||||
-- JSON array, e.g. ["github"] or ["github","google"]. Empty string = no providers.
|
||||
ALTER TABLE users ADD COLUMN oauth_providers TEXT DEFAULT '';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Index stripe_customer_id for fast lookups during Stripe webhook processing.
|
||||
-- Only non-empty values need to be indexed (most users won't have a Stripe customer ID).
|
||||
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id) WHERE stripe_customer_id != '';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Track which OAuth providers a user has explicitly linked.
|
||||
-- JSON array, e.g. ["github"] or ["github","google"]. Empty string = no providers.
|
||||
ALTER TABLE users ADD COLUMN oauth_providers TEXT DEFAULT '';
|
||||
@@ -161,6 +161,8 @@ func (s *Store) migrate() error {
|
||||
"033_grants.sql",
|
||||
"034_share_links.sql",
|
||||
"035_plan_fields.sql",
|
||||
"036_stripe_customer_index.sql",
|
||||
"037_oauth_providers.sql",
|
||||
}
|
||||
|
||||
for _, name := range migrations {
|
||||
@@ -220,6 +222,8 @@ func (s *Store) migratePostgres() error {
|
||||
"013_grants.sql",
|
||||
"014_share_links.sql",
|
||||
"015_plan_fields.sql",
|
||||
"016_stripe_customer_index.sql",
|
||||
"017_oauth_providers.sql",
|
||||
}
|
||||
|
||||
for _, name := range migrations {
|
||||
|
||||
+194
-2
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -18,7 +19,7 @@ var usernameCleanRe = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
const bcryptCost = 12
|
||||
|
||||
// user SELECT columns — used by all user queries.
|
||||
const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, created_at, updated_at`
|
||||
const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, created_at, updated_at`
|
||||
|
||||
// scanUser scans a user row into a User struct.
|
||||
// Note: does NOT decrypt the TOTP secret — call store.decryptUserTOTP() after
|
||||
@@ -30,7 +31,7 @@ func scanUser(row interface{ Scan(...interface{}) error }) (*models.User, error)
|
||||
err := row.Scan(
|
||||
&u.ID, &u.Email, &u.Username, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL,
|
||||
&u.TOTPSecret, &u.TOTPEnabled, &u.RecoveryCodes,
|
||||
&u.Plan, &u.PlanExpiresAt, &u.StripeCustomerID, &u.PlanOverrides,
|
||||
&u.Plan, &u.PlanExpiresAt, &u.StripeCustomerID, &u.PlanOverrides, &u.OAuthProviders,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -210,6 +211,83 @@ func (s *Store) ListUsers() ([]models.User, error) {
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// AdminUserSearchParams holds parameters for the admin user search query.
|
||||
type AdminUserSearchParams struct {
|
||||
Query string // Search in email, name, username
|
||||
Plan string // Filter by plan (free, pro, self-hosted)
|
||||
Limit int // Max results (default 50, max 200)
|
||||
Offset int // Pagination offset
|
||||
}
|
||||
|
||||
// AdminUserSearchResult holds the paginated search results.
|
||||
type AdminUserSearchResult struct {
|
||||
Users []models.User `json:"users"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// SearchUsers returns a filtered, paginated list of users for admin management.
|
||||
// Filters and pagination are pushed into SQL to avoid loading all users into memory.
|
||||
func (s *Store) SearchUsers(params AdminUserSearchParams) (*AdminUserSearchResult, error) {
|
||||
if params.Limit <= 0 || params.Limit > 200 {
|
||||
params.Limit = 50
|
||||
}
|
||||
if params.Offset < 0 {
|
||||
params.Offset = 0
|
||||
}
|
||||
|
||||
var where []string
|
||||
var args []interface{}
|
||||
|
||||
if params.Query != "" {
|
||||
q := "%" + strings.ToLower(params.Query) + "%"
|
||||
where = append(where, "(LOWER(email) LIKE ? OR LOWER(name) LIKE ? OR LOWER(username) LIKE ?)")
|
||||
args = append(args, q, q, q)
|
||||
}
|
||||
if params.Plan != "" {
|
||||
where = append(where, "plan = ?")
|
||||
args = append(args, params.Plan)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if len(where) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
// Get total count
|
||||
countQuery := s.q("SELECT COUNT(*) FROM users " + whereClause)
|
||||
var total int
|
||||
if err := s.db.QueryRow(countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, fmt.Errorf("search users count: %w", err)
|
||||
}
|
||||
|
||||
// Get paginated results
|
||||
query := s.q("SELECT " + userColumns + " FROM users " + whereClause + " ORDER BY created_at DESC LIMIT ? OFFSET ?")
|
||||
fullArgs := append(args, params.Limit, params.Offset)
|
||||
rows, err := s.db.Query(query, fullArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []models.User
|
||||
for rows.Next() {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search users scan: %w", err)
|
||||
}
|
||||
_ = s.decryptUserTOTP(u)
|
||||
users = append(users, *u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("search users rows: %w", err)
|
||||
}
|
||||
|
||||
return &AdminUserSearchResult{
|
||||
Users: users,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UserCount returns the total number of registered users.
|
||||
func (s *Store) UserCount() (int, error) {
|
||||
var count int
|
||||
@@ -254,6 +332,71 @@ func (s *Store) CreateOAuthUser(email, name, avatarURL string) (*models.User, er
|
||||
return s.GetUser(id)
|
||||
}
|
||||
|
||||
// AddOAuthProvider adds a provider to the user's oauth_providers list.
|
||||
// No-op if the provider is already linked.
|
||||
func (s *Store) AddOAuthProvider(userID, provider string) error {
|
||||
user, err := s.GetUser(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add oauth provider: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return fmt.Errorf("add oauth provider: user not found")
|
||||
}
|
||||
|
||||
if user.HasOAuthProvider(provider) {
|
||||
return nil // Already linked
|
||||
}
|
||||
|
||||
providers := user.GetOAuthProviders()
|
||||
providers = append(providers, provider)
|
||||
data, err := json.Marshal(providers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add oauth provider: marshal: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(s.q(`UPDATE users SET oauth_providers = ?, updated_at = ? WHERE id = ?`),
|
||||
string(data), now(), userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add oauth provider: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveOAuthProvider removes a provider from the user's oauth_providers list.
|
||||
func (s *Store) RemoveOAuthProvider(userID, provider string) error {
|
||||
user, err := s.GetUser(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove oauth provider: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return fmt.Errorf("remove oauth provider: user not found")
|
||||
}
|
||||
|
||||
providers := user.GetOAuthProviders()
|
||||
var filtered []string
|
||||
for _, p := range providers {
|
||||
if p != provider {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
|
||||
var val string
|
||||
if len(filtered) > 0 {
|
||||
data, err := json.Marshal(filtered)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove oauth provider: marshal: %w", err)
|
||||
}
|
||||
val = string(data)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(s.q(`UPDATE users SET oauth_providers = ?, updated_at = ? WHERE id = ?`),
|
||||
val, now(), userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove oauth provider: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser permanently deletes a user by ID.
|
||||
func (s *Store) DeleteUser(id string) error {
|
||||
_, err := s.db.Exec(s.q(`DELETE FROM users WHERE id = ?`), id)
|
||||
@@ -263,6 +406,55 @@ func (s *Store) DeleteUser(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAccountAtomic deletes a user and all their owned workspaces in a single
|
||||
// transaction. If any step fails, the entire operation is rolled back and no data
|
||||
// is modified. This prevents orphaned workspaces from partial deletions.
|
||||
func (s *Store) DeleteAccountAtomic(userID string, ownedWorkspaceSlugs []string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete account: begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
ts := now()
|
||||
|
||||
// 1. Soft-delete all owned workspaces
|
||||
for _, slug := range ownedWorkspaceSlugs {
|
||||
result, err := tx.Exec(s.q(`
|
||||
UPDATE workspaces SET deleted_at = ?, updated_at = ?
|
||||
WHERE slug = ? AND deleted_at IS NULL
|
||||
`), ts, ts, slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete account: delete workspace %s: %w", slug, err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
// Workspace already deleted or not found — not an error
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Revoke all sessions
|
||||
if _, err := tx.Exec(s.q("DELETE FROM sessions WHERE user_id = ?"), userID); err != nil {
|
||||
return fmt.Errorf("delete account: delete sessions: %w", err)
|
||||
}
|
||||
|
||||
// 3. Revoke all API tokens
|
||||
if _, err := tx.Exec(s.q("DELETE FROM api_tokens WHERE user_id = ?"), userID); err != nil {
|
||||
return fmt.Errorf("delete account: delete api tokens: %w", err)
|
||||
}
|
||||
|
||||
// 4. Delete the user record
|
||||
if _, err := tx.Exec(s.q("DELETE FROM users WHERE id = ?"), userID); err != nil {
|
||||
return fmt.Errorf("delete account: delete user: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("delete account: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Username backfill ---
|
||||
|
||||
// GenerateUsername derives a URL-safe username from a display name.
|
||||
|
||||
@@ -51,6 +51,9 @@ class PadApiError extends Error {
|
||||
|
||||
function getCSRFToken(): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
// Check __Host- prefixed cookie first (secure/TLS mode), fall back to unprefixed
|
||||
const hostMatch = document.cookie.match(/(?:^|;\s*)__Host-pad_csrf=([^;]+)/);
|
||||
if (hostMatch) return hostMatch[1];
|
||||
const match = document.cookie.match(/(?:^|;\s*)pad_csrf=([^;]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
@@ -627,6 +630,11 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
unlinkProvider: (provider: string) =>
|
||||
request<{ ok: boolean; provider: string }>('/auth/oauth-unlink', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider })
|
||||
}),
|
||||
tokens: {
|
||||
list: () => request<APIToken[]>('/auth/tokens'),
|
||||
create: (name: string) =>
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface User {
|
||||
name: string;
|
||||
role: string;
|
||||
avatar_url?: string;
|
||||
oauth_providers?: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
function csrfToken(): string {
|
||||
function getCSRFToken(): string | null {
|
||||
// Check __Host- prefixed cookie first (secure/TLS mode), fall back to unprefixed
|
||||
const hostMatch = document.cookie.match(/(?:^|;\s*)__Host-pad_csrf=([^;]+)/);
|
||||
if (hostMatch) return hostMatch[1];
|
||||
const match = document.cookie.match(/(?:^|;\s*)pad_csrf=([^;]+)/);
|
||||
return match?.[1] ?? '';
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function adminFetch(path: string, opts?: RequestInit) {
|
||||
@@ -16,9 +19,12 @@
|
||||
}
|
||||
|
||||
async function adminPatch(path: string, body: unknown) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const csrf = getCSRFToken();
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
return adminFetch(path, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken() },
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,12 +3,35 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface PlanLimits {
|
||||
workspaces: number;
|
||||
items_per_workspace: number;
|
||||
members_per_workspace: number;
|
||||
api_tokens: number;
|
||||
storage_bytes: number;
|
||||
}
|
||||
|
||||
let plan = $derived(authStore.user?.plan ?? 'free');
|
||||
let isPro = $derived(plan === 'pro');
|
||||
let limits = $state<{ free: PlanLimits; pro: PlanLimits } | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
function formatLimit(value: number | undefined): string {
|
||||
if (value === undefined) return '...';
|
||||
if (value === -1) return 'Unlimited';
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.cloudMode) {
|
||||
goto('/console', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/plan-limits', { credentials: 'same-origin' });
|
||||
if (resp.ok) limits = await resp.json();
|
||||
} catch {
|
||||
/* use fallback rendering */
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -58,11 +81,15 @@
|
||||
</div>
|
||||
<div class="usage-row">
|
||||
<span class="usage-label">Workspaces</span>
|
||||
<span class="usage-value">{isPro ? 'Unlimited' : 'Up to 5'}</span>
|
||||
<span class="usage-value">{isPro ? formatLimit(limits?.pro?.workspaces) : formatLimit(limits?.free?.workspaces)}</span>
|
||||
</div>
|
||||
<div class="usage-row">
|
||||
<span class="usage-label">Items per workspace</span>
|
||||
<span class="usage-value">{isPro ? formatLimit(limits?.pro?.items_per_workspace) : formatLimit(limits?.free?.items_per_workspace)}</span>
|
||||
</div>
|
||||
<div class="usage-row">
|
||||
<span class="usage-label">Members per workspace</span>
|
||||
<span class="usage-value">{isPro ? 'Unlimited' : 'Up to 3'}</span>
|
||||
<span class="usage-value">{isPro ? formatLimit(limits?.pro?.members_per_workspace) : formatLimit(limits?.free?.members_per_workspace)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -20,6 +20,23 @@
|
||||
let passwordMsg = $state('');
|
||||
let passwordError = $state('');
|
||||
|
||||
// OAuth providers
|
||||
let providerMsg = $state('');
|
||||
let providerError = $state('');
|
||||
|
||||
async function unlinkProvider(provider: string) {
|
||||
providerMsg = '';
|
||||
providerError = '';
|
||||
try {
|
||||
await api.auth.unlinkProvider(provider);
|
||||
// Refresh profile to get updated providers list
|
||||
profile = await api.auth.me();
|
||||
providerMsg = `${provider === 'github' ? 'GitHub' : 'Google'} unlinked.`;
|
||||
} catch (err) {
|
||||
providerError = err instanceof Error ? err.message : 'Failed to unlink provider';
|
||||
}
|
||||
}
|
||||
|
||||
// Tokens
|
||||
let tokens = $state<APIToken[]>([]);
|
||||
let newTokenName = $state('');
|
||||
@@ -201,6 +218,36 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Linked Accounts (cloud mode only) -->
|
||||
{#if authStore.cloudMode}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Linked Accounts</h2>
|
||||
<div class="card-body">
|
||||
<p class="section-desc">Link OAuth providers for single sign-on. You can sign in with any linked provider.</p>
|
||||
{#each ['github', 'google'] as provider (provider)}
|
||||
{@const linked = profile?.oauth_providers?.includes(provider) ?? false}
|
||||
<div class="provider-row">
|
||||
<div class="provider-info">
|
||||
<span class="provider-name">{provider === 'github' ? 'GitHub' : 'Google'}</span>
|
||||
{#if linked}
|
||||
<span class="provider-badge linked">Linked</span>
|
||||
{:else}
|
||||
<span class="provider-badge">Not linked</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if linked}
|
||||
<button class="delete-btn" onclick={() => unlinkProvider(provider)}>Unlink</button>
|
||||
{:else}
|
||||
<a href="/auth/{provider}/link" class="primary-btn small">Link {provider === 'github' ? 'GitHub' : 'Google'}</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if providerMsg}<p class="success">{providerMsg}</p>{/if}
|
||||
{#if providerError}<p class="error">{providerError}</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- API Tokens -->
|
||||
<section class="card">
|
||||
<h2 class="card-title">API Tokens</h2>
|
||||
@@ -451,4 +498,51 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.provider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.provider-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.provider-badge {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: color-mix(in srgb, var(--accent-gray, #888) 15%, transparent);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.provider-badge.linked {
|
||||
background: color-mix(in srgb, var(--accent-green) 15%, transparent);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: calc(-1 * var(--space-2));
|
||||
}
|
||||
|
||||
.primary-btn.small {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user