Skip to content

Commit 0003af6

Browse files
committed
bridgev2/provisioning: support challenges
1 parent b0d3240 commit 0003af6

3 files changed

Lines changed: 161 additions & 1 deletion

File tree

bridgev2/matrix/provisioning.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ func (prov *ProvisioningAPI) Init() {
102102
prov.Router.HandleFunc("GET /v3/capabilities", prov.GetCapabilities)
103103
prov.Router.HandleFunc("GET /v3/login/flows", prov.GetLoginFlows)
104104
prov.Router.HandleFunc("POST /v3/login/start/{flowID}", prov.PostLoginStart)
105+
prov.Router.HandleFunc("POST /v3/login/challenge", prov.PostLoginChallenge)
105106
prov.Router.HandleFunc("POST /v3/login/step/{loginProcessID}/{stepID}/{stepType}", prov.PostLoginStep)
106107
prov.Router.HandleFunc("POST /v3/login/cancel/{loginProcessID}", prov.PostLoginCancel)
107108
prov.Router.HandleFunc("POST /v3/logout/{loginID}", prov.PostLogout)

bridgev2/matrix/provisioninglogin.go

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121

2222
"maunium.net/go/mautrix"
2323
"maunium.net/go/mautrix/bridgev2"
24+
"maunium.net/go/mautrix/bridgev2/networkid"
2425
"maunium.net/go/mautrix/bridgev2/status"
2526
)
2627

@@ -30,13 +31,18 @@ type ProvLogin struct {
3031
PrevStep *bridgev2.LoginStep
3132
NextStep *bridgev2.LoginStep
3233
Override *bridgev2.UserLogin
33-
Lock sync.Mutex
34+
// ChallengeFor is set to the login this process is resolving a challenge
35+
// for, if it was created via PostLoginChallenge. At most one challenge
36+
// process may be outstanding per login.
37+
ChallengeFor networkid.UserLoginID
38+
Lock sync.Mutex
3439

3540
Ctx context.Context
3641
CancelCtx context.CancelFunc
3742
}
3843

3944
var ErrNilStep = errors.New("bridge returned nil step with no error")
45+
var ErrNoPendingChallenge = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.NO_PENDING_CHALLENGE", Err: "No pending challenge to resolve", StatusCode: http.StatusNotFound}
4046
var ErrTooManyLogins = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.TOO_MANY_LOGINS", Err: "Maximum number of logins exceeded"}
4147
var ErrLoginCancelled = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.LOGIN_CANCELLED", Err: "Login process was cancelled"}
4248
var ErrLoginTimedOut = bridgev2.RespError{ErrCode: "FI.MAU.BRIDGE.LOGIN_TIMED_OUT", Err: "Login process timed out"}
@@ -97,6 +103,84 @@ func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Reque
97103
exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: loginID, LoginStep: firstStep})
98104
}
99105

