Skip to content

Commit 6f0dc5d

Browse files
authored
[4.0.1 backport] CBG-4969 create a one time session id for blipsync (#7856)
1 parent 0245d2a commit 6f0dc5d

File tree

9 files changed

+405
-74
lines changed

9 files changed

+405
-74
lines changed

auth/session.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type LoginSession struct {
2525
Expiration time.Time `json:"expiration"`
2626
Ttl time.Duration `json:"ttl"`
2727
SessionUUID string `json:"session_uuid"` // marker of when the user object changes, to match with session docs to determine if they are valid
28+
OneTime *bool `json:"one_time,omitempty"`
2829
}
2930

3031
const DefaultCookieName = "SyncGatewaySession"
@@ -76,10 +77,51 @@ func (auth *Authenticator) AuthenticateCookie(rq *http.Request, response http.Re
7677
base.InfofCtx(auth.LogCtx, base.KeyAuth, "Session no longer valid for user %s", base.UD(session.Username))
7778
return nil, base.HTTPErrorf(http.StatusUnauthorized, "Session no longer valid for user")
7879
}
80+
err = auth.deleteOneTimeSession(auth.LogCtx, &session)
81+
if err != nil {
82+
return nil, err
83+
}
84+
7985
return user, err
8086
}
8187

82-
func (auth *Authenticator) CreateSession(ctx context.Context, user User, ttl time.Duration) (*LoginSession, error) {
88+
// AuthenticateOneTimeSession authenticates a session and deletes it upon successful authentication if it was marked as
89+
// a one time sesssion. If it is a one time session, delete the session.
90+
func (auth *Authenticator) AuthenticateOneTimeSession(ctx context.Context, sessionID string) (User, error) {
91+
session, user, err := auth.GetSession(sessionID)
92+
if err != nil {
93+
return nil, base.HTTPErrorf(http.StatusUnauthorized, "Session Invalid")
94+
}
95+
96+
err = auth.deleteOneTimeSession(ctx, session)
97+
if err != nil {
98+
return nil, err
99+
}
100+
return user, nil
101+
}
102+
103+
// deleteOneTimeSession deletes the session if it is marked as a one-time session. If the session can be not deleted, or
104+
// was already deleted an error is returned.
105+
func (auth *Authenticator) deleteOneTimeSession(ctx context.Context, session *LoginSession) error {
106+
if session.OneTime == nil || !*session.OneTime {
107+
return nil
108+
}
109+
err := auth.datastore.Delete(auth.DocIDForSession(session.ID))
110+
if err != nil {
111+
// If doc is not found, it probably means someone else is simultaneously using the one-time session, error.
112+
// If the delete error comes from another source, still treat this as an error, expecting the client to retry
113+
// due to a temporary KV issue.
114+
if !base.IsDocNotFoundError(err) {
115+
base.InfofCtx(ctx, base.KeyAuth, "Unable to delete one-time session %s. Not allowing login: %v", base.UD(session.ID), err)
116+
}
117+
return base.HTTPErrorf(http.StatusUnauthorized, "Session Invalid")
118+
}
119+
return nil
120+
}
121+
122+
// CreateSession creates a new login session for the specified user with the specified TTL. If oneTime is true, the
123+
// session is marked as a one-time session and will be removed with a successful authentication.
124+
func (auth *Authenticator) CreateSession(ctx context.Context, user User, ttl time.Duration, oneTime bool) (*LoginSession, error) {
83125
ttlSec := int(ttl.Seconds())
84126
if ttlSec <= 0 {
85127
return nil, base.HTTPErrorf(400, "Invalid session time-to-live")
@@ -103,6 +145,10 @@ func (auth *Authenticator) CreateSession(ctx context.Context, user User, ttl tim
103145
Ttl: ttl,
104146
SessionUUID: user.GetSessionUUID(),
105147
}
148+
// only serialize one_time if set
149+
if oneTime {
150+
session.OneTime = &oneTime
151+
}
106152
if err := auth.datastore.Set(auth.DocIDForSession(session.ID), base.DurationToCbsExpiry(ttl), nil, session); err != nil {
107153
return nil, err
108154
}
@@ -115,24 +161,24 @@ func (auth *Authenticator) CreateSession(ctx context.Context, user User, ttl tim
115161
}
116162

117163
// GetSession returns a session by ID. Return a not found error if the session is not found, or is invalid.
118-
func (auth *Authenticator) GetSession(sessionID string) (*LoginSession, error) {
164+
func (auth *Authenticator) GetSession(sessionID string) (*LoginSession, User, error) {
119165
var session LoginSession
120166
_, err := auth.datastore.Get(auth.DocIDForSession(sessionID), &session)
121167
if err != nil {
122-
return nil, err
168+
return nil, nil, err
123169
}
124170
user, err := auth.GetUser(session.Username)
125171
if err != nil {
126-
return nil, err
172+
return nil, nil, err
127173
}
128174
if user == nil {
129-
return nil, base.ErrNotFound
175+
return nil, nil, base.ErrNotFound
130176
}
131177
if session.SessionUUID != user.GetSessionUUID() {
132-
return nil, base.ErrNotFound
178+
return nil, nil, base.ErrNotFound
133179
}
134180

135-
return &session, nil
181+
return &session, user, nil
136182
}
137183

138184
func (auth *Authenticator) MakeSessionCookie(session *LoginSession, secureCookie bool, httpOnly bool, sameSite http.SameSite) *http.Cookie {

auth/session_test.go

Lines changed: 104 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ licenses/APL2.txt.
1111
package auth
1212

1313
import (
14+
"fmt"
1415
"net/http"
1516
"net/http/httptest"
1617
"strings"
@@ -23,50 +24,66 @@ import (
2324
)
2425

2526
func TestCreateSession(t *testing.T) {
26-
const username = "Alice"
27-
const invalidSessionTTLError = "400 Invalid session time-to-live"
2827
base.SetUpTestLogging(t, base.LevelDebug, base.KeyAuth)
29-
ctx := base.TestCtx(t)
30-
testBucket := base.GetTestBucket(t)
31-
defer testBucket.Close(ctx)
32-
dataStore := testBucket.GetSingleDataStore()
33-
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
34-
35-
user, err := auth.NewUser(username, "password", base.Set{})
36-
require.NoError(t, err)
37-
require.NotNil(t, user)
38-
require.NoError(t, auth.Save(user))
39-
40-
// Create session with a username and valid TTL of 2 hours.
41-
session, err := auth.CreateSession(ctx, user, 2*time.Hour)
42-
require.NoError(t, err)
43-
44-
assert.Equal(t, username, session.Username)
45-
assert.Equal(t, 2*time.Hour, session.Ttl)
46-
assert.NotEmpty(t, session.ID)
47-
assert.NotEmpty(t, session.Expiration)
48-
49-
// Once the session is created, the details should be persisted on the bucket
50-
// and it must be accessible anytime later within the session expiration time.
51-
session, err = auth.GetSession(session.ID)
52-
assert.NoError(t, err)
28+
for _, oneTime := range []bool{true, false} {
29+
t.Run(fmt.Sprintf("oneTime=%t", oneTime), func(t *testing.T) {
30+
const username = "Alice"
31+
const invalidSessionTTLError = "400 Invalid session time-to-live"
32+
ctx := base.TestCtx(t)
33+
testBucket := base.GetTestBucket(t)
34+
defer testBucket.Close(ctx)
35+
dataStore := testBucket.GetSingleDataStore()
36+
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
5337

54-
assert.Equal(t, username, session.Username)
55-
assert.Equal(t, 2*time.Hour, session.Ttl)
56-
assert.NotEmpty(t, session.ID)
57-
assert.NotEmpty(t, session.Expiration)
38+
user, err := auth.NewUser(username, "password", base.Set{})
39+
require.NoError(t, err)
40+
require.NotNil(t, user)
41+
require.NoError(t, auth.Save(user))
5842

59-
// Session must not be created with zero TTL; it's illegal.
60-
session, err = auth.CreateSession(ctx, user, time.Duration(0))
61-
assert.Nil(t, session)
62-
assert.Error(t, err)
63-
assert.Contains(t, err.Error(), invalidSessionTTLError)
43+
// Create session with a username and valid TTL of 2 hours.
44+
session, err := auth.CreateSession(ctx, user, 2*time.Hour, oneTime)
45+
require.NoError(t, err)
6446

65-
// Session must not be created with negative TTL; it's illegal.
66-
session, err = auth.CreateSession(ctx, user, time.Duration(-1))
67-
assert.Nil(t, session)
68-
assert.Error(t, err)
69-
assert.Contains(t, err.Error(), invalidSessionTTLError)
47+
assert.Equal(t, username, session.Username)
48+
assert.Equal(t, 2*time.Hour, session.Ttl)
49+
assert.NotEmpty(t, session.ID)
50+
assert.NotEmpty(t, session.Expiration)
51+
if oneTime {
52+
require.NotNil(t, session.OneTime)
53+
require.True(t, *session.OneTime)
54+
} else {
55+
assert.Empty(t, session.OneTime)
56+
}
57+
58+
// Once the session is created, the details should be persisted on the bucket
59+
// and it must be accessible anytime later within the session expiration time.
60+
session, _, err = auth.GetSession(session.ID)
61+
assert.NoError(t, err)
62+
63+
assert.Equal(t, username, session.Username)
64+
assert.Equal(t, 2*time.Hour, session.Ttl)
65+
assert.NotEmpty(t, session.ID)
66+
assert.NotEmpty(t, session.Expiration)
67+
if oneTime {
68+
require.NotNil(t, session.OneTime)
69+
require.True(t, *session.OneTime)
70+
} else {
71+
assert.Empty(t, session.OneTime)
72+
}
73+
74+
// Session must not be created with zero TTL; it's illegal.
75+
session, err = auth.CreateSession(ctx, user, time.Duration(0), oneTime)
76+
assert.Nil(t, session)
77+
assert.Error(t, err)
78+
assert.Contains(t, err.Error(), invalidSessionTTLError)
79+
80+
// Session must not be created with negative TTL; it's illegal.
81+
session, err = auth.CreateSession(ctx, user, time.Duration(-1), oneTime)
82+
assert.Nil(t, session)
83+
assert.Error(t, err)
84+
assert.Contains(t, err.Error(), invalidSessionTTLError)
85+
})
86+
}
7087
}
7188

7289
func TestDeleteSession(t *testing.T) {
@@ -91,7 +108,7 @@ func TestDeleteSession(t *testing.T) {
91108
assert.NoError(t, dataStore.Set(auth.DocIDForSession(mockSession.ID), noSessionExpiry, nil, mockSession))
92109
assert.NoError(t, auth.DeleteSession(ctx, mockSession.ID, ""))
93110

94-
session, err := auth.GetSession(mockSession.ID)
111+
session, _, err := auth.GetSession(mockSession.ID)
95112
assert.Nil(t, session)
96113
base.RequireDocNotFoundError(t, err)
97114
}
@@ -239,11 +256,12 @@ func TestCreateSessionChangePassword(t *testing.T) {
239256
require.NotNil(t, user)
240257
require.NoError(t, auth.Save(user))
241258

259+
oneTime := false
242260
// Create session with a username and valid TTL of 2 hours.
243-
session, err := auth.CreateSession(ctx, user, 2*time.Hour)
261+
session, err := auth.CreateSession(ctx, user, 2*time.Hour, oneTime)
244262
require.NoError(t, err)
245263

246-
session, err = auth.GetSession(session.ID)
264+
session, _, err = auth.GetSession(session.ID)
247265
require.NoError(t, err)
248266

249267
request, err := http.NewRequest(http.MethodGet, "", nil)
@@ -295,11 +313,11 @@ func TestUserWithoutSessionUUID(t *testing.T) {
295313
require.NoError(t, err)
296314
require.NotNil(t, user)
297315

298-
// Create session with a username and valid TTL of 2 hours.
299-
session, err := auth.CreateSession(ctx, user, 2*time.Hour)
316+
oneTime := false
317+
session, err := auth.CreateSession(ctx, user, 2*time.Hour, oneTime)
300318
require.NoError(t, err)
301319

302-
session, err = auth.GetSession(session.ID)
320+
session, _, err = auth.GetSession(session.ID)
303321
require.NoError(t, err)
304322

305323
request, err := http.NewRequest(http.MethodGet, "", nil)
@@ -325,11 +343,11 @@ func TestUserDeleteAllSessions(t *testing.T) {
325343
require.NotNil(t, user)
326344
require.NoError(t, auth.Save(user))
327345

328-
// Create session with a username and valid TTL of 2 hours.
329-
session, err := auth.CreateSession(ctx, user, 2*time.Hour)
346+
oneTime := false
347+
session, err := auth.CreateSession(ctx, user, 2*time.Hour, oneTime)
330348
require.NoError(t, err)
331349

332-
session, err = auth.GetSession(session.ID)
350+
session, _, err = auth.GetSession(session.ID)
333351
require.NoError(t, err)
334352

335353
request, err := http.NewRequest(http.MethodGet, "", nil)
@@ -348,3 +366,40 @@ func TestUserDeleteAllSessions(t *testing.T) {
348366
_, err = auth.AuthenticateCookie(request, recorder)
349367
require.EqualError(t, err, "401 Session no longer valid for user")
350368
}
369+
370+
func TestCreateOneTimeSession(t *testing.T) {
371+
ctx := base.TestCtx(t)
372+
testBucket := base.GetTestBucket(t)
373+
defer testBucket.Close(ctx)
374+
dataStore := testBucket.GetSingleDataStore()
375+
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
376+
const username = "Alice"
377+
user, err := auth.NewUser(username, "password", base.Set{})
378+
require.NoError(t, err)
379+
require.NoError(t, auth.Save(user))
380+
381+
oneTime := true
382+
session, err := auth.CreateSession(ctx, user, 2*time.Hour, oneTime)
383+
require.NoError(t, err)
384+
385+
session, user, err = auth.GetSession(session.ID)
386+
require.NoError(t, err)
387+
require.Equal(t, username, session.Username)
388+
require.Equal(t, username, user.Name())
389+
390+
// make sure this can be retrieved again if not through AuthenticateOneTimeSession
391+
session, user, err = auth.GetSession(session.ID)
392+
require.NoError(t, err)
393+
require.Equal(t, username, session.Username)
394+
require.Equal(t, username, user.Name())
395+
396+
// now test AuthenticateOneTimeSession deletes it
397+
user, err = auth.AuthenticateOneTimeSession(ctx, session.ID)
398+
require.NoError(t, err)
399+
require.Equal(t, username, user.Name())
400+
401+
// make sure session is deleted
402+
session, _, err = auth.GetSession(session.ID)
403+
require.Nil(t, session)
404+
base.RequireDocNotFoundError(t, err)
405+
}

docs/api/paths/public/db-_session.yaml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ post:
2424
Generates a login session for the user based on the credentials provided in the request body or if that fails (due to invalid credentials or none provided at all), generates the new session for the currently authenticated user instead. On a successful session creation, a session cookie is stored to keep the user authenticated for future API calls.
2525
2626
If `Origin` header is passed to this endpoint, the `Origin` header must match both the `cors.login_origin` and `cors.origin` configuration options.
27+
parameters:
28+
- name: one_time
29+
description: Sets the session to only be valid for a single authentication. This session will expire in 5 minutes if not used.
30+
in: query
31+
schema:
32+
type: boolean
2733
requestBody:
2834
description: When name and password are included in the request body, the session will be created for the specified user. Otherwise the session will be created for the authenticated user making the request.
2935
required: false
@@ -40,8 +46,19 @@ post:
4046
description: Password of the user to generate the session for. Omit this value to generate a session for the authenticated user.
4147
type: string
4248
responses:
43-
"200":
44-
$ref: ../../components/responses.yaml#/User-session-information
49+
'200':
50+
description: Session created successfully. Returned body is dependant on if using Public or Admin APIs
51+
content:
52+
application/json:
53+
schema:
54+
allOf:
55+
- $ref: ../../components/schemas.yaml#/User-session-information
56+
- type: object
57+
properties:
58+
one_time_session_id:
59+
description: The id of a single use session if `one_time=true` query parameter was used.
60+
type: string
61+
example: c5af80a039db4ed9d2b6865576b6999935282689
4562
"400":
4663
$ref: ../../components/responses.yaml#/Invalid-CORS-LoginOrigin
4764
"401":

rest/blip_sync.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import (
2222
"github.com/couchbase/sync_gateway/base"
2323
)
2424

25+
const (
26+
secWebSocketProtocolHeader = "Sec-WebSocket-Protocol"
27+
blipSessionIDPrefix = "SyncGatewaySession_"
28+
)
29+
2530
// HTTP handler for incoming BLIP sync WebSocket request (/db/_blipsync)
2631
func (h *handler) handleBLIPSync() error {
2732
needRelease, err := h.server.incrementConcurrentReplications(h.rqCtx)

0 commit comments

Comments
 (0)