arblant
firefox-vpn-client / core / internal / service / signin_flow.go
signin_flow.go324 lines · 11.4 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 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324
package service

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"strings"
	"sync"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/fxa"
	"github.com/armanlife/firefox-vpn-client/core/internal/logx"
	"github.com/armanlife/firefox-vpn-client/core/internal/proxy"
)

const (
	// signInTimeout bounds a whole dashboard sign-in, including the time
	// the person needs to read an email or solve a CAPTCHA.
	signInTimeout = 15 * time.Minute
	// signInRetention keeps finished sign-ins visible to the dashboard.
	signInRetention = 10 * time.Minute
	// maxCodeAttempts bounds retries of a wrong verification code.
	maxCodeAttempts = 5
)

// Sign-in steps reported to the dashboard. The "need_*" steps wait for input.
const (
	stepWorking     = "working"
	stepEmailCode   = "need_email_code"
	stepTOTP        = "need_totp"
	stepUnblockCode = "need_unblock_code"
	stepCaptcha     = "need_captcha"
	stepEmailLink   = "need_email_link"
	stepDone        = "done"
	stepFailed      = "failed"
	stepCancelled   = "cancelled"
)

var errSignInCancelled = errors.New("sign-in cancelled")

// signInView is the dashboard's view of a sign-in. It never contains the
// password or any token.
type signInView struct {
	ID      string `json:"id"`
	Email   string `json:"email"`
	Step    string `json:"step"`
	Message string `json:"message"`
	// Error is what went wrong with the last answer or the sign-in, shown
	// apart from the instruction in Message; ErrorCode says which, for
	// apps to say it in their language ("" for anything else).
	Error     string `json:"error,omitempty"`
	ErrorCode string `json:"error_code,omitempty"`
	// PasswordAccepted is set once Mozilla accepted the email and the
	// password (the steps after it confirm the sign-in), so an app can let
	// a password manager save them only then.
	PasswordAccepted bool      `json:"password_accepted,omitempty"`
	Captcha          string    `json:"captcha,omitempty"`
	AccountID        string    `json:"account_id,omitempty"`
	Updated          time.Time `json:"updated"`
}

// signIn runs one interactive Firefox Accounts sign-in in the background.
// Whenever FxA needs something from the person (a code or a CAPTCHA
// answer), it publishes a "need_*" step and waits for input.
type signIn struct {
	mu     sync.Mutex
	view   signInView
	input  chan string
	cancel context.CancelFunc
}

type signInRegistry struct {
	d        *Daemon
	mu       sync.Mutex
	sessions map[string]*signIn
}

func newSignInRegistry(d *Daemon) *signInRegistry {
	return &signInRegistry{d: d, sessions: make(map[string]*signIn)}
}

// start begins a sign-in for email and password.
func (r *signInRegistry) start(email, password string) (signInView, error) {
	email = fxa.NormalizeEmail(email)
	if email == "" || password == "" {
		return signInView{}, errors.New("email and password are required")
	}
	var idBytes [12]byte
	if _, err := rand.Read(idBytes[:]); err != nil {
		return signInView{}, err
	}
	ctx, cancel := context.WithTimeout(context.Background(), signInTimeout)
	s := &signIn{
		view:   signInView{ID: hex.EncodeToString(idBytes[:]), Email: email, Step: stepWorking, Message: "Signing in", Updated: time.Now()},
		input:  make(chan string),
		cancel: cancel,
	}
	r.mu.Lock()
	r.pruneLocked()
	r.sessions[s.view.ID] = s
	r.mu.Unlock()
	go s.run(ctx, r.d, email, password)
	return s.snapshot(), nil
}

func (r *signInRegistry) get(id string) (*signIn, bool) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[id]
	return s, ok
}

func (r *signInRegistry) pruneLocked() {
	for id, s := range r.sessions {
		v := s.snapshot()
		if (v.Step == stepDone || v.Step == stepFailed || v.Step == stepCancelled) && time.Since(v.Updated) > signInRetention {
			delete(r.sessions, id)
		}
	}
}

func (s *signIn) snapshot() signInView {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.view
}

