diff --git a/api/admin_users_metrics.go b/api/admin_users_metrics.go index f287bcf..7f42106 100644 --- a/api/admin_users_metrics.go +++ b/api/admin_users_metrics.go @@ -60,6 +60,10 @@ func (h *handler) requireAdminOrOperator(c *gin.Context) (*store.User, bool) { respondError(c, http.StatusForbidden, "forbidden", "insufficient permissions") return nil, false } + if h.isReadOnlyAccount(user) && c.Request.Method != http.MethodGet { + respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only") + return nil, false + } return user, true } diff --git a/api/api.go b/api/api.go index 78b3103..9535b33 100644 --- a/api/api.go +++ b/api/api.go @@ -695,6 +695,10 @@ func (h *handler) requestPasswordReset(c *gin.Context) { c.JSON(http.StatusAccepted, gin.H{"message": "if the account exists a reset email will be sent"}) return } + if h.isReadOnlyAccount(user) { + respondError(c, http.StatusForbidden, "read_only_account", "demo account cannot change password") + return + } if err := h.enqueuePasswordReset(c.Request.Context(), user); err != nil { slog.Error("failed to send password reset email", "err", err, "email", user.Email) @@ -748,6 +752,11 @@ func (h *handler) confirmPasswordReset(c *gin.Context) { respondError(c, http.StatusBadRequest, "invalid_token", "reset token is invalid or expired") return } + if h.isReadOnlyAccount(user) { + h.removePasswordReset(token) + respondError(c, http.StatusForbidden, "read_only_account", "demo account cannot change password") + return + } hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -807,7 +816,12 @@ func (h *handler) getAdminSettings(c *gin.Context) { } func (h *handler) updateAdminSettings(c *gin.Context) { - if _, ok := h.requireAdminOrOperator(c); !ok { + adminUser, ok := h.requireAdminOrOperator(c) + if !ok { + return + } + if h.isReadOnlyAccount(adminUser) { + respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only") return } @@ -1705,6 +1719,10 @@ func (h *handler) provisionTOTP(c *gin.Context) { respondError(c, http.StatusBadRequest, "mfa_already_enabled", "mfa already enabled for this account") return } + if h.isReadOnlyAccount(user) { + respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only") + return + } issuer := strings.TrimSpace(req.Issuer) if issuer == "" { @@ -1852,6 +1870,10 @@ func (h *handler) verifyTOTP(c *gin.Context) { respondError(c, http.StatusInternalServerError, "mfa_user_lookup_failed", "failed to load user for verification") return } + if h.isReadOnlyAccount(user) { + respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only") + return + } challenge, ok = h.updateMFAChallenge(token, func(ch *mfaChallenge) bool { if ch.userID != user.ID { @@ -2076,6 +2098,10 @@ func (h *handler) upsertSubscription(c *gin.Context) { if !ok { return } + if h.isReadOnlyAccount(user) { + respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only") + return + } var req subscriptionUpsertRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -2132,6 +2158,10 @@ func (h *handler) cancelSubscription(c *gin.Context) { if !ok { return } + if h.isReadOnlyAccount(user) { + respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only") + return + } var req subscriptionCancelRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -2274,6 +2304,10 @@ func (h *handler) disableMFA(c *gin.Context) { respondError(c, http.StatusInternalServerError, "mfa_disable_failed", "failed to load user for mfa disable") return } + if h.isReadOnlyAccount(user) { + respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only") + return + } hasSecret := strings.TrimSpace(user.MFATOTPSecret) != "" if !user.MFAEnabled && !hasSecret { @@ -2535,6 +2569,23 @@ func (h *handler) generateState() string { return hex.EncodeToString(b) } +func (h *handler) isReadOnlyAccount(user *store.User) bool { + if user == nil { + return false + } + name := strings.TrimSpace(user.Name) + email := strings.TrimSpace(user.Email) + if strings.EqualFold(name, "demo") || strings.EqualFold(email, "demo@svc.plus") { + return true + } + for _, group := range user.Groups { + if strings.EqualFold(strings.TrimSpace(group), "ReadOnly Role") { + return true + } + } + return false +} + func respondError(c *gin.Context, status int, code, message string) { c.JSON(status, gin.H{ "error": code, diff --git a/api/config_sync.go b/api/config_sync.go index 77b5484..a97c186 100644 --- a/api/config_sync.go +++ b/api/config_sync.go @@ -32,5 +32,14 @@ func (h *handler) syncConfig(c *gin.Context) { return } + user, ok := h.requireAuthenticatedUser(c) + if !ok { + return + } + if h.isReadOnlyAccount(user) { + respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only") + return + } + respondError(c, http.StatusNotImplemented, "desktop_sync_unavailable", "desktop configuration sync is not yet available") } diff --git a/cmd/accountsvc/main.go b/cmd/accountsvc/main.go index 6302743..7b9d8c4 100644 --- a/cmd/accountsvc/main.go +++ b/cmd/accountsvc/main.go @@ -17,7 +17,9 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -40,6 +42,14 @@ var ( logLevel string ) +const ( + demoUsername = "Demo" + demoPassword = "Demo" + demoEmail = "demo@svc.plus" + demoGroup = "ReadOnly Role" + demoUUIDTTL = time.Hour +) + type mailerAdapter struct { sender mailer.Sender } @@ -104,6 +114,124 @@ func (a *metricsAdapter) FetchSubscriptionStates(ctx context.Context, userIDs [] return states, nil } +func ensureDemoUser(ctx context.Context, st store.Store, logger *slog.Logger) error { + demoUser, err := findDemoUser(ctx, st) + if err != nil { + return err + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(demoPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash demo password: %w", err) + } + + expiresAt := time.Now().UTC().Add(demoUUIDTTL) + if demoUser == nil { + user := &store.User{ + Name: demoUsername, + Email: demoEmail, + EmailVerified: true, + PasswordHash: string(hashed), + Level: store.LevelUser, + Role: store.RoleUser, + Groups: []string{demoGroup}, + Permissions: []string{}, + Active: true, + ProxyUUID: uuid.NewString(), + ProxyUUIDExpiresAt: &expiresAt, + } + if err := st.CreateUser(ctx, user); err != nil { + return fmt.Errorf("create demo user: %w", err) + } + if logger != nil { + logger.Info("demo read-only user created", "username", demoUsername, "email", demoEmail) + } + return nil + } + + demoUser.Name = demoUsername + demoUser.Email = demoEmail + demoUser.EmailVerified = true + demoUser.PasswordHash = string(hashed) + demoUser.Level = store.LevelUser + demoUser.Role = store.RoleUser + demoUser.Groups = []string{demoGroup} + demoUser.Permissions = []string{} + demoUser.Active = true + demoUser.ProxyUUID = uuid.NewString() + demoUser.ProxyUUIDExpiresAt = &expiresAt + if err := st.UpdateUser(ctx, demoUser); err != nil { + return fmt.Errorf("update demo user: %w", err) + } + if logger != nil { + logger.Info("demo read-only user ensured", "username", demoUsername, "email", demoEmail) + } + return nil +} + +func findDemoUser(ctx context.Context, st store.Store) (*store.User, error) { + userByName, errByName := st.GetUserByName(ctx, demoUsername) + if errByName != nil && !errors.Is(errByName, store.ErrUserNotFound) { + return nil, fmt.Errorf("get demo by name: %w", errByName) + } + userByEmail, errByEmail := st.GetUserByEmail(ctx, demoEmail) + if errByEmail != nil && !errors.Is(errByEmail, store.ErrUserNotFound) { + return nil, fmt.Errorf("get demo by email: %w", errByEmail) + } + + if userByName != nil && userByEmail != nil && userByName.ID != userByEmail.ID { + return nil, fmt.Errorf("demo account conflict: username %q and email %q belong to different users", demoUsername, demoEmail) + } + if userByName != nil { + return userByName, nil + } + if userByEmail != nil { + return userByEmail, nil + } + return nil, nil +} + +func startDemoUUIDRotator(ctx context.Context, st store.Store, logger *slog.Logger) { + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + user, err := findDemoUser(context.Background(), st) + if err != nil { + if logger != nil { + logger.Warn("demo uuid rotation skipped: lookup failed", "err", err) + } + continue + } + if user == nil { + if err := ensureDemoUser(context.Background(), st, logger); err != nil && logger != nil { + logger.Warn("demo uuid rotation failed to recreate user", "err", err) + } + continue + } + + expiresAt := time.Now().UTC().Add(demoUUIDTTL) + user.ProxyUUID = uuid.NewString() + user.ProxyUUIDExpiresAt = &expiresAt + if err := st.UpdateUser(context.Background(), user); err != nil { + if logger != nil { + logger.Warn("demo uuid rotation failed", "err", err) + } + continue + } + if logger != nil { + logger.Info("demo uuid rotated", "userID", user.ID, "expiresAt", expiresAt) + } + } + } + }() +} + func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) error { if ctx == nil { ctx = context.Background() @@ -150,6 +278,11 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err } }() + if err := ensureDemoUser(ctx, st, logger); err != nil { + return err + } + startDemoUUIDRotator(ctx, st, logger) + var emailSender api.EmailSender emailVerificationEnabled := true smtpHost := strings.TrimSpace(cfg.SMTP.Host)