Quellcode durchsuchen

feat(login): add login with qr code (#608)

Co-authored-by: Ayyan <ayn2op@gmail.com>
Z1xus vor 6 Monaten
Ursprung
Commit
b738593590
5 geänderte Dateien mit 508 neuen und 4 gelöschten Zeilen
  1. 1 1
      cmd/application.go
  2. 2 1
      go.mod
  3. 2 0
      go.sum
  4. 29 2
      internal/login/form.go
  5. 474 0
      internal/login/qr.go

+ 1 - 1
cmd/application.go

@@ -51,7 +51,7 @@ func newApplication(cfg *config.Config) *application {
 
 func (a *application) run(token string) error {
 	if token == "" {
-		loginForm := login.NewForm(a.cfg, func(token string) {
+		loginForm := login.NewForm(a.Application, a.cfg, func(token string) {
 			if err := a.run(token); err != nil {
 				slog.Error("failed to run application", "err", err)
 			}

+ 2 - 1
go.mod

@@ -12,10 +12,12 @@ require (
 	github.com/gdamore/tcell/v2 v2.9.0
 	github.com/gen2brain/beeep v0.11.1
 	github.com/google/uuid v1.6.0
+	github.com/gorilla/websocket v1.5.3
 	github.com/klauspost/compress v1.18.0
 	github.com/lmittmann/tint v1.1.2
 	github.com/ncruces/zenity v0.10.14
 	github.com/sahilm/fuzzy v0.1.1
+	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
 	github.com/yuin/goldmark v1.7.13
 	github.com/zalando/go-keyring v0.2.6
@@ -34,7 +36,6 @@ require (
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
 	github.com/gorilla/schema v1.4.1 // indirect
-	github.com/gorilla/websocket v1.5.3 // indirect
 	github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
 	github.com/josephspurrier/goversioninfo v1.5.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect

+ 2 - 0
go.sum

@@ -142,6 +142,8 @@ github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNW
 github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
 github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
 github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

+ 29 - 2
internal/login/form.go

@@ -17,20 +17,23 @@ import (
 const (
 	formPageName  = "form"
 	errorPageName = "error"
+	qrPageName    = "qr"
 )
 
 type DoneFn = func(token string)
 
 type Form struct {
 	*tview.Pages
+	app  *tview.Application
 	cfg  *config.Config
 	form *tview.Form
 	done DoneFn
 }
 
-func NewForm(cfg *config.Config, done DoneFn) *Form {
+func NewForm(app *tview.Application, cfg *config.Config, done DoneFn) *Form {
 	f := &Form{
 		Pages: tview.NewPages(),
+		app:   app,
 		cfg:   cfg,
 		form:  tview.NewForm(),
 		done:  done,
@@ -40,7 +43,8 @@ func NewForm(cfg *config.Config, done DoneFn) *Form {
 		AddInputField("Email", "", 0, nil, nil).
 		AddPasswordField("Password", "", 0, 0, nil).
 		AddPasswordField("Code (optional)", "", 0, 0, nil).
-		AddButton("Login", f.login)
+		AddButton("Login", f.login).
+		AddButton("Login with QR", f.loginWithQR)
 	f.AddAndSwitchToPage(formPageName, f.form, true)
 	return f
 }
@@ -114,3 +118,26 @@ func (f *Form) onError(err error) {
 		AddAndSwitchToPage(errorPageName, ui.Centered(modal, 0, 0), true).
 		ShowPage(formPageName)
 }
+
+func (f *Form) loginWithQR() {
+	qr := newQRLogin(f.app, f.cfg, func(token string, err error) {
+		if err != nil {
+			f.onError(err)
+			return
+		}
+
+		if token == "" {
+			f.RemovePage(qrPageName).SwitchToPage(formPageName)
+			return
+		}
+
+		go keyring.Set(consts.Name, "token", token)
+		f.RemovePage(qrPageName)
+		if f.done != nil {
+			f.done(token)
+		}
+	})
+
+	f.AddAndSwitchToPage(qrPageName, qr, true)
+	qr.start()
+}

+ 474 - 0
internal/login/qr.go

@@ -0,0 +1,474 @@
+package login
+
+import (
+	"bytes"
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	stdhttp "net/http"
+	"strings"
+	"time"
+
+	"github.com/ayn2op/discordo/internal/config"
+	apphttp "github.com/ayn2op/discordo/internal/http"
+	"github.com/ayn2op/discordo/internal/ui"
+	"github.com/ayn2op/tview"
+	"github.com/diamondburned/arikawa/v3/api"
+	"github.com/gdamore/tcell/v2"
+	"github.com/gorilla/websocket"
+	"github.com/skip2/go-qrcode"
+)
+
+const gatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2"
+
+var remoteAuthLogin = api.EndpointMe + "/remote-auth/login"
+
+type qrLogin struct {
+	*tview.TextView
+	app         *tview.Application
+	cfg         *config.Config
+	view        *tview.TextView
+	done        func(token string, err error)
+	conn        *websocket.Conn
+	privKey     *rsa.PrivateKey
+	cancel      context.CancelFunc
+	fingerprint string
+}
+
+func newQRLogin(app *tview.Application, cfg *config.Config, done func(token string, err error)) *qrLogin {
+	view := tview.NewTextView().
+		SetDynamicColors(true).
+		SetScrollable(true).
+		SetWrap(false).
+		SetTextAlign(tview.AlignmentCenter)
+	view.Box = ui.ConfigureBox(view.Box, &cfg.Theme)
+	view.SetTitle("Login with QR")
+
+	q := &qrLogin{
+		app:  app,
+		cfg:  cfg,
+		view: view,
+		done: done,
+	}
+	q.TextView = view
+
+	view.SetChangedFunc(func() {
+		q.app.QueueUpdateDraw(func() {})
+	})
+
+	view.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
+		if ev.Key() == tcell.KeyEsc {
+			q.stop()
+			if q.done != nil {
+				q.done("", nil)
+			}
+			return nil
+		}
+		return ev
+	})
+
+	return q
+}
+
+
+func (q *qrLogin) centerText(s string) string {
+	_, _, _, height := q.view.GetInnerRect()
+	if height == 0 {
+		height = 40
+	}
+	lines := strings.Count(s, "\n") + 1
+	padding := (height - lines) / 2
+	if padding < 0 {
+		padding = 0
+	} else if padding < 1 && height > lines {
+		padding = 1
+	}
+	return strings.Repeat("\n", padding) + s
+}
+
+func (q *qrLogin) start() {
+	ctx, cancel := context.WithCancel(context.Background())
+	q.cancel = cancel
+	go q.run(ctx)
+}
+
+func (q *qrLogin) stop() {
+	if q.cancel != nil {
+		q.cancel()
+	}
+	if q.conn != nil {
+		q.conn.Close()
+	}
+}
+
+func (q *qrLogin) writeJSON(data any) error {
+	return q.conn.WriteJSON(data)
+}
+
+type raHello struct {
+	TimeoutMs         int    `json:"timeout_ms"`
+	HeartbeatInterval int    `json:"heartbeat_interval"`
+}
+
+type raNonceProof struct {
+	EncryptedNonce string `json:"encrypted_nonce"`
+}
+
+type raPendingInit struct {
+	Fingerprint string `json:"fingerprint"`
+}
+
+type raPendingLogin struct {
+	Ticket string `json:"ticket"`
+}
+
+type raPendingTicket struct {
+	EncryptedUserPayload string `json:"encrypted_user_payload"`
+}
+
+func (q *qrLogin) run(ctx context.Context) {
+	defer q.stop()
+
+	setText := func(s string) {
+		q.app.QueueUpdateDraw(func() {
+			q.view.SetText(q.centerText(s))
+		})
+	}
+
+	setText("Preparing QR code...\n\n[::d]Press Esc to cancel[-]")
+
+	privKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		q.fail(err)
+		return
+	}
+	q.privKey = privKey
+
+	pubDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
+	if err != nil {
+		q.fail(err)
+		return
+	}
+	encodedPublicKey := base64.StdEncoding.EncodeToString(pubDER)
+
+	headers := stdhttp.Header{}
+	headers.Set("User-Agent", apphttp.BrowserUserAgent)
+	headers.Set("Origin", "https://discord.com")
+
+	setText("Connecting to Remote Auth Gateway...\n\n[::d]Press Esc to cancel[-]")
+
+	dialer := websocket.Dialer{
+		Proxy:             stdhttp.ProxyFromEnvironment,
+		HandshakeTimeout:  15 * time.Second,
+		EnableCompression: true,
+	}
+
+	conn, resp, err := dialer.DialContext(ctx, gatewayURL, headers)
+	if err != nil {
+		var body []byte
+		if resp != nil && resp.Body != nil {
+			body, _ = io.ReadAll(resp.Body)
+		}
+		status := ""
+		if resp != nil {
+			status = resp.Status
+		}
+		q.fail(fmt.Errorf("websocket dial failed: %w, status=%s, body=%s", err, status, string(body)))
+		return
+	}
+	q.conn = conn
+
+	readCh := make(chan []byte, 1)
+	readErr := make(chan error, 1)
+	go func() {
+		for {
+			_, data, err := conn.ReadMessage()
+			if err != nil {
+				readErr <- err
+				return
+			}
+			readCh <- data
+		}
+	}()
+
+	var heartbeatTicker *time.Ticker
+	defer func() {
+		if heartbeatTicker != nil {
+			heartbeatTicker.Stop()
+		}
+	}()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case err := <-readErr:
+			if mapped := mapWSCloseError(err); mapped == nil {
+				return
+			} else {
+				q.fail(mapped)
+			}
+			return
+		case data := <-readCh:
+			var opOnly struct {
+				Op string `json:"op"`
+			}
+			if err := json.Unmarshal(data, &opOnly); err != nil {
+				setText("[red]Bad JSON:[-] " + err.Error())
+				q.fail(err)
+				return
+			}
+
+			switch opOnly.Op {
+			case "hello":
+				var h raHello
+				if err := json.Unmarshal(data, &h); err != nil {
+					setText("[red]Hello decode failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				if h.HeartbeatInterval > 0 {
+					heartbeatTicker = time.NewTicker(time.Duration(h.HeartbeatInterval) * time.Millisecond)
+					go func() {
+						for {
+							select {
+							case <-ctx.Done():
+								return
+							case <-heartbeatTicker.C:
+								_ = q.writeJSON(map[string]any{"op": "heartbeat"})
+							}
+						}
+					}()
+				}
+				setText("Connected. Handshaking...\n\n[::d]Press Esc to cancel[-]")
+				if err := q.writeJSON(map[string]any{
+					"op":                 "init",
+					"encoded_public_key": encodedPublicKey,
+				}); err != nil {
+					setText("[red]Init send failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+			case "nonce_proof":
+				var n raNonceProof
+				if err := json.Unmarshal(data, &n); err != nil {
+					setText("[red]Nonce decode failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				enc, err := base64.StdEncoding.DecodeString(n.EncryptedNonce)
+				if err != nil {
+					setText("[red]Nonce b64 decode failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, enc, nil)
+				if err != nil {
+					setText("[red]Nonce decrypt failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				nonce := base64.RawURLEncoding.EncodeToString(pt)
+				if err := q.writeJSON(map[string]any{"op": "nonce_proof", "nonce": nonce}); err != nil {
+					setText("[red]Nonce send failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+			case "pending_remote_init":
+				var p raPendingInit
+				if err := json.Unmarshal(data, &p); err != nil {
+					setText("[red]Init decode failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				q.fingerprint = p.Fingerprint
+				content := "https://discord.com/ra/" + p.Fingerprint
+				ascii, err := renderQR(content)
+				if err != nil {
+					setText("[red]QR render failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				setText(ascii + "\n\n[::b]Scan with Discord mobile app[::-]\n\n[::d]Press Esc to cancel[-]")
+			case "heartbeat_ack":
+			case "pending_ticket":
+				var t raPendingTicket
+				if err := json.Unmarshal(data, &t); err != nil {
+					setText("[red]Ticket decode failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				payload, err := base64.StdEncoding.DecodeString(t.EncryptedUserPayload)
+				if err != nil {
+					setText("[red]Ticket payload b64 failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, payload, nil)
+				if err != nil {
+					setText("[red]Ticket payload decrypt failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				parts := strings.SplitN(string(pt), ":", 4)
+				var discriminator, username string
+				if len(parts) == 4 {
+					discriminator = parts[1]
+					username = parts[3]
+				}
+				if discriminator == "" && username == "" {
+					setText("Scan received.\n\nWaiting for approval on mobile...\n\n[::d]Press Esc to cancel[-]")
+				} else {
+					setText("Logging in as [::b]" + username + "[#]" + discriminator + "[::-]\n\nConfirm on mobile...\n\n[::d]Press Esc to cancel[-]")
+				}
+			case "pending_login":
+				var p raPendingLogin
+				if err := json.Unmarshal(data, &p); err != nil {
+					setText("[red]Login decode failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				setText("Authenticating...\n\n[::d]Please wait[-]")
+				token, err := exchangeTicket(ctx, p.Ticket, q.fingerprint, q.privKey)
+				if err != nil {
+					setText("[red]Ticket exchange failed:[-] " + err.Error())
+					q.fail(err)
+					return
+				}
+				q.success(token)
+				return
+			case "cancel":
+				setText("Login canceled on mobile")
+				if q.done != nil {
+					q.done("", nil)
+				}
+				return
+			default:
+			}
+		}
+	}
+}
+
+func renderQR(content string) (string, error) {
+	code, err := qrcode.New(content, qrcode.Low)
+	if err != nil {
+		return "", err
+	}
+	bm := code.Bitmap()
+	var b strings.Builder
+	for y := 0; y < len(bm); y += 2 {
+		for x := range bm[y] {
+			top := bm[y][x]
+			bottom := false
+			if y+1 < len(bm) {
+				bottom = bm[y+1][x]
+			}
+			if top && bottom {
+				b.WriteString("█")
+			} else if top && !bottom {
+				b.WriteString("▀")
+			} else if !top && bottom {
+				b.WriteString("▄")
+			} else {
+				b.WriteString(" ")
+			}
+		}
+		b.WriteByte('\n')
+	}
+	return b.String(), nil
+}
+
+func exchangeTicket(ctx context.Context, ticket string, fingerprint string, priv *rsa.PrivateKey) (string, error) {
+	if ticket == "" {
+		return "", errors.New("empty ticket")
+	}
+	body := map[string]string{"ticket": ticket}
+	raw, err := json.Marshal(body)
+	if err != nil {
+		return "", err
+	}
+	req, err := stdhttp.NewRequestWithContext(ctx, stdhttp.MethodPost, remoteAuthLogin, bytes.NewReader(raw))
+	if err != nil {
+		return "", err
+	}
+
+	req.Header = apphttp.Headers()
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("User-Agent", apphttp.BrowserUserAgent)
+	if fingerprint != "" {
+		req.Header.Set("X-Fingerprint", fingerprint)
+		req.Header.Set("Referer", "https://discord.com/ra/"+fingerprint)
+	}
+
+	client := &stdhttp.Client{Transport: apphttp.NewTransport(), Timeout: 20 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		b, _ := io.ReadAll(resp.Body)
+		return "", fmt.Errorf("remote-auth login failed: %s: %s", resp.Status, string(b))
+	}
+
+	decoder := json.NewDecoder(resp.Body)
+
+	var out struct {
+		EncryptedToken string `json:"encrypted_token"`
+	}
+	if err := decoder.Decode(&out); err != nil {
+		return "", err
+	}
+	if out.EncryptedToken == "" {
+		return "", fmt.Errorf("no encrypted_token in response")
+	}
+	enc, err := base64.StdEncoding.DecodeString(out.EncryptedToken)
+	if err != nil {
+		return "", err
+	}
+	pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, enc, nil)
+	if err != nil {
+		return "", err
+	}
+	return string(pt), nil
+}
+
+func (q *qrLogin) success(token string) {
+	if q.done != nil {
+		q.done(token, nil)
+	}
+}
+
+func (q *qrLogin) fail(err error) {
+	slog.Error("qr login failed", "err", err)
+	if q.done != nil {
+		q.done("", err)
+	}
+}
+
+func mapWSCloseError(err error) error {
+	var cerr *websocket.CloseError
+	if errors.As(err, &cerr) {
+		switch cerr.Code {
+		case 1000:
+			return errors.New("session closed")
+		case 4000:
+			return errors.New("remote auth: invalid version")
+		case 4001:
+			return errors.New("remote auth: decode error")
+		case 4002:
+			return errors.New("remote auth: handshake failure")
+		case 4003:
+			return errors.New("remote auth: session timed out")
+		}
+	}
+	return err
+}