2026-03-26 19:56:36 -07:00
|
|
|
// mautrix-discord - A Matrix-Discord puppeting bridge.
|
|
|
|
|
// Copyright (C) 2026 Tulir Asokan
|
|
|
|
|
//
|
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
|
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
|
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
|
|
|
// (at your option) any later version.
|
|
|
|
|
//
|
|
|
|
|
// This program is distributed in the hope that it will be useful,
|
|
|
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
|
// GNU Affero General Public License for more details.
|
|
|
|
|
//
|
|
|
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
|
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
|
|
package discordauth
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"maps"
|
|
|
|
|
"net/http"
|
2026-03-26 21:52:51 -07:00
|
|
|
"slices"
|
|
|
|
|
"strings"
|
2026-03-26 19:56:36 -07:00
|
|
|
|
|
|
|
|
"github.com/rs/zerolog"
|
|
|
|
|
"go.mau.fi/util/ptr"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// An AuthMachine governs the core logic to authenticate with Discord. It is
|
|
|
|
|
// concerned with:
|
|
|
|
|
//
|
|
|
|
|
// - Detecting CAPTCHA challenges.
|
|
|
|
|
// - Sending the correct set of headers to each endpoint.
|
|
|
|
|
// - Stashing the necessary state in-memory and threading them into requests
|
|
|
|
|
// as necessary.
|
|
|
|
|
type AuthMachine struct {
|
|
|
|
|
log *zerolog.Logger
|
|
|
|
|
LogFilters AuthMachineLogFilters
|
|
|
|
|
|
2026-03-27 20:40:52 -07:00
|
|
|
http HTTP
|
|
|
|
|
APIBase string
|
|
|
|
|
handler ChallengeHandler
|
2026-03-26 19:56:36 -07:00
|
|
|
|
|
|
|
|
State AuthMachineState
|
|
|
|
|
|
2026-03-26 21:52:51 -07:00
|
|
|
Personality *Personality
|
2026-03-26 19:56:36 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AuthMachineState struct {
|
|
|
|
|
Fingerprint Fingerprint
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CaptchaSolution struct {
|
|
|
|
|
Solution string
|
|
|
|
|
}
|
2026-03-26 20:19:13 -07:00
|
|
|
type CaptchaHandler func(ctx context.Context, captcha *Captcha) (*CaptchaSolution, error)
|
2026-03-26 19:56:36 -07:00
|
|
|
|
2026-03-27 20:40:52 -07:00
|
|
|
func NewAuthMachine(ctx context.Context, http HTTP, personality *Personality, handler ChallengeHandler) *AuthMachine {
|
2026-03-26 19:56:36 -07:00
|
|
|
if http == nil {
|
|
|
|
|
panic("http interface is required")
|
|
|
|
|
}
|
|
|
|
|
if personality == nil {
|
|
|
|
|
panic("personality is required")
|
|
|
|
|
}
|
2026-03-27 20:40:52 -07:00
|
|
|
if handler == nil {
|
|
|
|
|
panic("handler is required")
|
|
|
|
|
}
|
2026-03-26 19:56:36 -07:00
|
|
|
|
|
|
|
|
log := zerolog.Ctx(ctx).With().Str("component", "discord auth").Logger()
|
|
|
|
|
|
|
|
|
|
return &AuthMachine{
|
2026-03-27 20:40:52 -07:00
|
|
|
log: &log,
|
|
|
|
|
|
|
|
|
|
http: http,
|
|
|
|
|
handler: handler,
|
2026-03-26 19:56:36 -07:00
|
|
|
|
2026-03-26 21:52:51 -07:00
|
|
|
APIBase: "https://discord.com/api/v9",
|
|
|
|
|
Personality: personality,
|
2026-03-26 19:56:36 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 21:52:51 -07:00
|
|
|
func formatHTTPHeaderDump(prefix string, headers http.Header) string {
|
|
|
|
|
keys := make([]string, 0, len(headers))
|
|
|
|
|
for key := range headers {
|
|
|
|
|
keys = append(keys, key)
|
|
|
|
|
}
|
|
|
|
|
slices.Sort(keys)
|
|
|
|
|
|
|
|
|
|
var msg strings.Builder
|
|
|
|
|
msg.WriteString(prefix)
|
|
|
|
|
for _, key := range keys {
|
|
|
|
|
for _, value := range headers[key] {
|
|
|
|
|
msg.WriteByte('\n')
|
|
|
|
|
msg.WriteString(key)
|
|
|
|
|
msg.WriteString(": ")
|
|
|
|
|
msg.WriteString(value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return msg.String()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 19:56:36 -07:00
|
|
|
func (am *AuthMachine) captchaRetryLoop(ctx context.Context, req *http.Request) (*http.Response, []byte, error) {
|
|
|
|
|
// Check if we can clone the request body. We need this since we might need
|
|
|
|
|
// to retry the request.
|
|
|
|
|
if req.GetBody == nil && req.ContentLength > 0 {
|
|
|
|
|
return nil, nil, fmt.Errorf("tried to make request with a body that isn't retriable")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log := zerolog.Ctx(ctx)
|
|
|
|
|
nCaptchas := 0
|
|
|
|
|
var resp *http.Response
|
|
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
defer func() {
|
|
|
|
|
if resp == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
respLogLevel := zerolog.DebugLevel
|
|
|
|
|
respStatusOk := respIsOk(resp)
|
|
|
|
|
if !respStatusOk {
|
|
|
|
|
respLogLevel = zerolog.ErrorLevel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if am.LogFilters.EveryHTTPResponse || !respStatusOk {
|
|
|
|
|
// Erroneous responses are always logged.
|
|
|
|
|
log.WithLevel(respLogLevel).
|
|
|
|
|
Int("n_captchas", nCaptchas).
|
|
|
|
|
Int("http_status", resp.StatusCode).
|
|
|
|
|
Int("http_content_length", int(resp.ContentLength)).
|
|
|
|
|
Msg("Received response")
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
if am.LogFilters.EveryHTTPRequest {
|
|
|
|
|
log.Debug().
|
|
|
|
|
Int("n_captchas", nCaptchas).
|
|
|
|
|
Msg("Making request")
|
|
|
|
|
}
|
2026-03-26 21:52:51 -07:00
|
|
|
if am.LogFilters.DangerouslyLeakyHTTPHeaders {
|
|
|
|
|
log.Debug().
|
|
|
|
|
Int("n_captchas", nCaptchas).
|
|
|
|
|
Msg(formatHTTPHeaderDump("Sending request headers", req.Header))
|
|
|
|
|
}
|
2026-03-26 19:56:36 -07:00
|
|
|
|
|
|
|
|
// Make the HTTP request.
|
|
|
|
|
resp, err = am.http.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("failed to make http request: %w", err)
|
|
|
|
|
}
|
2026-03-26 21:52:51 -07:00
|
|
|
if am.LogFilters.DangerouslyLeakyHTTPHeaders {
|
|
|
|
|
log.Debug().
|
|
|
|
|
Int("n_captchas", nCaptchas).
|
|
|
|
|
Msg(formatHTTPHeaderDump("Received response headers", resp.Header))
|
|
|
|
|
}
|
2026-03-26 19:56:36 -07:00
|
|
|
|
|
|
|
|
// We need to consume the entire response body so we can test for a
|
|
|
|
|
// CAPTCHA challenge.
|
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("failed to slurp http response body: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if err := resp.Body.Close(); err != nil {
|
|
|
|
|
log.Warn().Err(err).Msg("Failed to close response body, proceeding")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
captcha := TryUnmarshalingCaptcha(ctx, resp, body)
|
|
|
|
|
if captcha != nil {
|
|
|
|
|
goto solveCaptchaAndRetry
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !respIsOk(resp) {
|
|
|
|
|
// (defer block above logs for us.)
|
2026-03-26 20:44:39 -07:00
|
|
|
|
|
|
|
|
var apiError APIError
|
|
|
|
|
err := json.Unmarshal(body, &apiError)
|
|
|
|
|
|
|
|
|
|
if err != nil || apiError.Code == 0 {
|
|
|
|
|
// Doesn't look like we got {"code": 00000, "message": "..."}
|
|
|
|
|
return nil, nil, HTTPError{body: body, resp: resp}
|
|
|
|
|
} else {
|
|
|
|
|
apiError.ResponseBody = body
|
|
|
|
|
return nil, nil, apiError
|
|
|
|
|
}
|
2026-03-26 19:56:36 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No CAPTCHA, we're good.
|
|
|
|
|
return resp, body, nil
|
|
|
|
|
|
|
|
|
|
solveCaptchaAndRetry:
|
|
|
|
|
// We got a CAPTCHA. Invoke the handler provided by the client and
|
|
|
|
|
// retry with the challenge response once the CAPTCHA is completed.
|
|
|
|
|
|
|
|
|
|
log = ptr.Ptr(captcha.LogContext(log.With()).Logger())
|
|
|
|
|
log.Info().Msg("Encountered CAPTCHA challenge")
|
|
|
|
|
|
|
|
|
|
solution, err := am.waitForCaptchaSolve(ctx, captcha)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("failed to wait for captcha solution: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// We're going to try the request again once we come back around in the
|
|
|
|
|
// loop.
|
|
|
|
|
req, err = refreshReq(ctx, req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("failed to refresh request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
// Add the solution and other CAPTCHA state to the headers.
|
|
|
|
|
req.Header.Set(HeaderCaptchaKey, solution.Solution)
|
|
|
|
|
captcha.UpdateHeaders(&req.Header)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (am *AuthMachine) waitForCaptchaSolve(ctx context.Context, captcha *Captcha) (*CaptchaSolution, error) {
|
|
|
|
|
log := zerolog.Ctx(ctx).With().Str("action", "wait for discord captcha solve").Logger()
|
|
|
|
|
ctx = log.WithContext(ctx)
|
|
|
|
|
|
2026-03-26 20:19:13 -07:00
|
|
|
log.Info().Msg("Invoking CAPTCHA handler")
|
2026-03-27 20:40:52 -07:00
|
|
|
solution, err := am.handler.SolveCaptcha(ctx, captcha)
|
2026-03-26 20:19:13 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("captcha handler failed: %w", err)
|
2026-03-26 19:56:36 -07:00
|
|
|
}
|
2026-03-26 20:19:13 -07:00
|
|
|
if solution == nil {
|
|
|
|
|
return nil, fmt.Errorf("captcha handler returned nil solution")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return solution, nil
|
2026-03-26 19:56:36 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// doHandlingCaptcha performs an HTTP request, mutating it to contain headers
|
|
|
|
|
// from the [Personality].
|
|
|
|
|
//
|
|
|
|
|
// - In order to detect and respond to CAPTCHA challenges, this method buffers
|
|
|
|
|
// all request and response bodies into memory.
|
|
|
|
|
//
|
|
|
|
|
// - Should a CAPTCHA challenge occur, note that multiple attempts to solve the
|
|
|
|
|
// CAPTCHA may be necessary.
|
|
|
|
|
func (am *AuthMachine) doHandlingCaptcha(ctx context.Context, req *http.Request) (*http.Response, []byte, error) {
|
|
|
|
|
log := zerolog.Ctx(ctx).With().
|
|
|
|
|
Str("http_method", req.Method).
|
|
|
|
|
Stringer("http_url", req.URL).
|
|
|
|
|
Logger()
|
|
|
|
|
ctx = log.WithContext(ctx)
|
|
|
|
|
|
|
|
|
|
// Add all personality headers to the request.
|
|
|
|
|
personalityHeaders, err := am.Personality.Headers()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("failed to get personality headers: %w", err)
|
|
|
|
|
}
|
|
|
|
|
maps.Copy(req.Header, personalityHeaders)
|
2026-03-26 21:52:51 -07:00
|
|
|
// Set X-Debug-Options if we have one.
|
|
|
|
|
debugOptions := am.Personality.DebugOptions
|
|
|
|
|
if debugOptions != "" {
|
|
|
|
|
req.Header.Set(HeaderDebugOptions, debugOptions)
|
2026-03-26 21:01:58 -07:00
|
|
|
}
|
2026-03-26 19:56:36 -07:00
|
|
|
// Set X-Fingerprint if we have one.
|
|
|
|
|
if !am.State.Fingerprint.IsZero() {
|
|
|
|
|
req.Header.Set(HeaderFingerprint, am.State.Fingerprint.HeaderValue())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Make the request, anticipating any potential CAPTCHAs.
|
|
|
|
|
resp, body, err := am.captchaRetryLoop(ctx, req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("failed to make request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return resp, body, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (am *AuthMachine) performLegacyExperiments(ctx context.Context) (*ExperimentsLegacy, error) {
|
|
|
|
|
url := fmt.Sprintf("%s/experiments?with_guild_experiments=true", am.APIBase)
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to construct legacy experiments request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set X-Context-Properties.
|
|
|
|
|
contextProps, err := EncodeBasicContextProperties(ContextLocationLogin)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to encode login context properties: %w", err)
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set(HeaderContextProperties, contextProps)
|
|
|
|
|
|
|
|
|
|
_, body, err := am.doHandlingCaptcha(ctx, req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to request legacy experiments: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var legacy ExperimentsLegacy
|
|
|
|
|
err = json.Unmarshal(body, &legacy)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to decode legacy experiments: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &legacy, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (am *AuthMachine) performApexExperiments(ctx context.Context) (any, error) {
|
|
|
|
|
url := fmt.Sprintf("%s/apex/experiments?surface=2", am.APIBase)
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to construct apex experiments request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// (Apex experiments don't get `X-Context-Properties`.)
|
|
|
|
|
_, _, err = am.doHandlingCaptcha(ctx, req)
|
|
|
|
|
if err != nil {
|
2026-03-26 21:01:58 -07:00
|
|
|
return nil, fmt.Errorf("failed to request apex experiments: %w", err)
|
2026-03-26 19:56:36 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prepare loads the login page and situates the AuthMachine with an
|
2026-03-27 20:40:52 -07:00
|
|
|
// experiments-related [Fingerprint]. It is important for Prepare to be called
|
|
|
|
|
// before [AuthMachine.Login].
|
|
|
|
|
//
|
|
|
|
|
// Calling this method can lead to your [ChallengeHandler] being called.
|
2026-03-26 19:56:36 -07:00
|
|
|
func (am *AuthMachine) Prepare(ctx context.Context) error {
|
|
|
|
|
log := am.log.With().Str("action", "prepare discord auth machine").Logger()
|
|
|
|
|
ctx = log.WithContext(ctx)
|
|
|
|
|
log.Info().Msg("Preparing Discord auth")
|
|
|
|
|
|
|
|
|
|
legacy, err := am.performLegacyExperiments(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to perform legacy experiments: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err = am.performApexExperiments(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to perform apex experiments: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// (Apex experiments aren't fetched with the fingerprint, so only set it
|
|
|
|
|
// now.)
|
|
|
|
|
am.State.Fingerprint = legacy.Fingerprint
|
2026-03-26 21:01:58 -07:00
|
|
|
if am.LogFilters.Fingerprint {
|
|
|
|
|
log.Info().Str("fingerprint", am.State.Fingerprint.HeaderValue()).Msg("Loaded Discord fingerprint")
|
|
|
|
|
}
|
2026-03-26 19:56:36 -07:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 21:52:51 -07:00
|
|
|
// FIXME(skip): Load the HTML /login page before anything else so we can seed our cookies with Cloudflare stuff.
|
2026-03-26 19:56:36 -07:00
|
|
|
// FIXME(skip): Handle IP verification.
|
|
|
|
|
// FIXME(skip): Handle suspended user tokens.
|
|
|
|
|
|
2026-03-27 20:40:52 -07:00
|
|
|
// Once you have called [AuthMachine.Prepare], Login kicks off the login
|
|
|
|
|
// process and doesn't return until the login is complete and a token is
|
|
|
|
|
// acquired, unless an error occurs at any point.
|
|
|
|
|
//
|
|
|
|
|
// CAPTCHA and MFA handling is automatically relegated to your
|
|
|
|
|
// [ChallengeHandler] and its methods will be called as necessary.
|
|
|
|
|
func (am *AuthMachine) Login(ctx context.Context, creds *Creds) (*LoginCompleted, error) {
|
2026-03-26 20:44:39 -07:00
|
|
|
log := zerolog.Ctx(ctx)
|
|
|
|
|
|
2026-03-26 19:56:36 -07:00
|
|
|
if am.State.Fingerprint.IsZero() {
|
|
|
|
|
return nil, fmt.Errorf("can't log in without a fingerprint (forgot to call Prepare?)")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 23:20:26 -07:00
|
|
|
firstLoginReq, err := am.POST(ctx, "/auth/login", creds)
|
2026-03-26 19:56:36 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to construct login request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 20:40:52 -07:00
|
|
|
_, body, err := am.doHandlingCaptcha(ctx, firstLoginReq)
|
2026-03-26 19:56:36 -07:00
|
|
|
if err != nil {
|
2026-03-26 21:01:58 -07:00
|
|
|
return nil, fmt.Errorf("failed to request login: %w", err)
|
2026-03-26 19:56:36 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-27 20:40:52 -07:00
|
|
|
loginResponse, err := am.handleFirstLoginResponse(ctx, body)
|
2026-03-26 20:44:39 -07:00
|
|
|
if err != nil {
|
2026-03-27 20:40:52 -07:00
|
|
|
return nil, err
|
2026-03-26 20:44:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if am.LogFilters.SuccessfulLogin {
|
|
|
|
|
ev := log.Info()
|
|
|
|
|
if am.LogFilters.LoggedInUserID {
|
|
|
|
|
ev = ev.Str("user_id", loginResponse.UserID).Str("user_locale", loginResponse.UserSettings.Locale)
|
|
|
|
|
}
|
|
|
|
|
ev.Msg("Logged in successfully")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 20:40:52 -07:00
|
|
|
return loginResponse, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleFirstLoginResponse handles the response body from POSTing to
|
|
|
|
|
// /auth/login. This will either complete the login or begin an MFA flow.
|
|
|
|
|
func (am *AuthMachine) handleFirstLoginResponse(ctx context.Context, loginRespBody []byte) (*LoginCompleted, error) {
|
|
|
|
|
log := zerolog.Ctx(ctx)
|
|
|
|
|
|
|
|
|
|
var completed LoginCompleted
|
|
|
|
|
err := json.Unmarshal(loginRespBody, &completed)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to unmarshal login response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if !completed.HasToken() {
|
|
|
|
|
log.Debug().Msg("Response lacked a token, attempting to handle as MFA")
|
|
|
|
|
completedMfa, err := am.tryHandlingMFA(ctx, loginRespBody)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to handle potential MFA: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if completedMfa == nil || !completedMfa.HasToken() {
|
|
|
|
|
// Still unable to handle whatever we got as a response from POST
|
|
|
|
|
// /auth/login, give up. Log the response for diagnostics.
|
|
|
|
|
log.Error().Str("response_body", string(loginRespBody)).Msg("Received corrupted login response")
|
|
|
|
|
return nil, fmt.Errorf("corrupted login response")
|
|
|
|
|
}
|
|
|
|
|
return completedMfa, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &completed, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (am *AuthMachine) requestSMSCode(ctx context.Context, state *MFAState) (*SMSSendResponse, error) {
|
2026-04-06 23:20:26 -07:00
|
|
|
smsSendReq, err := am.POST(ctx, "/auth/mfa/sms/send", SMSSendRequest{
|
2026-03-27 20:40:52 -07:00
|
|
|
Ticket: state.Ticket,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to construct SMS send code request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
smsSendReq.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
_, body, err := am.doHandlingCaptcha(ctx, smsSendReq)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to request SMS code: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var resp SMSSendResponse
|
|
|
|
|
err = json.Unmarshal(body, &resp)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to unmarshal SMS code response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &resp, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (am *AuthMachine) tryHandlingMFA(ctx context.Context, loginRespBody []byte) (*LoginCompleted, error) {
|
|
|
|
|
baseLog := zerolog.Ctx(ctx)
|
|
|
|
|
|
|
|
|
|
var required LoginMFARequired
|
|
|
|
|
err := json.Unmarshal(loginRespBody, &required)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to unmarshal mfa required: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !required.MFARequired {
|
|
|
|
|
// This isn't actually a LoginMFARequired.
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logCtx := baseLog.With().
|
|
|
|
|
Str("mfa_login_instance_id", required.LoginInstanceID).
|
|
|
|
|
Bool("mfa_accepting_backup_codes", required.BackupCodesAccepted).
|
|
|
|
|
Bool("mfa_sms_enabled", required.SMSEnabled).
|
|
|
|
|
Bool("mfa_totp_enabled", required.TOTPEnabled).
|
|
|
|
|
Bool("mfa_has_webauthn_credential", required.WebAuthnCredential != nil)
|
|
|
|
|
if am.LogFilters.LoggedInUserID {
|
|
|
|
|
logCtx = logCtx.Str("user_id", required.UserID)
|
|
|
|
|
}
|
|
|
|
|
log := logCtx.Logger()
|
|
|
|
|
ctx = log.WithContext(ctx)
|
|
|
|
|
|
|
|
|
|
log.Info().Msg("Need to log in with MFA")
|
|
|
|
|
cont, err := am.handler.ContinueMFA(ctx, &MFAChallenge{
|
|
|
|
|
LoginMFARequired: &required,
|
|
|
|
|
RequestSMS: func(ctx context.Context) (*SMSSendResponse, error) {
|
|
|
|
|
// Thread the MFAState through on behalf of the client.
|
|
|
|
|
return am.requestSMSCode(ctx, &required.MFAState)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to continue mfa flow: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if cont == nil {
|
|
|
|
|
return nil, fmt.Errorf("no MFA continuation returned")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Info().Str("mfa_type", string(cont.Type)).Msg("Continuing with MFA flow")
|
|
|
|
|
|
2026-04-06 23:20:26 -07:00
|
|
|
contReq, err := am.POST(ctx, fmt.Sprintf("/auth/mfa/%s", cont.Type), cont.MFAContinuation)
|
2026-03-27 20:40:52 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to construct MFA continuation request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, body, err := am.doHandlingCaptcha(ctx, contReq)
|
2026-03-27 21:00:04 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to complete MFA flow: %w", err)
|
|
|
|
|
}
|
2026-03-27 20:40:52 -07:00
|
|
|
var completed LoginCompleted
|
|
|
|
|
err = json.Unmarshal(body, &completed)
|
|
|
|
|
if err != nil {
|
2026-03-27 21:00:04 -07:00
|
|
|
return nil, fmt.Errorf("failed to unmarshal completed MFA: %w", err)
|
2026-03-27 20:40:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Discord omits the user ID when completing the MFA flow as we already
|
|
|
|
|
// received it as part of LoginMFARequired. Re-add it here.
|
|
|
|
|
if completed.UserID == "" {
|
|
|
|
|
log.Trace().Msg("Fixing up MFA completion with the user ID")
|
|
|
|
|
completed.UserID = required.UserID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &completed, nil
|
2026-03-26 19:56:36 -07:00
|
|
|
}
|
2026-04-06 23:20:26 -07:00
|
|
|
|
|
|
|
|
func (am *AuthMachine) POST(ctx context.Context, endpoint string, jsonBody any) (*http.Request, error) {
|
|
|
|
|
jsonBytes, err := json.Marshal(jsonBody)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to marshal body for request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := am.APIBase + endpoint
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBytes))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to make POST request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
return req, nil
|
|
|
|
|
}
|