From 7e257105067033939c88ccfa47e0b016e3bf6540 Mon Sep 17 00:00:00 2001 From: FIGBERT Date: Wed, 23 Aug 2023 22:55:57 -0700 Subject: [PATCH] Use Beeper JWT login --- beeper/internal.go | 80 ++++++++++++++++++++++++++++++++++++ beeper/login.go | 59 +++++++++++++++++++++++++++ interface/matrix.go | 1 + matrix/matrix.go | 26 ++++++++++++ ui/view-login.go | 99 ++++++++++++++++++++++----------------------- 5 files changed, 215 insertions(+), 50 deletions(-) create mode 100644 beeper/internal.go create mode 100644 beeper/login.go diff --git a/beeper/internal.go b/beeper/internal.go new file mode 100644 index 0000000..635fc94 --- /dev/null +++ b/beeper/internal.go @@ -0,0 +1,80 @@ +package beeper + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "maunium.net/go/mautrix" +) + +var cli = &http.Client{Timeout: 30 * time.Second} + +func newRequest(token, method, path string) *http.Request { + req := &http.Request{ + URL: &url.URL{ + Scheme: "https", + Host: "api.beeper.com", + Path: path, + }, + Method: method, + Header: http.Header{ + "Authorization": {fmt.Sprintf("Bearer %s", token)}, + "User-Agent": {mautrix.DefaultUserAgent}, + }, + } + if method == http.MethodPut || method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } + return req +} + +func encodeContent(into *http.Request, body any) error { + var buf bytes.Buffer + err := json.NewEncoder(&buf).Encode(body) + if err != nil { + return fmt.Errorf("failed to encode request: %w", err) + } + into.Body = io.NopCloser(&buf) + return nil +} + +func doRequest(req *http.Request, reqData, resp any) (err error) { + if reqData != nil { + err = encodeContent(req, reqData) + if err != nil { + return + } + } + r, err := cli.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer r.Body.Close() + if r.StatusCode < 200 || r.StatusCode >= 300 { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if body != nil { + retryCount, ok := body["retries"].(float64) + if ok && retryCount > 0 && r.StatusCode == 403 && req.URL.Path == "/user/login/response" { + return fmt.Errorf("%w (%d retries left)", ErrInvalidLoginCode, int(retryCount)) + } + errorMsg, ok := body["error"].(string) + if ok { + return fmt.Errorf("server returned error (HTTP %d): %s", r.StatusCode, errorMsg) + } + } + return fmt.Errorf("unexpected status code %d", r.StatusCode) + } + if resp != nil { + err = json.NewDecoder(r.Body).Decode(resp) + if err != nil { + return fmt.Errorf("error decoding response: %w", err) + } + } + return nil +} diff --git a/beeper/login.go b/beeper/login.go new file mode 100644 index 0000000..037d5e7 --- /dev/null +++ b/beeper/login.go @@ -0,0 +1,59 @@ +package beeper + +import ( + "bytes" + "fmt" + "io" + "net/http" + "time" +) + +type RespStartLogin struct { + RequestID string `json:"request"` + Type []string `json:"type"` + Expires time.Time `json:"expires"` +} + +type ReqSendLoginEmail struct { + RequestID string `json:"request"` + Email string `json:"email"` +} + +type ReqSendLoginCode struct { + RequestID string `json:"request"` + Code string `json:"response"` +} + +type RespSendLoginCode struct { + LoginToken string `json:"token"` +} + +var ErrInvalidLoginCode = fmt.Errorf("invalid login code") + +const loginAuth = "BEEPER-PRIVATE-API-PLEASE-DONT-USE" + +func StartLogin() (resp *RespStartLogin, err error) { + req := newRequest(loginAuth, http.MethodPost, "/user/login") + req.Body = io.NopCloser(bytes.NewReader([]byte("{}"))) + err = doRequest(req, nil, &resp) + return +} + +func SendLoginEmail(request, email string) error { + req := newRequest(loginAuth, http.MethodPost, "/user/login/email") + reqData := &ReqSendLoginEmail{ + RequestID: request, + Email: email, + } + return doRequest(req, reqData, nil) +} + +func SendLoginCode(request, code string) (resp *RespSendLoginCode, err error) { + req := newRequest(loginAuth, http.MethodPost, "/user/login/response") + reqData := &ReqSendLoginCode{ + RequestID: request, + Code: code, + } + err = doRequest(req, reqData, &resp) + return +} diff --git a/interface/matrix.go b/interface/matrix.go index d4b2baa..67300f7 100644 --- a/interface/matrix.go +++ b/interface/matrix.go @@ -50,6 +50,7 @@ type MatrixContainer interface { Stop() Login(user, password string) error + BeeperLogin(session, code string) error Logout() UIAFallback(authType mautrix.AuthType, sessionID string) error diff --git a/matrix/matrix.go b/matrix/matrix.go index 42bf644..2efd7ff 100644 --- a/matrix/matrix.go +++ b/matrix/matrix.go @@ -41,6 +41,7 @@ import ( "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/pushrules" + "maunium.net/go/gomuks/beeper" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" @@ -190,6 +191,22 @@ func (c *Container) Initialized() bool { return c.client != nil } +func (c *Container) JWTLogin(token string) error { + resp, err := c.client.Login(&mautrix.ReqLogin{ + Type: "org.matrix.login.jwt", + Token: token, + InitialDeviceDisplayName: "gomuks", + + StoreCredentials: true, + StoreHomeserverURL: true, + }) + if err != nil { + return err + } + c.finishLogin(resp) + return nil +} + func (c *Container) PasswordLogin(user, password string) error { resp, err := c.client.Login(&mautrix.ReqLogin{ Type: "m.login.password", @@ -288,6 +305,15 @@ func (c *Container) SingleSignOn() error { return err } +func (c *Container) BeeperLogin(request, code string) error { + resp, err := beeper.SendLoginCode(request, code) + if err != nil { + return err + } + + return c.JWTLogin(resp.LoginToken) +} + // Login sends a password login request with the given username and password. func (c *Container) Login(user, password string) error { resp, err := c.client.GetLoginFlows() diff --git a/ui/view-login.go b/ui/view-login.go index 100a56c..6300c88 100644 --- a/ui/view-login.go +++ b/ui/view-login.go @@ -25,8 +25,8 @@ import ( "go.mau.fi/tcell" "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" + "maunium.net/go/gomuks/beeper" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" @@ -37,19 +37,18 @@ type LoginView struct { container *mauview.Centerer - homeserverLabel *mauview.TextField - usernameLabel *mauview.TextField - passwordLabel *mauview.TextField + emailLabel *mauview.TextField + codeLabel *mauview.TextField - homeserver *mauview.InputField - username *mauview.InputField - password *mauview.InputField - error *mauview.TextView + email *mauview.InputField + code *mauview.InputField + error *mauview.TextView loginButton *mauview.Button quitButton *mauview.Button loading bool + session string matrix ifc.MatrixContainer config *config.Config @@ -60,13 +59,11 @@ func (ui *GomuksUI) NewLoginView() mauview.Component { view := &LoginView{ Form: mauview.NewForm(), - usernameLabel: mauview.NewTextField().SetText("Username"), - passwordLabel: mauview.NewTextField().SetText("Password"), - homeserverLabel: mauview.NewTextField().SetText("Homeserver"), + emailLabel: mauview.NewTextField().SetText("Email"), + codeLabel: mauview.NewTextField().SetText("Code"), - username: mauview.NewInputField(), - password: mauview.NewInputField(), - homeserver: mauview.NewInputField(), + email: mauview.NewInputField(), + code: mauview.NewInputField(), loginButton: mauview.NewButton("Login"), quitButton: mauview.NewButton("Quit"), @@ -76,10 +73,8 @@ func (ui *GomuksUI) NewLoginView() mauview.Component { parent: ui, } - hs := ui.gmx.Config().HS - view.homeserver.SetPlaceholder("https://example.com").SetText(hs).SetTextColor(tcell.ColorWhite) - view.username.SetPlaceholder("@user:example.com").SetText(string(ui.gmx.Config().UserID)).SetTextColor(tcell.ColorWhite) - view.password.SetPlaceholder("correct horse battery staple").SetMaskCharacter('*').SetTextColor(tcell.ColorWhite) + view.email.SetPlaceholder("example@example.com").SetTextColor(tcell.ColorWhite) + view.code.SetPlaceholder("123456").SetTextColor(tcell.ColorWhite) view.quitButton. SetOnClick(func() { ui.gmx.Stop(true) }). @@ -93,45 +88,51 @@ func (ui *GomuksUI) NewLoginView() mauview.Component { SetFocusedForegroundColor(tcell.ColorWhite) view. - SetColumns([]int{1, 10, 1, 30, 1}). - SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) + SetColumns([]int{1, 5, 1, 30, 1}). + SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) view. - AddFormItem(view.username, 3, 1, 1, 1). - AddFormItem(view.password, 3, 3, 1, 1). - AddFormItem(view.homeserver, 3, 5, 1, 1). - AddFormItem(view.loginButton, 1, 7, 3, 1). - AddFormItem(view.quitButton, 1, 9, 3, 1). - AddComponent(view.usernameLabel, 1, 1, 1, 1). - AddComponent(view.passwordLabel, 1, 3, 1, 1). - AddComponent(view.homeserverLabel, 1, 5, 1, 1) + AddFormItem(view.email, 3, 1, 1, 1). + AddFormItem(view.code, 3, 3, 1, 1). + AddFormItem(view.loginButton, 1, 5, 3, 1). + AddFormItem(view.quitButton, 1, 7, 3, 1). + AddComponent(view.emailLabel, 1, 1, 1, 1). + AddComponent(view.codeLabel, 1, 3, 1, 1) view.SetOnFocusChanged(view.focusChanged) view.FocusNextItem() ui.loginView = view - view.container = mauview.Center(mauview.NewBox(view).SetTitle("Log in to Matrix"), 45, 13) + view.container = mauview.Center(mauview.NewBox(view).SetTitle("Log in to Matrix"), 40, 11) view.container.SetAlwaysFocusChild(true) return view.container } -func (view *LoginView) resolveWellKnown() { - _, homeserver, err := id.UserID(view.username.GetText()).Parse() +func (view *LoginView) emailAuthFlow() { + view.Error("") + + resp, err := beeper.StartLogin() if err != nil { + view.code.SetText("") + view.Error(err.Error()) return } - view.homeserver.SetText("Resolving...") - resp, err := mautrix.DiscoverClientAPI(homeserver) + view.code.SetPlaceholder("Talking to Beeper servers…") + view.parent.Render() + + err = beeper.SendLoginEmail(resp.RequestID, view.email.GetText()) if err != nil { - view.homeserver.SetText("") + view.code.SetText("") + view.code.SetPlaceholder("123456") view.Error(err.Error()) - } else if resp != nil { - view.homeserver.SetText(resp.Homeserver.BaseURL) - view.parent.Render() + return } + view.session = resp.RequestID + view.code.SetPlaceholder("Check your inbox…") + view.parent.Render() } func (view *LoginView) focusChanged(from, to mauview.Component) { - if from == view.username && view.homeserver.GetText() == "" { - go view.resolveWellKnown() + if from == view.email { + go view.emailAuthFlow() } } @@ -139,32 +140,32 @@ func (view *LoginView) Error(err string) { if len(err) == 0 && view.error != nil { debug.Print("Hiding error") view.RemoveComponent(view.error) - view.container.SetHeight(13) + view.container.SetHeight(11) view.SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1}) view.error = nil } else if len(err) > 0 { debug.Print("Showing error", err) if view.error == nil { view.error = mauview.NewTextView().SetTextColor(tcell.ColorRed) - view.AddComponent(view.error, 1, 11, 3, 1) + view.AddComponent(view.error, 1, 9, 3, 1) } view.error.SetText(err) errorHeight := int(math.Ceil(float64(runewidth.StringWidth(err)) / 41)) - view.container.SetHeight(14 + errorHeight) + view.container.SetHeight(12 + errorHeight) view.SetRow(11, errorHeight) } view.parent.Render() } -func (view *LoginView) actuallyLogin(hs, mxid, password string) { - debug.Printf("Logging into %s as %s...", hs, mxid) - view.config.HS = hs +func (view *LoginView) actuallyLogin(session, code string) { + debug.Printf("Logging into Beeper with code %s...", code) + view.config.HS = "https://matrix.beeper.com" if err := view.matrix.InitClient(false); err != nil { debug.Print("Init error:", err) view.Error(err.Error()) - } else if err = view.matrix.Login(mxid, password); err != nil { + } else if err = view.matrix.BeeperLogin(session, code); err != nil { if httpErr, ok := err.(mautrix.HTTPError); ok { if httpErr.RespError != nil && len(httpErr.RespError.Err) > 0 { view.Error(httpErr.RespError.Err) @@ -186,11 +187,9 @@ func (view *LoginView) Login() { if view.loading { return } - hs := view.homeserver.GetText() - mxid := view.username.GetText() - password := view.password.GetText() + code := view.code.GetText() view.loading = true view.loginButton.SetText("Logging in...") - go view.actuallyLogin(hs, mxid, password) + go view.actuallyLogin(view.session, code) }