feat: add read-only Demo account with hourly UUID rotation
This commit is contained in:
parent
89c8957c57
commit
09eb26da25
@ -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
|
||||
}
|
||||
|
||||
53
api/api.go
53
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,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user