forked from Mirrors/gomuks
parent
3df871b783
commit
50eabb7b56
14 changed files with 500 additions and 32 deletions
|
@ -22,6 +22,7 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"image"
|
"image"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
|
@ -38,7 +39,6 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/hlog"
|
"github.com/rs/zerolog/hlog"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
"golang.org/x/net/html"
|
|
||||||
|
|
||||||
"go.mau.fi/util/exhttp"
|
"go.mau.fi/util/exhttp"
|
||||||
"go.mau.fi/util/ffmpeg"
|
"go.mau.fi/util/ffmpeg"
|
||||||
|
|
|
@ -46,6 +46,8 @@ func (gmx *Gomuks) StartServer() {
|
||||||
api.HandleFunc("GET /websocket", gmx.HandleWebsocket)
|
api.HandleFunc("GET /websocket", gmx.HandleWebsocket)
|
||||||
api.HandleFunc("POST /auth", gmx.Authenticate)
|
api.HandleFunc("POST /auth", gmx.Authenticate)
|
||||||
api.HandleFunc("POST /upload", gmx.UploadMedia)
|
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 /media/{server}/{media_id}", gmx.DownloadMedia)
|
||||||
api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
|
api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
|
||||||
apiHandler := exhttp.ApplyMiddleware(
|
apiHandler := exhttp.ApplyMiddleware(
|
||||||
|
@ -111,8 +113,8 @@ type tokenData struct {
|
||||||
ImageOnly bool `json:"image_only,omitempty"`
|
ImageOnly bool `json:"image_only,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gmx *Gomuks) validateAuth(token string, imageOnly bool) bool {
|
func (gmx *Gomuks) validateToken(token string, output any) bool {
|
||||||
if len(token) > 500 {
|
if len(token) > 4096 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
|
@ -133,9 +135,19 @@ func (gmx *Gomuks) validateAuth(token string, imageOnly bool) bool {
|
||||||
return false
|
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
|
var td tokenData
|
||||||
err = json.Unmarshal(rawJSON, &td)
|
return gmx.validateToken(token, &td) &&
|
||||||
return err == nil && td.Username == gmx.Config.Web.Username && td.Expiry.After(time.Now()) && td.ImageOnly == imageOnly
|
td.Username == gmx.Config.Web.Username &&
|
||||||
|
td.Expiry.After(time.Now()) &&
|
||||||
|
td.ImageOnly == imageOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gmx *Gomuks) generateToken() (string, time.Time) {
|
func (gmx *Gomuks) generateToken() (string, time.Time) {
|
||||||
|
@ -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))
|
data := exerrors.Must(json.Marshal(td))
|
||||||
hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey))
|
hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey))
|
||||||
hasher.Write(data)
|
hasher.Write(data)
|
||||||
|
@ -202,10 +214,7 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func isUserFetch(header http.Header) bool {
|
func isUserFetch(header http.Header) bool {
|
||||||
return (header.Get("Sec-Fetch-Site") == "none" ||
|
return header.Get("Sec-Fetch-Mode") == "navigate" &&
|
||||||
header.Get("Sec-Fetch-Site") == "same-site" ||
|
|
||||||
header.Get("Sec-Fetch-Site") == "same-origin") &&
|
|
||||||
header.Get("Sec-Fetch-Mode") == "navigate" &&
|
|
||||||
header.Get("Sec-Fetch-Dest") == "document" &&
|
header.Get("Sec-Fetch-Dest") == "document" &&
|
||||||
header.Get("Sec-Fetch-User") == "?1"
|
header.Get("Sec-Fetch-User") == "?1"
|
||||||
}
|
}
|
||||||
|
|
128
pkg/gomuks/sso.go
Normal file
128
pkg/gomuks/sso.go
Normal file
|
@ -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)
|
||||||
|
}
|
|
@ -121,6 +121,19 @@ func New(rawDB, cryptoDB *dbutil.Database, log zerolog.Logger, pickleKey []byte,
|
||||||
return c
|
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 {
|
func (h *HiClient) IsLoggedIn() bool {
|
||||||
return h.Account != nil
|
return h.Account != nil
|
||||||
}
|
}
|
||||||
|
@ -191,7 +204,11 @@ var ErrOutdatedServer = errors.New("homeserver is outdated")
|
||||||
var MinimumSpecVersion = mautrix.SpecV11
|
var MinimumSpecVersion = mautrix.SpecV11
|
||||||
|
|
||||||
func (h *HiClient) CheckServerVersions(ctx context.Context) error {
|
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 {
|
if err != nil {
|
||||||
return exerrors.NewDualError(ErrFailedToCheckServerVersions, err)
|
return exerrors.NewDualError(ErrFailedToCheckServerVersions, err)
|
||||||
} else if !versions.Contains(MinimumSpecVersion) {
|
} else if !versions.Contains(MinimumSpecVersion) {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
|
@ -117,6 +118,15 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
return unmarshalAndCall(req.Data, func(params *loginParams) (bool, error) {
|
return unmarshalAndCall(req.Data, func(params *loginParams) (bool, error) {
|
||||||
return true, h.LoginPassword(ctx, params.HomeserverURL, params.Username, params.Password)
|
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":
|
case "verify":
|
||||||
return unmarshalAndCall(req.Data, func(params *verifyParams) (bool, error) {
|
return unmarshalAndCall(req.Data, func(params *verifyParams) (bool, error) {
|
||||||
return true, h.VerifyWithRecoveryKey(ctx, params.RecoveryKey)
|
return true, h.VerifyWithRecoveryKey(ctx, params.RecoveryKey)
|
||||||
|
@ -129,6 +139,18 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
}
|
}
|
||||||
return mautrix.DiscoverClientAPI(ctx, homeserver)
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("unknown command %q", req.Command)
|
return nil, fmt.Errorf("unknown command %q", req.Command)
|
||||||
}
|
}
|
||||||
|
@ -236,6 +258,11 @@ type loginParams struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type loginCustomParams struct {
|
||||||
|
HomeserverURL string `json:"homeserver_url"`
|
||||||
|
Request *mautrix.ReqLogin `json:"request"`
|
||||||
|
}
|
||||||
|
|
||||||
type verifyParams struct {
|
type verifyParams struct {
|
||||||
RecoveryKey string `json:"recovery_key"`
|
RecoveryKey string `json:"recovery_key"`
|
||||||
}
|
}
|
||||||
|
@ -244,6 +271,10 @@ type discoverHomeserverParams struct {
|
||||||
UserID id.UserID `json:"user_id"`
|
UserID id.UserID `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type getLoginFlowsParams struct {
|
||||||
|
HomeserverURL string `json:"homeserver_url"`
|
||||||
|
}
|
||||||
|
|
||||||
type paginateParams struct {
|
type paginateParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
MaxTimelineID database.TimelineRowID `json:"max_timeline_id"`
|
MaxTimelineID database.TimelineRowID `json:"max_timeline_id"`
|
||||||
|
|
|
@ -31,8 +31,7 @@ func (h *HiClient) LoginPassword(ctx context.Context, homeserverURL, username, p
|
||||||
Type: mautrix.IdentifierTypeUser,
|
Type: mautrix.IdentifierTypeUser,
|
||||||
User: username,
|
User: username,
|
||||||
},
|
},
|
||||||
Password: password,
|
Password: password,
|
||||||
InitialDeviceDisplayName: InitialDeviceDisplayName,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,6 +40,7 @@ func (h *HiClient) Login(ctx context.Context, req *mautrix.ReqLogin) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
req.InitialDeviceDisplayName = InitialDeviceDisplayName
|
||||||
req.StoreCredentials = true
|
req.StoreCredentials = true
|
||||||
req.StoreHomeserverURL = true
|
req.StoreHomeserverURL = true
|
||||||
resp, err := h.Client.Login(ctx, req)
|
resp, err := h.Client.Login(ctx, req)
|
||||||
|
|
73
web/src/api/beeper.ts
Normal file
73
web/src/api/beeper.ts
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -20,6 +20,8 @@ import type {
|
||||||
EventID,
|
EventID,
|
||||||
EventRowID,
|
EventRowID,
|
||||||
EventType,
|
EventType,
|
||||||
|
LoginFlowsResponse,
|
||||||
|
LoginRequest,
|
||||||
Mentions,
|
Mentions,
|
||||||
MessageEventContent,
|
MessageEventContent,
|
||||||
PaginationResponse,
|
PaginationResponse,
|
||||||
|
@ -202,10 +204,18 @@ export default abstract class RPCClient {
|
||||||
return this.request("discover_homeserver", { user_id })
|
return this.request("discover_homeserver", { user_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLoginFlows(homeserver_url: string): Promise<LoginFlowsResponse> {
|
||||||
|
return this.request("get_login_flows", { homeserver_url })
|
||||||
|
}
|
||||||
|
|
||||||
login(homeserver_url: string, username: string, password: string): Promise<boolean> {
|
login(homeserver_url: string, username: string, password: string): Promise<boolean> {
|
||||||
return this.request("login", { homeserver_url, username, password })
|
return this.request("login", { homeserver_url, username, password })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginCustom(homeserver_url: string, request: LoginRequest): Promise<boolean> {
|
||||||
|
return this.request("login_custom", { homeserver_url, request })
|
||||||
|
}
|
||||||
|
|
||||||
verify(recovery_key: string): Promise<boolean> {
|
verify(recovery_key: string): Promise<boolean> {
|
||||||
return this.request("verify", { recovery_key })
|
return this.request("verify", { recovery_key })
|
||||||
}
|
}
|
||||||
|
|
|
@ -153,6 +153,12 @@ export interface ResolveAliasResponse {
|
||||||
servers: string[]
|
servers: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginFlowsResponse {
|
||||||
|
flows: {
|
||||||
|
type: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventUnsigned {
|
export interface EventUnsigned {
|
||||||
prev_content?: unknown
|
prev_content?: unknown
|
||||||
prev_sender?: UserID
|
prev_sender?: UserID
|
||||||
|
@ -191,3 +197,24 @@ export interface RoomStateGUID {
|
||||||
type: EventType
|
type: EventType
|
||||||
state_key: string
|
state_key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasswordLoginRequest {
|
||||||
|
type: "m.login.password"
|
||||||
|
identifier: {
|
||||||
|
type: "m.id.user"
|
||||||
|
user: string
|
||||||
|
}
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSOLoginRequest {
|
||||||
|
type: "m.login.token"
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JWTLoginRequest {
|
||||||
|
type: "org.matrix.login.jwt"
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginRequest = PasswordLoginRequest | SSOLoginRequest | JWTLoginRequest
|
||||||
|
|
|
@ -165,13 +165,14 @@ button, a.button, span.button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
background-color: var(--button-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
color: var(--secondary-text-color);
|
color: var(--secondary-text-color);
|
||||||
}
|
background: none;
|
||||||
|
|
||||||
&:hover:not(:disabled), &:focus:not(:disabled) {
|
|
||||||
background-color: var(--button-hover-color);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
94
web/src/ui/login/BeeperLogin.tsx
Normal file
94
web/src/ui/login/BeeperLogin.tsx
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
// 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/>.
|
||||||
|
import React, { useCallback, useState } from "react"
|
||||||
|
import * as beeper from "@/api/beeper.ts"
|
||||||
|
import type Client from "@/api/client.ts"
|
||||||
|
import useEvent from "@/util/useEvent.ts"
|
||||||
|
|
||||||
|
interface BeeperLoginProps {
|
||||||
|
domain: string
|
||||||
|
client: Client
|
||||||
|
}
|
||||||
|
|
||||||
|
const BeeperLogin = ({ domain, client }: BeeperLoginProps) => {
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [requestID, setRequestID] = useState("")
|
||||||
|
const [code, setCode] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const onChangeEmail = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setEmail(evt.target.value)
|
||||||
|
}, [])
|
||||||
|
const onChangeCode = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let codeDigits = evt.target.value.replace(/\D/g, "").slice(0, 6)
|
||||||
|
if (codeDigits.length > 3) {
|
||||||
|
codeDigits = codeDigits.slice(0, 3) + " " + codeDigits.slice(3)
|
||||||
|
}
|
||||||
|
setCode(codeDigits)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const requestCode = useEvent((evt: React.FormEvent) => {
|
||||||
|
evt.preventDefault()
|
||||||
|
beeper.doStartLogin(domain).then(
|
||||||
|
request => beeper.doRequestCode(domain, request, email).then(
|
||||||
|
() => setRequestID(request),
|
||||||
|
err => setError(`Failed to request code: ${err}`),
|
||||||
|
),
|
||||||
|
err => setError(`Failed to start login: ${err}`),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const submitCode = useEvent((evt: React.FormEvent) => {
|
||||||
|
evt.preventDefault()
|
||||||
|
beeper.doSubmitCode(domain, requestID, code).then(
|
||||||
|
token => {
|
||||||
|
client.rpc.loginCustom(`https://matrix.${domain}`, {
|
||||||
|
type: "org.matrix.login.jwt",
|
||||||
|
token,
|
||||||
|
}).catch(err => setError(`Failed to login with token: ${err}`))
|
||||||
|
},
|
||||||
|
err => setError(`Failed to submit code: ${err}`),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return <form onSubmit={requestID ? submitCode : requestCode} className="beeper-login">
|
||||||
|
<h2>Beeper email login</h2>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="beeperlogin-email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={onChangeEmail}
|
||||||
|
disabled={!!requestID}
|
||||||
|
/>
|
||||||
|
{requestID && <input
|
||||||
|
type="text"
|
||||||
|
pattern="[0-9]{3} [0-9]{3}"
|
||||||
|
id="beeperlogin-code"
|
||||||
|
placeholder="Confirmation Code"
|
||||||
|
value={code}
|
||||||
|
onChange={onChangeCode}
|
||||||
|
/>}
|
||||||
|
<button
|
||||||
|
className="beeper-login-button"
|
||||||
|
type="submit"
|
||||||
|
>{requestID ? "Submit Code" : "Request Code"}</button>
|
||||||
|
{error && <div className="error">
|
||||||
|
{error}
|
||||||
|
</div>}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BeeperLogin
|
|
@ -18,9 +18,16 @@ main.matrix-login {
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0 0 2rem;
|
margin: 0 0 2rem;
|
||||||
|
}
|
||||||
|
h1, h2, h3 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
button, input {
|
button, input {
|
||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
@ -61,5 +68,6 @@ main.matrix-login {
|
||||||
border: 2px solid var(--error-color);
|
border: 2px solid var(--error-color);
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
margin-top: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,10 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import React, { useCallback, useEffect, useState } from "react"
|
import React, { useCallback, useEffect, useState } from "react"
|
||||||
import type Client from "../../api/client.ts"
|
import type Client from "@/api/client.ts"
|
||||||
import { ClientState } from "../../api/types"
|
import type { ClientState } from "@/api/types"
|
||||||
|
import useEvent from "@/util/useEvent.ts"
|
||||||
|
import BeeperLogin from "./BeeperLogin.tsx"
|
||||||
import "./LoginScreen.css"
|
import "./LoginScreen.css"
|
||||||
|
|
||||||
export interface LoginScreenProps {
|
export interface LoginScreenProps {
|
||||||
|
@ -23,26 +25,65 @@ export interface LoginScreenProps {
|
||||||
clientState: ClientState
|
clientState: ClientState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const beeperServerRegex = /^https:\/\/matrix\.(beeper(?:-dev|-staging)?\.com)$/
|
||||||
|
|
||||||
export const LoginScreen = ({ client }: LoginScreenProps) => {
|
export const LoginScreen = ({ client }: LoginScreenProps) => {
|
||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [homeserverURL, setHomeserverURL] = useState("")
|
const [homeserverURL, setHomeserverURL] = useState("")
|
||||||
|
const [loginFlows, setLoginFlows] = useState<string[] | null>(null)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
const login = useCallback((evt: React.FormEvent) => {
|
const loginSSO = useEvent(() => {
|
||||||
|
fetch("_gomuks/sso", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ homeserver_url: homeserverURL }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}).then(resp => resp.json()).then(
|
||||||
|
resp => {
|
||||||
|
const redirectURL = new URL(window.location.href)
|
||||||
|
if (!redirectURL.pathname.endsWith("/")) {
|
||||||
|
redirectURL.pathname += "/"
|
||||||
|
}
|
||||||
|
redirectURL.pathname += "_gomuks/sso"
|
||||||
|
redirectURL.search = `?gomuksSession=${resp.session_id}`
|
||||||
|
redirectURL.hash = ""
|
||||||
|
const redir = encodeURIComponent(redirectURL.toString())
|
||||||
|
window.location.href = `${homeserverURL}/_matrix/client/v3/login/sso/redirect?redirectUrl=${redir}`
|
||||||
|
},
|
||||||
|
err => setError(`Failed to start SSO login: ${err}`),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const login = useEvent((evt: React.FormEvent) => {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
|
if (!loginFlows?.includes("m.login.password")) {
|
||||||
|
loginSSO()
|
||||||
|
return
|
||||||
|
}
|
||||||
client.rpc.login(homeserverURL, username, password).then(
|
client.rpc.login(homeserverURL, username, password).then(
|
||||||
() => {},
|
() => {},
|
||||||
err => setError(err.toString()),
|
err => setError(err.toString()),
|
||||||
)
|
)
|
||||||
}, [homeserverURL, username, password, client])
|
})
|
||||||
|
|
||||||
|
const resolveLoginFlows = useCallback((serverURL: string) => {
|
||||||
|
client.rpc.getLoginFlows(serverURL).then(
|
||||||
|
resp => setLoginFlows(resp.flows.map(flow => flow.type)),
|
||||||
|
err => setError(`Failed to get login flows: ${err}`),
|
||||||
|
)
|
||||||
|
}, [client])
|
||||||
const resolveHomeserver = useCallback(() => {
|
const resolveHomeserver = useCallback(() => {
|
||||||
client.rpc.discoverHomeserver(username).then(
|
client.rpc.discoverHomeserver(username).then(
|
||||||
resp => setHomeserverURL(resp["m.homeserver"].base_url),
|
resp => {
|
||||||
|
const url = resp["m.homeserver"].base_url
|
||||||
|
setLoginFlows(null)
|
||||||
|
setHomeserverURL(url)
|
||||||
|
resolveLoginFlows(url)
|
||||||
|
},
|
||||||
err => setError(`Failed to resolve homeserver: ${err}`),
|
err => setError(`Failed to resolve homeserver: ${err}`),
|
||||||
)
|
)
|
||||||
}, [client, username])
|
}, [client, username, resolveLoginFlows])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!username.startsWith("@") || !username.includes(":") || !username.includes(".")) {
|
if (!username.startsWith("@") || !username.includes(":") || !username.includes(".")) {
|
||||||
|
@ -53,7 +94,20 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
}, [username, resolveHomeserver])
|
}, [username, resolveHomeserver])
|
||||||
|
const onChangeUsername = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setUsername(evt.target.value)
|
||||||
|
}, [])
|
||||||
|
const onChangePassword = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPassword(evt.target.value)
|
||||||
|
}, [])
|
||||||
|
const onChangeHomeserverURL = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setLoginFlows(null)
|
||||||
|
setHomeserverURL(evt.target.value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const supportsSSO = loginFlows?.includes("m.login.sso") ?? false
|
||||||
|
const supportsPassword = loginFlows?.includes("m.login.password")
|
||||||
|
const beeperDomain = homeserverURL.match(beeperServerRegex)?.[1]
|
||||||
return <main className="matrix-login">
|
return <main className="matrix-login">
|
||||||
<h1>gomuks web</h1>
|
<h1>gomuks web</h1>
|
||||||
<form onSubmit={login}>
|
<form onSubmit={login}>
|
||||||
|
@ -62,26 +116,41 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
|
||||||
id="mxlogin-username"
|
id="mxlogin-username"
|
||||||
placeholder="User ID"
|
placeholder="User ID"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={evt => setUsername(evt.target.value)}
|
onChange={onChangeUsername}
|
||||||
/>
|
/>
|
||||||
<input
|
{supportsPassword !== false && <input
|
||||||
type="password"
|
type="password"
|
||||||
id="mxlogin-password"
|
id="mxlogin-password"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={evt => setPassword(evt.target.value)}
|
onChange={onChangePassword}
|
||||||
/>
|
/>}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="mxlogin-homeserver-url"
|
id="mxlogin-homeserver-url"
|
||||||
placeholder="Homeserver URL"
|
placeholder="Homeserver URL"
|
||||||
value={homeserverURL}
|
value={homeserverURL}
|
||||||
onChange={evt => setHomeserverURL(evt.target.value)}
|
onChange={onChangeHomeserverURL}
|
||||||
/>
|
/>
|
||||||
<button className="mx-login-button" type="submit">Login</button>
|
<div className="buttons">
|
||||||
|
{supportsSSO && <button
|
||||||
|
className="mx-login-button"
|
||||||
|
type={supportsPassword ? "button" : "submit"}
|
||||||
|
onClick={supportsPassword ? loginSSO : undefined}
|
||||||
|
>Login with SSO</button>}
|
||||||
|
{supportsPassword !== false && <button
|
||||||
|
className="mx-login-button"
|
||||||
|
type="submit"
|
||||||
|
>Login{supportsSSO || beeperDomain ? " with password" : ""}</button>}
|
||||||
|
</div>
|
||||||
|
{error && <div className="error">
|
||||||
|
{error}
|
||||||
|
</div>}
|
||||||
</form>
|
</form>
|
||||||
{error && <div className="error">
|
|
||||||
{error}
|
{beeperDomain && <>
|
||||||
</div>}
|
<hr/>
|
||||||
|
<BeeperLogin domain={beeperDomain} client={client}/>
|
||||||
|
</>}
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,7 @@ export const VerificationScreen = ({ client, clientState }: LoginScreenProps) =>
|
||||||
<p>Successfully logged in as <code>{clientState.user_id}</code></p>
|
<p>Successfully logged in as <code>{clientState.user_id}</code></p>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
id="mxlogin-recoverykey"
|
id="mxlogin-recoverykey"
|
||||||
placeholder="Recovery key"
|
placeholder="Recovery key"
|
||||||
value={recoveryKey}
|
value={recoveryKey}
|
||||||
|
|
Loading…
Add table
Reference in a new issue