Skip to content

Commit

Permalink
web/login: add support for SSO and Beeper email login
Browse files Browse the repository at this point in the history
Fixes #493
  • Loading branch information
tulir committed Nov 15, 2024
1 parent 3df871b commit 50eabb7
Show file tree
Hide file tree
Showing 14 changed files with 500 additions and 32 deletions.
2 changes: 1 addition & 1 deletion pkg/gomuks/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"html"
"image"
_ "image/gif"
_ "image/jpeg"
Expand All @@ -38,7 +39,6 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
_ "golang.org/x/image/webp"
"golang.org/x/net/html"

"go.mau.fi/util/exhttp"
"go.mau.fi/util/ffmpeg"
Expand Down
27 changes: 18 additions & 9 deletions pkg/gomuks/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ func (gmx *Gomuks) StartServer() {
api.HandleFunc("GET /websocket", gmx.HandleWebsocket)
api.HandleFunc("POST /auth", gmx.Authenticate)
api.HandleFunc("POST /upload", gmx.UploadMedia)
api.HandleFunc("GET /sso", gmx.HandleSSOComplete)
api.HandleFunc("POST /sso", gmx.PrepareSSO)
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
apiHandler := exhttp.ApplyMiddleware(
Expand Down Expand Up @@ -111,8 +113,8 @@ type tokenData struct {
ImageOnly bool `json:"image_only,omitempty"`
}

func (gmx *Gomuks) validateAuth(token string, imageOnly bool) bool {
if len(token) > 500 {
func (gmx *Gomuks) validateToken(token string, output any) bool {
if len(token) > 4096 {
return false
}
parts := strings.Split(token, ".")
Expand All @@ -133,9 +135,19 @@ func (gmx *Gomuks) validateAuth(token string, imageOnly bool) bool {
return false
}

err = json.Unmarshal(rawJSON, output)
return err == nil
}

func (gmx *Gomuks) validateAuth(token string, imageOnly bool) bool {
if len(token) > 500 {
return false
}
var td tokenData
err = json.Unmarshal(rawJSON, &td)
return err == nil && td.Username == gmx.Config.Web.Username && td.Expiry.After(time.Now()) && td.ImageOnly == imageOnly
return gmx.validateToken(token, &td) &&
td.Username == gmx.Config.Web.Username &&
td.Expiry.After(time.Now()) &&
td.ImageOnly == imageOnly
}

func (gmx *Gomuks) generateToken() (string, time.Time) {
Expand All @@ -154,7 +166,7 @@ func (gmx *Gomuks) generateImageToken() string {
})
}

func (gmx *Gomuks) signToken(td tokenData) string {
func (gmx *Gomuks) signToken(td any) string {
data := exerrors.Must(json.Marshal(td))
hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey))
hasher.Write(data)
Expand Down Expand Up @@ -202,10 +214,7 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
}

func isUserFetch(header http.Header) bool {
return (header.Get("Sec-Fetch-Site") == "none" ||
header.Get("Sec-Fetch-Site") == "same-site" ||
header.Get("Sec-Fetch-Site") == "same-origin") &&
header.Get("Sec-Fetch-Mode") == "navigate" &&
return header.Get("Sec-Fetch-Mode") == "navigate" &&
header.Get("Sec-Fetch-Dest") == "document" &&
header.Get("Sec-Fetch-User") == "?1"
}
Expand Down
128 changes: 128 additions & 0 deletions pkg/gomuks/sso.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package gomuks

import (
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"time"

"go.mau.fi/util/random"
"maunium.net/go/mautrix"
)

const ssoErrorPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>gomuks web</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
</style>
</head>
<body>
<h1>Failed to log in</h1>
<p><code>%s</code></p>
</body>
</html>`

func (gmx *Gomuks) parseSSOServerURL(r *http.Request) error {
cookie, _ := r.Cookie("gomuks_sso_session")
if cookie == nil {
return fmt.Errorf("no SSO session cookie")
}
var cookieData SSOCookieData
if !gmx.validateToken(cookie.Value, &cookieData) {
return fmt.Errorf("invalid SSO session cookie")
} else if cookieData.SessionID != r.URL.Query().Get("gomuksSession") {
return fmt.Errorf("session ID mismatch in query param and cookie")
} else if time.Until(cookieData.Expiry) < 0 {
return fmt.Errorf("SSO session cookie expired")
}
var err error
gmx.Client.Client.HomeserverURL, err = url.Parse(cookieData.HomeserverURL)
if err != nil {
return fmt.Errorf("failed to parse server URL: %w", err)
}
return nil
}

func (gmx *Gomuks) HandleSSOComplete(w http.ResponseWriter, r *http.Request) {
err := gmx.parseSSOServerURL(r)
if err != nil {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, ssoErrorPage, html.EscapeString(err.Error()))
return
}
err = gmx.Client.Login(r.Context(), &mautrix.ReqLogin{
Type: mautrix.AuthTypeToken,
Token: r.URL.Query().Get("loginToken"),
})
if err != nil {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, ssoErrorPage, html.EscapeString(err.Error()))
} else {
w.Header().Set("Location", "..")
w.WriteHeader(http.StatusFound)
}
}

type SSOCookieData struct {
SessionID string `json:"session_id"`
HomeserverURL string `json:"homeserver_url"`
Expiry time.Time `json:"expiry"`
}

func (gmx *Gomuks) PrepareSSO(w http.ResponseWriter, r *http.Request) {
var data SSOCookieData
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
mautrix.MBadJSON.WithMessage("Failed to decode request JSON").Write(w)
return
}
data.SessionID = random.String(16)
data.Expiry = time.Now().Add(30 * time.Minute)
cookieData, err := json.Marshal(&data)
if err != nil {
mautrix.MUnknown.WithMessage("Failed to encode cookie data").Write(w)
return
}
http.SetCookie(w, &http.Cookie{
Name: "gomuks_sso_session",
Value: gmx.signToken(json.RawMessage(cookieData)),
Expires: data.Expiry,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(cookieData)
}
19 changes: 18 additions & 1 deletion pkg/hicli/hicli.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,19 @@ func New(rawDB, cryptoDB *dbutil.Database, log zerolog.Logger, pickleKey []byte,
return c
}

func (h *HiClient) tempClient(homeserverURL string) (*mautrix.Client, error) {
parsedURL, err := url.Parse(homeserverURL)
if err != nil {
return nil, err
}
return &mautrix.Client{
HomeserverURL: parsedURL,
UserAgent: h.Client.UserAgent,
Client: h.Client.Client,
Log: h.Log.With().Str("component", "temp mautrix client").Logger(),
}, nil
}

func (h *HiClient) IsLoggedIn() bool {
return h.Account != nil
}
Expand Down Expand Up @@ -191,7 +204,11 @@ var ErrOutdatedServer = errors.New("homeserver is outdated")
var MinimumSpecVersion = mautrix.SpecV11

func (h *HiClient) CheckServerVersions(ctx context.Context) error {
versions, err := h.Client.Versions(ctx)
return h.checkServerVersions(ctx, h.Client)
}

func (h *HiClient) checkServerVersions(ctx context.Context, cli *mautrix.Client) error {
versions, err := cli.Versions(ctx)
if err != nil {
return exerrors.NewDualError(ErrFailedToCheckServerVersions, err)
} else if !versions.Contains(MinimumSpecVersion) {
Expand Down
31 changes: 31 additions & 0 deletions pkg/hicli/json-commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"time"

"maunium.net/go/mautrix"
Expand Down Expand Up @@ -117,6 +118,15 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *loginParams) (bool, error) {
return true, h.LoginPassword(ctx, params.HomeserverURL, params.Username, params.Password)
})
case "login_custom":
return unmarshalAndCall(req.Data, func(params *loginCustomParams) (bool, error) {
var err error
h.Client.HomeserverURL, err = url.Parse(params.HomeserverURL)
if err != nil {
return false, err
}
return true, h.Login(ctx, params.Request)
})
case "verify":
return unmarshalAndCall(req.Data, func(params *verifyParams) (bool, error) {
return true, h.VerifyWithRecoveryKey(ctx, params.RecoveryKey)
Expand All @@ -129,6 +139,18 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
}
return mautrix.DiscoverClientAPI(ctx, homeserver)
})
case "get_login_flows":
return unmarshalAndCall(req.Data, func(params *getLoginFlowsParams) (*mautrix.RespLoginFlows, error) {
cli, err := h.tempClient(params.HomeserverURL)
if err != nil {
return nil, err
}
err = h.checkServerVersions(ctx, cli)
if err != nil {
return nil, err
}
return cli.GetLoginFlows(ctx)
})
default:
return nil, fmt.Errorf("unknown command %q", req.Command)
}
Expand Down Expand Up @@ -236,6 +258,11 @@ type loginParams struct {
Password string `json:"password"`
}

type loginCustomParams struct {
HomeserverURL string `json:"homeserver_url"`
Request *mautrix.ReqLogin `json:"request"`
}

type verifyParams struct {
RecoveryKey string `json:"recovery_key"`
}
Expand All @@ -244,6 +271,10 @@ type discoverHomeserverParams struct {
UserID id.UserID `json:"user_id"`
}

type getLoginFlowsParams struct {
HomeserverURL string `json:"homeserver_url"`
}

type paginateParams struct {
RoomID id.RoomID `json:"room_id"`
MaxTimelineID database.TimelineRowID `json:"max_timeline_id"`
Expand Down
4 changes: 2 additions & 2 deletions pkg/hicli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ func (h *HiClient) LoginPassword(ctx context.Context, homeserverURL, username, p
Type: mautrix.IdentifierTypeUser,
User: username,
},
Password: password,
InitialDeviceDisplayName: InitialDeviceDisplayName,
Password: password,
})
}

Expand All @@ -41,6 +40,7 @@ func (h *HiClient) Login(ctx context.Context, req *mautrix.ReqLogin) error {
if err != nil {
return err
}
req.InitialDeviceDisplayName = InitialDeviceDisplayName
req.StoreCredentials = true
req.StoreHomeserverURL = true
resp, err := h.Client.Login(ctx, req)
Expand Down
73 changes: 73 additions & 0 deletions web/src/api/beeper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

const headers = {
"Authorization": "Bearer BEEPER-PRIVATE-API-PLEASE-DONT-USE",
"Content-Type": "application/json",
}

async function tryJSON(resp: Response): Promise<unknown> {
try {
return await resp.json()
} catch (err) {
console.error(err)
}
}

export async function doSubmitCode(domain: string, request: string, response: string): Promise<string> {
const resp = await fetch(`https://api.${domain}/user/login/response`, {
method: "POST",
body: JSON.stringify({ response, request }),
headers,
})
const data = await tryJSON(resp) as { token?: string, error?: string }
console.log("Login code submit response data:", data)
if (!resp.ok) {
throw new Error(data ? `HTTP ${resp.status} / ${data?.error ?? JSON.stringify(data)}` : `HTTP ${resp.status}`)
} else if (!data || typeof data !== "object" || typeof data.token !== "string") {
throw new Error(`No token returned`)
}
return data.token
}

export async function doRequestCode(domain: string, request: string, email: string) {
const resp = await fetch(`https://api.${domain}/user/login/email`, {
method: "POST",
body: JSON.stringify({ email, request }),
headers,
})
const data = await tryJSON(resp) as { error?: string }
console.log("Login email submit response data:", data)
if (!resp.ok) {
throw new Error(data ? `HTTP ${resp.status} / ${data?.error ?? JSON.stringify(data)}` : `HTTP ${resp.status}`)
}
}

export async function doStartLogin(domain: string): Promise<string> {
const resp = await fetch(`https://api.${domain}/user/login`, {
method: "POST",
body: "{}",
headers,
})
const data = await tryJSON(resp) as { request?: string, error?: string }
console.log("Login start response data:", data)
if (!resp.ok) {
throw new Error(data ? `HTTP ${resp.status} / ${data?.error ?? JSON.stringify(data)}` : `HTTP ${resp.status}`)
} else if (!data || typeof data !== "object" || typeof data.request !== "string") {
throw new Error(`No request ID returned`)
}
return data.request
}
Loading

0 comments on commit 50eabb7

Please sign in to comment.