mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-10-31 02:28:31 -04:00
feat(authguest): basica data structs and config for rate limiting
This commit is contained in:
1 parent
40095460a1
commit
985c39de1f
9 files changed
+191
-1
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
|
||||
// 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user