arblant
firefox-vpn-client / core / internal / fxa / signin.go
signin.go151 lines · 4.8 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151
package fxa

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"

	"github.com/armanlife/firefox-vpn-client/core/internal/controlplane"
)

// ErrnoRequestBlocked is FxA's "request blocked" errno: the sign-in looks
// unusual (new location, many attempts) and must be repeated with an unblock
// code that FxA emails on request.
const ErrnoRequestBlocked = 125

// Errno returns the FxA API errno carried by err, or 0.
func Errno(err error) int {
	var apiErr *fxaAPIError
	if errors.As(err, &apiErr) {
		return apiErr.Errno
	}
	return 0
}

// Message returns the human-readable FxA error message carried by err,
// falling back to the error text.
func Message(err error) string {
	var apiErr *fxaAPIError
	if errors.As(err, &apiErr) {
		var body struct {
			Message string `json:"message"`
		}
		if json.Unmarshal([]byte(apiErr.Body), &body) == nil && body.Message != "" {
			return body.Message
		}
	}
	if err == nil {
		return ""
	}
	return err.Error()
}

// SignedInEmail is the email for the rest of a sign-in begun with typed:
// the account's own spelling when FxA corrected its letter case, whether
// the sign-in then succeeded or failed for another reason (such as a block
// that wants an unblock code); else typed.
func SignedInEmail(typed string, resp *LoginResponse, err error) string {
	if resp != nil && resp.Email != "" {
		return resp.Email
	}
	var caseErr *emailCaseError
	if errors.As(err, &caseErr) {
		return caseErr.email
	}
	return typed
}

// LoginWithUnblockCode repeats a blocked sign-in with the unblock code that
// SendUnblockCode had emailed.
func LoginWithUnblockCode(ctx context.Context, email, password, unblockCode string) (*LoginResponse, error) {
	return fxaLogin(ctx, email, password, strings.ToUpper(NormalizeCode(unblockCode)))
}

// SendUnblockCode asks FxA to email an unblock code for a blocked sign-in.
func SendUnblockCode(ctx context.Context, email string) error {
	body, _ := json.Marshal(map[string]string{"email": email})
	target := fxaAuthServer + "/account/login/send_unblock_code"
	resp, err := fxaDo(ctx, func() (*http.Request, error) {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, strings.NewReader(string(body)))
		if err != nil {
			return nil, err
		}
		req.Header.Set("Content-Type", "application/json")
		controlplane.ApplyFirefoxHeaders(req)
		return req, nil
	})
	if err != nil {
		return fmt.Errorf("unblock code request: %w", err)
	}
	defer resp.Body.Close()
	data, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
	if resp.StatusCode != http.StatusOK {
		return newFxaAPIError(resp.StatusCode, data)
	}
	return nil
}

// VerifyTOTP verifies an unverified session with a code from the account's
// authenticator app (verification method "totp-2fa").
func VerifyTOTP(ctx context.Context, sessionToken, code string) error {
	hawkID, hawkKey, err := deriveHawkCredentials(sessionToken, "sessionToken")
	if err != nil {
		return fmt.Errorf("deriving hawk credentials: %w", err)
	}
	body, _ := json.Marshal(map[string]string{"code": NormalizeCode(code)})
	target := fxaAuthServer + "/session/verify/totp"
	resp, err := fxaDo(ctx, func() (*http.Request, error) {
		authHeader, err := hawkHeader(http.MethodPost, target, hawkID, hawkKey, string(body))
		if err != nil {
			return nil, err
		}
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, strings.NewReader(string(body)))
		if err != nil {
			return nil, err
		}
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("Authorization", authHeader)
		controlplane.ApplyFirefoxHeaders(req)
		return req, nil
	})
	if err != nil {
		return fmt.Errorf("TOTP verification request: %w", err)
	}
	defer resp.Body.Close()
	data, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
	if resp.StatusCode != http.StatusOK {
		return newFxaAPIError(resp.StatusCode, data)
	}
	var result struct {
		Success bool `json:"success"`
	}
	if err := json.Unmarshal(data, &result); err != nil {
		return fmt.Errorf("parsing TOTP verification response: %w", err)
	}
	if !result.Success {
		return errors.New("the authenticator code was not accepted")
	}
	return nil
}

// CaptchaSolver shows a Fastly CAPTCHA image (a data: URL) to a person and
// returns the characters they read. It is called while an FxA request is in
// progress, so it should return within a few minutes.
type CaptchaSolver func(ctx context.Context, imageDataURL string) (string, error)

type captchaSolverKey struct{}

// WithCaptchaSolver lets FxA calls made with the returned context answer
// image CAPTCHAs, which the challenge solver cannot answer by itself.
func WithCaptchaSolver(ctx context.Context, solver CaptchaSolver) context.Context {
	return context.WithValue(ctx, captchaSolverKey{}, solver)
}

func captchaSolverFrom(ctx context.Context) CaptchaSolver {
	solver, _ := ctx.Value(captchaSolverKey{}).(CaptchaSolver)
	return solver
}