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

Co-authored-by: Ayyan <ayn2op@gmail.com>
This commit is contained in:
Z1xus 2025-10-16 00:15:08 +03:00 • committed by GitHub
parent ab1ec9e108
commit b738593590
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 508 additions and 4 deletions

View file

@ -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)
}

3
go.mod
View file

@ -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
go.sum
View file

@ -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=

View file

@ -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
internal/login/qr.go Normal file
View file

@ -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
}