feat: add read-only Demo account with hourly UUID rotation

This commit is contained in:
Haitao Pan 2026-02-04 12:37:31 +08:00
parent 89c8957c57
commit 09eb26da25
4 changed files with 198 additions and 1 deletions

View File

@ -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
}

View File

@ -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,

View File

@ -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")
}

View File

@ -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)