// Codes of signInView.ErrorCode.
const (
	signInNoAccount       = "no_account"            // no Firefox Account has the email
	signInWrongPassword   = "wrong_password"        // the password is wrong
	signInNoPassword      = "no_password"           // the account has none (Google or Apple sign-in)
	signInThrottled       = "too_many_attempts"     // FxA asks to wait
	signInUnblockRejected = "unblock_code_rejected" // the unblock code was refused
	signInWrongCode       = "wrong_code"            // the emailed code was refused
	signInWrongTOTP       = "wrong_totp"            // the authenticator code was refused
	signInTooManyCodes    = "too_many_codes"        // the sign-in gave up after wrong codes
	signInTimedOut        = "timed_out"             // nobody answered in time
	signInNetwork         = "network"               // Mozilla could not be reached
)

var (
	errTooManyCodes   = errors.New("too many wrong codes; start the sign-in again")
	errSignInTimedOut = errors.New("the sign-in timed out")
)

// signInProblem is what went wrong with an answer or the sign-in.
type signInProblem struct{ code, text string }

func (s *signIn) set(step, message string, problem signInProblem, captcha string) {
	s.mu.Lock()
	s.view.Step, s.view.Message, s.view.Captcha, s.view.Updated = step, message, captcha, time.Now()
	s.view.Error, s.view.ErrorCode = problem.text, problem.code
	s.mu.Unlock()
}

// answer delivers input for the current "need_*" step.
func (s *signIn) answer(value string) error {
	step := s.snapshot().Step
	if !strings.HasPrefix(step, "need_") {
		return errors.New("this sign-in is not waiting for input")
	}
	select {
	case s.input <- value:
		return nil
	case <-time.After(5 * time.Second):
		return errors.New("this sign-in is not waiting for input")
	}
}

// ask publishes a step that needs input and waits for it; problem says
// why the previous answer was refused, if it was.
func (s *signIn) ask(ctx context.Context, step, message string, problem signInProblem, captcha string) (string, error) {
	s.set(step, message, problem, captcha)
	select {
	case value := <-s.input:
		s.set(stepWorking, "Checking", signInProblem{}, "")
		return value, nil
	case <-ctx.Done():
		if errors.Is(ctx.Err(), context.Canceled) {
			return "", errSignInCancelled
		}
		return "", errSignInTimedOut
	}
}

func (s *signIn) run(ctx context.Context, d *Daemon, email, password string) {
	defer s.cancel()
	accountID, err := s.signIn(ctx, d, email, password)
	switch {
	case errors.Is(err, errSignInCancelled):
		s.set(stepCancelled, "Sign-in cancelled", signInProblem{}, "")
	case err != nil:
		logx.Warn("dashboard sign-in failed email=%s err=%s", logx.Email(email), fxa.Message(err))
		s.set(stepFailed, "The account was not added.", signInFailure(err), "")
	default:
		s.mu.Lock()
		s.view.AccountID = accountID
		s.mu.Unlock()
		s.set(stepDone, "Account added", signInProblem{}, "")
	}
}

func (s *signIn) signIn(ctx context.Context, d *Daemon, email, password string) (string, error) {
	ctx = fxa.WithCaptchaSolver(ctx, func(ctx context.Context, image string) (string, error) {
		return s.ask(ctx, stepCaptcha, "Firefox Accounts asks for a CAPTCHA. Type the characters shown.", signInProblem{}, image)
	})

	resp, err := fxa.Login(ctx, email, password)
	// Typed in another letter case than the account's: its own spelling from
	// here on (the unblock code, the messages, the stored account).
	email = fxa.SignedInEmail(email, resp, err)
	var problem signInProblem
	for attempt := 0; fxa.Errno(err) == fxa.ErrnoRequestBlocked && attempt < maxCodeAttempts; attempt++ {
		if attempt == 0 {
			if sendErr := fxa.SendUnblockCode(ctx, email); sendErr != nil {
				return "", sendErr
			}
		} else {
			problem = signInProblem{signInUnblockRejected, "That unblock code was not accepted. Check it against the latest email from Mozilla and try again."}
		}
		code, askErr := s.ask(ctx, stepUnblockCode, fmt.Sprintf("Firefox Accounts blocked this sign-in as unusual and emailed an unblock code to %s. Enter it.", email), problem, "")
		if askErr != nil {
			return "", askErr
		}
		resp, err = fxa.LoginWithUnblockCode(ctx, email, password, code)
		email = fxa.SignedInEmail(email, resp, err)
	}
	if err != nil {
		return "", err
	}
	s.mu.Lock()
	s.view.PasswordAccepted = true
	s.mu.Unlock()

	if !resp.Verified {
		if err := s.verify(ctx, email, resp); err != nil {
			return "", err
		}
	}

	s.set(stepWorking, "Getting the account token", signInProblem{}, "")
	token, err := fxa.OAuthToken(ctx, resp.SessionToken)
	if err != nil {
		return "", err
	}
	// The sign-in session is not needed once the tokens exist; left alive
	// it shows as "unknown" in the account's connected services.
	if err := fxa.DestroySession(ctx, resp.SessionToken); err != nil {
		logx.Warn("ending the sign-in session failed email=%s err=%s", logx.Email(email), logx.Err(err))
	}
	acc, err := d.addSignedInAccount(email, password, token)
	if err != nil {
		return "", err
	}
	return acc.ID, nil
}

