From 985c39de1fc7fe2f5033b8b69df2df5cd212d2eb Mon Sep 17 00:00:00 2001 From: Alex Ababii Date: Thu, 8 Oct 2026 14:41:38 +0200 Subject: [PATCH] feat(authguest): basica data structs and config for rate limiting --- services/auth-guest/pkg/command/server.go | 1 + services/auth-guest/pkg/config/config.go | 18 ++++++ .../pkg/config/defaults/defaultconfig.go | 47 +++++++++++++- .../pkg/config/defaults/defaultconfig_test.go | 64 +++++++++++++++++++ .../pkg/service/authguest/options.go | 8 +++ .../pkg/service/authguest/service.go | 8 +++ .../pkg/service/authguest/service_test.go | 15 +++++ .../pkg/service/storage/file_manager_test.go | 22 +++++++ .../auth-guest/pkg/service/storage/storage.go | 9 +++ 9 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 services/auth-guest/pkg/config/defaults/defaultconfig_test.go diff --git a/services/auth-guest/pkg/command/server.go b/services/auth-guest/pkg/command/server.go index 074e5bff7f..aff76690c7 100644 --- a/services/auth-guest/pkg/command/server.go +++ b/services/auth-guest/pkg/command/server.go @@ -104,6 +104,7 @@ func Server(cfg *config.Config) *cobra.Command { authguest.ServiceAccount(cfg.ServiceAccount), authguest.JWT(jwtService), authguest.EventsPublisher(evStream), + authguest.Challenge(cfg.Challenge), ) if !cfg.HTTP.Disabled { diff --git a/services/auth-guest/pkg/config/config.go b/services/auth-guest/pkg/config/config.go index 23af285b91..063d6fc999 100644 --- a/services/auth-guest/pkg/config/config.go +++ b/services/auth-guest/pkg/config/config.go @@ -33,6 +33,8 @@ type Config struct { Storage Storage `yaml:"storage"` TokenManager *TokenManager `yaml:"token_manager"` JWT JWT `yaml:"jwt"` + Challenge Challenge `yaml:"challenge"` + Renew Renew `yaml:"renew"` ServiceAccount ServiceAccount `yaml:"service_account"` @@ -101,6 +103,22 @@ type JWT struct { TTL time.Duration `yaml:"ttl" env:"AUTH_GUEST_JWT_TTL" desc:"The lifetime of a redeemed guest session token." introductionVersion:"%%NEXT%%"` } +// Challenge defines the configuration of the browser-bound PIN challenge that +// is created on renewal and required to verify a PIN. +type Challenge struct { + CookieName string `yaml:"cookie_name" env:"AUTH_GUEST_CHALLENGE_COOKIE_NAME" desc:"The name of the cookie that binds a PIN challenge to the browser that requested the renewal." introductionVersion:"%%NEXT%%"` + TTL time.Duration `yaml:"ttl" env:"AUTH_GUEST_CHALLENGE_TTL" desc:"The lifetime of a PIN challenge. After it expires the PIN has to be renewed again. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` + MaxAttempts int `yaml:"max_attempts" env:"AUTH_GUEST_CHALLENGE_MAX_ATTEMPTS" desc:"The maximum number of failed PIN verification attempts before the challenge and the PIN are invalidated." introductionVersion:"%%NEXT%%"` + BackoffBase time.Duration `yaml:"backoff_base" env:"AUTH_GUEST_CHALLENGE_BACKOFF_BASE" desc:"The base duration for the exponential backoff between failed PIN verification attempts. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` + BackoffCap time.Duration `yaml:"backoff_cap" env:"AUTH_GUEST_CHALLENGE_BACKOFF_CAP" desc:"The maximum duration for the exponential backoff between failed PIN verification attempts. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` +} + +// Renew defines the configuration to throttle renewal requests per guest link. +type Renew struct { + Cooldown time.Duration `yaml:"cooldown" env:"AUTH_GUEST_RENEW_COOLDOWN" desc:"The minimum time between two renewal requests for the same guest link. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"` + DailyCap int `yaml:"daily_cap" env:"AUTH_GUEST_RENEW_DAILY_CAP" desc:"The maximum number of renewal requests for the same guest link within the renewal window of 24 hours." introductionVersion:"%%NEXT%%"` +} + const ( // sessionSecretInfo binds the derived guest session key to its purpose. Changing it // invalidates all existing guest sessions. diff --git a/services/auth-guest/pkg/config/defaults/defaultconfig.go b/services/auth-guest/pkg/config/defaults/defaultconfig.go index 880e62d019..32e9acebbd 100644 --- a/services/auth-guest/pkg/config/defaults/defaultconfig.go +++ b/services/auth-guest/pkg/config/defaults/defaultconfig.go @@ -13,6 +13,18 @@ import ( "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" ) +// Default values for the PIN challenge and renewal throttling. +const ( + defaultSessionCookieName = "__Host-oc_guest_session" + defaultChallengeCookieName = "__Host-oc_guest_challenge" + defaultChallengeTTL = 15 * time.Minute + defaultChallengeMaxAttempts = 5 + defaultChallengeBackoffBase = time.Second + defaultChallengeBackoffCap = 30 * time.Second + defaultRenewCooldown = time.Minute + defaultRenewDailyCap = 20 +) + // FullDefaultConfig returns the full default config func FullDefaultConfig() *config.Config { cfg := DefaultConfig() @@ -60,9 +72,20 @@ func DefaultConfig() *config.Config { RootDirectory: path.Join(defaults.BaseDataPath(), "auth-guest"), }, JWT: config.JWT{ - CookieName: "__Host-oc_guest_session", + CookieName: defaultSessionCookieName, TTL: 24 * time.Hour, }, + Challenge: config.Challenge{ + CookieName: defaultChallengeCookieName, + TTL: defaultChallengeTTL, + MaxAttempts: defaultChallengeMaxAttempts, + BackoffBase: defaultChallengeBackoffBase, + BackoffCap: defaultChallengeBackoffCap, + }, + Renew: config.Renew{ + Cooldown: defaultRenewCooldown, + DailyCap: defaultRenewDailyCap, + }, } } @@ -90,6 +113,28 @@ func EnsureDefaults(cfg *config.Config) { if cfg.Commons != nil { cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS } + + if cfg.Challenge.CookieName == "" { + cfg.Challenge.CookieName = defaultChallengeCookieName + } + if cfg.Challenge.TTL <= 0 { + cfg.Challenge.TTL = defaultChallengeTTL + } + if cfg.Challenge.MaxAttempts <= 0 { + cfg.Challenge.MaxAttempts = defaultChallengeMaxAttempts + } + if cfg.Challenge.BackoffBase <= 0 { + cfg.Challenge.BackoffBase = defaultChallengeBackoffBase + } + if cfg.Challenge.BackoffCap <= 0 { + cfg.Challenge.BackoffCap = defaultChallengeBackoffCap + } + if cfg.Renew.Cooldown <= 0 { + cfg.Renew.Cooldown = defaultRenewCooldown + } + if cfg.Renew.DailyCap <= 0 { + cfg.Renew.DailyCap = defaultRenewDailyCap + } } // Sanitize sanitizes the config diff --git a/services/auth-guest/pkg/config/defaults/defaultconfig_test.go b/services/auth-guest/pkg/config/defaults/defaultconfig_test.go new file mode 100644 index 0000000000..080f2b53d1 --- /dev/null +++ b/services/auth-guest/pkg/config/defaults/defaultconfig_test.go @@ -0,0 +1,64 @@ +// Copyright 2026 OpenCloud GmbH +// SPDX-License-Identifier: Apache-2.0 + +package defaults + +import ( + "testing" + "time" + + "github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config" + "github.com/stretchr/testify/assert" +) + +func TestDefaultConfigChallengeAndRenew(t *testing.T) { + cfg := DefaultConfig() + + assert.Equal(t, defaultChallengeCookieName, cfg.Challenge.CookieName) + assert.Equal(t, defaultChallengeTTL, cfg.Challenge.TTL) + assert.Equal(t, defaultChallengeMaxAttempts, cfg.Challenge.MaxAttempts) + assert.Equal(t, defaultChallengeBackoffBase, cfg.Challenge.BackoffBase) + assert.Equal(t, defaultChallengeBackoffCap, cfg.Challenge.BackoffCap) + assert.Equal(t, defaultRenewCooldown, cfg.Renew.Cooldown) + assert.Equal(t, defaultRenewDailyCap, cfg.Renew.DailyCap) +} + +func TestEnsureDefaultsFillsChallengeAndRenew(t *testing.T) { + cfg := &config.Config{} + + EnsureDefaults(cfg) + + assert.Equal(t, defaultChallengeCookieName, cfg.Challenge.CookieName) + assert.Equal(t, defaultChallengeTTL, cfg.Challenge.TTL) + assert.Equal(t, defaultChallengeMaxAttempts, cfg.Challenge.MaxAttempts) + assert.Equal(t, defaultChallengeBackoffBase, cfg.Challenge.BackoffBase) + assert.Equal(t, defaultChallengeBackoffCap, cfg.Challenge.BackoffCap) + assert.Equal(t, defaultRenewCooldown, cfg.Renew.Cooldown) + assert.Equal(t, defaultRenewDailyCap, cfg.Renew.DailyCap) +} + +func TestEnsureDefaultsKeepsCustomChallengeAndRenew(t *testing.T) { + cfg := &config.Config{ + Challenge: config.Challenge{ + CookieName: "custom-challenge", + TTL: 2 * time.Minute, + MaxAttempts: 3, + BackoffBase: 2 * time.Second, + BackoffCap: 10 * time.Second, + }, + Renew: config.Renew{ + Cooldown: 30 * time.Second, + DailyCap: 5, + }, + } + + EnsureDefaults(cfg) + + assert.Equal(t, "custom-challenge", cfg.Challenge.CookieName) + assert.Equal(t, 2*time.Minute, cfg.Challenge.TTL) + assert.Equal(t, 3, cfg.Challenge.MaxAttempts) + assert.Equal(t, 2*time.Second, cfg.Challenge.BackoffBase) + assert.Equal(t, 10*time.Second, cfg.Challenge.BackoffCap) + assert.Equal(t, 30*time.Second, cfg.Renew.Cooldown) + assert.Equal(t, 5, cfg.Renew.DailyCap) +} diff --git a/services/auth-guest/pkg/service/authguest/options.go b/services/auth-guest/pkg/service/authguest/options.go index c5cf9f5c60..4cc368200c 100644 --- a/services/auth-guest/pkg/service/authguest/options.go +++ b/services/auth-guest/pkg/service/authguest/options.go @@ -19,6 +19,7 @@ type Options struct { ServiceAccount config.ServiceAccount JWT *jwt.JwtService Publisher events.Publisher + Challenge config.Challenge } // GatewaySelector adds a grpc client selector for the gateway service @@ -48,3 +49,10 @@ func EventsPublisher(p events.Publisher) Option { o.Publisher = p } } + +// Challenge configures the PIN challenge for the auth-guest service +func Challenge(challenge config.Challenge) Option { + return func(o *Options) { + o.Challenge = challenge + } +} diff --git a/services/auth-guest/pkg/service/authguest/service.go b/services/auth-guest/pkg/service/authguest/service.go index 70d59e4cb9..5333d2f991 100644 --- a/services/auth-guest/pkg/service/authguest/service.go +++ b/services/auth-guest/pkg/service/authguest/service.go @@ -72,6 +72,7 @@ type AuthGuestService struct { serviceAccount config.ServiceAccount jwtService *jwt.JwtService publisher revaevents.Publisher + challenge config.Challenge } func NewAuthGuestService(tokenSvc *token.TokenService, store storage.Manager, opts ...Option) *AuthGuestService { @@ -87,6 +88,7 @@ func NewAuthGuestService(tokenSvc *token.TokenService, store storage.Manager, op serviceAccount: o.ServiceAccount, jwtService: o.JWT, publisher: o.Publisher, + challenge: o.Challenge, } } @@ -185,6 +187,7 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID, tokenString, sess rec.PinHash = pinHash rec.Expiry = now.Add(guestLinkTokenTTL) rec.PinExpiry = now.Add(guestPinTTL) + rec.ChallengeExpiry = now.Add(s.challenge.TTL) rec.Redeemed = false return nil }) @@ -232,6 +235,10 @@ func (s *AuthGuestService) VerifyPin(ctx context.Context, shareID, pinValue stri return ErrPinExpired } + if !rec.ChallengeExpiry.IsZero() && rec.ChallengeExpiry.Before(time.Now()) { + return ErrPinExpired + } + ok, err := pin.Verify(pinValue, rec.PinHash) if err != nil || !ok { return ErrPinInvalid @@ -239,6 +246,7 @@ func (s *AuthGuestService) VerifyPin(ctx context.Context, shareID, pinValue stri rec.PinHash = "" rec.PinExpiry = time.Time{} + rec.ChallengeExpiry = time.Time{} return nil }) diff --git a/services/auth-guest/pkg/service/authguest/service_test.go b/services/auth-guest/pkg/service/authguest/service_test.go index a881db9cc1..51f48d39b1 100644 --- a/services/auth-guest/pkg/service/authguest/service_test.go +++ b/services/auth-guest/pkg/service/authguest/service_test.go @@ -660,6 +660,7 @@ func TestVerifyPinSuccess(t *testing.T) { require.NoError(t, err) assert.Empty(t, got.PinHash) assert.True(t, got.PinExpiry.IsZero()) + assert.True(t, got.ChallengeExpiry.IsZero()) } func TestVerifyPinErrors(t *testing.T) { @@ -678,6 +679,13 @@ func TestVerifyPinErrors(t *testing.T) { PinHash: pinHash, PinExpiry: time.Now().Add(-time.Minute), } + challengeExpiredRecord := storage.Record{ + ShareID: testShareID, + ShareIDHash: token.Hash(testShareID), + PinHash: pinHash, + PinExpiry: time.Now().Add(time.Minute), + ChallengeExpiry: time.Now().Add(-time.Minute), + } noPinRecord := storage.Record{ShareID: testShareID, ShareIDHash: token.Hash(testShareID)} validShare := &collaboration.GetShareResponse{ @@ -707,6 +715,13 @@ func TestVerifyPinErrors(t *testing.T) { response: validShare, wantErr: ErrPinExpired, }, + { + name: "expired challenge", + pin: "123456", + rec: challengeExpiredRecord, + response: validShare, + wantErr: ErrPinExpired, + }, { name: "no pin set", pin: "123456", diff --git a/services/auth-guest/pkg/service/storage/file_manager_test.go b/services/auth-guest/pkg/service/storage/file_manager_test.go index 6a5884a2c1..3b15894954 100644 --- a/services/auth-guest/pkg/service/storage/file_manager_test.go +++ b/services/auth-guest/pkg/service/storage/file_manager_test.go @@ -40,6 +40,28 @@ func TestFileManagerAddGet(t *testing.T) { assert.Equal(t, rec, *got) } +func TestFileManagerChallengeAndRenewFieldsRoundTrip(t *testing.T) { + dir := t.TempDir() + s := NewFileManager(dir) + + rec := newRecord("e0123456-7890-abcd-ef01-234567890abc") + rec.PinHash = "pinhash" + rec.PinExpiry = time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC) + rec.ChallengeNonceHash = "noncehash" + rec.ChallengeExpiry = time.Date(2026, 2, 3, 4, 5, 9, 0, time.UTC) + rec.PinFailures = 2 + rec.PinNotBefore = time.Date(2026, 2, 3, 4, 5, 7, 0, time.UTC) + rec.LastRenewAt = time.Date(2026, 2, 3, 4, 5, 8, 0, time.UTC) + rec.RenewCount = 3 + rec.RenewWindowStart = time.Date(2026, 2, 3, 0, 0, 0, 0, time.UTC) + + require.NoError(t, s.Add(rec)) + + got, err := s.Get(rec.ShareIDHash) + require.NoError(t, err) + assert.Equal(t, rec, *got) +} + func TestFileManagerGetMissing(t *testing.T) { dir := t.TempDir() s := NewFileManager(dir) diff --git a/services/auth-guest/pkg/service/storage/storage.go b/services/auth-guest/pkg/service/storage/storage.go index 1392bb8e4b..7c1da3789e 100644 --- a/services/auth-guest/pkg/service/storage/storage.go +++ b/services/auth-guest/pkg/service/storage/storage.go @@ -22,6 +22,15 @@ type Record struct { PinExpiry time.Time `json:"pinexpiry,omitzero"` Redeemed bool `json:"redeemed"` Revision uint64 `json:"revision"` + + // The following fields are needed for rate limiting. + ChallengeNonceHash string `json:"challengenoncehash,omitempty"` + ChallengeExpiry time.Time `json:"challengeexpiry,omitzero"` + PinFailures int `json:"pinfailures,omitempty"` + PinNotBefore time.Time `json:"pinnotbefore,omitzero"` + LastRenewAt time.Time `json:"lastrenewat,omitzero"` + RenewCount int `json:"renewcount,omitempty"` + RenewWindowStart time.Time `json:"renewwindowstart,omitzero"` } type Manager interface {