Add register via username and refactor email-login to username-login (#313)

* Added config option to enable/disable the email signup

* Added register API call

* Added register button and error handling

* Show register button only if enabled in config

* Renamed login handler, added dir-attribute, removed obsolete css class

* Added separate registration page, changed email-login to internal-login

As an username is sufficient for registration, this commit changes the email-login into an username-based login. This login method is now called "internal" in the code.
This commit also introduces a new registration page instead of using the same form as for login.

* Added information texts below form fields

* Added error differentiation

* Added CHANGELOG entry

* Replace "magic string" with Enum representation

* Removed password-field to DOM rewrite

With the value attribute set, the password would be written to the DOM while typing. That's bad practise as attackers could read that password (e.g. with dirty CSS-hacks).

* Fixed backendConfig to config renaming

* Fixed links on register page being external links

* Refactored error handling to use string-enum that corresponds with i18n keys

* Fix chrome warnings regarding autocomplete and duplicated id

* Refactor login action buttons to use callbacks and handle promises directly

* Remove unnecessary async function

* Added promise chaining
This commit is contained in:
Erik Michelson 2020-08-04 23:13:12 +02:00 committed by GitHub
parent 4054e130bb
commit dbce0181a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 347 additions and 119 deletions

View file

@ -38,6 +38,7 @@
### Changed
- The sign-in/sign-up functions are now on a separate page
- The email sign-in/registration does not require an email address anymore but uses a username
- The history shows both the entries saved in LocalStorage and the entries saved on the server together
- The gist and pdf embeddings now use a one-click aproach similar to vimeo and youtube
- Use [Twemoji](https://twemoji.twitter.com/) as icon font

View file

@ -10,9 +10,10 @@
"google": true,
"saml": true,
"oauth2": true,
"email": true,
"internal": true,
"openid": true
},
"allowRegister": true,
"branding": {
"name": "ACME Corp",
"logo": "http://localhost:3001/acme.png"

View file

@ -3,5 +3,5 @@
"photo": "https://1.gravatar.com/avatar/767fc9c115a1b989744c755db47feb60?s=200&r=pg&d=mp",
"name": "Test",
"status": "ok",
"provider": "email"
"provider": "internal"
}

View file

@ -181,7 +181,9 @@
"signInVia": "لِج عبر {{service}}",
"signIn": "لِج",
"signOut": "خروج",
"register": "انشئ حسابا",
"register": {
"title": "انشئ حسابا"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Entrar a través de {{service}}",
"signIn": "Entrar",
"signOut": "Sortir",
"register": "Registrar-se",
"register": {
"title": "Registrar-se"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Přihlásit se přes {{service}}",
"signIn": "Přihlásit",
"signOut": "Odhlásit",
"register": "Registrovat",
"register": {
"title": "Registrovat"
},
"auth": {
"error": {}
}

View file

@ -181,15 +181,16 @@
"signInVia": "Einloggen über {{service}}",
"signIn": "Einloggen",
"signOut": "Ausloggen",
"register": "Registrieren",
"register": {
"title": "Registrieren"
},
"auth": {
"email": "E-Mail",
"password": "Passwort",
"username": "Benutzername",
"error": {
"openIdLogin": "OpenID nicht korrekt",
"emailLogin": "E-Mail oder Passwort nicht korrekt",
"ldapLogin": "Benutzername oder Passwort nicht korrekt"
"usernamePassword": "Benutzername oder Passwort nicht korrekt"
}
}
}

View file

@ -284,15 +284,24 @@
"signInVia": "Sign in via {{service}}",
"signIn": "Sign In",
"signOut": "Sign Out",
"register": "Register",
"auth": {
"email": "Email",
"password": "Password",
"username": "Username",
"error": {
"openIdLogin": "Invalid OpenID provided",
"emailLogin": "Invalid email or password",
"ldapLogin": "Invalid username or password"
"usernamePassword": "Invalid username or password"
}
},
"register": {
"title": "Register",
"passwordAgain": "Password (again)",
"usernameInfo": "The username is your unique identifier for login.",
"passwordInfo": "Choose a unique and secure password. It must contain at least 8 characters.",
"infoTermsPrivacy": "With the registration of my user account I agree to the following terms:",
"error": {
"usernameExisting": "There is already an account with this username.",
"other": "There was an error while registering your account. Just try it again."
}
}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Ingresar via {{service}}",
"signIn": "Ingresar",
"signOut": "Salir",
"register": "Registrar",
"register": {
"title": "Registrar"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Se connecter depuis {{service}}",
"signIn": "Se connecter",
"signOut": "Se déconnecter",
"register": "S'enregistrer",
"register": {
"title": "S'enregistrer"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Masuk menggunakan {{service}}",
"signIn": "Masuk",
"signOut": "Keluar",
"register": "Daftar",
"register": {
"title": "Daftar"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Entra con {{service}}",
"signIn": "Entra",
"signOut": "Disconettiti",
"register": "Registrati",
"register": {
"title": "Registrati"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "{{service}}でサインイン",
"signIn": "サインイン",
"signOut": "サインアウト",
"register": "登録",
"register": {
"title": "登録"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Log in via {{service}}",
"signIn": "Inloggen",
"signOut": "Uitloggen",
"register": "Registreren",
"register": {
"title": "Registreren"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Zaloguj się poprzez {{service}}",
"signIn": "Zaloguj się",
"signOut": "Wyloguj się",
"register": "Zarejestruj",
"register": {
"title": "Zarejestruj"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Entrar via {{service}}",
"signIn": "Entrar",
"signOut": "Sair",
"register": "Register",
"register": {
"title": "Register"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Войти с помощью {{service}}",
"signIn": "Войти",
"signOut": "Выйти",
"register": "Регистрация",
"register": {
"title": "Регистрация"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Prihlásiť sa cez {{service}}",
"signIn": "Prihlásiť sa",
"signOut": "Odhlásiť sa",
"register": "Registrovať",
"register": {
"title": "Registrovať"
},
"auth": {
"error": {}
}

View file

@ -180,7 +180,9 @@
"signInVia": "Пријави се уз {{service}}",
"signIn": "Пријави се",
"signOut": "Одјави се",
"register": "Региструј се",
"register": {
"title": "Региструј се"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "Logga in via {{service}}",
"signIn": "Logga in",
"signOut": "Logga ut",
"register": "Registrera",
"register": {
"title": "Registrera"
},
"auth": {
"error": {}
}

View file

@ -180,7 +180,9 @@
"signInVia": "Đăng nhấp với {{service}}",
"signIn": "Đăng nhập",
"signOut": "Đăng xuất",
"register": "Đăng ký",
"register": {
"title": "Đăng ký"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "通过 {{service}} 登录",
"signIn": "登录",
"signOut": "登出",
"register": "注册",
"register": {
"title": "注册"
},
"auth": {
"error": {}
}

View file

@ -181,7 +181,9 @@
"signInVia": "透過 {{service}} 登入",
"signIn": "登入",
"signOut": "登出",
"register": "註冊",
"register": {
"title": "註冊"
},
"auth": {
"error": {}
}

View file

@ -1,12 +1,13 @@
import { RegisterError } from '../components/landing/pages/register/register'
import { expectResponseCode, getApiUrl } from '../utils/apiUtils'
import { defaultFetchConfig } from './default'
export const doEmailLogin = async (email: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + '/auth/email', {
export const doInternalLogin = async (username: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + '/auth/internal', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
email: email,
username: username,
password: password
})
})
@ -14,6 +15,23 @@ export const doEmailLogin = async (email: string, password: string): Promise<voi
expectResponseCode(response)
}
export const doInternalRegister = async (username: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + '/auth/register', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
username: username,
password: password
})
})
if (response.status === 409) {
throw new Error(RegisterError.USERNAME_EXISTING)
}
expectResponseCode(response)
}
export const doLdapLogin = async (username: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + '/auth/ldap', {
...defaultFetchConfig,

View file

@ -1,5 +1,6 @@
export interface Config {
allowAnonymous: boolean,
allowRegister: boolean,
authProviders: AuthProvidersState,
branding: BrandingConfig,
banner: BannerConfig,
@ -36,7 +37,7 @@ export interface AuthProvidersState {
google: boolean,
saml: boolean,
oauth2: boolean,
email: boolean,
internal: boolean,
openid: boolean,
}

View file

@ -1,64 +0,0 @@
import React, { FormEvent, useState } from 'react'
import { Alert, Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { doEmailLogin } from '../../../../../api/auth'
import { getAndSetUser } from '../../../../../utils/apiUtils'
export const ViaEMail: React.FC = () => {
const { t } = useTranslation()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(false)
const doAsyncLogin = async () => {
await doEmailLogin(email, password)
await getAndSetUser()
}
const onFormSubmit = (event: FormEvent) => {
doAsyncLogin().catch(() => setError(true))
event.preventDefault()
}
return (
<Card className="bg-dark mb-4">
<Card.Body>
<Card.Title>
<Trans i18nKey="login.signInVia" values={{ service: 'E-Mail' }}/>
</Card.Title>
<Form onSubmit={onFormSubmit}>
<Form.Group controlId="email">
<Form.Control
isInvalid={error}
type="email"
size="sm"
placeholder={t('login.auth.email')}
onChange={(event) => setEmail(event.currentTarget.value)} className="bg-dark text-white"
/>
</Form.Group>
<Form.Group controlId="password">
<Form.Control
isInvalid={error}
type="password"
size="sm"
placeholder={t('login.auth.password')}
onChange={(event) => setPassword(event.currentTarget.value)}
className="bg-dark text-white"/>
</Form.Group>
<Alert className="small" show={error} variant="danger">
<Trans i18nKey="login.auth.error.emailLogin"/>
</Alert>
<Button
type="submit"
variant="primary">
<Trans i18nKey="login.signIn"/>
</Button>
</Form>
</Card.Body>
</Card>
)
}

View file

@ -0,0 +1,81 @@
import React, { FormEvent, useCallback, useState } from 'react'
import { Alert, Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { doInternalLogin } from '../../../../../api/auth'
import { ApplicationState } from '../../../../../redux'
import { getAndSetUser } from '../../../../../utils/apiUtils'
import { ShowIf } from '../../../../common/show-if/show-if'
export const ViaInternal: React.FC = () => {
const { t } = useTranslation()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(false)
const allowRegister = useSelector((state: ApplicationState) => state.config.allowRegister)
const onLoginSubmit = useCallback((event: FormEvent) => {
doInternalLogin(username, password)
.then(() => getAndSetUser())
.catch(() => setError(true))
event.preventDefault()
}, [username, password])
return (
<Card className="bg-dark mb-4">
<Card.Body>
<Card.Title>
<Trans i18nKey="login.signInVia" values={{ service: t('login.auth.username') }}/>
</Card.Title>
<Form onSubmit={onLoginSubmit}>
<Form.Group controlId="internal-username">
<Form.Control
isInvalid={error}
type="text"
size="sm"
placeholder={t('login.auth.username')}
onChange={(event) => setUsername(event.currentTarget.value)} className="bg-dark text-white"
autoComplete='username'
/>
</Form.Group>
<Form.Group controlId="internal-password">
<Form.Control
isInvalid={error}
type="password"
size="sm"
placeholder={t('login.auth.password')}
onChange={(event) => setPassword(event.currentTarget.value)}
className="bg-dark text-white"
autoComplete='current-password'
/>
</Form.Group>
<Alert className="small" show={error} variant="danger">
<Trans i18nKey="login.auth.error.usernamePassword"/>
</Alert>
<div className='flex flex-row' dir='auto'>
<Button
type="submit"
variant="primary"
className='mx-2'>
<Trans i18nKey="login.signIn"/>
</Button>
<ShowIf condition={allowRegister}>
<Link to={'/register'}>
<Button
type='button'
variant='secondary'
className='mx-2'>
<Trans i18nKey='login.register.title'/>
</Button>
</Link>
</ShowIf>
</div>
</Form>
</Card.Body>
</Card>
)
}

View file

@ -1,4 +1,4 @@
import React, { FormEvent, useState } from 'react'
import React, { FormEvent, useCallback, useState } from 'react'
import { Alert, Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
@ -17,19 +17,12 @@ export const ViaLdap: React.FC = () => {
const name = ldapCustomName ? `${ldapCustomName} (LDAP)` : 'LDAP'
const doAsyncLogin = async () => {
try {
await doLdapLogin(username, password)
await getAndSetUser()
} catch {
setError(true)
}
}
const onFormSubmit = (event: FormEvent) => {
doAsyncLogin().catch(() => setError(true))
const onLoginSubmit = useCallback((event: FormEvent) => {
doLdapLogin(username, password)
.then(() => getAndSetUser())
.catch(() => setError(true))
event.preventDefault()
}
}, [username, password])
return (
<Card className="bg-dark mb-4">
@ -37,29 +30,32 @@ export const ViaLdap: React.FC = () => {
<Card.Title>
<Trans i18nKey="login.signInVia" values={{ service: name }}/>
</Card.Title>
<Form onSubmit={onFormSubmit}>
<Form.Group controlId="username">
<Form onSubmit={onLoginSubmit}>
<Form.Group controlId="ldap-username">
<Form.Control
isInvalid={error}
type="text"
size="sm"
placeholder={t('login.auth.username')}
onChange={(event) => setUsername(event.currentTarget.value)} className="bg-dark text-white"
autoComplete='username'
/>
</Form.Group>
<Form.Group controlId="password">
<Form.Group controlId="ldap-password">
<Form.Control
isInvalid={error}
type="password"
size="sm"
placeholder={t('login.auth.password')}
onChange={(event) => setPassword(event.currentTarget.value)}
className="bg-dark text-white"/>
className="bg-dark text-white"
autoComplete='current-password'
/>
</Form.Group>
<Alert className="small" show={error} variant="danger">
<Trans i18nKey="login.auth.error.ldapLogin"/>
<Trans i18nKey="login.auth.error.usernamePassword"/>
</Alert>
<Button

View file

@ -5,7 +5,7 @@ import { useSelector } from 'react-redux'
import { Redirect } from 'react-router'
import { ApplicationState } from '../../../../redux'
import { ShowIf } from '../../../common/show-if/show-if'
import { ViaEMail } from './auth/via-email'
import { ViaInternal } from './auth/via-internal'
import { ViaLdap } from './auth/via-ldap'
import { OneClickType, ViaOneClick } from './auth/via-one-click'
import { ViaOpenId } from './auth/via-openid'
@ -40,9 +40,9 @@ export const Login: React.FC = () => {
return (
<div className="my-3">
<Row className="h-100 flex justify-content-center">
<ShowIf condition={authProviders.email || authProviders.ldap || authProviders.openid}>
<ShowIf condition={authProviders.internal || authProviders.ldap || authProviders.openid}>
<Col xs={12} sm={10} lg={4}>
<ShowIf condition={authProviders.email}><ViaEMail/></ShowIf>
<ShowIf condition={authProviders.internal}><ViaInternal/></ShowIf>
<ShowIf condition={authProviders.ldap}><ViaLdap/></ShowIf>
<ShowIf condition={authProviders.openid}><ViaOpenId/></ShowIf>
</Col>

View file

@ -23,7 +23,7 @@ export const Profile: React.FC = () => {
<Row className="h-100 flex justify-content-center">
<Col lg={6}>
<ProfileDisplayName/>
<ShowIf condition={user.provider === LoginProvider.EMAIL}>
<ShowIf condition={user.provider === LoginProvider.INTERNAL}>
<ProfileChangePassword/>
</ShowIf>
<ProfileAccountManagement/>

View file

@ -0,0 +1,141 @@
import React, { FormEvent, useCallback, useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { Redirect } from 'react-router'
import { doInternalRegister } from '../../../../api/auth'
import { ApplicationState } from '../../../../redux'
import { getAndSetUser } from '../../../../utils/apiUtils'
import { Row, Col, Card, Form, Button, Alert } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { TranslatedExternalLink } from '../../../common/links/translated-external-link'
import { ShowIf } from '../../../common/show-if/show-if'
export enum RegisterError {
NONE = 'none',
USERNAME_EXISTING = 'usernameExisting',
OTHER = 'other'
}
export const Register: React.FC = () => {
const { t } = useTranslation()
const config = useSelector((state: ApplicationState) => state.config)
const user = useSelector((state: ApplicationState) => state.user)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [passwordAgain, setPasswordAgain] = useState('')
const [error, setError] = useState(RegisterError.NONE)
const [ready, setReady] = useState(false)
const doRegisterSubmit = useCallback((event: FormEvent) => {
doInternalRegister(username, password)
.then(() => getAndSetUser())
.catch((err: Error) => {
console.error(err)
setError(err.message === RegisterError.USERNAME_EXISTING ? err.message : RegisterError.OTHER)
})
event.preventDefault()
}, [username, password])
useEffect(() => {
setReady(username !== '' && password !== '' && password.length >= 8 && password === passwordAgain)
}, [username, password, passwordAgain])
if (!config.allowRegister) {
return (
<Redirect to={'/login'}/>
)
}
if (user) {
return (
<Redirect to={'/intro'}/>
)
}
return (
<div className='my-3'>
<h1 className='mb-4'><Trans i18nKey='login.register.title'/></h1>
<Row className='h-100 d-flex justify-content-center'>
<Col lg={6}>
<Card className='bg-dark mb-4 text-start'>
<Card.Body>
<Form onSubmit={doRegisterSubmit}>
<Form.Group controlId='username'>
<Form.Label><Trans i18nKey='login.auth.username'/></Form.Label>
<Form.Control
type='text'
size='sm'
value={username}
isValid={username !== ''}
onChange={(event) => setUsername(event.target.value)}
placeholder={t('login.auth.username')}
className='bg-dark text-white'
autoComplete='username'
autoFocus={true}
required
/>
<Form.Text><Trans i18nKey='login.register.usernameInfo'/></Form.Text>
</Form.Group>
<Form.Group controlId='password'>
<Form.Label><Trans i18nKey='login.auth.password'/></Form.Label>
<Form.Control
type='password'
size='sm'
isValid={password !== '' && password.length >= 8}
onChange={(event) => setPassword(event.target.value)}
placeholder={t('login.auth.password')}
className='bg-dark text-white'
minLength={8}
autoComplete='new-password'
required
/>
<Form.Text><Trans i18nKey='login.register.passwordInfo'/></Form.Text>
</Form.Group>
<Form.Group controlId='re-password'>
<Form.Label><Trans i18nKey='login.register.passwordAgain'/></Form.Label>
<Form.Control
type='password'
size='sm'
isInvalid={passwordAgain !== '' && password !== passwordAgain}
isValid={passwordAgain !== '' && password === passwordAgain}
onChange={(event) => setPasswordAgain(event.target.value)}
placeholder={t('login.register.passwordAgain')}
className='bg-dark text-white'
autoComplete='new-password'
required
/>
</Form.Group>
<ShowIf condition={!!config.specialLinks?.termsOfUse || !!config.specialLinks?.privacy}>
<Trans i18nKey='login.register.infoTermsPrivacy'/>
<ul>
<ShowIf condition={!!config.specialLinks?.termsOfUse}>
<li>
<TranslatedExternalLink i18nKey='landing.footer.termsOfUse' href={config.specialLinks.termsOfUse}/>
</li>
</ShowIf>
<ShowIf condition={!!config.specialLinks?.privacy}>
<li>
<TranslatedExternalLink i18nKey='landing.footer.privacy' href={config.specialLinks.privacy}/>
</li>
</ShowIf>
</ul>
</ShowIf>
<Button
variant='primary'
type='submit'
block={true}
disabled={!ready}>
<Trans i18nKey='login.register.title'/>
</Button>
</Form>
<br/>
<Alert show={error !== RegisterError.NONE} variant='danger'>
<Trans i18nKey={`login.register.error.${error}`}/>
</Alert>
</Card.Body>
</Card>
</Col>
</Row>
</div>
)
}

View file

@ -10,10 +10,11 @@ import { History } from './components/landing/pages/history/history'
import { Intro } from './components/landing/pages/intro/intro'
import { Login } from './components/landing/pages/login/login'
import { Profile } from './components/landing/pages/profile/profile'
import { Register } from './components/landing/pages/register/register'
import { Redirector } from './components/redirector/redirector'
import './global-style/index.scss'
import * as serviceWorker from './service-worker'
import { store } from './utils/store'
import { Redirector } from './components/redirector/redirector'
ReactDOM.render(
<Provider store={store}>
@ -35,6 +36,11 @@ ReactDOM.render(
<Login/>
</LandingLayout>
</Route>
<Route path="/register">
<LandingLayout>
<Register/>
</LandingLayout>
</Route>
<Route path="/profile">
<LandingLayout>
<Profile/>

View file

@ -4,6 +4,7 @@ import { ConfigActions, ConfigActionType, SetConfigAction } from './types'
export const initialState: Config = {
allowAnonymous: true,
allowRegister: true,
authProviders: {
facebook: false,
github: false,
@ -14,7 +15,7 @@ export const initialState: Config = {
google: false,
saml: false,
oauth2: false,
email: false,
internal: false,
openid: false
},
branding: {

View file

@ -31,7 +31,7 @@ export enum LoginProvider {
GOOGLE = 'google',
SAML = 'saml',
OAUTH2 = 'oauth2',
EMAIL = 'email',
INTERNAL = 'internal',
LDAP = 'ldap',
OPENID = 'openid'
}