// verify completes the sign-in confirmation FxA asks for.
func (s *signIn) verify(ctx context.Context, email string, resp *fxa.LoginResponse) error {
	if resp.VerificationMethod == "email" {
		if _, err := s.ask(ctx, stepEmailLink, fmt.Sprintf("Open the confirmation link sent to %s, then continue.", email), signInProblem{}, ""); err != nil {
			return err
		}
		return nil
	}
	step, message, verifyCode := stepEmailCode, fmt.Sprintf("Enter the verification code sent to %s.", email), fxa.VerifySession
	if resp.VerificationMethod == "totp-2fa" {
		step, message, verifyCode = stepTOTP, "Enter the code from your authenticator app.", fxa.VerifyTOTP
	}
	var problem signInProblem
	for attempt := 0; attempt < maxCodeAttempts; attempt++ {
		code, err := s.ask(ctx, step, message, problem, "")
		if err != nil {
			return err
		}
		err = verifyCode(ctx, resp.SessionToken, strings.TrimSpace(code))
		if err == nil {
			return nil
		}
		problem = codeProblem(err, step == stepTOTP)
	}
	return errTooManyCodes
}

// codeProblem explains a refused verification code.
func codeProblem(err error, totp bool) signInProblem {
	switch fxa.Errno(err) {
	case 105, 152, 183: // invalid verification code, invalid TOTP code, invalid or expired confirmation code
		if totp {
			return signInProblem{signInWrongTOTP, "Wrong code. Enter the code the authenticator app shows now."}
		}
		return signInProblem{signInWrongCode, "Wrong or expired code. Use the code from the latest email from Mozilla; an older code stops working when a new one is sent."}
	}
	return signInProblem{"", "The code was not accepted: " + fxa.Message(err) + "."}
}

// signInFailure explains why a sign-in failed.
func signInFailure(err error) signInProblem {
	switch fxa.Errno(err) {
	case 102:
		return signInProblem{signInNoAccount, "No Firefox Account exists for this email."}
	case 103:
		return signInProblem{signInWrongPassword, "Incorrect password."}
	case 114:
		return signInProblem{signInThrottled, "Too many attempts. Wait a while and try again."}
	case 210: // UNABLE_TO_LOGIN_NO_PASSWORD_SET: made with Google or Apple sign-in
		return signInProblem{signInNoPassword, "This account has no password: it was made with Google or Apple sign-in. Set one on accounts.firefox.com (Forgot password), then sign in with it."}
	case 127:
		return signInProblem{signInUnblockRejected, "The unblock code was not accepted."}
	}
	switch {
	case errors.Is(err, errTooManyCodes):
		return signInProblem{signInTooManyCodes, "Too many wrong codes. Start the sign-in again."}
	case errors.Is(err, errSignInTimedOut):
		return signInProblem{signInTimedOut, "The sign-in took too long. Start it again."}
	}
	if code := proxy.FailureCode(err); code == proxy.FailureNoNetwork || code == proxy.FailureUnreachable {
		return signInProblem{signInNetwork, fxa.Message(err)}
	}
	return signInProblem{"", fxa.Message(err)}
}