web/login: add support for SSO and Beeper email login

Fixes #493
This commit is contained in:
Tulir Asokan 2024-11-15 15:57:18 +02:00
parent 3df871b783
commit 50eabb7b56
14 changed files with 500 additions and 32 deletions

View file

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

View file

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

View file

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

View file

@ -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"`

View file

@ -32,7 +32,6 @@ func (h *HiClient) LoginPassword(ctx context.Context, homeserverURL, username, p
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
View 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
}

View file

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

View file

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

View file

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

View 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

View file

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

View file

@ -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">
</form> {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 className="error">
{error} {error}
</div>} </div>}
</form>
{beeperDomain && <>
<hr/>
<BeeperLogin domain={beeperDomain} client={client}/>
</>}
</main> </main>
} }

View file

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