diff --git a/README.md b/README.md index 5dce072..f5bf6eb 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,21 @@ go build . ## Usage -### Token (UI, recommended) +### Password (UI, recommended) + +1. Run the `discordo` executable with no arguments. + +2. Open the "Password" tab, enter your email address or E.164-formatted phone number and password, then click the "Login" button. + +### QR (UI) + +1. Run the `discordo` executable with no arguments. + +2. Click on the "Login with QR" button. + +3. Follow the instructions in the QR Login screen. + +### Token (UI) 1. Run the `discordo` executable with no arguments. @@ -54,14 +68,6 @@ Set the value of the `DISCORDO_TOKEN` environment variable to the authentication DISCORDO_TOKEN="OTI2MDU5NTQxNDE2Nzc5ODA2.Yc2KKA.2iZ-5JxgxG-9Ub8GHzBSn-NJjNg" discordo ``` -### QR (UI) - -1. Run the `discordo` executable with no arguments. - -2. Click on the "Login with QR" button. - -3. Follow the instructions in the QR Login screen. - ## Configuration The configuration file allows you to configure and customize the behavior, keybindings, and theme of the application. diff --git a/internal/ui/login/model.go b/internal/ui/login/model.go index 15642ac..21372a2 100644 --- a/internal/ui/login/model.go +++ b/internal/ui/login/model.go @@ -8,6 +8,7 @@ import ( "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/discordo/internal/ui/login/password" "github.com/ayn2op/discordo/internal/ui/login/qr" "github.com/ayn2op/discordo/internal/ui/login/token" "github.com/ayn2op/tview" @@ -23,7 +24,7 @@ type Model struct { } func NewModel(cfg *config.Config) *Model { - tabs := tabs.NewModel([]tabs.Tab{token.NewModel(), qr.NewModel()}) + tabs := tabs.NewModel([]tabs.Tab{password.NewModel(), qr.NewModel(), token.NewModel()}) l := layers.New() ui.ConfigureBox(l.Box, &cfg.Theme) @@ -38,7 +39,9 @@ func NewModel(cfg *config.Config) *Model { func (m *Model) Update(msg tview.Msg) tview.Cmd { switch msg := msg.(type) { case errMsg: - return m.showErrorDialog(msg.err) + return m.showErrorDialog(msg) + case password.ErrMsg: + return m.showErrorDialog(msg) case copyErrorMsg: return setClipboard(string(msg)) } diff --git a/internal/ui/login/msg.go b/internal/ui/login/msg.go index 364d157..16d04d5 100644 --- a/internal/ui/login/msg.go +++ b/internal/ui/login/msg.go @@ -5,9 +5,7 @@ import ( "golang.design/x/clipboard" ) -type errMsg struct { - err error -} +type errMsg error type copyErrorMsg string diff --git a/internal/ui/login/password/model.go b/internal/ui/login/password/model.go new file mode 100644 index 0000000..52da93d --- /dev/null +++ b/internal/ui/login/password/model.go @@ -0,0 +1,37 @@ +package password + +import ( + "github.com/ayn2op/tview" + "github.com/ayn2op/tview/tabs" +) + +type Model struct { + *tview.Form +} + +func NewModel() *Model { + form := tview.NewForm(). + AddInputField("Login", "", 0). + AddPasswordField("Password", "", 0, 0). + AddButton("Login") + return &Model{Form: form} +} + +var _ tabs.Tab = (*Model)(nil) + +func (m *Model) Label() string { + return "Password" +} + +func (m *Model) Update(msg tview.Msg) tview.Cmd { + switch msg.(type) { + case tview.FormSubmitMsg: + login := m.GetFormItem(0).(*tview.InputField).Text() + password := m.GetFormItem(1).(*tview.InputField).Text() + if login == "" || password == "" { + return nil + } + return loginCmd(login, password) + } + return m.Form.Update(msg) +} diff --git a/internal/ui/login/password/msg.go b/internal/ui/login/password/msg.go new file mode 100644 index 0000000..a4631b9 --- /dev/null +++ b/internal/ui/login/password/msg.go @@ -0,0 +1,36 @@ +package password + +import ( + "errors" + + "github.com/ayn2op/arikawa/v3/utils/httputil" + "github.com/ayn2op/discordo/internal/http" + "github.com/ayn2op/tview" +) + +type ( + TokenMsg string + ErrMsg error +) + +func loginCmd(login, password string) tview.Cmd { + return func() tview.Msg { + headers := http.Headers() + headers.Set("Referer", "https://discord.com/login") + + client := http.NewClient("") + client.OnRequest = append(client.OnRequest, httputil.WithHeaders(headers)) + + loginResp, err := client.Login(login, password) + if err != nil { + return ErrMsg(err) + } + if loginResp.Token == "" { + if loginResp.MFA { + return ErrMsg(errors.New("multi-factor authentication is required; use token or QR login")) + } + return ErrMsg(errors.New("login response did not include a token")) + } + return TokenMsg(loginResp.Token) + } +} diff --git a/internal/ui/login/qr/model.go b/internal/ui/login/qr/model.go index 68d44d1..62696c6 100644 --- a/internal/ui/login/qr/model.go +++ b/internal/ui/login/qr/model.go @@ -101,7 +101,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { return tview.Batch(m.heartbeat(), m.sendHeartbeat()) case errMsg: - m.msg = msg.err.Error() + m.msg = msg.Error() return m.close() } diff --git a/internal/ui/login/qr/msg.go b/internal/ui/login/qr/msg.go index f944fd3..e11f954 100644 --- a/internal/ui/login/qr/msg.go +++ b/internal/ui/login/qr/msg.go @@ -19,15 +19,10 @@ import ( "github.com/skip2/go-qrcode" ) -type errMsg struct { - err error -} - -func newErrMsg(err error) errMsg { - return errMsg{err: err} -} - -type TokenMsg string +type ( + TokenMsg string + errMsg error +) const remoteAuthGatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2" @@ -44,7 +39,7 @@ func (m *Model) connect() tview.Cmd { dialer := gateway.NewDialer() conn, _, err := dialer.Dial(remoteAuthGatewayURL, headers) if err != nil { - return newErrMsg(err) + return errMsg(err) } return connCreateMsg{conn: conn} } @@ -54,7 +49,7 @@ func (m *Model) close() tview.Cmd { return func() tview.Msg { if m.conn != nil { if err := m.conn.Close(); err != nil { - return newErrMsg(err) + return errMsg(err) } } return connCloseMsg{} @@ -92,14 +87,14 @@ func (m *Model) listen() tview.Cmd { _, data, err := m.conn.ReadMessage() if err != nil { - return newErrMsg(err) + return errMsg(err) } var payload struct { Op string `json:"op"` } if err := json.Unmarshal(data, &payload); err != nil { - return newErrMsg(err) + return errMsg(err) } switch payload.Op { @@ -109,7 +104,7 @@ func (m *Model) listen() tview.Cmd { TimeoutMS int `json:"timeout_ms"` } if err := json.Unmarshal(data, &payload); err != nil { - return newErrMsg(err) + return errMsg(err) } return helloMsg{heartbeatInterval: payload.HeartbeatInterval, timeoutMS: payload.TimeoutMS} case "nonce_proof": @@ -117,7 +112,7 @@ func (m *Model) listen() tview.Cmd { EncryptedNonce string `json:"encrypted_nonce"` } if err := json.Unmarshal(data, &payload); err != nil { - return newErrMsg(err) + return errMsg(err) } return nonceProofMsg{encryptedNonce: payload.EncryptedNonce} case "pending_remote_init": @@ -125,7 +120,7 @@ func (m *Model) listen() tview.Cmd { Fingerprint string `json:"fingerprint"` } if err := json.Unmarshal(data, &payload); err != nil { - return newErrMsg(err) + return errMsg(err) } return pendingRemoteInitMsg{fingerprint: payload.Fingerprint} case "pending_ticket": @@ -133,7 +128,7 @@ func (m *Model) listen() tview.Cmd { EncryptedUserPayload string `json:"encrypted_user_payload"` } if err := json.Unmarshal(data, &payload); err != nil { - return newErrMsg(err) + return errMsg(err) } return pendingTicketMsg{encryptedUserPayload: payload.EncryptedUserPayload} case "cancel": @@ -143,7 +138,7 @@ func (m *Model) listen() tview.Cmd { Ticket string `json:"ticket"` } if err := json.Unmarshal(data, &payload); err != nil { - return newErrMsg(err) + return errMsg(err) } return pendingLoginMsg{ticket: payload.Ticket} default: @@ -170,7 +165,7 @@ func (m *Model) sendHeartbeat() tview.Cmd { Op string `json:"op"` }{"heartbeat"} if err := m.conn.WriteJSON(data); err != nil { - return newErrMsg(err) + return errMsg(err) } return nil } @@ -184,7 +179,7 @@ func (m *Model) generatePrivateKey() tview.Cmd { return func() tview.Msg { privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - return newErrMsg(err) + return errMsg(err) } return privateKeyMsg{privateKey: privateKey} } @@ -193,11 +188,11 @@ func (m *Model) generatePrivateKey() tview.Cmd { func (m *Model) sendInit() tview.Cmd { return func() tview.Msg { if m.privateKey == nil { - return newErrMsg(errors.New("missing private key")) + return errMsg(errors.New("missing private key")) } spki, err := x509.MarshalPKIXPublicKey(m.privateKey.Public()) if err != nil { - return newErrMsg(err) + return errMsg(err) } encodedPublicKey := base64.StdEncoding.EncodeToString(spki) data := struct { @@ -205,7 +200,7 @@ func (m *Model) sendInit() tview.Cmd { EncodedPublicKey string `json:"encoded_public_key"` }{"init", encodedPublicKey} if err := m.conn.WriteJSON(data); err != nil { - return newErrMsg(err) + return errMsg(err) } return nil } @@ -215,12 +210,12 @@ func (m *Model) sendNonceProof(encryptedNonce string) tview.Cmd { return func() tview.Msg { decodedNonce, err := base64.StdEncoding.DecodeString(encryptedNonce) if err != nil { - return newErrMsg(err) + return errMsg(err) } decryptedNonce, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedNonce, nil) if err != nil { - return newErrMsg(err) + return errMsg(err) } encodedNonce := base64.RawURLEncoding.EncodeToString(decryptedNonce) @@ -229,7 +224,7 @@ func (m *Model) sendNonceProof(encryptedNonce string) tview.Cmd { Nonce string `json:"nonce"` }{"nonce_proof", encodedNonce} if err := m.conn.WriteJSON(data); err != nil { - return newErrMsg(err) + return errMsg(err) } return nil } @@ -244,7 +239,7 @@ func (m *Model) generateQRCode(fingerprint string) tview.Cmd { content := "https://discord.com/ra/" + fingerprint qrCode, err := qrcode.New(content, qrcode.Low) if err != nil { - return newErrMsg(err) + return errMsg(err) } qrCode.DisableBorder = true return qrCodeMsg{qrCode: qrCode} @@ -260,17 +255,17 @@ func (m *Model) decryptUserPayload(encryptedPayload string) tview.Cmd { return func() tview.Msg { decodedPayload, err := base64.StdEncoding.DecodeString(encryptedPayload) if err != nil { - return newErrMsg(err) + return errMsg(err) } decryptedPayload, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedPayload, nil) if err != nil { - return newErrMsg(err) + return errMsg(err) } parts := strings.Split(string(decryptedPayload), ":") if len(parts) != 4 { - return newErrMsg(errors.New("invalid user payload")) + return errMsg(errors.New("invalid user payload")) } return userMsg{discriminator: parts[1], username: parts[3]} @@ -290,17 +285,17 @@ func (m *Model) exchangeTicket(ticket string) tview.Cmd { encryptedToken, err := client.ExchangeRemoteAuthTicket(ticket) if err != nil { - return newErrMsg(err) + return errMsg(err) } decodedToken, err := base64.StdEncoding.DecodeString(encryptedToken) if err != nil { - return newErrMsg(err) + return errMsg(err) } decryptedToken, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedToken, nil) if err != nil { - return newErrMsg(err) + return errMsg(err) } return TokenMsg(string(decryptedToken)) } diff --git a/internal/ui/login/token/model.go b/internal/ui/login/token/model.go index 444d919..13cc06d 100644 --- a/internal/ui/login/token/model.go +++ b/internal/ui/login/token/model.go @@ -29,7 +29,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { if token == "" { return nil } - return tokenCommand(token) + return tokenCmd(token) } return m.Form.Update(msg) } diff --git a/internal/ui/login/token/msg.go b/internal/ui/login/token/msg.go index 6cb1827..c945d63 100644 --- a/internal/ui/login/token/msg.go +++ b/internal/ui/login/token/msg.go @@ -6,7 +6,7 @@ import ( type TokenMsg string -func tokenCommand(token string) tview.Cmd { +func tokenCmd(token string) tview.Cmd { return func() tview.Msg { return TokenMsg(token) } diff --git a/internal/ui/root/model.go b/internal/ui/root/model.go index cf2f651..a54a2dc 100644 --- a/internal/ui/root/model.go +++ b/internal/ui/root/model.go @@ -8,6 +8,7 @@ import ( "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/discordo/internal/ui/chat" "github.com/ayn2op/discordo/internal/ui/login" + "github.com/ayn2op/discordo/internal/ui/login/password" "github.com/ayn2op/discordo/internal/ui/login/qr" "github.com/ayn2op/discordo/internal/ui/login/token" "github.com/ayn2op/tview" @@ -115,10 +116,12 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { case tokenMsg: return m.showChat(string(msg)) - case token.TokenMsg: + case password.TokenMsg: return tview.Batch(m.showChat(string(msg)), setToken(string(msg))) case qr.TokenMsg: return tview.Batch(m.showChat(string(msg)), setToken(string(msg))) + case token.TokenMsg: + return tview.Batch(m.showChat(string(msg)), setToken(string(msg))) case chat.LogoutMsg: return tview.Batch(