feat(authguest): basica data structs and config for rate limiting

This commit is contained in:
Alex Ababii committed 2026-10-16 09:32:46 +02:00
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 {
+18
View File
@@ -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 {