mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-11-01 01:58:34 -05:00
feat(authguest): upd renewal logic to verify token and session, storage has now revisions
This commit is contained in:
1 parent
fb09fedc20
commit
b266546a45
16 files changed
+657
-97
No files matched your search
@@ -75,24 +75,23 @@ sequenceDiagram
|
||||
|
||||
A guest session (signed JWT) is valid for 24 hours by default
|
||||
(`AUTH_GUEST_JWT_TTL`); a link token is single-use and valid for 30 minutes.
|
||||
Renewal starts from a *failed* attempt`:
|
||||
Renewal starts from a *failed* attempt:
|
||||
|
||||
- an authenticated request made with an expired session is rejected with `401`;
|
||||
the browser still holds the (expired) session cookie;
|
||||
- `redeem` on a link that was already used answers `409`, and on an expired
|
||||
link `401`.
|
||||
link `401`; the client still holds the link token.
|
||||
|
||||
Both errors carry the share id, which the client sends to `renew` as
|
||||
`permissionId`.
|
||||
|
||||
`renew` itself does not create a
|
||||
session. It requires an existing token record for the share
|
||||
, validates the share, generates a new link token and a 6-digit PIN (both valid
|
||||
for 30 minutes), overwrites the stored token and PIN hashes (invalidating the
|
||||
previous link and PIN), resets the redeemed flag so the new link can be
|
||||
redeemed, and publishes `GuestTokenRenewed`. It answers `200`. This works
|
||||
whether the record is unused, expired or already redeemed (the guest already
|
||||
redeemed an earlier link); if publishing fails, the previous record is
|
||||
restored.
|
||||
`renew` requires the share id (`permissionId`) and is authorized by either the
|
||||
(possibly expired) session cookie or the previous link token, whichever the
|
||||
client has; the credential must belong to the given share. It does not create a
|
||||
session. It requires an existing token record for the share, validates the
|
||||
share, generates a new link token and a 6-digit PIN (both valid for 30 minutes),
|
||||
overwrites the stored token and PIN hashes (invalidating the previous link and
|
||||
PIN), resets the redeemed flag so the new link can be redeemed, and publishes
|
||||
`GuestTokenRenewed`. It answers `200`. This works whether the record is unused,
|
||||
expired or already redeemed (the guest already redeemed an earlier link); if
|
||||
publishing fails, the previous record is restored.
|
||||
|
||||
To turn the new credentials into a session the guest either redeems the link
|
||||
(`redeem`) or enters the PIN (`verify/pin`); both return a session cookie and
|
||||
@@ -109,7 +108,7 @@ sequenceDiagram
|
||||
participant Bus as Event bus
|
||||
|
||||
Note over Web: Session expired / previous link used or expired
|
||||
Web->>+Renew: POST renew { permissionId }
|
||||
Web->>+Renew: POST renew { permissionId } (+ session cookie or link token)
|
||||
Note right of Renew: Validate share, generate token + PIN, store hashes
|
||||
Renew->>Bus: publish GuestTokenRenewed
|
||||
Note right of Bus: External consumer delivers token + PIN (out of scope)
|
||||
@@ -144,14 +143,16 @@ sequenceDiagram
|
||||
30 minutes; the session cookie lives for 24 hours by default
|
||||
(`AUTH_GUEST_JWT_TTL`).
|
||||
3. **Renew** — the guest posts the `permissionId` to
|
||||
`POST /graph/v1beta1/extensions/org.libregraph/guestLinks/renew`.
|
||||
An existing record is required; otherwise the request fails with
|
||||
`tokenNotFound`. The service validates the share, generates a new link token
|
||||
and a 6-digit PIN (both valid for 30 minutes), overwrites the stored token
|
||||
and PIN hashes (invalidating the previous link and PIN) and resets the
|
||||
redeemed flag so the new link can be redeemed, then publishes the
|
||||
`GuestTokenRenewed` event. This works whether the record is unused, expired
|
||||
or already redeemed. If publishing fails, the previous record is restored.
|
||||
`POST /graph/v1beta1/extensions/org.libregraph/guestLinks/renew`, authorized
|
||||
by either the previous link token (`{ "token": "<token>" }`) or the (possibly
|
||||
expired) session cookie, which must both belong to that share. An existing
|
||||
record is required; otherwise the request fails with `tokenNotFound`. The
|
||||
service validates the share, generates a new link token and a 6-digit PIN
|
||||
(both valid for 30 minutes), overwrites the stored token and PIN hashes
|
||||
(invalidating the previous link and PIN) and resets the redeemed flag so the
|
||||
new link can be redeemed, then publishes the `GuestTokenRenewed` event. This
|
||||
works whether the record is unused, expired or already redeemed. If
|
||||
publishing fails, the previous record is restored.
|
||||
4. **Verify PIN** — the guest posts the PIN and the `permissionId` to
|
||||
`POST /graph/v1beta1/extensions/org.libregraph/guestLinks/verify/pin`.
|
||||
The service validates the record and the share, compares the PIN against the
|
||||
@@ -177,12 +178,15 @@ Alias: `.../guestLinks/verify/token`
|
||||
|
||||
#### `POST .../guestLinks/renew`
|
||||
|
||||
- **Request body:** `{ "permissionId": "<share-id>" }`
|
||||
- **Request body:** `{ "permissionId": "<share-id>", "token": "<previous link token>" }`
|
||||
(`token` optional when the session cookie is sent)
|
||||
- **Authorization:** the share id is required; the body link token or the
|
||||
session cookie must belong to it, the link token takes precedence
|
||||
- **Success:** `200` (no session cookie; the new link/PIN are delivered out
|
||||
of band)
|
||||
- **Errors:** `400 invalidRequest`, `404 tokenNotFound`,
|
||||
`404 shareNotFound`, `410 shareExpired`, `503 serviceUnavailable`,
|
||||
`500 internalError`
|
||||
- **Errors:** `400 invalidRequest`, `401 tokenInvalid`, `401 sessionInvalid`,
|
||||
`404 tokenNotFound`, `404 shareNotFound`, `410 shareExpired`,
|
||||
`409 tokenAlreadyRedeemed`, `503 serviceUnavailable`, `500 internalError`
|
||||
|
||||
#### `POST .../guestLinks/verify/pin`
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/storage"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/token"
|
||||
)
|
||||
@@ -58,6 +59,10 @@ 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, jwt.ErrInvalidSession):
|
||||
status, errorType = http.StatusUnauthorized, "sessionInvalid"
|
||||
case errors.Is(ge.ErrorType, authguest.ErrPermissionMismatch):
|
||||
status, errorType = http.StatusBadRequest, "invalidRequest"
|
||||
}
|
||||
|
||||
message := ge.ErrorType.Error()
|
||||
|
||||
@@ -8,16 +8,22 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
)
|
||||
|
||||
// RenewRequest is the request body for token renewal.
|
||||
// RenewRequest is the request body for token renewal. The share id is required;
|
||||
// the previous link token is optional when the (possibly expired) session
|
||||
// cookie is sent instead.
|
||||
type RenewRequest struct {
|
||||
PermissionID string `json:"permissionId"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// RenewHandler generates a new guest link token for the submitted share.
|
||||
func RenewHandler(log log.Logger, s authguest.AuthGuest) func(w http.ResponseWriter, r *http.Request) {
|
||||
// RenewHandler generates a new guest link token and PIN for the share
|
||||
// identified by the permission id, authorized by the submitted link token or
|
||||
// the session cookie.
|
||||
func RenewHandler(log log.Logger, s authguest.AuthGuest, cfg *config.Config) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req RenewRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -32,7 +38,12 @@ func RenewHandler(log log.Logger, s authguest.AuthGuest) func(w http.ResponseWri
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Renew(r.Context(), req.PermissionID); err != nil {
|
||||
sessionToken := ""
|
||||
if cookie, err := r.Cookie(cfg.JWT.CookieName); err == nil {
|
||||
sessionToken = cookie.Value
|
||||
}
|
||||
|
||||
if err := s.Renew(r.Context(), req.PermissionID, req.Token, sessionToken); err != nil {
|
||||
log.Debug().Err(err).Msg("renew failed")
|
||||
writeGuestError(w, err)
|
||||
return
|
||||
|
||||
@@ -10,32 +10,60 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/authguest/mocks"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-guest/pkg/service/jwt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testCookieName = "__Host-oc_guest_session"
|
||||
|
||||
func newRenewHandler(t *testing.T, svc authguest.AuthGuest) http.HandlerFunc {
|
||||
t.Helper()
|
||||
return RenewHandler(log.NopLogger(), svc)
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWT{
|
||||
CookieName: testCookieName,
|
||||
TTL: time.Hour,
|
||||
},
|
||||
}
|
||||
return RenewHandler(log.NopLogger(), svc, cfg)
|
||||
}
|
||||
|
||||
func TestRenewHandler(t *testing.T) {
|
||||
func TestRenewHandlerWithLinkToken(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svcMock.On("Renew", mock.Anything, "share-1").Return(nil)
|
||||
svcMock.On("Renew", mock.Anything, "share-1", "link-token", "").Return(nil)
|
||||
|
||||
body, err := json.Marshal(RenewRequest{PermissionID: "share-1"})
|
||||
body, err := json.Marshal(RenewRequest{PermissionID: "share-1", Token: "link-token"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
newRenewHandler(t, svcMock)(rr, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))))
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1")
|
||||
svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1", "link-token", "")
|
||||
}
|
||||
|
||||
func TestRenewHandlerWithSessionCookie(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svcMock.On("Renew", mock.Anything, "share-1", "", "session-token").Return(nil)
|
||||
|
||||
body, err := json.Marshal(RenewRequest{PermissionID: "share-1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
|
||||
req.AddCookie(&http.Cookie{Name: testCookieName, Value: "session-token"})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
newRenewHandler(t, svcMock)(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
svcMock.AssertCalled(t, "Renew", mock.Anything, "share-1", "", "session-token")
|
||||
}
|
||||
|
||||
func TestRenewHandlerErrorMapping(t *testing.T) {
|
||||
@@ -60,6 +88,20 @@ func TestRenewHandlerErrorMapping(t *testing.T) {
|
||||
wantType: "shareExpired",
|
||||
wantPermission: "share-1",
|
||||
},
|
||||
{
|
||||
name: "permission mismatch",
|
||||
err: &authguest.GuestError{ErrorType: authguest.ErrPermissionMismatch, ShareID: "share-1"},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantType: "invalidRequest",
|
||||
wantPermission: "share-1",
|
||||
},
|
||||
{
|
||||
name: "invalid session",
|
||||
err: &authguest.GuestError{ErrorType: jwt.ErrInvalidSession, ShareID: "share-1"},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantType: "sessionInvalid",
|
||||
wantPermission: "share-1",
|
||||
},
|
||||
{
|
||||
name: "internal error",
|
||||
err: errors.New("boom"),
|
||||
@@ -77,9 +119,9 @@ func TestRenewHandlerErrorMapping(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
svcMock.On("Renew", mock.Anything, "share-1").Return(tt.err)
|
||||
svcMock.On("Renew", mock.Anything, "share-1", "link-token", "").Return(tt.err)
|
||||
|
||||
body, err := json.Marshal(RenewRequest{PermissionID: "share-1"})
|
||||
body, err := json.Marshal(RenewRequest{PermissionID: "share-1", Token: "link-token"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -111,7 +153,7 @@ func TestRenewHandlerMalformedBody(t *testing.T) {
|
||||
func TestRenewHandlerMissingPermissionID(t *testing.T) {
|
||||
svcMock := mocks.NewAuthGuest(t)
|
||||
|
||||
body, err := json.Marshal(RenewRequest{})
|
||||
body, err := json.Marshal(RenewRequest{Token: "link-token"})
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@@ -61,7 +61,7 @@ func Server(opts ...Option) (ohttp.Service, error) {
|
||||
mux.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/redeem", RedeemHandler(options.Logger, options.Service, options.Config))
|
||||
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/verify/token", RedeemHandler(options.Logger, options.Service, options.Config))
|
||||
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/renew", RenewHandler(options.Logger, options.Service))
|
||||
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/renew", RenewHandler(options.Logger, options.Service, options.Config))
|
||||
r.Post("/v1beta1/extensions/org.libregraph/guestLinks/verify/pin", VerifyPinHandler(options.Logger, options.Service, options.Config))
|
||||
})
|
||||
|
||||
|
||||
@@ -227,16 +227,16 @@ func (_c *AuthGuest_Redeem_Call) RunAndReturn(run func(ctx context.Context, toke
|
||||
}
|
||||
|
||||
// Renew provides a mock function for the type AuthGuest
|
||||
func (_mock *AuthGuest) Renew(ctx context.Context, shareID string) error {
|
||||
ret := _mock.Called(ctx, shareID)
|
||||
func (_mock *AuthGuest) Renew(ctx context.Context, shareID string, tokenString string, sessionToken string) error {
|
||||
ret := _mock.Called(ctx, shareID, tokenString, sessionToken)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Renew")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
|
||||
r0 = returnFunc(ctx, shareID)
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok {
|
||||
r0 = returnFunc(ctx, shareID, tokenString, sessionToken)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
@@ -251,11 +251,13 @@ type AuthGuest_Renew_Call struct {
|
||||
// Renew is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - shareID string
|
||||
func (_e *AuthGuest_Expecter) Renew(ctx any, shareID any) *AuthGuest_Renew_Call {
|
||||
return &AuthGuest_Renew_Call{Call: _e.mock.On("Renew", ctx, shareID)}
|
||||
// - tokenString string
|
||||
// - sessionToken string
|
||||
func (_e *AuthGuest_Expecter) Renew(ctx any, shareID any, tokenString any, sessionToken any) *AuthGuest_Renew_Call {
|
||||
return &AuthGuest_Renew_Call{Call: _e.mock.On("Renew", ctx, shareID, tokenString, sessionToken)}
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_Renew_Call) Run(run func(ctx context.Context, shareID string)) *AuthGuest_Renew_Call {
|
||||
func (_c *AuthGuest_Renew_Call) Run(run func(ctx context.Context, shareID string, tokenString string, sessionToken string)) *AuthGuest_Renew_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -265,9 +267,19 @@ func (_c *AuthGuest_Renew_Call) Run(run func(ctx context.Context, shareID string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
var arg3 string
|
||||
if args[3] != nil {
|
||||
arg3 = args[3].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
arg3,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
@@ -278,7 +290,7 @@ func (_c *AuthGuest_Renew_Call) Return(err error) *AuthGuest_Renew_Call {
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *AuthGuest_Renew_Call) RunAndReturn(run func(ctx context.Context, shareID string) error) *AuthGuest_Renew_Call {
|
||||
func (_c *AuthGuest_Renew_Call) RunAndReturn(run func(ctx context.Context, shareID string, tokenString string, sessionToken string) error) *AuthGuest_Renew_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ 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")
|
||||
var ErrPermissionMismatch = errors.New("permissionId does not match the provided credentials")
|
||||
|
||||
const guestLinkTokenTTL = 30 * time.Minute
|
||||
const guestPinTTL = 30 * time.Minute
|
||||
@@ -55,7 +56,7 @@ type SessionResponse struct {
|
||||
type AuthGuest interface {
|
||||
CreateToken(ctx context.Context, shareID string) (*token.Token, error)
|
||||
Redeem(ctx context.Context, tokenString string) (*SessionResponse, error)
|
||||
Renew(ctx context.Context, shareID string) error
|
||||
Renew(ctx context.Context, shareID, tokenString, sessionToken string) error
|
||||
VerifyPin(ctx context.Context, shareID, pinValue string) (*SessionResponse, error)
|
||||
CleanupShare(shareID string) error
|
||||
}
|
||||
@@ -119,13 +120,16 @@ func (s *AuthGuestService) Redeem(ctx context.Context, tokenString string) (*Ses
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.store.Update(rec.ShareIDHash, func(r *storage.Record) error {
|
||||
if _, err := s.store.UpdateFrom(*rec, func(r *storage.Record) error {
|
||||
if r.Redeemed {
|
||||
return ErrAlreadyRedeemed
|
||||
}
|
||||
r.Redeemed = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
if errors.Is(err, storage.ErrConflict) {
|
||||
err = ErrAlreadyRedeemed
|
||||
}
|
||||
return nil, &GuestError{ErrorType: err, ShareID: rec.ShareID}
|
||||
}
|
||||
|
||||
@@ -137,12 +141,17 @@ func (s *AuthGuestService) Redeem(ctx context.Context, tokenString string) (*Ses
|
||||
return &SessionResponse{SessionToken: sessionToken, ShareID: rec.ShareID}, nil
|
||||
}
|
||||
|
||||
// Renew generates a new guest link token and PIN for the given share.
|
||||
func (s *AuthGuestService) Renew(ctx context.Context, shareID string) error {
|
||||
// Renew generates a new guest link token and PIN for shareID, authorized by the
|
||||
// previous link token or a (possibly expired) session token.
|
||||
func (s *AuthGuestService) Renew(ctx context.Context, shareID, tokenString, sessionToken string) error {
|
||||
if s.publisher == nil {
|
||||
return ErrEventsNotConfigured
|
||||
}
|
||||
|
||||
if err := s.verifyRenewCredentials(shareID, tokenString, sessionToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
share, err := s.validateShare(ctx, shareID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -165,16 +174,23 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID string) error {
|
||||
|
||||
now := time.Now()
|
||||
|
||||
var previous storage.Record
|
||||
if err := s.store.Update(token.Hash(shareID), func(rec *storage.Record) error {
|
||||
previous = *rec
|
||||
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.Redeemed = false
|
||||
return nil
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrConflict) {
|
||||
err = ErrAlreadyRedeemed
|
||||
}
|
||||
return &GuestError{ErrorType: err, ShareID: shareID}
|
||||
}
|
||||
|
||||
@@ -187,8 +203,8 @@ func (s *AuthGuestService) Renew(ctx context.Context, shareID string) error {
|
||||
Pin: pinStr,
|
||||
Timestamp: now,
|
||||
}); err != nil {
|
||||
if rbErr := s.store.Update(token.Hash(shareID), func(rec *storage.Record) error {
|
||||
*rec = previous
|
||||
if _, rbErr := s.store.UpdateFrom(*updated, func(rec *storage.Record) error {
|
||||
*rec = *current
|
||||
return nil
|
||||
}); rbErr != nil {
|
||||
return fmt.Errorf("publishing guest token renewed event failed: %w (rollback failed: %v)", err, rbErr)
|
||||
@@ -252,6 +268,53 @@ func (s *AuthGuestService) CleanupShare(shareID string) error {
|
||||
return 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 {
|
||||
switch {
|
||||
case tokenString != "":
|
||||
return s.verifyRenewToken(shareID, tokenString)
|
||||
case sessionToken != "":
|
||||
if s.jwtService == nil {
|
||||
return &GuestError{ErrorType: jwt.ErrInvalidSession, ShareID: shareID}
|
||||
}
|
||||
sessionShareID, err := s.jwtService.Verify(sessionToken)
|
||||
if err != nil {
|
||||
return &GuestError{ErrorType: err, ShareID: shareID}
|
||||
}
|
||||
if sessionShareID != shareID {
|
||||
return &GuestError{ErrorType: ErrPermissionMismatch, ShareID: shareID}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return &GuestError{ErrorType: jwt.ErrInvalidSession, ShareID: shareID}
|
||||
}
|
||||
}
|
||||
|
||||
// verifyRenewToken checks that the link token belongs to shareID and matches
|
||||
// the stored record, ignoring its expiry and redeemed state.
|
||||
func (s *AuthGuestService) verifyRenewToken(shareID, tokenString string) error {
|
||||
tok, err := s.tokenSvc.Parse(tokenString)
|
||||
if err != nil {
|
||||
return &GuestError{ErrorType: err, ShareID: shareID}
|
||||
}
|
||||
|
||||
if tok.ShareIDHash != token.Hash(shareID) {
|
||||
return &GuestError{ErrorType: ErrPermissionMismatch, ShareID: shareID}
|
||||
}
|
||||
|
||||
rec, err := s.store.Get(tok.ShareIDHash)
|
||||
if err != nil {
|
||||
return &GuestError{ErrorType: err, ShareID: shareID}
|
||||
}
|
||||
|
||||
if err := s.tokenSvc.Verify(*tok, rec.SecretHash); err != nil {
|
||||
return &GuestError{ErrorType: err, ShareID: shareID}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyToken validates a token and returns its stored record.
|
||||
//
|
||||
// Until the secret has been verified, all failures are reported as
|
||||
@@ -280,7 +343,7 @@ func (s *AuthGuestService) verifyToken(tokenString string) (*storage.Record, err
|
||||
return nil, &GuestError{ErrorType: ErrAlreadyRedeemed, ShareID: rec.ShareID}
|
||||
}
|
||||
|
||||
return &rec, nil
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// validateShare extracts the share information from the gateway and checks its existence and expiration.
|
||||
|
||||
@@ -46,6 +46,41 @@ func (p *testPublisher) Publish(_ string, ev any, _ ...microevents.PublishOption
|
||||
return p.err
|
||||
}
|
||||
|
||||
// rewriteStore wraps a Manager and runs onGet once after the first Get, which
|
||||
// lets tests simulate a concurrent modification between a read and a later
|
||||
// write.
|
||||
type rewriteStore struct {
|
||||
storage.Manager
|
||||
onGet func()
|
||||
}
|
||||
|
||||
func (s *rewriteStore) Get(shareIDHash string) (*storage.Record, error) {
|
||||
rec, err := s.Manager.Get(shareIDHash)
|
||||
if s.onGet != nil {
|
||||
onGet := s.onGet
|
||||
s.onGet = nil
|
||||
onGet()
|
||||
}
|
||||
return rec, err
|
||||
}
|
||||
|
||||
// mutatingPublisher wraps testPublisher and runs onPublish once before
|
||||
// publishing, which lets tests simulate a concurrent modification during a
|
||||
// failed publish.
|
||||
type mutatingPublisher struct {
|
||||
testPublisher
|
||||
onPublish func()
|
||||
}
|
||||
|
||||
func (p *mutatingPublisher) Publish(name string, ev any, opts ...microevents.PublishOption) error {
|
||||
if p.onPublish != nil {
|
||||
onPublish := p.onPublish
|
||||
p.onPublish = nil
|
||||
onPublish()
|
||||
}
|
||||
return p.testPublisher.Publish(name, ev, opts...)
|
||||
}
|
||||
|
||||
type gatewayTestSelector struct {
|
||||
client gateway.GatewayAPIClient
|
||||
}
|
||||
@@ -99,6 +134,22 @@ func newToken(t *testing.T) (string, storage.Record) {
|
||||
return tok.String(), rec
|
||||
}
|
||||
|
||||
func newTokenFor(t *testing.T, shareID string) (string, storage.Record) {
|
||||
t.Helper()
|
||||
ts := token.NewTokenService()
|
||||
tok, err := ts.Generate(shareID)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := storage.Record{
|
||||
ShareID: shareID,
|
||||
ShareIDHash: tok.ShareIDHash,
|
||||
SecretHash: tok.SecretHash(),
|
||||
Expiry: time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC),
|
||||
}
|
||||
|
||||
return tok.String(), rec
|
||||
}
|
||||
|
||||
func newShareService(t *testing.T, gwc *cs3mocks.GatewayAPIClient) *AuthGuestService {
|
||||
t.Helper()
|
||||
return NewAuthGuestService(
|
||||
@@ -177,7 +228,7 @@ func TestVerifyToken(t *testing.T) {
|
||||
if tt.redeemed {
|
||||
rec.Redeemed = true
|
||||
}
|
||||
store.On("Get", rec.ShareIDHash).Return(rec, nil)
|
||||
store.On("Get", rec.ShareIDHash).Return(&rec, nil)
|
||||
|
||||
got, err := s.verifyToken(tok)
|
||||
if tt.wantErr != nil {
|
||||
@@ -291,6 +342,34 @@ func TestRedeemAlreadyRedeemed(t *testing.T) {
|
||||
assert.ErrorIs(t, ge.ErrorType, ErrAlreadyRedeemed)
|
||||
}
|
||||
|
||||
func TestRedeemConflictIsAlreadyRedeemed(t *testing.T) {
|
||||
base := storage.NewFileManager(t.TempDir())
|
||||
tok, rec := newToken(t)
|
||||
require.NoError(t, base.Add(rec))
|
||||
|
||||
store := &rewriteStore{Manager: base}
|
||||
store.onGet = func() {
|
||||
require.NoError(t, base.Update(rec.ShareIDHash, func(r *storage.Record) error {
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
share := &collaboration.Share{Id: &collaboration.ShareId{OpaqueId: testShareID}}
|
||||
s := newRedeemService(t, store, newGatewayMock(&collaboration.GetShareResponse{
|
||||
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
|
||||
Share: share,
|
||||
}))
|
||||
|
||||
_, err := s.Redeem(context.Background(), tok)
|
||||
var ge *GuestError
|
||||
require.ErrorAs(t, err, &ge)
|
||||
assert.ErrorIs(t, ge.ErrorType, ErrAlreadyRedeemed)
|
||||
|
||||
got, err := base.Get(rec.ShareIDHash)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, got.Redeemed)
|
||||
}
|
||||
|
||||
func newRenewService(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayAPIClient, publisher revaevents.Publisher) *AuthGuestService {
|
||||
t.Helper()
|
||||
return NewAuthGuestService(
|
||||
@@ -298,6 +377,7 @@ func newRenewService(t *testing.T, store storage.Manager, gwc *cs3mocks.GatewayA
|
||||
store,
|
||||
GatewaySelector(newGatewayTestSelector(gwc)),
|
||||
ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}),
|
||||
JWT(jwt.NewJwtService("test-secret", 24*time.Hour)),
|
||||
EventsPublisher(publisher),
|
||||
)
|
||||
}
|
||||
@@ -311,7 +391,8 @@ func existingRecord() storage.Record {
|
||||
|
||||
func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) {
|
||||
store := storage.NewFileManager(t.TempDir())
|
||||
require.NoError(t, store.Add(existingRecord()))
|
||||
tok, rec := newToken(t)
|
||||
require.NoError(t, store.Add(rec))
|
||||
|
||||
publisher := &testPublisher{}
|
||||
gwc := newGatewayMock(&collaboration.GetShareResponse{
|
||||
@@ -321,10 +402,11 @@ func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) {
|
||||
|
||||
s := newRenewService(t, store, gwc, publisher)
|
||||
|
||||
require.NoError(t, s.Renew(context.Background(), testShareID))
|
||||
require.NoError(t, s.Renew(context.Background(), testShareID, tok, ""))
|
||||
|
||||
replaced, err := store.Get(token.Hash(testShareID))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rec.Revision+1, replaced.Revision)
|
||||
assert.Equal(t, testShareID, replaced.ShareID)
|
||||
assert.Equal(t, token.Hash(testShareID), replaced.ShareIDHash)
|
||||
assert.NotEmpty(t, replaced.SecretHash)
|
||||
@@ -351,8 +433,82 @@ func TestRenewPersistsRecordAndPublishesEvent(t *testing.T) {
|
||||
assert.True(t, pinOK)
|
||||
}
|
||||
|
||||
func TestRenewWithExpiredSessionToken(t *testing.T) {
|
||||
store := storage.NewFileManager(t.TempDir())
|
||||
require.NoError(t, store.Add(existingRecord()))
|
||||
|
||||
jwtSvc := jwt.NewJwtService("test-secret", -time.Hour)
|
||||
sessionToken, err := jwtSvc.Sign(testShareID)
|
||||
require.NoError(t, err)
|
||||
|
||||
publisher := &testPublisher{}
|
||||
s := NewAuthGuestService(
|
||||
token.NewTokenService(),
|
||||
store,
|
||||
GatewaySelector(newGatewayTestSelector(newGatewayMock(&collaboration.GetShareResponse{
|
||||
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
|
||||
Share: newGuestShare(time.Now().Add(time.Hour)),
|
||||
}))),
|
||||
ServiceAccount(config.ServiceAccount{ServiceAccountID: "sa-id", ServiceAccountSecret: "sa-secret"}),
|
||||
JWT(jwtSvc),
|
||||
EventsPublisher(publisher),
|
||||
)
|
||||
|
||||
require.NoError(t, s.Renew(context.Background(), testShareID, "", sessionToken))
|
||||
require.Len(t, publisher.published, 1)
|
||||
}
|
||||
|
||||
func TestRenewLinkTokenIgnoresRedeemedAndExpiry(t *testing.T) {
|
||||
store := storage.NewFileManager(t.TempDir())
|
||||
tok, rec := newToken(t)
|
||||
rec.Redeemed = true
|
||||
rec.Expiry = time.Now().Add(-time.Hour)
|
||||
require.NoError(t, store.Add(rec))
|
||||
|
||||
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{
|
||||
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
|
||||
Share: newGuestShare(time.Now().Add(time.Hour)),
|
||||
}), &testPublisher{})
|
||||
|
||||
require.NoError(t, s.Renew(context.Background(), testShareID, tok, ""))
|
||||
}
|
||||
|
||||
func TestRenewInvalidCredentials(t *testing.T) {
|
||||
store := storage.NewFileManager(t.TempDir())
|
||||
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{}), &testPublisher{})
|
||||
|
||||
otherSession, err := jwt.NewJwtService("test-secret", 24*time.Hour).Sign("other-share")
|
||||
require.NoError(t, err)
|
||||
otherToken, _ := newTokenFor(t, "other-share")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shareID string
|
||||
token string
|
||||
sessionToken string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "no credentials", shareID: testShareID, wantErr: jwt.ErrInvalidSession},
|
||||
{name: "invalid session", shareID: testShareID, sessionToken: "garbage", wantErr: jwt.ErrInvalidSession},
|
||||
{name: "invalid link token prefers token", shareID: testShareID, token: "garbage", sessionToken: "garbage", wantErr: token.ErrInvalidToken},
|
||||
{name: "session for another share", shareID: testShareID, sessionToken: otherSession, wantErr: ErrPermissionMismatch},
|
||||
{name: "token for another share", shareID: testShareID, token: otherToken, wantErr: ErrPermissionMismatch},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := s.Renew(context.Background(), tt.shareID, tt.token, tt.sessionToken)
|
||||
|
||||
var ge *GuestError
|
||||
require.ErrorAs(t, err, &ge)
|
||||
assert.ErrorIs(t, ge.ErrorType, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewRecordNotFound(t *testing.T) {
|
||||
store := storage.NewFileManager(t.TempDir())
|
||||
tok, _ := newToken(t)
|
||||
|
||||
publisher := &testPublisher{}
|
||||
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{
|
||||
@@ -360,7 +516,7 @@ func TestRenewRecordNotFound(t *testing.T) {
|
||||
Share: newGuestShare(time.Now().Add(time.Hour)),
|
||||
}), publisher)
|
||||
|
||||
err := s.Renew(context.Background(), testShareID)
|
||||
err := s.Renew(context.Background(), testShareID, tok, "")
|
||||
require.Error(t, err)
|
||||
|
||||
var ge *GuestError
|
||||
@@ -377,7 +533,7 @@ func TestRenewWithoutPublisher(t *testing.T) {
|
||||
|
||||
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{}), nil)
|
||||
|
||||
err := s.Renew(context.Background(), testShareID)
|
||||
err := s.Renew(context.Background(), testShareID, "irrelevant", "")
|
||||
require.ErrorIs(t, err, ErrEventsNotConfigured)
|
||||
|
||||
got, err := store.Get(token.Hash(testShareID))
|
||||
@@ -388,7 +544,8 @@ func TestRenewWithoutPublisher(t *testing.T) {
|
||||
|
||||
func TestRenewRollsBackOnPublishFailure(t *testing.T) {
|
||||
store := storage.NewFileManager(t.TempDir())
|
||||
require.NoError(t, store.Add(existingRecord()))
|
||||
tok, rec := newToken(t)
|
||||
require.NoError(t, store.Add(rec))
|
||||
|
||||
publisher := &testPublisher{err: errors.New("publish failed")}
|
||||
gwc := newGatewayMock(&collaboration.GetShareResponse{
|
||||
@@ -398,27 +555,56 @@ func TestRenewRollsBackOnPublishFailure(t *testing.T) {
|
||||
|
||||
s := newRenewService(t, store, gwc, publisher)
|
||||
|
||||
err := s.Renew(context.Background(), testShareID)
|
||||
err := s.Renew(context.Background(), testShareID, tok, "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "publish failed")
|
||||
|
||||
got, err := store.Get(token.Hash(testShareID))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got.SecretHash)
|
||||
assert.Equal(t, rec.SecretHash, got.SecretHash)
|
||||
assert.Empty(t, got.PinHash)
|
||||
assert.True(t, got.PinExpiry.IsZero())
|
||||
assert.False(t, got.Redeemed)
|
||||
}
|
||||
|
||||
func TestRenewRollbackConflictKeepsConcurrentChange(t *testing.T) {
|
||||
store := storage.NewFileManager(t.TempDir())
|
||||
tok, rec := newToken(t)
|
||||
require.NoError(t, store.Add(rec))
|
||||
|
||||
publisher := &mutatingPublisher{testPublisher: testPublisher{err: errors.New("publish failed")}}
|
||||
publisher.onPublish = func() {
|
||||
require.NoError(t, store.Update(rec.ShareIDHash, func(r *storage.Record) error {
|
||||
r.SecretHash = "concurrent"
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{
|
||||
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
|
||||
Share: newGuestShare(time.Now().Add(time.Hour)),
|
||||
}), publisher)
|
||||
|
||||
err := s.Renew(context.Background(), testShareID, tok, "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "publish failed")
|
||||
assert.Contains(t, err.Error(), "rollback failed")
|
||||
|
||||
got, err := store.Get(rec.ShareIDHash)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "concurrent", got.SecretHash)
|
||||
}
|
||||
|
||||
func TestRenewShareNotFound(t *testing.T) {
|
||||
store := storage.NewFileManager(t.TempDir())
|
||||
require.NoError(t, store.Add(existingRecord()))
|
||||
tok, rec := newToken(t)
|
||||
require.NoError(t, store.Add(rec))
|
||||
|
||||
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{
|
||||
Status: &rpc.Status{Code: rpc.Code_CODE_NOT_FOUND},
|
||||
}), &testPublisher{})
|
||||
|
||||
err := s.Renew(context.Background(), testShareID)
|
||||
err := s.Renew(context.Background(), testShareID, tok, "")
|
||||
require.Error(t, err)
|
||||
|
||||
var ge *GuestError
|
||||
@@ -429,14 +615,15 @@ func TestRenewShareNotFound(t *testing.T) {
|
||||
|
||||
func TestRenewShareExpired(t *testing.T) {
|
||||
store := storage.NewFileManager(t.TempDir())
|
||||
require.NoError(t, store.Add(existingRecord()))
|
||||
tok, rec := newToken(t)
|
||||
require.NoError(t, store.Add(rec))
|
||||
|
||||
s := newRenewService(t, store, newGatewayMock(&collaboration.GetShareResponse{
|
||||
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
|
||||
Share: newGuestShare(time.Now().Add(-time.Hour)),
|
||||
}), &testPublisher{})
|
||||
|
||||
err := s.Renew(context.Background(), testShareID)
|
||||
err := s.Renew(context.Background(), testShareID, tok, "")
|
||||
require.Error(t, err)
|
||||
|
||||
var ge *GuestError
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var ErrInvalidSession = errors.New("invalid session")
|
||||
|
||||
type jwtClaims struct {
|
||||
ShareID string `json:"permissionId"`
|
||||
jwt.RegisteredClaims
|
||||
@@ -35,3 +38,17 @@ func (m *JwtService) Sign(shareID string) (string, error) {
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret)
|
||||
}
|
||||
|
||||
// Verify returns the share id of a session token, ignoring its expiry so an
|
||||
// expired session can still be used to renew the guest link.
|
||||
func (m *JwtService) Verify(tokenString string) (string, error) {
|
||||
claims := &jwtClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(*jwt.Token) (any, error) {
|
||||
return m.secret, nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithoutClaimsValidation())
|
||||
if err != nil || !token.Valid || claims.ShareID == "" {
|
||||
return "", ErrInvalidSession
|
||||
}
|
||||
|
||||
return claims.ShareID, nil
|
||||
}
|
||||
@@ -42,3 +42,52 @@ func TestParseExpired(t *testing.T) {
|
||||
_, err = parseClaims(t, m, tok)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
m := NewJwtService("test-secret", time.Hour)
|
||||
|
||||
tok, err := m.Sign("share-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
shareID, err := m.Verify(tok)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "share-id", shareID)
|
||||
}
|
||||
|
||||
func TestVerifyExpired(t *testing.T) {
|
||||
m := NewJwtService("test-secret", -time.Minute)
|
||||
|
||||
tok, err := m.Sign("share-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
shareID, err := m.Verify(tok)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "share-id", shareID)
|
||||
}
|
||||
|
||||
func TestVerifyInvalid(t *testing.T) {
|
||||
m := NewJwtService("test-secret", time.Hour)
|
||||
|
||||
tok, err := m.Sign("share-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
other, err := NewJwtService("other-secret", time.Hour).Sign("share-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
}{
|
||||
{name: "garbage", token: "not-a-token"},
|
||||
{name: "empty", token: ""},
|
||||
{name: "wrong secret", token: other},
|
||||
{name: "tampered", token: tok + "x"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := m.Verify(tt.token)
|
||||
assert.ErrorIs(t, err, ErrInvalidSession)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func Generate() (string, error) {
|
||||
}
|
||||
|
||||
func Hash(p string) (string, error) {
|
||||
if p == "" {
|
||||
if len(p) != Length {
|
||||
return "", ErrInvalidPin
|
||||
}
|
||||
|
||||
|
||||
@@ -41,9 +41,11 @@ func TestHashProducesArgon2id(t *testing.T) {
|
||||
assert.NotEqual(t, "123456", h)
|
||||
}
|
||||
|
||||
func TestHashEmptyPin(t *testing.T) {
|
||||
_, err := Hash("")
|
||||
assert.ErrorIs(t, err, ErrInvalidPin)
|
||||
func TestHashInvalidLength(t *testing.T) {
|
||||
for _, p := range []string{"", "12345", "1234567"} {
|
||||
_, err := Hash(p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
|
||||
@@ -54,7 +54,7 @@ func (s *FileManager) Add(rec Record) error {
|
||||
}
|
||||
|
||||
// Get returns the record for the given share id hash.
|
||||
func (s *FileManager) Get(shareIDHash string) (Record, error) {
|
||||
func (s *FileManager) Get(shareIDHash string) (*Record, error) {
|
||||
return s.get(shareIDHash)
|
||||
}
|
||||
|
||||
@@ -83,8 +83,7 @@ func (s *FileManager) Remove(shareIDHash string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update applies fn to the record under the record lock and writes it back.
|
||||
// If fn returns an error the record is left unchanged.
|
||||
// Update applies fn to the record under lock and writes it back.
|
||||
func (s *FileManager) Update(shareIDHash string, fn func(*Record) error) error {
|
||||
lock, err := s.lockRecord(shareIDHash)
|
||||
if err != nil {
|
||||
@@ -97,11 +96,45 @@ func (s *FileManager) Update(shareIDHash string, fn func(*Record) error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := fn(&rec); err != nil {
|
||||
if err := fn(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.upsert(rec)
|
||||
rec.Revision++
|
||||
|
||||
return s.upsert(*rec)
|
||||
}
|
||||
|
||||
// UpdateFrom applies fn to the record under lock and writes it back, unless it
|
||||
// changed since seen (its revision advanced), in which case it returns
|
||||
// ErrConflict. It returns the updated record.
|
||||
func (s *FileManager) UpdateFrom(seen Record, fn func(*Record) error) (*Record, error) {
|
||||
lock, err := s.lockRecord(seen.ShareIDHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = lock.Unlock() }()
|
||||
|
||||
rec, err := s.get(seen.ShareIDHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if seen.Revision < rec.Revision {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
|
||||
if err := fn(rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec.Revision = seen.Revision + 1
|
||||
|
||||
if err := s.upsert(*rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *FileManager) lockRecord(shareIDHash string) (*flock.Flock, error) {
|
||||
@@ -141,23 +174,23 @@ func (s *FileManager) upsert(rec Record) error {
|
||||
return renameio.WriteFile(p, data, filePerm)
|
||||
}
|
||||
|
||||
func (s *FileManager) get(shareIDHash string) (Record, error) {
|
||||
func (s *FileManager) get(shareIDHash string) (*Record, error) {
|
||||
p, err := s.path(shareIDHash)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return Record{}, ErrNotFound
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return Record{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec := Record{}
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
return Record{}, err
|
||||
rec := &Record{}
|
||||
if err := json.Unmarshal(data, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rec, nil
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestFileManagerAddGet(t *testing.T) {
|
||||
|
||||
got, err := s.Get(rec.ShareIDHash)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rec, got)
|
||||
assert.Equal(t, rec, *got)
|
||||
}
|
||||
|
||||
func TestFileManagerGetMissing(t *testing.T) {
|
||||
@@ -72,6 +72,8 @@ func TestFileManagerInvalidHash(t *testing.T) {
|
||||
require.ErrorIs(t, s.Remove("ab"), ErrInvalidHash)
|
||||
require.ErrorIs(t, s.Add(Record{ShareIDHash: "ab"}), ErrInvalidHash)
|
||||
require.ErrorIs(t, s.Update("ab", func(*Record) error { return nil }), ErrInvalidHash)
|
||||
_, err = s.UpdateFrom(Record{ShareIDHash: "ab"}, func(*Record) error { return nil })
|
||||
require.ErrorIs(t, err, ErrInvalidHash)
|
||||
}
|
||||
|
||||
func TestFileManagerRemove(t *testing.T) {
|
||||
@@ -112,6 +114,7 @@ func TestFileManagerUpdate(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pinhash", got.PinHash)
|
||||
assert.True(t, got.Redeemed)
|
||||
assert.Equal(t, uint64(1), got.Revision)
|
||||
}
|
||||
|
||||
func TestFileManagerUpdateErrorLeavesRecordUnchanged(t *testing.T) {
|
||||
@@ -130,7 +133,7 @@ func TestFileManagerUpdateErrorLeavesRecordUnchanged(t *testing.T) {
|
||||
|
||||
got, err := s.Get(rec.ShareIDHash)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rec, got)
|
||||
assert.Equal(t, rec, *got)
|
||||
}
|
||||
|
||||
func TestFileManagerUpdateMissing(t *testing.T) {
|
||||
@@ -141,6 +144,65 @@ func TestFileManagerUpdateMissing(t *testing.T) {
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestFileManagerUpdateFrom(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
rec := newRecord("e0123456-7890-abcd-ef01-234567890abc")
|
||||
require.NoError(t, s.Add(rec))
|
||||
|
||||
seen, err := s.Get(rec.ShareIDHash)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := s.UpdateFrom(*seen, func(r *Record) error {
|
||||
r.PinHash = "pinhash"
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated)
|
||||
assert.Equal(t, seen.Revision+1, updated.Revision)
|
||||
assert.Equal(t, "pinhash", updated.PinHash)
|
||||
|
||||
got, err := s.Get(rec.ShareIDHash)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, *updated, *got)
|
||||
}
|
||||
|
||||
func TestFileManagerUpdateFromConflict(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
rec := newRecord("e0123456-7890-abcd-ef01-234567890abc")
|
||||
require.NoError(t, s.Add(rec))
|
||||
|
||||
seen, err := s.Get(rec.ShareIDHash)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.Update(rec.ShareIDHash, func(r *Record) error {
|
||||
r.SecretHash = "other"
|
||||
return nil
|
||||
}))
|
||||
|
||||
_, err = s.UpdateFrom(*seen, func(r *Record) error {
|
||||
r.PinHash = "pinhash"
|
||||
return nil
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrConflict)
|
||||
|
||||
got, err := s.Get(rec.ShareIDHash)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "other", got.SecretHash)
|
||||
assert.Empty(t, got.PinHash)
|
||||
}
|
||||
|
||||
func TestFileManagerUpdateFromMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
_, err := s.UpdateFrom(Record{ShareIDHash: "doesnotexist"}, func(*Record) error { return nil })
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestFileManagerAddConcurrent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewFileManager(dir)
|
||||
|
||||
@@ -88,22 +88,24 @@ func (_c *Manager_Add_Call) RunAndReturn(run func(rec storage.Record) error) *Ma
|
||||
}
|
||||
|
||||
// Get provides a mock function for the type Manager
|
||||
func (_mock *Manager) Get(shareIDHash string) (storage.Record, error) {
|
||||
func (_mock *Manager) Get(shareIDHash string) (*storage.Record, error) {
|
||||
ret := _mock.Called(shareIDHash)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Get")
|
||||
}
|
||||
|
||||
var r0 storage.Record
|
||||
var r0 *storage.Record
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string) (storage.Record, error)); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(string) (*storage.Record, error)); ok {
|
||||
return returnFunc(shareIDHash)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(string) storage.Record); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(string) *storage.Record); ok {
|
||||
r0 = returnFunc(shareIDHash)
|
||||
} else {
|
||||
r0 = ret.Get(0).(storage.Record)
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*storage.Record)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(string) error); ok {
|
||||
r1 = returnFunc(shareIDHash)
|
||||
@@ -137,12 +139,12 @@ func (_c *Manager_Get_Call) Run(run func(shareIDHash string)) *Manager_Get_Call
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_Get_Call) Return(record storage.Record, err error) *Manager_Get_Call {
|
||||
func (_c *Manager_Get_Call) Return(record *storage.Record, err error) *Manager_Get_Call {
|
||||
_c.Call.Return(record, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_Get_Call) RunAndReturn(run func(shareIDHash string) (storage.Record, error)) *Manager_Get_Call {
|
||||
func (_c *Manager_Get_Call) RunAndReturn(run func(shareIDHash string) (*storage.Record, error)) *Manager_Get_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -254,3 +256,71 @@ func (_c *Manager_Update_Call) RunAndReturn(run func(shareIDHash string, fn func
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// UpdateFrom provides a mock function for the type Manager
|
||||
func (_mock *Manager) UpdateFrom(seen storage.Record, fn func(*storage.Record) error) (*storage.Record, error) {
|
||||
ret := _mock.Called(seen, fn)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for UpdateFrom")
|
||||
}
|
||||
|
||||
var r0 *storage.Record
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(storage.Record, func(*storage.Record) error) (*storage.Record, error)); ok {
|
||||
return returnFunc(seen, fn)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(storage.Record, func(*storage.Record) error) *storage.Record); ok {
|
||||
r0 = returnFunc(seen, fn)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*storage.Record)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(storage.Record, func(*storage.Record) error) error); ok {
|
||||
r1 = returnFunc(seen, fn)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Manager_UpdateFrom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFrom'
|
||||
type Manager_UpdateFrom_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// UpdateFrom is a helper method to define mock.On call
|
||||
// - seen storage.Record
|
||||
// - fn func(*storage.Record) error
|
||||
func (_e *Manager_Expecter) UpdateFrom(seen any, fn any) *Manager_UpdateFrom_Call {
|
||||
return &Manager_UpdateFrom_Call{Call: _e.mock.On("UpdateFrom", seen, fn)}
|
||||
}
|
||||
|
||||
func (_c *Manager_UpdateFrom_Call) Run(run func(seen storage.Record, fn func(*storage.Record) error)) *Manager_UpdateFrom_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 storage.Record
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(storage.Record)
|
||||
}
|
||||
var arg1 func(*storage.Record) error
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(func(*storage.Record) error)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_UpdateFrom_Call) Return(record *storage.Record, err error) *Manager_UpdateFrom_Call {
|
||||
_c.Call.Return(record, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Manager_UpdateFrom_Call) RunAndReturn(run func(seen storage.Record, fn func(*storage.Record) error) (*storage.Record, error)) *Manager_UpdateFrom_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
var ErrNotFound = errors.New("record not found")
|
||||
var ErrInvalidHash = errors.New("invalid share id hash")
|
||||
var ErrConflict = errors.New("record was modified concurrently")
|
||||
|
||||
// Record holds the data persisted for a guest share token.
|
||||
type Record struct {
|
||||
@@ -20,11 +21,13 @@ type Record struct {
|
||||
Expiry time.Time `json:"expiry,omitzero"`
|
||||
PinExpiry time.Time `json:"pinexpiry,omitzero"`
|
||||
Redeemed bool `json:"redeemed"`
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
|
||||
type Manager interface {
|
||||
Add(rec Record) error
|
||||
Get(shareIDHash string) (Record, error)
|
||||
Get(shareIDHash string) (*Record, error)
|
||||
Remove(shareIDHash string) error
|
||||
Update(shareIDHash string, fn func(*Record) error) error
|
||||
UpdateFrom(seen Record, fn func(*Record) error) (*Record, error)
|
||||
}
|
||||
Reference in new issue
Block a user