1
0
Fork 0
mirror of https://github.com/mautrix/discord.git synced 2026-05-15 05:36:54 -04:00
mautrix-discord/pkg/discordauth/machine.go
2026-04-06 23:33:15 -07:00

528 lines
16 KiB
Go

// 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"
"slices"
"strings"
"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
http HTTP
APIBase string
handler ChallengeHandler
State AuthMachineState
Personality *Personality
}
type AuthMachineState struct {
Fingerprint Fingerprint
}
type CaptchaSolution struct {
Solution string
}
type CaptchaHandler func(ctx context.Context, captcha *Captcha) (*CaptchaSolution, error)
func NewAuthMachine(ctx context.Context, http HTTP, personality *Personality, handler ChallengeHandler) *AuthMachine {
if http == nil {
panic("http interface is required")
}
if personality == nil {
panic("personality is required")
}
if handler == nil {
panic("handler is required")
}
log := zerolog.Ctx(ctx).With().Str("component", "discord auth").Logger()
return &AuthMachine{
log: &log,
http: http,
handler: handler,
APIBase: "https://discord.com/api/v9",
Personality: personality,
}
}
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()
}
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")
}
if am.LogFilters.DangerouslyLeakyHTTPHeaders {
log.Debug().
Int("n_captchas", nCaptchas).
Msg(formatHTTPHeaderDump("Sending request headers", req.Header))
}
// 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)
}
if am.LogFilters.DangerouslyLeakyHTTPHeaders {
log.Debug().
Int("n_captchas", nCaptchas).
Msg(formatHTTPHeaderDump("Received response headers", resp.Header))
}
// 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.)
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
}
}
// 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)
log.Info().Msg("Invoking CAPTCHA handler")
solution, err := am.handler.SolveCaptcha(ctx, captcha)
if err != nil {
return nil, fmt.Errorf("captcha handler failed: %w", err)
}
if solution == nil {
return nil, fmt.Errorf("captcha handler returned nil solution")
}
return solution, nil
}
// 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)
// Set X-Debug-Options if we have one.
debugOptions := am.Personality.DebugOptions
if debugOptions != "" {
req.Header.Set(HeaderDebugOptions, debugOptions)
}
// 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 {
return nil, fmt.Errorf("failed to request apex experiments: %w", err)
}
return nil, nil
}
// Prepare loads the login page and situates the AuthMachine with an
// experiments-related [Fingerprint]. It is important for Prepare to be called
// before [AuthMachine.Login].
//
// Calling this method can lead to your [ChallengeHandler] being called.
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
if am.LogFilters.Fingerprint {
log.Info().Str("fingerprint", am.State.Fingerprint.HeaderValue()).Msg("Loaded Discord fingerprint")
}
return nil
}
// FIXME(skip): Load the HTML /login page before anything else so we can seed our cookies with Cloudflare stuff.
// FIXME(skip): Handle IP verification.
// FIXME(skip): Handle suspended user tokens.
// 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) {
log := zerolog.Ctx(ctx)
if am.State.Fingerprint.IsZero() {
return nil, fmt.Errorf("can't log in without a fingerprint (forgot to call Prepare?)")
}
firstLoginReq, err := am.POST(ctx, "/auth/login", creds)
if err != nil {
return nil, fmt.Errorf("failed to construct login request: %w", err)
}
_, body, err := am.doHandlingCaptcha(ctx, firstLoginReq)
if err != nil {
return nil, fmt.Errorf("failed to request login: %w", err)
}
loginResponse, err := am.handleFirstLoginResponse(ctx, body)
if err != nil {
return nil, err
}
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")
}
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) {
smsSendReq, err := am.POST(ctx, "/auth/mfa/sms/send", SMSSendRequest{
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")
contReq, err := am.POST(ctx, fmt.Sprintf("/auth/mfa/%s", cont.Type), cont.MFAContinuation)
if err != nil {
return nil, fmt.Errorf("failed to construct MFA continuation request: %w", err)
}
_, body, err := am.doHandlingCaptcha(ctx, contReq)
if err != nil {
return nil, fmt.Errorf("failed to complete MFA flow: %w", err)
}
var completed LoginCompleted
err = json.Unmarshal(body, &completed)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal completed MFA: %w", err)
}
// 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
}
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
}