Skip to content

Commit d8e30f7

Browse files
authored
pam/adapter: fix authTracker race with a generation counter (#1449)
Closes #1424
2 parents e9e7bfd + 116ed05 commit d8e30f7

8 files changed

Lines changed: 138 additions & 71 deletions

File tree

internal/brokers/broker.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ type Broker struct {
4343
ongoingUserRequests map[string]string
4444
ongoingUserRequestsMu *sync.Mutex
4545

46+
// isAuthMu serialises IsAuthenticated calls per session. A new call for
47+
// the same session must wait until the previous one — including any broker-
48+
// side cancellation and cleanup — has fully returned.
49+
isAuthMu map[string]*sync.Mutex
50+
isAuthMuMu *sync.Mutex
51+
4652
brokerer brokerer
4753
}
4854

@@ -83,6 +89,8 @@ func newBroker(ctx context.Context, configFile string, bus *dbus.Conn) (b Broker
8389
layoutValidatorsMu: &sync.Mutex{},
8490
ongoingUserRequests: make(map[string]string),
8591
ongoingUserRequestsMu: &sync.Mutex{},
92+
isAuthMu: make(map[string]*sync.Mutex),
93+
isAuthMuMu: &sync.Mutex{},
8694
}, nil
8795
}
8896

@@ -142,6 +150,24 @@ func (b Broker) SelectAuthenticationMode(ctx context.Context, sessionID, authent
142150
func (b Broker) IsAuthenticated(ctx context.Context, sessionID, authenticationData string) (access string, data string, err error) {
143151
sessionID = b.parseSessionID(sessionID)
144152

153+
// Serialise concurrent IsAuthenticated calls for the same session.
154+
// A previous call may still be in-flight on the broker side after the
155+
// client-side context was cancelled; we must wait for it to fully finish
156+
// (including the broker's own cleanup) before sending a new request.
157+
// Because broker.IsAuthenticated already does <-done in the ctx.Done()
158+
// branch, this mutex is only released after the broker goroutine has
159+
// exited — so the second call is guaranteed to see a clean broker state.
160+
b.isAuthMuMu.Lock()
161+
mu, ok := b.isAuthMu[sessionID]
162+
if !ok {
163+
mu = &sync.Mutex{}
164+
b.isAuthMu[sessionID] = mu
165+
}
166+
b.isAuthMuMu.Unlock()
167+
168+
mu.Lock()
169+
defer mu.Unlock()
170+
145171
// monitor ctx in goroutine to call cancel
146172
done := make(chan struct{})
147173
go func() {
@@ -230,6 +256,10 @@ func (b Broker) endSession(ctx context.Context, sessionID string) (err error) {
230256
defer b.ongoingUserRequestsMu.Unlock()
231257
delete(b.ongoingUserRequests, sessionID)
232258

259+
b.isAuthMuMu.Lock()
260+
delete(b.isAuthMu, sessionID)
261+
b.isAuthMuMu.Unlock()
262+
233263
return b.brokerer.EndSession(ctx, sessionID)
234264
}
235265

internal/brokers/broker_test.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -223,20 +223,20 @@ func TestIsAuthenticated(t *testing.T) {
223223
"No_error_when_broker_returns_userinfo_with_mismatching_username": {sessionID: "ia_info_mismatching_user_name"},
224224

225225
// broker errors
226-
"Error_when_authenticating": {sessionID: "ia_error"},
227-
"Error_on_empty_data_even_if_granted": {sessionID: "ia_empty_data"},
228-
"Error_when_broker_returns_invalid_data": {sessionID: "ia_invalid_data"},
229-
"Error_when_broker_returns_invalid_access": {sessionID: "ia_invalid_access"},
230-
"Error_when_broker_returns_invalid_userinfo": {sessionID: "ia_invalid_userinfo"},
231-
"Error_when_broker_returns_userinfo_with_empty_username": {sessionID: "ia_info_empty_user_name"},
232-
"Error_when_broker_returns_userinfo_with_empty_group_name": {sessionID: "ia_info_empty_group_name"},
233-
"Error_when_broker_returns_userinfo_with_invalid_homedir": {sessionID: "ia_info_invalid_home"},
234-
"Error_when_broker_returns_userinfo_with_invalid_shell": {sessionID: "ia_info_invalid_shell"},
235-
"Error_when_broker_returns_invalid_data_on_auth.Next": {sessionID: "ia_next_with_invalid_data"},
236-
"Error_when_broker_returns_data_on_auth.Cancelled": {sessionID: "ia_cancelled_with_data"},
237-
"Error_when_broker_returns_no_data_on_auth.Denied": {sessionID: "ia_denied_without_data"},
238-
"Error_when_broker_returns_no_data_on_auth.Retry": {sessionID: "ia_retry_without_data"},
239-
"Error_when_calling_IsAuthenticated_a_second_time_without_cancelling": {sessionID: "ia_second_call", secondCall: true, cancelFirstCall: true},
226+
"Error_when_authenticating": {sessionID: "ia_error"},
227+
"Error_on_empty_data_even_if_granted": {sessionID: "ia_empty_data"},
228+
"Error_when_broker_returns_invalid_data": {sessionID: "ia_invalid_data"},
229+
"Error_when_broker_returns_invalid_access": {sessionID: "ia_invalid_access"},
230+
"Error_when_broker_returns_invalid_userinfo": {sessionID: "ia_invalid_userinfo"},
231+
"Error_when_broker_returns_userinfo_with_empty_username": {sessionID: "ia_info_empty_user_name"},
232+
"Error_when_broker_returns_userinfo_with_empty_group_name": {sessionID: "ia_info_empty_group_name"},
233+
"Error_when_broker_returns_userinfo_with_invalid_homedir": {sessionID: "ia_info_invalid_home"},
234+
"Error_when_broker_returns_userinfo_with_invalid_shell": {sessionID: "ia_info_invalid_shell"},
235+
"Error_when_broker_returns_invalid_data_on_auth.Next": {sessionID: "ia_next_with_invalid_data"},
236+
"Error_when_broker_returns_data_on_auth.Cancelled": {sessionID: "ia_cancelled_with_data"},
237+
"Error_when_broker_returns_no_data_on_auth.Denied": {sessionID: "ia_denied_without_data"},
238+
"Error_when_broker_returns_no_data_on_auth.Retry": {sessionID: "ia_retry_without_data"},
239+
"Successfully_authenticate_after_second_call_without_cancelling": {sessionID: "ia_second_call", secondCall: true, cancelFirstCall: true},
240240
}
241241
for name, tc := range tests {
242242
t.Run(name, func(t *testing.T) {

internal/brokers/testdata/golden/TestIsAuthenticated/Error_when_calling_IsAuthenticated_a_second_time_without_cancelling renamed to internal/brokers/testdata/golden/TestIsAuthenticated/Successfully_authenticate_after_second_call_without_cancelling

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ FIRST CALL:
33
data: {"Name":"ia_second_call@example.com","UID":0,"Gecos":"gecos for ia_second_call@example.com","Dir":"/home/ia_second_call@example.com","Shell":"/bin/sh/ia_second_call@example.com","Groups":[{"Name":"group-ia_second_call@example.com","GID":null,"UGID":"ugid-ia_second_call@example.com"}]}
44
err: <nil>
55
SECOND CALL:
6-
access:
7-
data:
8-
err: broker "TestIsAuthenticated": IsAuthenticated already running for session "TestIsAuthenticated/Error_when_calling_IsAuthenticated_a_second_time_without_cancelling_separator_ia_second_call"
6+
access: granted
7+
data: {"Name":"ia_second_call@example.com","UID":0,"Gecos":"gecos for ia_second_call@example.com","Dir":"/home/ia_second_call@example.com","Shell":"/bin/sh/ia_second_call@example.com","Groups":[{"Name":"group-ia_second_call@example.com","GID":null,"UGID":"ugid-ia_second_call@example.com"}]}
8+
err: <nil>

internal/services/pam/pam_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -451,12 +451,12 @@ func TestIsAuthenticated(t *testing.T) {
451451
"Error_when_user_is_locked": {username: "locked@example.com", existingDB: "cache-with-locked-user.db"},
452452

453453
// broker errors
454-
"Error_when_authenticating": {username: "ia_error@example.com"},
455-
"Error_on_empty_data_even_if_granted": {username: "ia_empty_data@example.com"},
456-
"Error_when_broker_returns_invalid_access": {username: "ia_invalid_access@example.com"},
457-
"Error_when_broker_returns_invalid_data": {username: "ia_invalid_data@example.com"},
458-
"Error_when_broker_returns_invalid_userinfo": {username: "ia_invalid_userinfo@example.com"},
459-
"Error_when_calling_second_time_without_cancelling": {username: "ia_second_call@example.com", secondCall: true},
454+
"Error_when_authenticating": {username: "ia_error@example.com"},
455+
"Error_on_empty_data_even_if_granted": {username: "ia_empty_data@example.com"},
456+
"Error_when_broker_returns_invalid_access": {username: "ia_invalid_access@example.com"},
457+
"Error_when_broker_returns_invalid_data": {username: "ia_invalid_data@example.com"},
458+
"Error_when_broker_returns_invalid_userinfo": {username: "ia_invalid_userinfo@example.com"},
459+
"Successfully_authenticate_after_calling_second_time_without_cancelling": {username: "ia_second_call@example.com", secondCall: true},
460460

461461
// local group error
462462
"Error_on_updating_local_groups_with_unexisting_file": {username: "success_with_local_groups@example.com", localGroupsFile: "does_not_exists.group"},

internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_calling_second_time_without_cancelling/IsAuthenticated

Lines changed: 0 additions & 8 deletions
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
FIRST CALL:
2+
access: granted
3+
msg:
4+
err: <nil>
5+
SECOND CALL:
6+
access: granted
7+
msg:
8+
err: <nil>

internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_calling_second_time_without_cancelling/cache.db renamed to internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_after_calling_second_time_without_cancelling/cache.db

File renamed without changes.

pam/internal/adapter/authentication.go

Lines changed: 77 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,12 @@ func sendIsAuthenticated(ctx context.Context, client authd.PAMClient, sessionID
5555
if st := status.Convert(err); st.Code() == codes.Canceled {
5656
// Note that this error is only the client-side error, so being here doesn't
5757
// mean the cancellation on broker side is fully completed.
58-
59-
// Wait for the cancellation requests to have been delivered and actually handled.
60-
// The multiplier can be increased to avoid that we return the cancelled event too
61-
// early, but it implies slowing down the UI responses.
62-
<-time.After(cancellationWait * 3)
58+
// We still wait briefly so that the CancelIsAuthenticated D-Bus call (sent
59+
// by the broker layer when ctx is cancelled) has time to arrive at the broker
60+
// after the IsAuthenticated call — not before it. Serialisation of
61+
// back-to-back IsAuthenticated calls for the same session is handled
62+
// server-side, so we no longer need a longer delay here.
63+
<-time.After(cancellationWait)
6364

6465
return isAuthenticatedResultReceived{
6566
access: auth.Cancelled,
@@ -138,9 +139,18 @@ type authenticationModel struct {
138139
errorMsg string
139140
}
140141

142+
// authTracker serialises IsAuthenticated calls and supports cancellation.
143+
//
144+
// At most one authentication goroutine is in flight at a time. A goroutine
145+
// that arrives while another is running blocks until the running one finishes.
146+
// cancelAndWait() cancels any in-flight goroutine and bumps the generation
147+
// counter so that any goroutine that is waiting (or has just been woken) knows
148+
// it has been superseded and must abort without making the RPC.
141149
type authTracker struct {
142-
cancelFunc func()
143-
cond *sync.Cond
150+
mu sync.Mutex
151+
generation uint64 // incremented by cancelAndWait; goroutines abort if theirs is stale
152+
cancelFunc func() // cancels the context of the current in-flight RPC; nil when idle
153+
done chan struct{} // closed by the goroutine when it exits; nil when idle
144154
}
145155

146156
// startAuthentication signals that the authentication model can start
@@ -174,7 +184,7 @@ func newAuthenticationModel(client authd.PAMClient, clientType PamClientType, mo
174184
client: client,
175185
clientType: clientType,
176186
mode: mode,
177-
authTracker: &authTracker{cond: sync.NewCond(&sync.Mutex{})},
187+
authTracker: &authTracker{},
178188
}
179189
}
180190

@@ -293,7 +303,12 @@ func (m authenticationModel) Update(msg tea.Msg) (authModel authenticationModel,
293303
clientType := m.clientType
294304
currentLayout := m.currentLayout
295305
return m, func() tea.Msg {
296-
authTracker.waitAndStart(cancelFunc)
306+
// waitForSlot blocks until no other auth is in flight, then registers
307+
// us as the active goroutine for this generation. It returns false
308+
// if we have been superseded by a cancelAndWait() call and must abort.
309+
if !authTracker.waitForSlot(cancelFunc) {
310+
return nil
311+
}
297312

298313
secret, hasSecret := msg.item.(*authd.IARequest_AuthenticationData_Secret)
299314
if hasSecret && clientType == Gdm && currentLayout == layouts.NewPassword {
@@ -332,7 +347,7 @@ func (m authenticationModel) Update(msg tea.Msg) (authModel authenticationModel,
332347
if msg.access != auth.Next && msg.access != auth.Retry {
333348
m.currentModel = nil
334349
}
335-
m.authTracker.reset()
350+
m.authTracker.finish()
336351
}()
337352

338353
var authMsg string
@@ -550,43 +565,65 @@ func (authData *isAuthenticatedRequestedSend) encryptSecretIfPresent(publicKey *
550565
return &secret.Secret, nil
551566
}
552567

553-
// wait waits for the current authentication to be completed.
554-
func (at *authTracker) wait() {
555-
at.cond.L.Lock()
556-
defer at.cond.L.Unlock()
557-
558-
for at.cancelFunc != nil {
559-
at.cond.Wait()
568+
// waitForSlot blocks until no other authentication goroutine is in flight,
569+
// then registers itself as the active goroutine. It returns true when the
570+
// caller should proceed with the RPC, or false when a cancelAndWait() call
571+
// has superseded this goroutine and the caller must abort.
572+
//
573+
// The generation counter is the key to correctness: cancelAndWait() bumps it
574+
// under the lock before signalling, so any goroutine that wakes up afterwards
575+
// will see a stale generation and abort — with no window for a race.
576+
func (at *authTracker) waitForSlot(cancelFunc func()) bool {
577+
at.mu.Lock()
578+
gen := at.generation
579+
// Wait until the previous auth goroutine has finished.
580+
for at.done != nil {
581+
done := at.done
582+
at.mu.Unlock()
583+
<-done
584+
at.mu.Lock()
585+
}
586+
// If cancelAndWait() was called while we were waiting (or before we even
587+
// started), our generation is stale — abort without making any RPC.
588+
if at.generation != gen {
589+
at.mu.Unlock()
590+
return false
560591
}
592+
// Register ourselves as the active goroutine.
593+
at.cancelFunc = cancelFunc
594+
at.done = make(chan struct{})
595+
at.mu.Unlock()
596+
return true
561597
}
562598

563-
// waitAndStart waits for the current authentication to be completed and
564-
// marks the authentication as in progress.
565-
func (at *authTracker) waitAndStart(cancelFunc func()) {
566-
at.cond.L.Lock()
567-
defer at.cond.L.Unlock()
568-
569-
for at.cancelFunc != nil {
570-
at.cond.Wait()
599+
// finish marks the active authentication goroutine as done. It must be called
600+
// by every goroutine that received true from waitForSlot, regardless of whether
601+
// the RPC was actually made (e.g. even for newPasswordCheck detours).
602+
func (at *authTracker) finish() {
603+
at.mu.Lock()
604+
done := at.done
605+
at.cancelFunc = nil
606+
at.done = nil
607+
at.mu.Unlock()
608+
if done != nil {
609+
close(done)
571610
}
572-
573-
at.cancelFunc = cancelFunc
574611
}
575612

613+
// cancelAndWait cancels the in-flight authentication (if any) and waits for
614+
// its goroutine to exit. After it returns, any goroutine currently blocked in
615+
// waitForSlot will also abort, because the generation counter was bumped.
576616
func (at *authTracker) cancelAndWait() {
577-
at.cond.L.Lock()
617+
at.mu.Lock()
618+
at.generation++
578619
cancelFunc := at.cancelFunc
579-
at.cond.L.Unlock()
580-
if cancelFunc == nil {
581-
return
582-
}
583-
cancelFunc()
584-
at.wait()
585-
}
620+
done := at.done
621+
at.mu.Unlock()
586622

587-
func (at *authTracker) reset() {
588-
at.cond.L.Lock()
589-
defer at.cond.L.Unlock()
590-
at.cancelFunc = nil
591-
at.cond.Signal()
623+
if cancelFunc != nil {
624+
cancelFunc()
625+
}
626+
if done != nil {
627+
<-done
628+
}
592629
}

0 commit comments

Comments
 (0)