106+
func (prov *ProvisioningAPI) PostLoginChallenge(w http.ResponseWriter, r *http.Request) {
107+
// A challenge always targets a specific existing login, so require an
108+
// explicit login_id.
109+
userLogin, failed := prov.GetExplicitLoginForRequest(w, r)
110+
if failed {
111+
return
112+
} else if userLogin == nil {
113+
ErrNotLoggedIn.Write(w)
114+
return
115+
}
116+
challengeable, ok := userLogin.Client.(bridgev2.ChallengeProvidingNetworkAPI)
117+
if !ok {
118+
ErrNoPendingChallenge.Write(w)
119+
return
120+
}
121+
// Check for an existing challenge and re-attach the caller if we have one.
122+
prov.loginsLock.RLock()
123+
existing := prov.findChallengeLogin(userLogin.ID)
124+
prov.loginsLock.RUnlock()
125+
if existing != nil {
126+
prov.respondWithExistingChallenge(w, r, existing)
127+
return
128+
}
129+
login, err := challengeable.Challenge(r.Context())
130+
if err != nil {
131+
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create challenge login process")
132+
RespondWithError(w, err, "Internal error starting challenge")
133+
return
134+
} else if login == nil {
135+
ErrNoPendingChallenge.Write(w)
136+
return
137+
}
138+
firstStep, err := login.Start(r.Context())
139+
if err == nil && firstStep == nil {
140+
err = ErrNilStep
141+
}
142+
if err != nil {
143+
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to start challenge login process")
144+
RespondWithError(w, err, "Internal error starting challenge")
145+
return
146+
}
147+
loginID := xid.New().String()
148+
ctx, cancel := context.WithTimeout(prov.br.Bridge.BackgroundCtx, 30*time.Minute)
149+
ctx = userLogin.Log.With().
150+
Str("login_id", loginID).
151+
Logger().WithContext(ctx)
152+
provLogin := &ProvLogin{
153+
ID: loginID,
154+
Process: login,
155+
NextStep: firstStep,
156+
Override: userLogin,
157+
ChallengeFor: userLogin.ID,
158+
Ctx: ctx,
159+
CancelCtx: cancel,
160+
}
161+
prov.loginsLock.Lock()
162+
existing = prov.findChallengeLogin(userLogin.ID)
163+
if existing == nil {
164+
prov.logins[loginID] = provLogin
165+
}
166+
prov.loginsLock.Unlock()
167+
if existing != nil {
168+
// A concurrent request registered its process first. Drop ours (it was
169+
// never handed out, so nothing can be driving it) and use theirs.
170+
login.Cancel()
171+
cancel()
172+
prov.respondWithExistingChallenge(w, r, existing)
173+
return
174+
}
175+
go prov.handleLoginTimeout(provLogin)
176+
zerolog.Ctx(r.Context()).Info().
177+
Str("login_id", loginID).
178+
Str("override_login_id", string(userLogin.ID)).
179+
Any("first_step", firstStep).
180+
Msg("Created challenge login process")
181+
exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{LoginID: loginID, LoginStep: firstStep})
182+
}
183+
100184
func (prov *ProvisioningAPI) PostLoginStep(w http.ResponseWriter, r *http.Request) {
101185
loginID := r.PathValue("loginProcessID")
102186
prov.loginsLock.RLock()
@@ -263,6 +347,15 @@ func (prov *ProvisioningAPI) handleCompleteStep(login *ProvLogin, step *bridgev2
263347
if login.Override == nil || login.Override.ID == step.CompleteParams.UserLoginID {
264348
return
265349
}
350+
if login.ChallengeFor != "" {
351+
// Challenges should never adversely affect existing logins; refuse
352+
// to delete it.
353+
zerolog.Ctx(ctx).Error().
354+
Str("challenge_for", string(login.ChallengeFor)).
355+
Str("new_login_id", string(step.CompleteParams.UserLoginID)).
356+
Msg("Challenge completed with a different login ID, not deleting the original login")
357+
return
358+
}
266359
zerolog.Ctx(ctx).Info().
267360
Str("old_login_id", string(login.Override.ID)).
268361
Str("new_login_id", string(step.CompleteParams.UserLoginID)).
@@ -284,6 +377,35 @@ func (prov *ProvisioningAPI) handleLoginTimeout(login *ProvLogin) {
284377
}
285378
}
286379

380+
// findChallengeLogin returns the outstanding challenge process for the given
381+
// login, or nil if there isn't one.
382+
//
383+
// loginsLock must be held.
384+
func (prov *ProvisioningAPI) findChallengeLogin(loginID networkid.UserLoginID) *ProvLogin {
385+
for _, login := range prov.logins {
386+
if login.ChallengeFor == loginID && login.Ctx.Err() == nil {
387+
return login
388+
}
389+
}
390+
return nil
391+
}
392+
393+
// respondWithExistingChallenge re-attaches the caller to a challenge process
394+
// that is already running.
395+
func (prov *ProvisioningAPI) respondWithExistingChallenge(w http.ResponseWriter, r *http.Request, login *ProvLogin) {
396+
login.Lock.Lock()
397+
nextStep := login.NextStep
398+
login.Lock.Unlock()
399+
zerolog.Ctx(r.Context()).Info().
400+
Str("login_id", login.ID).
401+
Str("challenge_for", string(login.ChallengeFor)).
402+
Msg("Re-attaching to existing challenge login process")
403+
exhttp.WriteJSONResponse(w, http.StatusOK, &RespSubmitLogin{
404+
LoginID: login.ID,
405+
LoginStep: nextStep,
406+
})
407+
}
408+
287409
func (prov *ProvisioningAPI) deleteLogin(login *ProvLogin, cancel bool) {
288410
if cancel {
289411
login.Process.Cancel()

bridgev2/networkinterface.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,43 @@ type NetworkAPIWithUserID interface {
425425
GetUserID() networkid.UserID
426426
}
427427

428+
// ChallengeProvidingNetworkAPI is an optional interface for network clients
429+
// that can be arbitrarily interrupted at runtime by a condition that the user
430+
// must interactively resolve. This is appropriate for situations such as
431+
// CAPTCHAs or verification checkpoints.
432+
//
433+
// Challenge resolution is performed through a [LoginProcess], which
434+
// effectively provides bridge-driven UI to clients.
435+
type ChallengeProvidingNetworkAPI interface {
436+
NetworkAPI
437+
// Challenge returns a [LoginProcess] attached to this existing login that
438+
// lets the user resolve the current challenge, or nil if a challenge isn't
439+
// pending.
440+
//
441+
// "Attached to this existing login" implies that, once the challenge is
442+
// resolved, you must:
443+
//
444+
// * Return the login to a working state, usually by calling Connect on
445+
// the existing client, and enqueue a bridge state reflecting that.
446+
//
447+
// * Persist anything new by calling [UserLogin.Save].
448+
//
449+
// * Return a [LoginCompleteParams] whose UserLoginID is the existing
450+
// login's ID.
451+
//
452+
// Do NOT call [User.NewLogin], as is usually done in the implementation of
453+
// a login process; this will leave behind a dangling client that is never
454+
// disconnected. The returned [LoginProcess] merely resolves a condition on
455+
// the existing [NetworkAPI].
456+
//
457+
// Implementations should always return a fresh LoginProcess; deduplication
458+
// occurs on the provisioning API.
459+
//
460+
// The returned process's Cancel (called after 30 minutes or explicit
461+
// cancel) must only drop the pending interaction; do not log the user out.
462+
Challenge(ctx context.Context) (LoginProcess, error)
463+
}
464+
428465
type ConnectBackgroundParams struct {
429466
// RawData is the raw data in the push that triggered the background connection.
430467
RawData json.RawMessage

0 commit comments

Comments
 (0)