feat(authguest): extracted restrict access logic into separate package, backoff and quota limiters

This commit is contained in:
Alex Ababii committed 2026-10-16 10:01:55 +02:00
1 parent 42a411c11a
commit d8ab8dc7ee
22 files changed
+1178 -400

No files matched your search

+3 -1
View File
@@ -104,7 +104,9 @@ func Server(cfg *config.Config) *cobra.Command {
authguest.ServiceAccount(cfg.ServiceAccount),
authguest.JWT(jwtService),
authguest.EventsPublisher(evStream),
authguest.Challenge(cfg.Challenge),
authguest.Nonce(cfg.Nonce),
authguest.BackoffLimiter(cfg.BackoffLimiter),
authguest.QuotaLimiter(cfg.QuotaLimiter),
)
if !cfg.HTTP.Disabled {
+28 -19
View File
@@ -28,13 +28,14 @@ type Config struct {
RevaGateway string `yaml:"reva_gateway" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" introductionVersion:"%%NEXT%%"`
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
GRPC GRPCConfig `yaml:"grpc"`
HTTP HTTP `yaml:"http"`
Storage Storage `yaml:"storage"`
TokenManager *TokenManager `yaml:"token_manager"`
JWT JWT `yaml:"jwt"`
Challenge Challenge `yaml:"challenge"`
Renew Renew `yaml:"renew"`
GRPC GRPCConfig `yaml:"grpc"`
HTTP HTTP `yaml:"http"`
Storage Storage `yaml:"storage"`
TokenManager *TokenManager `yaml:"token_manager"`
JWT JWT `yaml:"jwt"`
Nonce Nonce `yaml:"nonce"`
BackoffLimiter BackoffLimiter `yaml:"backoff_limiter"`
QuotaLimiter QuotaLimiter `yaml:"quota_limiter"`
ServiceAccount ServiceAccount `yaml:"service_account"`
@@ -103,20 +104,28 @@ 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%%"`
// Nonce defines the configuration of the browser-bound nonce that is
// created on renewal and required to verify a PIN.
type Nonce struct {
CookieName string `yaml:"cookie_name" env:"AUTH_GUEST_NONCE_COOKIE_NAME" desc:"The name of the cookie that binds a nonce to the browser that requested the renewal." introductionVersion:"%%NEXT%%"`
TTL time.Duration `yaml:"ttl" env:"AUTH_GUEST_NONCE_TTL" desc:"The lifetime of a nonce. After it expires the PIN has to be renewed again. 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%%"`
// BackoffLimiter defines the configuration of a limiter that counts events,
// blocks after a maximum number of them and increases the wait between events
// exponentially.
type BackoffLimiter struct {
MaxAttempts int `yaml:"max_attempts" env:"AUTH_GUEST_BACKOFF_MAX_ATTEMPTS" desc:"The maximum number of failed attempts before the PIN is invalidated." introductionVersion:"%%NEXT%%"`
BackoffBase time.Duration `yaml:"backoff_base" env:"AUTH_GUEST_BACKOFF_BASE" desc:"The base duration for the exponential backoff between failed attempts. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
BackoffCap time.Duration `yaml:"backoff_cap" env:"AUTH_GUEST_BACKOFF_CAP" desc:"The maximum duration for the exponential backoff between failed attempts. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
}
// QuotaLimiter defines the configuration of a limiter that allows a maximum
// number of events per window with a minimum cooldown between them.
type QuotaLimiter struct {
DailyCap int `yaml:"daily_cap" env:"AUTH_GUEST_QUOTA_DAILY_CAP" desc:"The maximum number of renewals for the same share within the window." introductionVersion:"%%NEXT%%"`
Cooldown time.Duration `yaml:"cooldown" env:"AUTH_GUEST_QUOTA_COOLDOWN" desc:"The minimum time between two renewals for the same share. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
Window time.Duration `yaml:"window" env:"AUTH_GUEST_QUOTA_WINDOW" desc:"The window over which the renewal quota is counted. See the Environment Variable Types description for more details." introductionVersion:"%%NEXT%%"`
}
const (
@@ -13,16 +13,17 @@ import (
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
)
// Default values for the PIN challenge and renewal throttling.
// Default values for the guard policies (nonce, backoff and quota limiters).
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
defaultSessionCookieName = "__Host-oc_guest_session"
defaultNonceCookieName = "__Host-oc_guest_nonce"
defaultNonceTTL = 15 * time.Minute
defaultBackoffMaxAttempts = 5
defaultBackoffBase = time.Second
defaultBackoffCap = 30 * time.Second
defaultQuotaCooldown = time.Minute
defaultQuotaWindow = 24 * time.Hour
defaultQuotaDailyCap = 20
)
// FullDefaultConfig returns the full default config
@@ -75,16 +76,19 @@ func DefaultConfig() *config.Config {
CookieName: defaultSessionCookieName,
TTL: 24 * time.Hour,
},
Challenge: config.Challenge{
CookieName: defaultChallengeCookieName,
TTL: defaultChallengeTTL,
MaxAttempts: defaultChallengeMaxAttempts,
BackoffBase: defaultChallengeBackoffBase,
BackoffCap: defaultChallengeBackoffCap,
Nonce: config.Nonce{
CookieName: defaultNonceCookieName,
TTL: defaultNonceTTL,
},
Renew: config.Renew{
Cooldown: defaultRenewCooldown,
DailyCap: defaultRenewDailyCap,
BackoffLimiter: config.BackoffLimiter{
MaxAttempts: defaultBackoffMaxAttempts,
BackoffBase: defaultBackoffBase,
BackoffCap: defaultBackoffCap,
},
QuotaLimiter: config.QuotaLimiter{
DailyCap: defaultQuotaDailyCap,
Cooldown: defaultQuotaCooldown,
Window: defaultQuotaWindow,
},
}
}
@@ -114,26 +118,29 @@ func EnsureDefaults(cfg *config.Config) {
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
}
if cfg.Challenge.CookieName == "" {
cfg.Challenge.CookieName = defaultChallengeCookieName
if cfg.Nonce.CookieName == "" {
cfg.Nonce.CookieName = defaultNonceCookieName
}
if cfg.Challenge.TTL <= 0 {
cfg.Challenge.TTL = defaultChallengeTTL
if cfg.Nonce.TTL <= 0 {
cfg.Nonce.TTL = defaultNonceTTL
}
if cfg.Challenge.MaxAttempts <= 0 {
cfg.Challenge.MaxAttempts = defaultChallengeMaxAttempts
if cfg.BackoffLimiter.MaxAttempts <= 0 {
cfg.BackoffLimiter.MaxAttempts = defaultBackoffMaxAttempts
}
if cfg.Challenge.BackoffBase <= 0 {
cfg.Challenge.BackoffBase = defaultChallengeBackoffBase
if cfg.BackoffLimiter.BackoffBase <= 0 {
cfg.BackoffLimiter.BackoffBase = defaultBackoffBase
}
if cfg.Challenge.BackoffCap <= 0 {
cfg.Challenge.BackoffCap = defaultChallengeBackoffCap
if cfg.BackoffLimiter.BackoffCap <= 0 {
cfg.BackoffLimiter.BackoffCap = defaultBackoffCap
}
if cfg.Renew.Cooldown <= 0 {
cfg.Renew.Cooldown = defaultRenewCooldown
if cfg.QuotaLimiter.Cooldown <= 0 {
cfg.QuotaLimiter.Cooldown = defaultQuotaCooldown
}
if cfg.Renew.DailyCap <= 0 {
cfg.Renew.DailyCap = defaultRenewDailyCap
if cfg.QuotaLimiter.Window <= 0 {
cfg.QuotaLimiter.Window = defaultQuotaWindow
}
if cfg.QuotaLimiter.DailyCap <= 0 {
cfg.QuotaLimiter.DailyCap = defaultQuotaDailyCap
}
}
@@ -11,54 +11,60 @@ import (
"github.com/stretchr/testify/assert"
)
func TestDefaultConfigChallengeAndRenew(t *testing.T) {
func TestDefaultConfigGuard(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)
assert.Equal(t, defaultNonceCookieName, cfg.Nonce.CookieName)
assert.Equal(t, defaultNonceTTL, cfg.Nonce.TTL)
assert.Equal(t, defaultBackoffMaxAttempts, cfg.BackoffLimiter.MaxAttempts)
assert.Equal(t, defaultBackoffBase, cfg.BackoffLimiter.BackoffBase)
assert.Equal(t, defaultBackoffCap, cfg.BackoffLimiter.BackoffCap)
assert.Equal(t, defaultQuotaCooldown, cfg.QuotaLimiter.Cooldown)
assert.Equal(t, defaultQuotaWindow, cfg.QuotaLimiter.Window)
assert.Equal(t, defaultQuotaDailyCap, cfg.QuotaLimiter.DailyCap)
}
func TestEnsureDefaultsFillsChallengeAndRenew(t *testing.T) {
func TestEnsureDefaultsFillsGuard(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)
assert.Equal(t, defaultNonceCookieName, cfg.Nonce.CookieName)
assert.Equal(t, defaultNonceTTL, cfg.Nonce.TTL)
assert.Equal(t, defaultBackoffMaxAttempts, cfg.BackoffLimiter.MaxAttempts)
assert.Equal(t, defaultBackoffBase, cfg.BackoffLimiter.BackoffBase)
assert.Equal(t, defaultBackoffCap, cfg.BackoffLimiter.BackoffCap)
assert.Equal(t, defaultQuotaCooldown, cfg.QuotaLimiter.Cooldown)
assert.Equal(t, defaultQuotaWindow, cfg.QuotaLimiter.Window)
assert.Equal(t, defaultQuotaDailyCap, cfg.QuotaLimiter.DailyCap)
}
func TestEnsureDefaultsKeepsCustomChallengeAndRenew(t *testing.T) {
func TestEnsureDefaultsKeepsCustomGuard(t *testing.T) {
cfg := &config.Config{
Challenge: config.Challenge{
CookieName: "custom-challenge",
TTL: 2 * time.Minute,
Nonce: config.Nonce{
CookieName: "custom-nonce",
TTL: 2 * time.Minute,
},
BackoffLimiter: config.BackoffLimiter{
MaxAttempts: 3,
BackoffBase: 2 * time.Second,
BackoffCap: 10 * time.Second,
},
Renew: config.Renew{
QuotaLimiter: config.QuotaLimiter{
Cooldown: 30 * time.Second,
Window: 12 * time.Hour,
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)
assert.Equal(t, "custom-nonce", cfg.Nonce.CookieName)
assert.Equal(t, 2*time.Minute, cfg.Nonce.TTL)
assert.Equal(t, 3, cfg.BackoffLimiter.MaxAttempts)
assert.Equal(t, 2*time.Second, cfg.BackoffLimiter.BackoffBase)
assert.Equal(t, 10*time.Second, cfg.BackoffLimiter.BackoffCap)
assert.Equal(t, 30*time.Second, cfg.QuotaLimiter.Cooldown)
assert.Equal(t, 12*time.Hour, cfg.QuotaLimiter.Window)
assert.Equal(t, 5, cfg.QuotaLimiter.DailyCap)
}
+16 -5
View File
@@ -7,6 +7,8 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
@@ -18,6 +20,7 @@ type errorResponse struct {
ErrorType string `json:"errorType"`
Message string `json:"message"`
PermissionID string `json:"permissionId"`
RetryAfter int `json:"retryAfter,omitempty"`
}
func writeError(w http.ResponseWriter, status int, body errorResponse) {
@@ -59,10 +62,12 @@ func writeGuestError(w http.ResponseWriter, err error) {
status, errorType = http.StatusUnauthorized, "pinInvalid"
case errors.Is(ge.ErrorType, authguest.ErrPinExpired):
status, errorType = http.StatusUnauthorized, "pinExpired"
case errors.Is(ge.ErrorType, authguest.ErrChallengeInvalid):
status, errorType = http.StatusUnauthorized, "challengeInvalid"
case errors.Is(ge.ErrorType, authguest.ErrChallengeExpired):
status, errorType = http.StatusUnauthorized, "challengeExpired"
case errors.Is(ge.ErrorType, authguest.ErrNonceInvalid):
status, errorType = http.StatusUnauthorized, "nonceInvalid"
case errors.Is(ge.ErrorType, authguest.ErrNonceExpired):
status, errorType = http.StatusUnauthorized, "nonceExpired"
case errors.Is(ge.ErrorType, authguest.ErrTooManyAttempts):
status, errorType = http.StatusTooManyRequests, "tooManyRequests"
case errors.Is(ge.ErrorType, jwt.ErrInvalidSession):
status, errorType = http.StatusUnauthorized, "sessionInvalid"
case errors.Is(ge.ErrorType, authguest.ErrPermissionMismatch):
@@ -74,5 +79,11 @@ func writeGuestError(w http.ResponseWriter, err error) {
message = "An internal error occurred."
}
writeError(w, status, errorResponse{ErrorType: errorType, Message: message, PermissionID: ge.ShareID})
retryAfter := 0
if ge.RetryAfter > 0 {
retryAfter = int((ge.RetryAfter + time.Second - 1) / time.Second)
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
}
writeError(w, status, errorResponse{ErrorType: errorType, Message: message, PermissionID: ge.ShareID, RetryAfter: retryAfter})
}
+1 -1
View File
@@ -50,7 +50,7 @@ func RenewHandler(log log.Logger, s authguest.AuthGuest, cfg *config.Config) fun
return
}
setChallengeCookie(w, cfg, nonce)
setNonceCookie(w, cfg, nonce)
w.WriteHeader(http.StatusOK)
}
@@ -24,7 +24,7 @@ import (
const testCookieName = "__Host-oc_guest_session"
const testChallengeCookieName = "__Host-oc_guest_challenge"
const testNonceCookieName = "__Host-oc_guest_nonce"
func newRenewHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc {
t.Helper()
@@ -33,8 +33,8 @@ func newRenewHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc {
CookieName: testCookieName,
TTL: time.Hour,
},
Challenge: config.Challenge{
CookieName: testChallengeCookieName,
Nonce: config.Nonce{
CookieName: testNonceCookieName,
TTL: time.Hour,
},
}
@@ -43,7 +43,7 @@ func newRenewHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc {
func TestRenewHandlerWithLinkToken(t *testing.T) {
svcMock := mocks.NewAuthGuest(t)
svcMock.On("Renew", mock.Anything, "share-1", "link-token", "").Return("challenge-nonce", nil)
svcMock.On("Renew", mock.Anything, "share-1", "link-token", "").Return("nonce-value", nil)
body, err := json.Marshal(RenewRequest{PermissionID: "share-1", Token: "link-token"})
require.NoError(t, err)
@@ -56,12 +56,12 @@ func TestRenewHandlerWithLinkToken(t *testing.T) {
var cookie *http.Cookie
for _, c := range rr.Result().Cookies() {
if c.Name == testChallengeCookieName {
if c.Name == testNonceCookieName {
cookie = c
}
}
require.NotNil(t, cookie)
assert.Equal(t, "challenge-nonce", cookie.Value)
assert.Equal(t, "nonce-value", cookie.Value)
assert.True(t, cookie.HttpOnly)
assert.True(t, cookie.Secure)
assert.Equal(t, "/", cookie.Path)
@@ -69,7 +69,7 @@ func TestRenewHandlerWithLinkToken(t *testing.T) {
func TestRenewHandlerWithSessionCookie(t *testing.T) {
svcMock := mocks.NewAuthGuest(t)
svcMock.On("Renew", mock.Anything, "share-1", "", "session-token").Return("challenge-nonce", nil)
svcMock.On("Renew", mock.Anything, "share-1", "", "session-token").Return("nonce-value", nil)
body, err := json.Marshal(RenewRequest{PermissionID: "share-1"})
require.NoError(t, err)
@@ -36,10 +36,10 @@ func VerifyPinHandler(log log.Logger, s authguest.AuthGuest, cfg *config.Config)
return
}
cookie, err := r.Cookie(cfg.Challenge.CookieName)
cookie, err := r.Cookie(cfg.Nonce.CookieName)
if err != nil || cookie.Value == "" {
log.Debug().Msg("the challenge cookie is missing from the request")
writeError(w, http.StatusBadRequest, errorResponse{ErrorType: "invalidRequest", Message: "The challenge cookie is missing from the request."})
log.Debug().Msg("the nonce cookie is missing from the request")
writeError(w, http.StatusBadRequest, errorResponse{ErrorType: "invalidRequest", Message: "The nonce cookie is missing from the request."})
return
}
@@ -50,7 +50,7 @@ func VerifyPinHandler(log log.Logger, s authguest.AuthGuest, cfg *config.Config)
return
}
clearChallengeCookie(w, cfg)
clearNonceCookie(w, cfg)
setSessionCookie(w, cfg, result.SessionToken)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
@@ -28,8 +28,8 @@ func newVerifyPinHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc
CookieName: "__Host-oc_guest_session",
TTL: time.Hour,
},
Challenge: config.Challenge{
CookieName: testChallengeCookieName,
Nonce: config.Nonce{
CookieName: testNonceCookieName,
TTL: time.Hour,
},
}
@@ -39,13 +39,13 @@ func newVerifyPinHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc
func TestVerifyPinHandler(t *testing.T) {
svcMock := mocks.NewAuthGuest(t)
expiresAt := time.Date(2026, 10, 15, 10, 30, 0, 0, time.FixedZone("CEST", 2*60*60))
svcMock.On("VerifyPin", mock.Anything, "share-1", "123456", "challenge-nonce").Return(&authguest.SessionResponse{SessionToken: "session-token", ExpiresAt: expiresAt, ShareID: "share-1"}, nil)
svcMock.On("VerifyPin", mock.Anything, "share-1", "123456", "nonce-value").Return(&authguest.SessionResponse{SessionToken: "session-token", ExpiresAt: expiresAt, ShareID: "share-1"}, nil)
body, err := json.Marshal(VerifyPinRequest{Pin: "123456", PermissionID: "share-1"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
req.AddCookie(&http.Cookie{Name: testChallengeCookieName, Value: "challenge-nonce"})
req.AddCookie(&http.Cookie{Name: testNonceCookieName, Value: "nonce-value"})
rr := httptest.NewRecorder()
newVerifyPinHandler(t, svcMock)(rr, req)
@@ -64,14 +64,14 @@ func TestVerifyPinHandler(t *testing.T) {
assert.True(t, cookie.Secure)
assert.Equal(t, "/", cookie.Path)
var challengeCookie *http.Cookie
var nonceCookie *http.Cookie
for _, c := range rr.Result().Cookies() {
if c.Name == testChallengeCookieName {
challengeCookie = c
if c.Name == testNonceCookieName {
nonceCookie = c
}
}
require.NotNil(t, challengeCookie)
assert.Equal(t, -1, challengeCookie.MaxAge)
require.NotNil(t, nonceCookie)
assert.Equal(t, -1, nonceCookie.MaxAge)
var resp verifyPinResponse
require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp))
@@ -102,17 +102,17 @@ func TestVerifyPinHandlerErrorMapping(t *testing.T) {
wantPermission: "share-1",
},
{
name: "challenge invalid",
err: &authguest.GuestError{ErrorType: authguest.ErrChallengeInvalid, ShareID: "share-1"},
name: "nonce invalid",
err: &authguest.GuestError{ErrorType: authguest.ErrNonceInvalid, ShareID: "share-1"},
wantStatus: http.StatusUnauthorized,
wantType: "challengeInvalid",
wantType: "nonceInvalid",
wantPermission: "share-1",
},
{
name: "challenge expired",
err: &authguest.GuestError{ErrorType: authguest.ErrChallengeExpired, ShareID: "share-1"},
name: "nonce expired",
err: &authguest.GuestError{ErrorType: authguest.ErrNonceExpired, ShareID: "share-1"},
wantStatus: http.StatusUnauthorized,
wantType: "challengeExpired",
wantType: "nonceExpired",
wantPermission: "share-1",
},
{
@@ -146,7 +146,7 @@ func TestVerifyPinHandlerErrorMapping(t *testing.T) {
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
req.AddCookie(&http.Cookie{Name: testChallengeCookieName, Value: "challenge-nonce"})
req.AddCookie(&http.Cookie{Name: testNonceCookieName, Value: "nonce-value"})
rr := httptest.NewRecorder()
newVerifyPinHandler(t, svcMock)(rr, req)
@@ -161,7 +161,30 @@ func TestVerifyPinHandlerErrorMapping(t *testing.T) {
}
}
func TestVerifyPinHandlerMissingChallengeCookie(t *testing.T) {
func TestVerifyPinHandlerTooManyAttempts(t *testing.T) {
svcMock := mocks.NewAuthGuest(t)
svcMock.On("VerifyPin", mock.Anything, "share-1", "123456", mock.Anything).
Return(nil, &authguest.GuestError{ErrorType: authguest.ErrTooManyAttempts, ShareID: "share-1", RetryAfter: 1500 * time.Millisecond})
body, err := json.Marshal(VerifyPinRequest{Pin: "123456", PermissionID: "share-1"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
req.AddCookie(&http.Cookie{Name: testNonceCookieName, Value: "nonce-value"})
rr := httptest.NewRecorder()
newVerifyPinHandler(t, svcMock)(rr, req)
assert.Equal(t, http.StatusTooManyRequests, rr.Code)
assert.Equal(t, "2", rr.Header().Get("Retry-After"))
var resp errorResponse
require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp))
assert.Equal(t, "tooManyRequests", resp.ErrorType)
assert.Equal(t, 2, resp.RetryAfter)
}
func TestVerifyPinHandlerMissingNonceCookie(t *testing.T) {
tests := []struct {
name string
addCookie bool
@@ -180,7 +203,7 @@ func TestVerifyPinHandlerMissingChallengeCookie(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
if tt.addCookie {
req.AddCookie(&http.Cookie{Name: testChallengeCookieName, Value: tt.cookieValue})
req.AddCookie(&http.Cookie{Name: testNonceCookieName, Value: tt.cookieValue})
}
rr := httptest.NewRecorder()
@@ -72,21 +72,21 @@ func setSessionCookie(w http.ResponseWriter, cfg *config.Config, sessionToken st
})
}
func setChallengeCookie(w http.ResponseWriter, cfg *config.Config, nonce string) {
func setNonceCookie(w http.ResponseWriter, cfg *config.Config, nonce string) {
http.SetCookie(w, &http.Cookie{
Name: cfg.Challenge.CookieName,
Name: cfg.Nonce.CookieName,
Value: nonce,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(cfg.Challenge.TTL.Seconds()),
MaxAge: int(cfg.Nonce.TTL.Seconds()),
})
}
func clearChallengeCookie(w http.ResponseWriter, cfg *config.Config) {
func clearNonceCookie(w http.ResponseWriter, cfg *config.Config) {
http.SetCookie(w, &http.Cookie{
Name: cfg.Challenge.CookieName,
Name: cfg.Nonce.CookieName,
Value: "",
Path: "/",
HttpOnly: true,
@@ -0,0 +1,52 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0
package authguest
import (
"errors"
"time"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/guard"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
)
var (
ErrExpired = errors.New("token expired")
ErrAlreadyRedeemed = errors.New("token already redeemed")
ErrShareNotFound = errors.New("share not found")
ErrShareExpired = errors.New("share expired")
ErrEventsNotConfigured = errors.New("event publisher not configured")
ErrPinInvalid = errors.New("pin invalid")
ErrPinExpired = errors.New("pin expired")
ErrNonceInvalid = errors.New("nonce invalid")
ErrNonceExpired = errors.New("nonce expired")
ErrTooManyAttempts = errors.New("too many attempts")
ErrPermissionMismatch = errors.New("permissionId does not match the provided credentials")
)
// GuestError wraps an auth-guest failure together with the share id. The HTTP
// transport inspects ErrorType to choose a status code and message.
type GuestError struct {
ErrorType error
ShareID string
RetryAfter time.Duration
}
func (e *GuestError) Error() string { return e.ErrorType.Error() }
func nonceError(err error) error {
if errors.Is(err, guard.ErrNonceExpired) {
return ErrNonceExpired
}
return ErrNonceInvalid
}
// nonceStoreError wraps a store error as a GuestError, treating a missing
// record as an invalid nonce.
func nonceStoreError(err error, shareID string) *GuestError {
if errors.Is(err, storage.ErrNotFound) {
err = ErrNonceInvalid
}
return &GuestError{ErrorType: err, ShareID: shareID}
}
@@ -19,7 +19,9 @@ type Options struct {
ServiceAccount config.ServiceAccount
JWT *jwt.JwtService
Publisher events.Publisher
Challenge config.Challenge
Nonce config.Nonce
BackoffLimiter config.BackoffLimiter
QuotaLimiter config.QuotaLimiter
}
// GatewaySelector adds a grpc client selector for the gateway service
@@ -50,9 +52,23 @@ func EventsPublisher(p events.Publisher) Option {
}
}
// Challenge configures the PIN challenge for the auth-guest service
func Challenge(challenge config.Challenge) Option {
// Nonce configures the browser-bound nonce for the auth-guest service
func Nonce(n config.Nonce) Option {
return func(o *Options) {
o.Challenge = challenge
o.Nonce = n
}
}
// BackoffLimiter configures the failed-attempt backoff limiter for the auth-guest service
func BackoffLimiter(l config.BackoffLimiter) Option {
return func(o *Options) {
o.BackoffLimiter = l
}
}
// QuotaLimiter configures the renewal quota limiter for the auth-guest service
func QuotaLimiter(l config.QuotaLimiter) Option {
return func(o *Options) {
o.QuotaLimiter = l
}
}
@@ -5,9 +5,6 @@ package authguest
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"time"
@@ -18,6 +15,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/events"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/guard"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/pin"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
@@ -27,45 +25,11 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
var ErrExpired = errors.New("token expired")
var ErrAlreadyRedeemed = errors.New("token already redeemed")
var ErrShareNotFound = errors.New("share not found")
var ErrShareExpired = errors.New("share expired")
var ErrEventsNotConfigured = errors.New("event publisher not configured")
var ErrPinInvalid = errors.New("pin invalid")
var ErrPinExpired = errors.New("pin expired")
// ErrChallengeInvalid is returned when the browser-bound challenge is missing
// or does not match the stored challenge.
var ErrChallengeInvalid = errors.New("challenge invalid")
// ErrChallengeExpired is returned when the browser-bound challenge has expired.
var ErrChallengeExpired = errors.New("challenge expired")
var ErrPermissionMismatch = errors.New("permissionId does not match the provided credentials")
const guestLinkTokenTTL = 30 * time.Minute
const guestPinTTL = 30 * time.Minute
const nonceLength = 32
// generateNonce returns a random value used to bind a PIN challenge to the
// browser that requested the renewal.
func generateNonce() (string, error) {
b := make([]byte, nonceLength)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("could not generate random nonce: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// GuestError wraps an auth-guest failure together with the share id. The HTTP
// transport inspects ErrorType to choose a status code and message.
type GuestError struct {
ErrorType error
ShareID string
}
func (e *GuestError) Error() string { return e.ErrorType.Error() }
const (
guestLinkTokenTTL = 30 * time.Minute
guestPinTTL = 30 * time.Minute
renewMaxAttempts = 3
)
// SessionResponse is the result of a successful authentication: a session token,
// its expiry and the share id it grants access to.
@@ -94,7 +58,15 @@ type AuthGuestService struct {
serviceAccount config.ServiceAccount
jwtService *jwt.JwtService
publisher revaevents.Publisher
challenge config.Challenge
guards guards
}
// guards bundles the rate limiters and the nonce protecting the guest
// endpoints. Their policies are fixed at construction from the service config.
type guards struct {
backoffLimiter guard.BackoffLimiter
quotaLimiter guard.QuotaLimiter
nonce guard.Nonce
}
func NewAuthGuestService(tokenSvc *token.TokenService, store storage.Manager, opts ...Option) *AuthGuestService {
@@ -110,7 +82,11 @@ func NewAuthGuestService(tokenSvc *token.TokenService, store storage.Manager, op
serviceAccount: o.ServiceAccount,
jwtService: o.JWT,
publisher: o.Publisher,
challenge: o.Challenge,
guards: guards{
backoffLimiter: guard.NewBackoffLimiter(o.BackoffLimiter),
quotaLimiter: guard.NewQuotaLimiter(o.QuotaLimiter),
nonce: guard.NewNonce(o.Nonce),
},
}
}
@@ -120,13 +96,8 @@ func (s *AuthGuestService) CreateToken(ctx context.Context, shareID string) (*to
return nil, err
}
if err := s.store.Add(storage.Record{
ShareID: shareID,
ShareIDHash: tok.ShareIDHash,
SecretHash: tok.SecretHash(),
Expiry: time.Now().Add(guestLinkTokenTTL),
Redeemed: false,
}); err != nil {
rec := storage.NewRecord(shareID, tok.ShareIDHash, tok.SecretHash(), time.Now().Add(guestLinkTokenTTL))
if err := s.store.Add(rec); err != nil {
return nil, err
}
@@ -166,9 +137,9 @@ func (s *AuthGuestService) VerifyToken(ctx context.Context, tokenString string)
return &SessionResponse{SessionToken: sessionToken, ExpiresAt: expiresAt, ShareID: rec.ShareID}, nil
}
// Renew generates a new guest link token, PIN and challenge nonce for shareID,
// Renew generates a new guest link token, PIN and nonce for shareID,
// authorized by the previous link token or a (possibly expired) session token.
// The returned nonce binds the PIN challenge to the requesting browser.
// The returned nonce binds the PIN to the requesting browser.
func (s *AuthGuestService) Renew(ctx context.Context, shareID, tokenString, sessionToken string) (string, error) {
if s.publisher == nil {
return "", ErrEventsNotConfigured
@@ -178,6 +149,19 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID, tokenString, sess
return "", err
}
hash := token.Hash(shareID)
now := time.Now()
current, err := s.store.Get(hash)
if err != nil {
return "", &GuestError{ErrorType: err, ShareID: shareID}
}
// Reject an active cooldown before doing the (remote) share validation.
if wait := s.guards.quotaLimiter.RetryAfter(&current.QuotaLimiterState, now); wait > 0 {
return "", &GuestError{ErrorType: ErrTooManyAttempts, ShareID: shareID, RetryAfter: wait}
}
share, err := s.validateShare(ctx, shareID)
if err != nil {
return "", err
@@ -198,32 +182,8 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID, tokenString, sess
return "", err
}
nonce, err := generateNonce()
nonce, seen, updated, err := s.rotateGuestToken(hash, tok, pinHash, now)
if err != nil {
return "", err
}
now := time.Now()
current, err := s.store.Get(token.Hash(shareID))
if err != nil {
return "", &GuestError{ErrorType: err, ShareID: shareID}
}
updated, err := s.store.UpdateFrom(*current, func(rec *storage.Record) error {
rec.SecretHash = tok.SecretHash()
rec.PinHash = pinHash
rec.Expiry = now.Add(guestLinkTokenTTL)
rec.PinExpiry = now.Add(guestPinTTL)
rec.ChallengeExpiry = now.Add(s.challenge.TTL)
rec.ChallengeNonceHash = token.Hash(nonce)
rec.Redeemed = false
return nil
})
if err != nil {
if errors.Is(err, storage.ErrConflict) {
err = ErrAlreadyRedeemed
}
return "", &GuestError{ErrorType: err, ShareID: shareID}
}
@@ -236,8 +196,8 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID, tokenString, sess
Pin: pinStr,
Timestamp: now,
}); err != nil {
if _, rbErr := s.store.UpdateFrom(*updated, func(rec *storage.Record) error {
*rec = *current
if _, rbErr := s.store.UpdateFrom(updated, func(rec *storage.Record) error {
*rec = seen
return nil
}); rbErr != nil {
return "", fmt.Errorf("publishing guest token renewed event failed: %w (rollback failed: %v)", err, rbErr)
@@ -248,57 +208,35 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID, tokenString, sess
return nonce, nil
}
// VerifyPin validates a PIN and the browser-bound challenge nonce and exchanges
// VerifyPin validates a PIN and the browser-bound nonce and exchanges
// them for a session token and the share id.
func (s *AuthGuestService) VerifyPin(ctx context.Context, shareID, pinValue, nonce string) (*SessionResponse, error) {
rec, err := s.store.Get(token.Hash(shareID))
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
err = ErrChallengeInvalid
}
return nil, &GuestError{ErrorType: err, ShareID: shareID}
}
hash := token.Hash(shareID)
// this check is here because the validateShare call is pretty costlly, and it's better to fail here in case the
// challenge is invalid or expired before making the remote call.
if err := verifyChallenge(rec, nonce, time.Now()); err != nil {
return nil, &GuestError{ErrorType: err, ShareID: shareID}
if err := s.precheckPin(shareID, nonce); err != nil {
return nil, err
}
if _, err := s.validateShare(ctx, shareID); err != nil {
return nil, err
}
err = s.store.Update(token.Hash(shareID), func(rec *storage.Record) error {
if err := verifyChallenge(rec, nonce, time.Now()); err != nil {
// verifyPinAttempt reports a wrong PIN as ok=false with a nil error, so the
// failed-attempt state is persisted by Update; real errors abort the write.
var denied bool
err := s.store.Update(hash, func(rec *storage.Record) error {
ok, err := s.verifyPinAttempt(rec, pinValue, nonce)
if err != nil {
return err
}
if rec.PinHash == "" {
return ErrPinInvalid
}
if !rec.PinExpiry.IsZero() && rec.PinExpiry.Before(time.Now()) {
return ErrPinExpired
}
ok, err := pin.Verify(pinValue, rec.PinHash)
if err != nil || !ok {
return ErrPinInvalid
}
rec.PinHash = ""
rec.PinExpiry = time.Time{}
rec.ChallengeExpiry = time.Time{}
rec.ChallengeNonceHash = ""
denied = !ok
return nil
})
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
err = ErrChallengeInvalid
}
return nil, &GuestError{ErrorType: err, ShareID: shareID}
return nil, nonceStoreError(err, shareID)
}
if denied {
return nil, &GuestError{ErrorType: ErrPinInvalid, ShareID: shareID}
}
sessionToken, expiresAt, err := s.jwtService.Sign(shareID)
@@ -320,6 +258,114 @@ func (s *AuthGuestService) CleanupShare(shareID string) error {
return nil
}
// rotateGuestToken replaces the guest token's secret, PIN and nonce
// under the record lock. When the record is modified concurrently it re-reads
// and re-applies the rotation, up to renewMaxAttempts. It returns the issued
// nonce, the record state seen before the successful update (for rollback) and
// the updated record.
func (s *AuthGuestService) rotateGuestToken(hash string, tok *token.Token, pinHash string, now time.Time) (string, storage.Record, storage.Record, error) {
var nonce string
for attempt := 1; ; attempt++ {
rec, err := s.store.Get(hash)
if err != nil {
return "", storage.Record{}, storage.Record{}, err
}
updated, err := s.store.UpdateFrom(*rec, func(record *storage.Record) error {
if s.guards.quotaLimiter.RetryAfter(&record.QuotaLimiterState, now) > 0 {
return ErrTooManyAttempts
}
n, err := s.guards.nonce.Issue(&record.NonceState, now)
if err != nil {
return err
}
nonce = n
record.BackoffLimiterState = guard.BackoffLimiterState{}
s.guards.quotaLimiter.Record(&record.QuotaLimiterState, now)
record.SecretHash = tok.SecretHash()
record.PinHash = pinHash
record.Expiry = now.Add(guestLinkTokenTTL)
record.PinExpiry = now.Add(guestPinTTL)
record.Redeemed = false
return nil
})
if err == nil {
return nonce, *rec, *updated, nil
}
if !errors.Is(err, storage.ErrConflict) || attempt >= renewMaxAttempts {
return "", storage.Record{}, storage.Record{}, err
}
}
}
// checkPinRequest verifies the browser-bound nonce and then the PIN
// failed-attempt backoff, returning the error to surface to the client. The
// nonce is checked first so a missing or mismatched nonce is not hidden behind
// rate limiting.
func (s *AuthGuestService) checkPinRequest(rec *storage.Record, nonce string, now time.Time) *GuestError {
if err := s.guards.nonce.Verify(&rec.NonceState, nonce, now); err != nil {
return &GuestError{ErrorType: nonceError(err), ShareID: rec.ShareID}
}
if wait := s.guards.backoffLimiter.RetryAfter(&rec.BackoffLimiterState, now); wait > 0 {
return &GuestError{ErrorType: ErrTooManyAttempts, ShareID: rec.ShareID, RetryAfter: wait}
}
return nil
}
// precheckPin cheaply rejects a bad or blocked request before the (remote)
// share validation. The same checks run again under the record lock in
// verifyPinAttempt, which is what actually consumes the attempt.
func (s *AuthGuestService) precheckPin(shareID, nonce string) error {
rec, err := s.store.Get(token.Hash(shareID))
if err != nil {
return nonceStoreError(err, shareID)
}
ge := s.checkPinRequest(rec, nonce, time.Now())
if ge != nil {
return ge
}
return nil
}
// verifyPinAttempt verifies the PIN under the caller's lock and updates the
// record: a success clears the credential, a wrong PIN records the attempt and
// its backoff. Gate and expiry failures leave the record untouched and return an
// error.
func (s *AuthGuestService) verifyPinAttempt(rec *storage.Record, pinValue, nonce string) (ok bool, err error) {
now := time.Now()
// The pre-check already surfaced Retry-After; the wait is dropped here
// because a store update cannot carry HTTP headers.
ge := s.checkPinRequest(rec, nonce, now)
if ge != nil {
return false, ge.ErrorType
}
switch {
case rec.PinHash == "":
return false, ErrPinInvalid
case !rec.PinExpiry.IsZero() && rec.PinExpiry.Before(now):
return false, ErrPinExpired
}
match, _ := pin.Verify(pinValue, rec.PinHash)
if !match && !s.guards.backoffLimiter.Record(&rec.BackoffLimiterState, now) {
// Wrong PIN, but attempts remain: keep the credential and its nonce.
return false, nil
}
// Either the PIN was correct (credential consumed) or the attempts are
// exhausted (credential invalidated); both spend the single-use nonce.
rec.PinHash = ""
rec.PinExpiry = time.Time{}
s.guards.nonce.Clear(&rec.NonceState)
s.guards.backoffLimiter.Reset(&rec.BackoffLimiterState)
return match, nil
}
// verifyRenewCredentials checks that the previous link token or a (possibly
// expired) session token belongs to shareID.
func (s *AuthGuestService) verifyRenewCredentials(shareID, tokenString, sessionToken string) error {
@@ -448,17 +494,3 @@ func (s *AuthGuestService) getShare(ctx context.Context, shareID string) (*colla
return share, nil
}
// verifyChallenge validates the browser-bound challenge nonce against the record
func verifyChallenge(rec *storage.Record, nonce string, now time.Time) error {
if rec.ChallengeNonceHash == "" ||
subtle.ConstantTimeCompare([]byte(token.Hash(nonce)), []byte(rec.ChallengeNonceHash)) != 1 {
return ErrChallengeInvalid
}
if !rec.ChallengeExpiry.IsZero() && rec.ChallengeExpiry.Before(now) {
return ErrChallengeExpired
}
return nil
}
@@ -19,6 +19,7 @@ import (
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
ocEvents "github.com/opencloud-eu/opencloud/pkg/events"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/guard"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/pin"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
@@ -36,8 +37,6 @@ import (
const testShareID = "e0123456-7890-abcd-ef01-234567890abc"
const testNonce = "test-nonce"
type testPublisher struct {
published []any
err error
@@ -170,6 +169,17 @@ func newAuthGuestService(t *testing.T, store storage.Manager, gwc *cs3mocks.Gate
GatewaySelector(newGatewayTestSelector(gwc)),
ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}),
JWT(jwt.NewJwtService("test-secret", time.Hour)),
Nonce(config.Nonce{TTL: time.Minute}),
BackoffLimiter(config.BackoffLimiter{
MaxAttempts: 5,
BackoffBase: time.Second,
BackoffCap: 30 * time.Second,
}),
QuotaLimiter(config.QuotaLimiter{
Cooldown: time.Minute,
Window: 24 * time.Hour,
DailyCap: 20,
}),
)
}
@@ -374,6 +384,14 @@ func TestVerifyTokenConflictIsAlreadyRedeemed(t *testing.T) {
}
func newRenewService(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayAPIClient, publisher revaevents.Publisher) *AuthGuestService {
t.Helper()
return newRenewServiceWithLimiter(t, store, gwc, publisher, config.QuotaLimiter{
Window: 24 * time.Hour,
DailyCap: 100,
})
}
func newRenewServiceWithLimiter(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayAPIClient, publisher revaevents.Publisher, quota config.QuotaLimiter) *AuthGuestService {
t.Helper()
return NewAuthGuestService(
token.NewTokenService(),
@@ -382,6 +400,13 @@ func newRenewService(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayA
ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}),
JWT(jwt.NewJwtService("test-secret", 24*time.Hour)),
EventsPublisher(publisher),
Nonce(config.Nonce{TTL: time.Hour}),
BackoffLimiter(config.BackoffLimiter{
MaxAttempts: 5,
BackoffBase: time.Second,
BackoffCap: 30 * time.Second,
}),
QuotaLimiter(quota),
)
}
@@ -392,6 +417,35 @@ func existingRecord() storage.Record {
}
}
// pinRecord returns a record holding a freshly issued nonce, together with the
// nonce to pass to VerifyPin.
func pinRecord(t *testing.T, pinHash string, pinExpiry time.Time, backoff guard.BackoffLimiterState, nonceExpiry time.Time) (storage.Record, string) {
t.Helper()
var state guard.NonceState
nonce, err := guard.NewNonce(config.Nonce{TTL: time.Hour}).Issue(&state, time.Now())
require.NoError(t, err)
if !nonceExpiry.IsZero() {
state.Expiry = nonceExpiry
}
return storage.Record{
ShareID: testShareID,
ShareIDHash: token.Hash(testShareID),
PinHash: pinHash,
PinExpiry: pinExpiry,
NonceState: state,
BackoffLimiterState: backoff,
}, nonce
}
func validShareResponse() *collaboration.GetShareResponse {
return &collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(time.Hour)),
}
}
func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
tok, rec := newToken(t)
@@ -417,7 +471,8 @@ func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) {
assert.True(t, strings.HasPrefix(replaced.PinHash, "$argon2id$"))
assert.WithinDuration(t, time.Now().Add(guestLinkTokenTTL), replaced.Expiry, time.Minute)
assert.WithinDuration(t, time.Now().Add(guestPinTTL), replaced.PinExpiry, time.Minute)
assert.Equal(t, token.Hash(nonce), replaced.ChallengeNonceHash)
assert.Equal(t, token.Hash(nonce), replaced.NonceState.Hash)
assert.NoError(t, guard.NewNonce(config.Nonce{TTL: time.Hour}).Verify(&replaced.NonceState, nonce, time.Now()))
assert.False(t, replaced.Redeemed)
require.Len(t, publisher.published, 1)
@@ -480,6 +535,46 @@ func TestRenewLinkTokenIgnoresRedeemedAndExpiry(t *testing.T) {
require.NoError(t, err)
}
// conflictOnceStore returns ErrConflict on the first UpdateFrom, simulating a
// concurrent modification, then delegates to the wrapped Manager.
type conflictOnceStore struct {
storage.Manager
updates int
}
func (s *conflictOnceStore) UpdateFrom(seen storage.Record, fn func(*storage.Record) error) (*storage.Record, error) {
s.updates++
if s.updates == 1 {
return nil, storage.ErrConflict
}
return s.Manager.UpdateFrom(seen, fn)
}
func TestRenewRetriesOnConflict(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
tok, rec := newToken(t)
require.NoError(t, store.Add(rec))
publisher := &testPublisher{}
gwc := newGatewayMock(validShareResponse())
flaky := &conflictOnceStore{Manager: store}
s := newRenewService(t, flaky, gwc, publisher)
nonce, err := s.Renew(context.Background(), testShareID, tok, "")
require.NoError(t, err)
assert.NotEmpty(t, nonce)
assert.Equal(t, 2, flaky.updates)
renewed, err := store.Get(token.Hash(testShareID))
require.NoError(t, err)
assert.Equal(t, rec.Revision+1, renewed.Revision)
assert.NoError(t, guard.NewNonce(config.Nonce{TTL: time.Hour}).Verify(&renewed.NonceState, nonce, time.Now()))
require.Len(t, publisher.published, 1)
// The remote share is validated only once; the retry re-reads the record only.
gwc.AssertNumberOfCalls(t, "GetShare", 1)
}
func TestRenewInvalidCredentials(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{}), &testPublisher{})
@@ -639,25 +734,94 @@ func TestRenewShareExpired(t *testing.T) {
assert.Equal(t, testShareID, ge.ShareID)
}
func TestRenewRecordsCooldown(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
tok, rec := newToken(t)
require.NoError(t, store.Add(rec))
s := newRenewServiceWithLimiter(t, store, newGatewayMock(validShareResponse()), &testPublisher{}, config.QuotaLimiter{
Cooldown: time.Minute,
Window: 24 * time.Hour,
DailyCap: 20,
})
_, err := s.Renew(context.Background(), testShareID, tok, "")
require.NoError(t, err)
got, err := store.Get(token.Hash(testShareID))
require.NoError(t, err)
assert.Equal(t, 1, got.QuotaLimiterState.Count)
assert.False(t, got.QuotaLimiterState.WindowStart.IsZero())
assert.WithinDuration(t, time.Now().Add(time.Minute), got.QuotaLimiterState.NotBefore, time.Second)
}
func TestRenewBlockedDuringCooldown(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
tok, rec := newToken(t)
rec.QuotaLimiterState = guard.QuotaLimiterState{
Count: 1,
NotBefore: time.Now().Add(time.Minute),
WindowStart: time.Now(),
}
require.NoError(t, store.Add(rec))
gwc := newGatewayMock(validShareResponse())
s := newRenewServiceWithLimiter(t, store, gwc, &testPublisher{}, config.QuotaLimiter{
Cooldown: time.Minute,
Window: 24 * time.Hour,
DailyCap: 20,
})
_, err := s.Renew(context.Background(), testShareID, tok, "")
require.Error(t, err)
var ge *GuestError
require.ErrorAs(t, err, &ge)
assert.ErrorIs(t, ge.ErrorType, ErrTooManyAttempts)
assert.Greater(t, ge.RetryAfter, time.Duration(0))
gwc.AssertNotCalled(t, "GetShare", mock.Anything, mock.Anything)
}
func TestRenewDailyCap(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
tok, rec := newToken(t)
rec.QuotaLimiterState = guard.QuotaLimiterState{
Count: 20,
NotBefore: time.Now().Add(time.Minute),
WindowStart: time.Now(),
}
require.NoError(t, store.Add(rec))
gwc := newGatewayMock(validShareResponse())
s := newRenewServiceWithLimiter(t, store, gwc, &testPublisher{}, config.QuotaLimiter{
Cooldown: time.Minute,
Window: 24 * time.Hour,
DailyCap: 20,
})
_, err := s.Renew(context.Background(), testShareID, tok, "")
require.Error(t, err)
var ge *GuestError
require.ErrorAs(t, err, &ge)
assert.ErrorIs(t, ge.ErrorType, ErrTooManyAttempts)
assert.Greater(t, ge.RetryAfter, time.Duration(0))
gwc.AssertNotCalled(t, "GetShare", mock.Anything, mock.Anything)
}
func TestVerifyPinSuccess(t *testing.T) {
pinHash, err := pin.Hash("123456")
require.NoError(t, err)
rec, nonce := pinRecord(t, pinHash, time.Now().Add(time.Minute), guard.BackoffLimiterState{}, time.Time{})
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(storage.Record{
ShareID: testShareID,
ShareIDHash: token.Hash(testShareID),
PinHash: pinHash,
PinExpiry: time.Now().Add(time.Minute),
ChallengeNonceHash: token.Hash(testNonce),
}))
require.NoError(t, store.Add(rec))
s := newAuthGuestService(t, store, newGatewayMock(&collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(time.Hour)),
}))
s := newAuthGuestService(t, store, newGatewayMock(validShareResponse()))
result, err := s.VerifyPin(context.Background(), testShareID, "123456", testNonce)
result, err := s.VerifyPin(context.Background(), testShareID, "123456", nonce)
require.NoError(t, err)
assert.NotEmpty(t, result.SessionToken)
assert.WithinDuration(t, time.Now().Add(time.Hour), result.ExpiresAt, 2*time.Second)
@@ -667,80 +831,67 @@ 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())
assert.Empty(t, got.ChallengeNonceHash)
assert.Empty(t, got.NonceState.Hash)
}
func TestVerifyPinErrors(t *testing.T) {
pinHash, err := pin.Hash("123456")
require.NoError(t, err)
validRecord := storage.Record{
ShareID: testShareID,
ShareIDHash: token.Hash(testShareID),
PinHash: pinHash,
PinExpiry: time.Now().Add(time.Minute),
ChallengeNonceHash: token.Hash(testNonce),
validShare := validShareResponse()
shareNotFound := &collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_NOT_FOUND},
}
expiredRecord := storage.Record{
ShareID: testShareID,
ShareIDHash: token.Hash(testShareID),
PinHash: pinHash,
PinExpiry: time.Now().Add(-time.Minute),
ChallengeNonceHash: token.Hash(testNonce),
}
challengeExpiredRecord := storage.Record{
ShareID: testShareID,
ShareIDHash: token.Hash(testShareID),
PinHash: pinHash,
PinExpiry: time.Now().Add(time.Minute),
ChallengeExpiry: time.Now().Add(-time.Minute),
ChallengeNonceHash: token.Hash(testNonce),
}
noPinRecord := storage.Record{
ShareID: testShareID,
ShareIDHash: token.Hash(testShareID),
ChallengeNonceHash: token.Hash(testNonce),
shareExpired := &collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(-time.Hour)),
}
validShare := &collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(time.Hour)),
freshRecord := func(t *testing.T) (storage.Record, string) {
return pinRecord(t, pinHash, time.Now().Add(time.Minute), guard.BackoffLimiterState{}, time.Time{})
}
tests := []struct {
name string
pin string
rec storage.Record
build func(t *testing.T) (storage.Record, string)
missing bool
response *collaboration.GetShareResponse
wantErr error
}{
{
name: "invalid pin",
pin: "000000",
rec: validRecord,
name: "invalid pin",
pin: "000000",
build: func(t *testing.T) (storage.Record, string) {
return freshRecord(t)
},
response: validShare,
wantErr: ErrPinInvalid,
},
{
name: "expired pin",
pin: "123456",
rec: expiredRecord,
name: "expired pin",
pin: "123456",
build: func(t *testing.T) (storage.Record, string) {
return pinRecord(t, pinHash, time.Now().Add(-time.Minute), guard.BackoffLimiterState{}, time.Time{})
},
response: validShare,
wantErr: ErrPinExpired,
},
{
name: "expired challenge",
pin: "123456",
rec: challengeExpiredRecord,
name: "expired nonce",
pin: "123456",
build: func(t *testing.T) (storage.Record, string) {
return pinRecord(t, pinHash, time.Now().Add(time.Minute), guard.BackoffLimiterState{}, time.Now().Add(-time.Minute))
},
response: validShare,
wantErr: ErrChallengeExpired,
wantErr: ErrNonceExpired,
},
{
name: "no pin set",
pin: "123456",
rec: noPinRecord,
name: "no pin set",
pin: "123456",
build: func(t *testing.T) (storage.Record, string) {
return pinRecord(t, "", time.Time{}, guard.BackoffLimiterState{}, time.Time{})
},
response: validShare,
wantErr: ErrPinInvalid,
},
@@ -749,39 +900,46 @@ func TestVerifyPinErrors(t *testing.T) {
pin: "123456",
missing: true,
response: validShare,
wantErr: ErrChallengeInvalid,
wantErr: ErrNonceInvalid,
},
{
name: "share not found",
pin: "123456",
rec: validRecord,
response: &collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_NOT_FOUND},
build: func(t *testing.T) (storage.Record, string) {
return freshRecord(t)
},
wantErr: ErrShareNotFound,
response: shareNotFound,
wantErr: ErrShareNotFound,
},
{
name: "share expired",
pin: "123456",
rec: validRecord,
response: &collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(-time.Hour)),
build: func(t *testing.T) (storage.Record, string) {
return freshRecord(t)
},
wantErr: ErrShareExpired,
response: shareExpired,
wantErr: ErrShareExpired,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var (
rec storage.Record
nonce string
)
if !tt.missing {
rec, nonce = tt.build(t)
}
store := storage.NewFileManager(t.TempDir())
if !tt.missing {
require.NoError(t, store.Add(tt.rec))
require.NoError(t, store.Add(rec))
}
s := newAuthGuestService(t, store, newGatewayMock(tt.response))
_, err := s.VerifyPin(context.Background(), testShareID, tt.pin, testNonce)
_, err := s.VerifyPin(context.Background(), testShareID, tt.pin, nonce)
require.Error(t, err)
var ge *GuestError
@@ -792,9 +950,9 @@ func TestVerifyPinErrors(t *testing.T) {
if !tt.missing {
got, err := store.Get(token.Hash(testShareID))
require.NoError(t, err)
assert.Equal(t, tt.rec.PinHash, got.PinHash)
assert.WithinDuration(t, tt.rec.PinExpiry, got.PinExpiry, time.Second)
assert.Equal(t, tt.rec.Redeemed, got.Redeemed)
assert.Equal(t, rec.PinHash, got.PinHash)
assert.WithinDuration(t, rec.PinExpiry, got.PinExpiry, time.Second)
assert.Equal(t, rec.Redeemed, got.Redeemed)
}
})
}
@@ -804,14 +962,6 @@ func TestVerifyPinNonceMismatch(t *testing.T) {
pinHash, err := pin.Hash("123456")
require.NoError(t, err)
rec := storage.Record{
ShareID: testShareID,
ShareIDHash: token.Hash(testShareID),
PinHash: pinHash,
PinExpiry: time.Now().Add(time.Minute),
ChallengeNonceHash: token.Hash(testNonce),
}
tests := []struct {
name string
nonce string
@@ -822,62 +972,58 @@ func TestVerifyPinNonceMismatch(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec, _ := pinRecord(t, pinHash, time.Now().Add(time.Minute), guard.BackoffLimiterState{}, time.Time{})
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(rec))
s := newAuthGuestService(t, store, newGatewayMock(&collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(time.Hour)),
}))
s := newAuthGuestService(t, store, newGatewayMock(validShareResponse()))
_, err := s.VerifyPin(context.Background(), testShareID, "123456", tt.nonce)
require.Error(t, err)
var ge *GuestError
require.ErrorAs(t, err, &ge)
assert.ErrorIs(t, ge.ErrorType, ErrChallengeInvalid)
assert.ErrorIs(t, ge.ErrorType, ErrNonceInvalid)
got, err := store.Get(token.Hash(testShareID))
require.NoError(t, err)
assert.Equal(t, rec.PinHash, got.PinHash)
assert.Equal(t, rec.ChallengeNonceHash, got.ChallengeNonceHash)
assert.NotEmpty(t, got.NonceState.Hash)
})
}
}
func TestVerifyPinChallengeRejectedBeforeGateway(t *testing.T) {
func TestVerifyPinNonceRejectedBeforeGateway(t *testing.T) {
pinHash, err := pin.Hash("123456")
require.NoError(t, err)
tests := []struct {
name string
nonce string
challengeExpiry time.Time
wantErr error
name string
override string
expiry time.Time
wantErr error
}{
{name: "wrong nonce", nonce: "wrong-nonce", wantErr: ErrChallengeInvalid},
{name: "expired challenge", nonce: testNonce, challengeExpiry: time.Now().Add(-time.Minute), wantErr: ErrChallengeExpired},
{name: "wrong nonce", override: "wrong-nonce", wantErr: ErrNonceInvalid},
{name: "expired nonce", expiry: time.Now().Add(-time.Minute), wantErr: ErrNonceExpired},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(storage.Record{
ShareID: testShareID,
ShareIDHash: token.Hash(testShareID),
PinHash: pinHash,
PinExpiry: time.Now().Add(time.Minute),
ChallengeNonceHash: token.Hash(testNonce),
ChallengeExpiry: tt.challengeExpiry,
}))
rec, issued := pinRecord(t, pinHash, time.Now().Add(time.Minute), guard.BackoffLimiterState{}, tt.expiry)
gwc := newGatewayMock(&collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(time.Hour)),
})
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(rec))
gwc := newGatewayMock(validShareResponse())
s := newAuthGuestService(t, store, gwc)
_, err := s.VerifyPin(context.Background(), testShareID, "123456", tt.nonce)
nonce := tt.override
if nonce == "" {
nonce = issued
}
_, err := s.VerifyPin(context.Background(), testShareID, "123456", nonce)
require.Error(t, err)
var ge *GuestError
@@ -890,23 +1036,117 @@ func TestVerifyPinChallengeRejectedBeforeGateway(t *testing.T) {
}
}
func TestVerifyPinFailedAttemptSetsBackoff(t *testing.T) {
pinHash, err := pin.Hash("123456")
require.NoError(t, err)
rec, nonce := pinRecord(t, pinHash, time.Now().Add(time.Minute), guard.BackoffLimiterState{}, time.Time{})
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(rec))
s := newAuthGuestService(t, store, newGatewayMock(validShareResponse()))
_, err = s.VerifyPin(context.Background(), testShareID, "000000", nonce)
require.Error(t, err)
var ge *GuestError
require.ErrorAs(t, err, &ge)
assert.ErrorIs(t, ge.ErrorType, ErrPinInvalid)
got, err := store.Get(token.Hash(testShareID))
require.NoError(t, err)
assert.Equal(t, 1, got.BackoffLimiterState.Count)
assert.WithinDuration(t, time.Now().Add(time.Second), got.BackoffLimiterState.NotBefore, time.Second)
assert.NotEmpty(t, got.PinHash, "PIN must not be invalidated before MaxAttempts")
}
func TestVerifyPinBlockedDuringBackoff(t *testing.T) {
pinHash, err := pin.Hash("123456")
require.NoError(t, err)
rec, nonce := pinRecord(t, pinHash, time.Now().Add(time.Minute), guard.BackoffLimiterState{
Count: 1,
NotBefore: time.Now().Add(time.Minute),
}, time.Time{})
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(rec))
gwc := newGatewayMock(validShareResponse())
s := newAuthGuestService(t, store, gwc)
_, err = s.VerifyPin(context.Background(), testShareID, "123456", nonce)
require.Error(t, err)
var ge *GuestError
require.ErrorAs(t, err, &ge)
assert.ErrorIs(t, ge.ErrorType, ErrTooManyAttempts)
assert.Greater(t, ge.RetryAfter, time.Duration(0))
gwc.AssertNotCalled(t, "GetShare", mock.Anything, mock.Anything)
}
func TestVerifyPinInvalidatedAfterMaxAttempts(t *testing.T) {
pinHash, err := pin.Hash("123456")
require.NoError(t, err)
rec, nonce := pinRecord(t, pinHash, time.Now().Add(time.Minute), guard.BackoffLimiterState{
Count: 4,
NotBefore: time.Now().Add(-time.Second),
}, time.Time{})
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(rec))
s := newAuthGuestService(t, store, newGatewayMock(validShareResponse()))
_, err = s.VerifyPin(context.Background(), testShareID, "000000", nonce)
require.Error(t, err)
var ge *GuestError
require.ErrorAs(t, err, &ge)
assert.ErrorIs(t, ge.ErrorType, ErrPinInvalid)
got, err := store.Get(token.Hash(testShareID))
require.NoError(t, err)
assert.Empty(t, got.PinHash)
assert.Empty(t, got.NonceState.Hash)
// The cleared nonce rejects the next attempt.
_, err = s.VerifyPin(context.Background(), testShareID, "123456", nonce)
require.Error(t, err)
var ge2 *GuestError
require.ErrorAs(t, err, &ge2)
assert.ErrorIs(t, ge2.ErrorType, ErrNonceInvalid)
}
func TestRenewResetsPinAttempts(t *testing.T) {
store := storage.NewFileManager(t.TempDir())
tok, rec := newToken(t)
rec.BackoffLimiterState = guard.BackoffLimiterState{Count: 3, NotBefore: time.Now().Add(time.Minute)}
require.NoError(t, store.Add(rec))
s := newRenewService(t, store, newGatewayMock(validShareResponse()), &testPublisher{})
_, err := s.Renew(context.Background(), testShareID, tok, "")
require.NoError(t, err)
got, err := store.Get(token.Hash(testShareID))
require.NoError(t, err)
assert.Zero(t, got.BackoffLimiterState.Count)
assert.True(t, got.BackoffLimiterState.NotBefore.IsZero())
}
func TestVerifyPinSingleUseConcurrent(t *testing.T) {
pinHash, err := pin.Hash("123456")
require.NoError(t, err)
rec, nonce := pinRecord(t, pinHash, time.Now().Add(time.Minute), guard.BackoffLimiterState{}, time.Time{})
store := storage.NewFileManager(t.TempDir())
require.NoError(t, store.Add(storage.Record{
ShareID: testShareID,
ShareIDHash: token.Hash(testShareID),
PinHash: pinHash,
PinExpiry: time.Now().Add(time.Minute),
ChallengeNonceHash: token.Hash(testNonce),
}))
require.NoError(t, store.Add(rec))
s := newAuthGuestService(t, store, newGatewayMock(&collaboration.GetShareResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Share: newGuestShare(time.Now().Add(time.Hour)),
}))
s := newAuthGuestService(t, store, newGatewayMock(validShareResponse()))
const workers = 10
var (
@@ -917,7 +1157,7 @@ func TestVerifyPinSingleUseConcurrent(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := s.VerifyPin(context.Background(), testShareID, "123456", testNonce); err == nil {
if _, err := s.VerifyPin(context.Background(), testShareID, "123456", nonce); err == nil {
success.Add(1)
}
}()
@@ -0,0 +1,65 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0
// Package guard provides generic building blocks for restricting access to
// endpoints: a browser-bound nonce, a backoff limiter and a quota limiter. The
// state is designed to be persisted per guarded resource; the policies come
// from the service config package, so guard depends on it.
package guard
import (
"time"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
)
const backoffFactor = 2
// BackoffLimiterState is the persisted state of a BackoffLimiter.
type BackoffLimiterState struct {
Count int `json:"count,omitempty"`
NotBefore time.Time `json:"notbefore,omitzero"`
}
// BackoffLimiter counts events, blocks once the configured maximum is reached
// and grows the wait between events exponentially.
type BackoffLimiter struct {
maxAttempts int
base time.Duration
cap time.Duration
}
// NewBackoffLimiter returns a BackoffLimiter configured from cfg.
func NewBackoffLimiter(cfg config.BackoffLimiter) BackoffLimiter {
return BackoffLimiter{maxAttempts: cfg.MaxAttempts, base: cfg.BackoffBase, cap: cfg.BackoffCap}
}
// RetryAfter returns the remaining wait before the next event is allowed, or 0.
func (l BackoffLimiter) RetryAfter(s *BackoffLimiterState, now time.Time) time.Duration {
if s.NotBefore.After(now) {
return s.NotBefore.Sub(now)
}
return 0
}
// Record records an event and reports whether the limit is exhausted.
func (l BackoffLimiter) Record(s *BackoffLimiterState, now time.Time) (exhausted bool) {
s.Count++
s.NotBefore = now.Add(delay(l.base, l.cap, s.Count))
return s.Count >= l.maxAttempts
}
// Reset clears the persisted state.
func (l BackoffLimiter) Reset(s *BackoffLimiterState) {
*s = BackoffLimiterState{}
}
// delay returns the wait before the next attempt after the given number of
// consecutive failures: min(base*2^(failures-1), maxWait).
func delay(base, maxWait time.Duration, failures int) time.Duration {
wait := min(base, maxWait)
for i := 1; i < failures; i++ {
wait = min(wait*backoffFactor, maxWait)
}
return wait
}
@@ -0,0 +1,52 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0
package guard
import (
"testing"
"time"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
"github.com/stretchr/testify/assert"
)
func TestBackoffLimiterExponentialBackoff(t *testing.T) {
now := time.Now()
limiter := NewBackoffLimiter(config.BackoffLimiter{MaxAttempts: 5, BackoffBase: time.Second, BackoffCap: 30 * time.Second})
var s BackoffLimiterState
want := []time.Duration{time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second}
for i, w := range want {
exhausted := limiter.Record(&s, now)
assert.Equal(t, i+1, s.Count, "failure %d", i+1)
assert.WithinDuration(t, now.Add(w), s.NotBefore, time.Millisecond, "failure %d", i+1)
assert.Equal(t, i+1 >= 5, exhausted, "failure %d", i+1)
assert.Equal(t, w, limiter.RetryAfter(&s, now), "retry after %d", i+1)
assert.Zero(t, limiter.RetryAfter(&s, now.Add(w)), "retry after elapsed %d", i+1)
}
}
func TestBackoffLimiterCap(t *testing.T) {
now := time.Now()
limiter := NewBackoffLimiter(config.BackoffLimiter{MaxAttempts: 10, BackoffBase: time.Second, BackoffCap: 4 * time.Second})
var s BackoffLimiterState
want := []time.Duration{time.Second, 2 * time.Second, 4 * time.Second, 4 * time.Second, 4 * time.Second}
for i, w := range want {
limiter.Record(&s, now)
assert.WithinDuration(t, now.Add(w), s.NotBefore, time.Millisecond, "attempt %d", i+1)
}
}
func TestBackoffLimiterReset(t *testing.T) {
limiter := NewBackoffLimiter(config.BackoffLimiter{MaxAttempts: 5, BackoffBase: time.Second, BackoffCap: 30 * time.Second})
s := BackoffLimiterState{
Count: 3,
NotBefore: time.Now().Add(time.Minute),
}
limiter.Reset(&s)
assert.Zero(t, s.Count)
assert.True(t, s.NotBefore.IsZero())
}
@@ -0,0 +1,78 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0
package guard
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"time"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
)
// Nonce verification errors.
var (
ErrNonceMissing = errors.New("nonce missing")
ErrNonceMismatch = errors.New("nonce mismatch")
ErrNonceExpired = errors.New("nonce expired")
)
const nonceLength = 32
// NonceState is the persisted browser-bound nonce.
type NonceState struct {
Hash string `json:"hash,omitempty"`
Expiry time.Time `json:"expiry,omitzero"`
}
// Nonce issues and verifies browser-bound nonces.
type Nonce struct {
ttl time.Duration
}
// NewNonce returns a Nonce configured from cfg.
func NewNonce(cfg config.Nonce) Nonce {
return Nonce{ttl: cfg.TTL}
}
// Issue generates a nonce, stores its hash and expiry, and returns the raw
// nonce to be handed to the client.
func (n Nonce) Issue(s *NonceState, now time.Time) (string, error) {
b := make([]byte, nonceLength)
_, err := rand.Read(b)
if err != nil {
return "", err
}
value := base64.RawURLEncoding.EncodeToString(b)
s.Hash = hash(value)
s.Expiry = now.Add(n.ttl)
return value, nil
}
// Verify checks the nonce against the stored hash and expiry.
func (n Nonce) Verify(s *NonceState, value string, now time.Time) error {
if s.Hash == "" {
return ErrNonceMissing
}
if subtle.ConstantTimeCompare([]byte(hash(value)), []byte(s.Hash)) != 1 {
return ErrNonceMismatch
}
if !s.Expiry.IsZero() && s.Expiry.Before(now) {
return ErrNonceExpired
}
return nil
}
// Clear removes the stored nonce.
func (n Nonce) Clear(s *NonceState) {
*s = NonceState{}
}
func hash(nonce string) string {
sum := sha256.Sum256([]byte(nonce))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
@@ -0,0 +1,56 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0
package guard
import (
"testing"
"time"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNonceIssueAndVerify(t *testing.T) {
now := time.Now()
n := NewNonce(config.Nonce{TTL: time.Minute})
var s NonceState
nonce, err := n.Issue(&s, now)
require.NoError(t, err)
assert.NotEmpty(t, nonce)
assert.NotEmpty(t, s.Hash)
require.NoError(t, n.Verify(&s, nonce, now))
assert.ErrorIs(t, n.Verify(&s, "other", now), ErrNonceMismatch)
}
func TestNonceVerifyMissing(t *testing.T) {
n := NewNonce(config.Nonce{TTL: time.Minute})
var s NonceState
assert.ErrorIs(t, n.Verify(&s, "whatever", time.Now()), ErrNonceMissing)
}
func TestNonceVerifyExpired(t *testing.T) {
now := time.Now()
n := NewNonce(config.Nonce{TTL: time.Minute})
var s NonceState
nonce, err := n.Issue(&s, now)
require.NoError(t, err)
assert.ErrorIs(t, n.Verify(&s, nonce, now.Add(2*time.Minute)), ErrNonceExpired)
}
func TestNonceClear(t *testing.T) {
now := time.Now()
n := NewNonce(config.Nonce{TTL: time.Minute})
var s NonceState
nonce, err := n.Issue(&s, now)
require.NoError(t, err)
n.Clear(&s)
assert.Empty(t, s.Hash)
assert.ErrorIs(t, n.Verify(&s, nonce, now), ErrNonceMissing)
}
@@ -0,0 +1,60 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0
package guard
import (
"time"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
)
// QuotaLimiterState is the persisted state of a QuotaLimiter.
type QuotaLimiterState struct {
Count int `json:"count,omitempty"`
NotBefore time.Time `json:"notbefore,omitzero"`
WindowStart time.Time `json:"windowstart,omitzero"`
}
// QuotaLimiter allows a maximum number of events per window, with a minimum
// cooldown between them.
type QuotaLimiter struct {
max int
cooldown time.Duration
window time.Duration
}
// NewQuotaLimiter returns a QuotaLimiter configured from cfg.
func NewQuotaLimiter(cfg config.QuotaLimiter) QuotaLimiter {
return QuotaLimiter{max: cfg.DailyCap, cooldown: cfg.Cooldown, window: cfg.Window}
}
// RetryAfter returns the remaining wait before the next event is allowed, or 0.
func (l QuotaLimiter) RetryAfter(s *QuotaLimiterState, now time.Time) time.Duration {
var wait time.Duration
if l.window > 0 && now.Sub(s.WindowStart) < l.window && s.Count >= l.max {
wait = l.window - now.Sub(s.WindowStart)
}
if s.NotBefore.After(now) {
if w := s.NotBefore.Sub(now); w > wait {
wait = w
}
}
return wait
}
// Record records an event and reports whether the limit is exhausted.
func (l QuotaLimiter) Record(s *QuotaLimiterState, now time.Time) (exhausted bool) {
if l.window > 0 && (s.WindowStart.IsZero() || now.Sub(s.WindowStart) >= l.window) {
s.WindowStart = now
s.Count = 0
}
s.Count++
s.NotBefore = now.Add(l.cooldown)
return s.Count >= l.max
}
// Reset clears the persisted state.
func (l QuotaLimiter) Reset(s *QuotaLimiterState) {
*s = QuotaLimiterState{}
}
@@ -0,0 +1,56 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0
package guard
import (
"testing"
"time"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
"github.com/stretchr/testify/assert"
)
func TestQuotaLimiterCooldownAndWindow(t *testing.T) {
now := time.Now()
limiter := NewQuotaLimiter(config.QuotaLimiter{DailyCap: 2, Cooldown: time.Minute, Window: time.Hour})
var s QuotaLimiterState
assert.Zero(t, limiter.RetryAfter(&s, now))
limiter.Record(&s, now)
assert.Equal(t, time.Minute, limiter.RetryAfter(&s, now))
assert.Zero(t, limiter.RetryAfter(&s, now.Add(time.Minute)))
limiter.Record(&s, now)
assert.Equal(t, time.Hour, limiter.RetryAfter(&s, now))
assert.Zero(t, limiter.RetryAfter(&s, now.Add(2*time.Hour)))
limiter.Record(&s, now.Add(2*time.Hour))
assert.Equal(t, 1, s.Count)
}
func TestQuotaLimiterCooldownOutlastsWindow(t *testing.T) {
now := time.Now()
limiter := NewQuotaLimiter(config.QuotaLimiter{DailyCap: 2, Cooldown: 2 * time.Hour, Window: time.Hour})
var s QuotaLimiterState
limiter.Record(&s, now)
limiter.Record(&s, now)
assert.Equal(t, 2*time.Hour, limiter.RetryAfter(&s, now))
}
func TestQuotaLimiterReset(t *testing.T) {
limiter := NewQuotaLimiter(config.QuotaLimiter{DailyCap: 2, Cooldown: time.Minute, Window: time.Hour})
s := QuotaLimiterState{
Count: 3,
NotBefore: time.Now().Add(time.Minute),
WindowStart: time.Now(),
}
limiter.Reset(&s)
assert.Zero(t, s.Count)
assert.True(t, s.NotBefore.IsZero())
assert.True(t, s.WindowStart.IsZero())
}
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/guard"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -40,20 +41,26 @@ func TestFileManagerAddGet(t *testing.T) {
assert.Equal(t, rec, *got)
}
func TestFileManagerChallengeAndRenewFieldsRoundTrip(t *testing.T) {
func TestFileManagerGuardsRoundTrip(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)
rec.NonceState = guard.NonceState{
Hash: "noncehash",
Expiry: time.Date(2026, 2, 3, 4, 5, 9, 0, time.UTC),
}
rec.BackoffLimiterState = guard.BackoffLimiterState{
Count: 2,
NotBefore: time.Date(2026, 2, 3, 4, 5, 7, 0, time.UTC),
}
rec.QuotaLimiterState = guard.QuotaLimiterState{
Count: 3,
NotBefore: time.Date(2026, 2, 3, 4, 5, 8, 0, time.UTC),
WindowStart: time.Date(2026, 2, 3, 0, 0, 0, 0, time.UTC),
}
require.NoError(t, s.Add(rec))
@@ -6,6 +6,8 @@ package storage
import (
"errors"
"time"
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/guard"
)
var ErrNotFound = errors.New("record not found")
@@ -14,23 +16,27 @@ var ErrConflict = errors.New("record was modified concurrently")
// Record holds the data persisted for a guest share token.
type Record struct {
ShareID string `json:"shareid"`
ShareIDHash string `json:"shareidhash"`
SecretHash string `json:"secrethash"`
PinHash string `json:"pinhash"`
Expiry time.Time `json:"expiry,omitzero"`
PinExpiry time.Time `json:"pinexpiry,omitzero"`
Redeemed bool `json:"redeemed"`
Revision uint64 `json:"revision"`
ShareID string `json:"shareid"`
ShareIDHash string `json:"shareidhash"`
SecretHash string `json:"secrethash"`
PinHash string `json:"pinhash"`
Expiry time.Time `json:"expiry,omitzero"`
PinExpiry time.Time `json:"pinexpiry,omitzero"`
Redeemed bool `json:"redeemed"`
Revision uint64 `json:"revision"`
NonceState guard.NonceState `json:"nonce,omitzero"`
BackoffLimiterState guard.BackoffLimiterState `json:"backoff_limiter,omitzero"`
QuotaLimiterState guard.QuotaLimiterState `json:"quota_limiter,omitzero"`
}
// 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"`
// NewRecord returns a record for the given token.
func NewRecord(shareID, shareIDHash, secretHash string, expiry time.Time) Record {
return Record{
ShareID: shareID,
ShareIDHash: shareIDHash,
SecretHash: secretHash,
Expiry: expiry,
}
}
type Manager interface {