forked from Mirrors/gomuks
parent
3df871b783
commit
50eabb7b56
14 changed files with 500 additions and 32 deletions
|
@ -22,6 +22,7 @@ import (
|
|||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
@ -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, ".")
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
}
|
||||
|
|
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -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) {
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"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 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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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"`
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
|
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,
|
||||
EventRowID,
|
||||
EventType,
|
||||
LoginFlowsResponse,
|
||||
LoginRequest,
|
||||
Mentions,
|
||||
MessageEventContent,
|
||||
PaginationResponse,
|
||||
|
@ -202,10 +204,18 @@ export default abstract class RPCClient {
|
|||
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> {
|
||||
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> {
|
||||
return this.request("verify", { recovery_key })
|
||||
}
|
||||
|
|
|
@ -153,6 +153,12 @@ export interface ResolveAliasResponse {
|
|||
servers: string[]
|
||||
}
|
||||
|
||||
export interface LoginFlowsResponse {
|
||||
flows: {
|
||||
type: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface EventUnsigned {
|
||||
prev_content?: unknown
|
||||
prev_sender?: UserID
|
||||
|
@ -191,3 +197,24 @@ export interface RoomStateGUID {
|
|||
type: EventType
|
||||
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;
|
||||
color: inherit;
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--button-hover-color);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled), &:focus:not(:disabled) {
|
||||
background-color: var(--button-hover-color);
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
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 {
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.buttons {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
button, input {
|
||||
margin-top: .5rem;
|
||||
padding: 1rem;
|
||||
|
@ -61,5 +68,6 @@ main.matrix-login {
|
|||
border: 2px solid var(--error-color);
|
||||
border-radius: .25rem;
|
||||
padding: 1rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,8 +14,10 @@
|
|||
// 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, useEffect, useState } from "react"
|
||||
import type Client from "../../api/client.ts"
|
||||
import { ClientState } from "../../api/types"
|
||||
import type Client from "@/api/client.ts"
|
||||
import type { ClientState } from "@/api/types"
|
||||
import useEvent from "@/util/useEvent.ts"
|
||||
import BeeperLogin from "./BeeperLogin.tsx"
|
||||
import "./LoginScreen.css"
|
||||
|
||||
export interface LoginScreenProps {
|
||||
|
@ -23,26 +25,65 @@ export interface LoginScreenProps {
|
|||
clientState: ClientState
|
||||
}
|
||||
|
||||
const beeperServerRegex = /^https:\/\/matrix\.(beeper(?:-dev|-staging)?\.com)$/
|
||||
|
||||
export const LoginScreen = ({ client }: LoginScreenProps) => {
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [homeserverURL, setHomeserverURL] = useState("")
|
||||
const [loginFlows, setLoginFlows] = useState<string[] | null>(null)
|
||||
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()
|
||||
if (!loginFlows?.includes("m.login.password")) {
|
||||
loginSSO()
|
||||
return
|
||||
}
|
||||
client.rpc.login(homeserverURL, username, password).then(
|
||||
() => {},
|
||||
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(() => {
|
||||
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}`),
|
||||
)
|
||||
}, [client, username])
|
||||
}, [client, username, resolveLoginFlows])
|
||||
|
||||
useEffect(() => {
|
||||
if (!username.startsWith("@") || !username.includes(":") || !username.includes(".")) {
|
||||
|
@ -53,7 +94,20 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
|
|||
clearTimeout(timeout)
|
||||
}
|
||||
}, [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">
|
||||
<h1>gomuks web</h1>
|
||||
<form onSubmit={login}>
|
||||
|
@ -62,26 +116,41 @@ export const LoginScreen = ({ client }: LoginScreenProps) => {
|
|||
id="mxlogin-username"
|
||||
placeholder="User ID"
|
||||
value={username}
|
||||
onChange={evt => setUsername(evt.target.value)}
|
||||
onChange={onChangeUsername}
|
||||
/>
|
||||
<input
|
||||
{supportsPassword !== false && <input
|
||||
type="password"
|
||||
id="mxlogin-password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={evt => setPassword(evt.target.value)}
|
||||
/>
|
||||
onChange={onChangePassword}
|
||||
/>}
|
||||
<input
|
||||
type="text"
|
||||
id="mxlogin-homeserver-url"
|
||||
placeholder="Homeserver URL"
|
||||
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>
|
||||
{error && <div className="error">
|
||||
{error}
|
||||
</div>}
|
||||
|
||||
{beeperDomain && <>
|
||||
<hr/>
|
||||
<BeeperLogin domain={beeperDomain} client={client}/>
|
||||
</>}
|
||||
</main>
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ export const VerificationScreen = ({ client, clientState }: LoginScreenProps) =>
|
|||
<p>Successfully logged in as <code>{clientState.user_id}</code></p>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
id="mxlogin-recoverykey"
|
||||
placeholder="Recovery key"
|
||||
value={recoveryKey}
|
||||
|
|
Loading…
Add table
Reference in a new issue