Add support for SSO login.

This commit is contained in:
jamesjulich 2021-10-10 16:36:44 -05:00 committed by Krishan
parent 7b67e4a6e6
commit 8a1b749667
4 changed files with 200 additions and 5 deletions

View file

@ -0,0 +1,98 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import './SSOButtons.scss';
import { createTemporaryClient, getLoginFlows, startSsoLogin } from '../../../client/action/auth';
function SSOButtons({ homeserver }) {
const [identityProviders, setIdentityProviders] = useState([]);
useEffect(() => {
// If the homeserver passed in is not a fully-qualified domain name, do not update.
if (!homeserver.match('(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(?<!-).)+[a-zA-Z]{2,63}$)')) {
return;
}
// TODO Check that there is a Matrix server at homename before making requests.
// This will prevent the CORS errors that happen when a user changes their homeserver.
createTemporaryClient(homeserver).then((client) => {
const providers = [];
getLoginFlows(client).then((flows) => {
if (flows.flows !== undefined) {
const ssoFlows = flows.flows.filter((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas');
ssoFlows.forEach((flow) => {
if (flow.identity_providers !== undefined) {
const type = flow.type.substring(8);
flow.identity_providers.forEach((idp) => {
const imageSrc = client.mxcUrlToHttp(idp.icon);
providers.push({
homeserver, id: idp.id, name: idp.name, type, imageSrc,
});
});
}
});
}
setIdentityProviders(providers);
}).catch(() => {});
}).catch(() => {
setIdentityProviders([]);
});
}, [homeserver]);
// TODO Render all non-icon providers at the end so that they are never inbetween icons.
return (
<div className="sso-buttons">
{identityProviders.map((idp) => {
if (idp.imageSrc == null || idp.imageSrc === undefined || idp.imageSrc === '') {
return (
<button
key={idp.id}
type="button"
onClick={() => { startSsoLogin(homeserver, idp.type, idp.id); }}
className="sso-buttons__fallback-text text-b1"
>
{`Log in with ${idp.name}`}
</button>
);
}
return (
<SSOButton
key={idp.id}
homeserver={idp.homeserver}
id={idp.id}
name={idp.name}
type={idp.type}
imageSrc={idp.imageSrc}
/>
);
})}
</div>
);
}
function SSOButton({
homeserver, id, name, type, imageSrc,
}) {
function handleClick() {
startSsoLogin(homeserver, type, id);
}
return (
<button type="button" className="sso-button" onClick={handleClick}>
<img className="sso-button__img" src={imageSrc} alt={name} />
</button>
);
}
SSOButtons.propTypes = {
homeserver: PropTypes.string.isRequired,
};
SSOButton.propTypes = {
homeserver: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
imageSrc: PropTypes.string.isRequired,
};
export default SSOButtons;

View file

@ -0,0 +1,31 @@
.sso-buttons {
margin-top: var(--sp-extra-loose);
display: flex;
justify-content: center;
flex-wrap: wrap;
&__fallback-text {
margin: var(--sp-tight) 0px;
flex-basis: 100%;
text-align: center;
color: var(--bg-primary);
cursor: pointer;
}
}
.sso-button {
margin-bottom: var(--sp-normal);
display: flex;
justify-content: center;
flex-basis: 20%;
cursor: pointer;
&__img {
height: var(--av-normal);
}
}

View file

@ -3,8 +3,9 @@ import PropTypes from 'prop-types';
import './Auth.scss'; import './Auth.scss';
import ReCAPTCHA from 'react-google-recaptcha'; import ReCAPTCHA from 'react-google-recaptcha';
import { Link } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import * as auth from '../../../client/action/auth'; import * as auth from '../../../client/action/auth';
import cons from '../../../client/state/cons';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button'; import Button from '../../atoms/button/Button';
@ -15,6 +16,7 @@ import ScrollView from '../../atoms/scroll/ScrollView';
import EyeIC from '../../../../public/res/ic/outlined/eye.svg'; import EyeIC from '../../../../public/res/ic/outlined/eye.svg';
import CinnySvg from '../../../../public/res/svg/cinny.svg'; import CinnySvg from '../../../../public/res/svg/cinny.svg';
import SSOButtons from '../../molecules/sso-buttons/SSOButtons';
// This regex validates historical usernames, which don't satisfy today's username requirements. // This regex validates historical usernames, which don't satisfy today's username requirements.
// See https://matrix.org/docs/spec/appendices#id13 for more info. // See https://matrix.org/docs/spec/appendices#id13 for more info.
@ -75,12 +77,35 @@ function normalizeUsername(rawUsername) {
function Auth({ type }) { function Auth({ type }) {
const [process, changeProcess] = useState(null); const [process, changeProcess] = useState(null);
const [homeserver, changeHomeserver] = useState('matrix.org');
const usernameRef = useRef(null); const usernameRef = useRef(null);
const homeserverRef = useRef(null); const homeserverRef = useRef(null);
const passwordRef = useRef(null); const passwordRef = useRef(null);
const confirmPasswordRef = useRef(null); const confirmPasswordRef = useRef(null);
const emailRef = useRef(null); const emailRef = useRef(null);
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
if (searchParams.has('loginToken')) {
const loginToken = searchParams.get('loginToken');
if (loginToken !== undefined) {
if (localStorage.getItem(cons.secretKey.BASE_URL) !== undefined) {
const baseUrl = localStorage.getItem(cons.secretKey.BASE_URL);
auth.loginWithToken(baseUrl, loginToken)
.then(() => {
window.location.replace('/');
})
.catch((error) => {
changeProcess(null);
if (!error.contains('CORS request rejected')) {
renderErrorMessage(error);
}
});
}
}
}
function register(recaptchaValue, terms, verified) { function register(recaptchaValue, terms, verified) {
auth.register( auth.register(
usernameRef.current.value, usernameRef.current.value,
@ -205,6 +230,7 @@ function Auth({ type }) {
/> />
<Input <Input
forwardRef={homeserverRef} forwardRef={homeserverRef}
onChange={(e) => changeHomeserver(e.target.value)}
id="auth_homeserver" id="auth_homeserver"
placeholder="Homeserver" placeholder="Homeserver"
value="matrix.org" value="matrix.org"
@ -281,6 +307,9 @@ function Auth({ type }) {
{type === 'login' ? 'Login' : 'Register' } {type === 'login' ? 'Login' : 'Register' }
</Button> </Button>
</div> </div>
{type === 'login' && (
<SSOButtons homeserver={homeserver} />
)}
</form> </form>
</div> </div>

View file

@ -2,7 +2,8 @@ import * as sdk from 'matrix-js-sdk';
import cons from '../state/cons'; import cons from '../state/cons';
import { getBaseUrl } from '../../util/matrixUtil'; import { getBaseUrl } from '../../util/matrixUtil';
async function login(username, homeserver, password) { // This method inspired by a similar one in matrix-react-sdk
async function createTemporaryClient(homeserver) {
let baseUrl = null; let baseUrl = null;
try { try {
baseUrl = await getBaseUrl(homeserver); baseUrl = await getBaseUrl(homeserver);
@ -12,7 +13,25 @@ async function login(username, homeserver, password) {
if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found'); if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found');
const client = sdk.createClient({ baseUrl }); return sdk.createClient({ baseUrl });
}
async function getLoginFlows(client) {
const flows = await client.loginFlows();
if (flows !== undefined) {
return flows;
}
return null;
}
async function startSsoLogin(homeserver, type, idpId) {
const client = await createTemporaryClient(homeserver);
localStorage.setItem(cons.secretKey.BASE_URL, client.baseUrl);
window.location.href = client.getSsoLoginUrl(window.location.href, type, idpId);
}
async function login(username, homeserver, password) {
const client = await createTemporaryClient(homeserver);
const response = await client.login('m.login.password', { const response = await client.login('m.login.password', {
identifier: { identifier: {
@ -26,7 +45,21 @@ async function login(username, homeserver, password) {
localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token); localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token);
localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id); localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id);
localStorage.setItem(cons.secretKey.USER_ID, response.user_id); localStorage.setItem(cons.secretKey.USER_ID, response.user_id);
localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || baseUrl); localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || client.baseUrl);
}
async function loginWithToken(baseUrl, token) {
const client = sdk.createClient(baseUrl);
const response = await client.login('m.login.token', {
token,
initial_device_display_name: cons.DEVICE_DISPLAY_NAME,
});
localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token);
localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id);
localStorage.setItem(cons.secretKey.USER_ID, response.user_id);
localStorage.setItem(cons.secretKey.BASE_URL, response?.well_known?.['m.homeserver']?.base_url || client.baseUrl);
} }
async function getAdditionalInfo(baseUrl, content) { async function getAdditionalInfo(baseUrl, content) {
@ -45,6 +78,7 @@ async function getAdditionalInfo(baseUrl, content) {
throw new Error(e); throw new Error(e);
} }
} }
async function verifyEmail(baseUrl, content) { async function verifyEmail(baseUrl, content) {
try { try {
const res = await fetch(`${baseUrl}/_matrix/client/r0/register/email/requestToken`, { const res = await fetch(`${baseUrl}/_matrix/client/r0/register/email/requestToken`, {
@ -149,4 +183,7 @@ async function register(username, homeserver, password, email, recaptchaValue, t
return {}; return {};
} }
export { login, register }; export {
createTemporaryClient, getLoginFlows, login,
loginWithToken, register, startSsoLogin,
};