From 60b2d75b0415025f7269d4c8c5f2982c804e46f3 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Wed, 12 Oct 2022 01:05:16 +0200 Subject: [PATCH 01/12] Add stubs for GraphQL endpoints --- .../auth/webservice/authGraphQLParams.kt | 6 +++- .../schema/ChangePasswordMutationService.kt | 36 +++++++++++++++++++ .../schema/ResetPasswordMutationService.kt | 16 +++++++++ .../schema/SignInMutationService.kt | 26 -------------- specs/backend-api.graphql | 4 +++ 5 files changed, 61 insertions(+), 27 deletions(-) create mode 100644 backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ChangePasswordMutationService.kt create mode 100644 backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/authGraphQLParams.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/authGraphQLParams.kt index 1bffe92d5..88591d02b 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/authGraphQLParams.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/authGraphQLParams.kt @@ -2,6 +2,8 @@ package app.ehrenamtskarte.backend.auth.webservice import app.ehrenamtskarte.backend.auth.webservice.dataloader.ADMINISTRATOR_LOADER_NAME import app.ehrenamtskarte.backend.auth.webservice.dataloader.administratorLoader +import app.ehrenamtskarte.backend.auth.webservice.schema.ChangePasswordMutationService +import app.ehrenamtskarte.backend.auth.webservice.schema.ResetPasswordMutationService import app.ehrenamtskarte.backend.auth.webservice.schema.SignInMutationService import app.ehrenamtskarte.backend.common.webservice.GraphQLParams import com.expediagroup.graphql.generator.SchemaGeneratorConfig @@ -18,7 +20,9 @@ val authGraphQlParams = GraphQLParams( config = SchemaGeneratorConfig(supportedPackages = listOf("app.ehrenamtskarte.backend.auth.webservice.schema")), dataLoaderRegistry = createDataLoaderRegistry(), mutations = listOf( - TopLevelObject(SignInMutationService()) + TopLevelObject(SignInMutationService()), + TopLevelObject(ChangePasswordMutationService()), + TopLevelObject(ResetPasswordMutationService()) ), queries = listOf() ) diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ChangePasswordMutationService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ChangePasswordMutationService.kt new file mode 100644 index 000000000..4451bed6b --- /dev/null +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ChangePasswordMutationService.kt @@ -0,0 +1,36 @@ +package app.ehrenamtskarte.backend.auth.webservice.schema + +import app.ehrenamtskarte.backend.auth.database.repos.AdministratorsRepository +import app.ehrenamtskarte.backend.common.webservice.GraphQLContext +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import com.expediagroup.graphql.generator.exceptions.GraphQLKotlinException +import graphql.schema.DataFetchingEnvironment +import org.jetbrains.exposed.sql.transactions.transaction + +@Suppress("unused") +class ChangePasswordMutationService { + + @GraphQLDescription("Changes an administrator's password") + fun changePassword( + project: String, + email: String, + currentPassword: String, + newPassword: String, + dfe: DataFetchingEnvironment + ): Boolean { + val context = dfe.getContext() + val jwtPayload = context.enforceSignedIn() + + if (email != jwtPayload.email) { + throw GraphQLKotlinException("You can only change your own password.") + } + transaction { + val administratorEntity = + AdministratorsRepository.findByAuthData(project, email, currentPassword) + ?: throw GraphQLKotlinException("Current password is wrong.") + + AdministratorsRepository.changePassword(administratorEntity, newPassword) + } + return true + } +} diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt new file mode 100644 index 000000000..673787a50 --- /dev/null +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt @@ -0,0 +1,16 @@ +package app.ehrenamtskarte.backend.auth.webservice.schema + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription + +@Suppress("unused") +class ResetPasswordMutationService { + @GraphQLDescription("Sends a mail that allows the administrator to reset their password.") + fun sendResetMail(project: String, email: String): Boolean { + return true + } + + @GraphQLDescription("Reset the administrator's password") + fun resetPassword(project: String, email: String, passwordResetHash: String, newPassword: String): Boolean { + return true + } +} diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/SignInMutationService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/SignInMutationService.kt index 4c95f8b8a..ef8bc745b 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/SignInMutationService.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/SignInMutationService.kt @@ -6,10 +6,8 @@ import app.ehrenamtskarte.backend.auth.webservice.schema.types.Administrator import app.ehrenamtskarte.backend.auth.webservice.schema.types.AuthData import app.ehrenamtskarte.backend.auth.webservice.schema.types.Role import app.ehrenamtskarte.backend.auth.webservice.schema.types.SignInPayload -import app.ehrenamtskarte.backend.common.webservice.GraphQLContext import com.expediagroup.graphql.generator.annotations.GraphQLDescription import com.expediagroup.graphql.generator.exceptions.GraphQLKotlinException -import graphql.schema.DataFetchingEnvironment import org.jetbrains.exposed.sql.transactions.transaction @Suppress("unused") @@ -29,28 +27,4 @@ class SignInMutationService { val token = JwtService.createToken(administrator) return SignInPayload(administrator, token) } - - @GraphQLDescription("Changes an administrator's password") - fun changePassword( - project: String, - email: String, - currentPassword: String, - newPassword: String, - dfe: DataFetchingEnvironment - ): Boolean { - val context = dfe.getContext() - val jwtPayload = context.enforceSignedIn() - - if (email != jwtPayload.email) { - throw GraphQLKotlinException("You can only change your own password.") - } - transaction { - val administratorEntity = - AdministratorsRepository.findByAuthData(project, email, currentPassword) - ?: throw GraphQLKotlinException("Current password is wrong.") - - AdministratorsRepository.changePassword(administratorEntity, newPassword) - } - return true - } } diff --git a/specs/backend-api.graphql b/specs/backend-api.graphql index b7a07fc55..0c1725c6e 100644 --- a/specs/backend-api.graphql +++ b/specs/backend-api.graphql @@ -88,6 +88,10 @@ type Mutation { changePassword(currentPassword: String!, email: String!, newPassword: String!, project: String!): Boolean! "Deletes the application with specified id" deleteApplication(applicationId: Int!): Boolean! + "Reset the administrator's password" + resetPassword(email: String!, newPassword: String!, passwordResetHash: String!, project: String!): Boolean! + "Sends a mail that allows the administrator to reset their password." + sendResetMail(email: String!, project: String!): Boolean! "Signs in an administrator" signIn(authData: AuthDataInput!, project: String!): SignInPayload! } From 51c21a0d6ae0bf43c2715642e1ec613809df61ba Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Wed, 12 Oct 2022 01:07:24 +0200 Subject: [PATCH 02/12] Add graphQL endpoints to administration frontend --- administration/src/graphql/auth/resetPassword.graphql | 8 ++++++++ administration/src/graphql/auth/sendResetMail.graphql | 3 +++ 2 files changed, 11 insertions(+) create mode 100644 administration/src/graphql/auth/resetPassword.graphql create mode 100644 administration/src/graphql/auth/sendResetMail.graphql diff --git a/administration/src/graphql/auth/resetPassword.graphql b/administration/src/graphql/auth/resetPassword.graphql new file mode 100644 index 000000000..633517ae5 --- /dev/null +++ b/administration/src/graphql/auth/resetPassword.graphql @@ -0,0 +1,8 @@ +mutation resetPassword($project: String!, $email: String!, $passwordResetHash: String!, $newPassword: String!) { + result: resetPassword( + project: $project + email: $email + passwordResetHash: $passwordResetHash + newPassword: $newPassword + ) +} diff --git a/administration/src/graphql/auth/sendResetMail.graphql b/administration/src/graphql/auth/sendResetMail.graphql new file mode 100644 index 000000000..bd646ee56 --- /dev/null +++ b/administration/src/graphql/auth/sendResetMail.graphql @@ -0,0 +1,3 @@ +mutation sendResetMail($project: String!, $email: String!) { + result: sendResetMail(project: $project, email: $email) +} From 3e3ec3fcc18bfba53b98537d5f2830f4cae5ba84 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Wed, 12 Oct 2022 01:09:30 +0200 Subject: [PATCH 03/12] Implement ForgotPasswordController --- administration/src/App.tsx | 54 +++++++----- administration/src/KeepAliveToken.tsx | 9 +- .../src/components/StandaloneCenter.tsx | 11 +++ .../auth/ForgotPasswordController.tsx | 85 +++++++++++++++++++ administration/src/components/auth/Login.tsx | 16 +--- .../src/components/auth/LoginForm.tsx | 6 +- .../auth/ResetPasswordController.tsx | 10 +++ 7 files changed, 154 insertions(+), 37 deletions(-) create mode 100644 administration/src/components/StandaloneCenter.tsx create mode 100644 administration/src/components/auth/ForgotPasswordController.tsx create mode 100644 administration/src/components/auth/ResetPasswordController.tsx diff --git a/administration/src/App.tsx b/administration/src/App.tsx index cc3a3f829..2473216a1 100644 --- a/administration/src/App.tsx +++ b/administration/src/App.tsx @@ -2,7 +2,7 @@ import React from 'react' import Navigation from './components/Navigation' import { ApolloClient, ApolloProvider, createHttpLink, InMemoryCache } from '@apollo/client' import { setContext } from '@apollo/client/link/context' -import { HashRouter, Route, Routes } from 'react-router-dom' +import { BrowserRouter, Route, Routes } from 'react-router-dom' import GenerationController from './components/generation/GenerationController' import styled from 'styled-components' import RegionProvider from './RegionProvider' @@ -15,6 +15,8 @@ import HomeController from './components/home/HomeController' import MetaTagsManager from './components/MetaTagsManager' import { AppToasterProvider } from './components/AppToaster' import UserSettingsController from './components/user-settings/UserSettingsController' +import ResetPasswordController from './components/auth/ResetPasswordController' +import ForgotPasswordController from './components/auth/ForgotPasswordController' if (!process.env.REACT_APP_API_BASE_URL) { throw new Error('REACT_APP_API_BASE_URL is not set!') @@ -53,25 +55,37 @@ const App = () => ( {({ data: authData, signIn, signOut }) => ( - {authData !== null && authData.expiry > new Date() ? ( - - - - -
- - } /> - } /> - } /> - } /> - -
-
-
-
- ) : ( - - )} + + + } /> + } /> + + ) : ( + + + +
+ + } + /> + } /> + } /> + } /> + +
+
+
+ ) + } + /> +
+
)}
diff --git a/administration/src/KeepAliveToken.tsx b/administration/src/KeepAliveToken.tsx index f70f9af93..a4da1e9f2 100644 --- a/administration/src/KeepAliveToken.tsx +++ b/administration/src/KeepAliveToken.tsx @@ -16,20 +16,21 @@ interface Props { const computeSecondsLeft = (authData: AuthData) => Math.round((authData.expiry.valueOf() - Date.now()) / 1000) const KeepAliveToken = (props: Props) => { + const { authData, onSignOut } = props const projectId = useContext(ProjectConfigContext).projectId const email = useContext(AuthContext).data!.administrator.email const [secondsLeft, setSecondsLeft] = useState(computeSecondsLeft(props.authData)) useEffect(() => { - setSecondsLeft(computeSecondsLeft(props.authData)) + setSecondsLeft(computeSecondsLeft(authData)) const interval = setInterval(() => { - const timeLeft = computeSecondsLeft(props.authData) + const timeLeft = computeSecondsLeft(authData) setSecondsLeft(Math.max(timeLeft, 0)) if (timeLeft <= 0) { - props.onSignOut() + onSignOut() } }, 1000) return () => clearInterval(interval) - }, [props.authData, props.onSignOut]) + }, [authData, onSignOut]) const appToaster = useAppToaster() const [password, setPassword] = useState('') diff --git a/administration/src/components/StandaloneCenter.tsx b/administration/src/components/StandaloneCenter.tsx new file mode 100644 index 000000000..57836b046 --- /dev/null +++ b/administration/src/components/StandaloneCenter.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components' + +const StandaloneCenter = styled('div')` + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: center; + align-items: center; +` + +export default StandaloneCenter diff --git a/administration/src/components/auth/ForgotPasswordController.tsx b/administration/src/components/auth/ForgotPasswordController.tsx new file mode 100644 index 000000000..fba5d62ca --- /dev/null +++ b/administration/src/components/auth/ForgotPasswordController.tsx @@ -0,0 +1,85 @@ +import { Button, Card, Classes, FormGroup, H2, H3, H4, InputGroup } from '@blueprintjs/core' +import { useContext, useState } from 'react' +import { Link } from 'react-router-dom' +import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext' +import StandaloneCenter from '../StandaloneCenter' +import { useAppToaster } from '../AppToaster' +import { useSendResetMailMutation } from '../../generated/graphql' + +const ForgotPasswordController = () => { + const config = useContext(ProjectConfigContext) + const appToaster = useAppToaster() + const [finished, setFinished] = useState(false) + const [email, setEmail] = useState('') + + const [sendResetMail, { loading }] = useSendResetMailMutation({ + onCompleted: () => setFinished(true), + onError: () => + appToaster?.show({ + intent: 'danger', + message: 'Etwas ist schief gelaufen. Prüfen Sie Ihre Eingaben.', + }), + }) + + const submit = () => + sendResetMail({ + variables: { + project: config.projectId, + email, + }, + }) + + return ( + + +

{config.name}

+

Verwaltung

+

Passwort vergessen

+ {finished ? ( + <> +

+ Wir haben eine E-Mail an {email} gesendet. Darin finden Sie einen Link, mit dem Sie Ihr Passwort + zurücksetzen können. +

+

Bitte prüfen Sie Ihren Spam-Ordner.

+

+ Zum Login +

+ + ) : ( + <> +

Falls Sie Ihr Passwort vergessen haben, können Sie es hier zurücksetzen.

+
{ + e.preventDefault() + submit() + }}> + + setEmail(e.target.value)} + type='email' + placeholder='erika.musterfrau@example.org' + /> + +
+ Zurück zum Login +
+
+ + )} +
+
+ ) +} + +export default ForgotPasswordController diff --git a/administration/src/components/auth/Login.tsx b/administration/src/components/auth/Login.tsx index 3062efa68..9ddb31116 100644 --- a/administration/src/components/auth/Login.tsx +++ b/administration/src/components/auth/Login.tsx @@ -1,20 +1,12 @@ import React, { useContext } from 'react' -import styled from 'styled-components' import LoginForm from './LoginForm' import { useAppToaster } from '../AppToaster' import { Card, H2, H3, H4 } from '@blueprintjs/core' import { SignInMutation, SignInPayload, useSignInMutation } from '../../generated/graphql' import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext' +import StandaloneCenter from "../StandaloneCenter"; -const Center = styled('div')` - display: flex; - flex-grow: 1; - flex-direction: column; - justify-content: center; - align-items: center; -` - interface State { email: string password: string @@ -37,8 +29,8 @@ const Login = (props: { onSignIn: (payload: SignInPayload) => void }) => { }) return ( -
- + +

{config.name}

Verwaltung

Login

@@ -51,7 +43,7 @@ const Login = (props: { onSignIn: (payload: SignInPayload) => void }) => { loading={mutationState.loading} />
-
+ ) } diff --git a/administration/src/components/auth/LoginForm.tsx b/administration/src/components/auth/LoginForm.tsx index 370cf680e..0f3de8f99 100644 --- a/administration/src/components/auth/LoginForm.tsx +++ b/administration/src/components/auth/LoginForm.tsx @@ -1,6 +1,7 @@ import React, { ChangeEvent } from 'react' import { Button, Classes, FormGroup, InputGroup } from '@blueprintjs/core' import PasswordInput from '../PasswordInput' +import { Link } from 'react-router-dom' interface Props { loading?: boolean @@ -37,7 +38,10 @@ const LoginForm = (props: Props) => { label='' /> -
+
+ Passwort vergessen
diff --git a/administration/src/components/auth/ResetPasswordController.tsx b/administration/src/components/auth/ResetPasswordController.tsx new file mode 100644 index 000000000..49da74829 --- /dev/null +++ b/administration/src/components/auth/ResetPasswordController.tsx @@ -0,0 +1,10 @@ +import { useParams } from 'react-router-dom' +import React from 'react' + +const ResetPasswordController = () => { + const { resetPasswordHash } = useParams() + + return
{resetPasswordHash}
+} + +export default ResetPasswordController From 0eec57e3c71c3f05681f29a469c42d5edb9505d0 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Wed, 12 Oct 2022 02:14:16 +0200 Subject: [PATCH 04/12] Rename Hash to Key and add stub for mail --- .../backend/auth/database/Schema.kt | 7 +++- .../repos/AdministratorsRepository.kt | 19 ++++++++++- .../schema/ResetPasswordMutationService.kt | 34 ++++++++++++++++++- .../ehrenamtskarte/backend/mail/SendMail.kt | 5 +++ specs/backend-api.graphql | 2 +- 5 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/SendMail.kt diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/Schema.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/Schema.kt index 7ad2430a4..d55a03a68 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/Schema.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/Schema.kt @@ -6,13 +6,16 @@ import org.jetbrains.exposed.dao.IntEntity import org.jetbrains.exposed.dao.IntEntityClass import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.javatime.datetime object Administrators : IntIdTable() { val email = varchar("email", 100).uniqueIndex() val projectId = reference("projectId", Projects) val regionId = optReference("regionId", Regions) val role = varchar("role", 32) - val passwordHash = binary("passwordHash") + val passwordHash = binary("passwordHash").nullable() + val passwordResetKey = varchar("passwordResetKey", 100).nullable() + val passwordResetKeyExpiry = datetime("passwordResetKeyExpiry").nullable() } class AdministratorEntity(id: EntityID) : IntEntity(id) { @@ -23,4 +26,6 @@ class AdministratorEntity(id: EntityID) : IntEntity(id) { var regionId by Administrators.regionId var role by Administrators.role var passwordHash by Administrators.passwordHash + var passwordResetKey by Administrators.passwordResetKey + var passwordResetKeyExpiry by Administrators.passwordResetKeyExpiry } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt index a26fd2311..71e6a5487 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt @@ -15,6 +15,10 @@ import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction +import java.security.SecureRandom +import java.time.LocalDateTime +import java.util.Base64 object AdministratorsRepository { @@ -30,7 +34,8 @@ object AdministratorsRepository { .firstOrNull() return resultRow?.let { val user = AdministratorEntity.wrapRow(it) - if (PasswordCrypto.verifyPassword(password, user.passwordHash)) { + val passwordHash = user.passwordHash + if (passwordHash !== null && PasswordCrypto.verifyPassword(password, passwordHash)) { user } else { null @@ -70,4 +75,16 @@ object AdministratorsRepository { administrator.passwordHash = PasswordCrypto.hashPasswort(newPassword) } + + fun setNewPasswordResetKey(administrator: AdministratorEntity): String { + val byteArray = ByteArray(64) + SecureRandom.getInstanceStrong().nextBytes(byteArray) + byteArray.toString() + val key = Base64.getUrlEncoder().encodeToString(byteArray) + transaction { + administrator.passwordResetKey = key + administrator.passwordResetKeyExpiry = LocalDateTime.now().plusDays(1) + } + return key + } } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt index 673787a50..de86bec3d 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt @@ -1,16 +1,48 @@ package app.ehrenamtskarte.backend.auth.webservice.schema +import app.ehrenamtskarte.backend.auth.database.AdministratorEntity +import app.ehrenamtskarte.backend.auth.database.Administrators +import app.ehrenamtskarte.backend.auth.database.repos.AdministratorsRepository +import app.ehrenamtskarte.backend.mail.sendMail +import app.ehrenamtskarte.backend.projects.database.Projects import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction @Suppress("unused") class ResetPasswordMutationService { @GraphQLDescription("Sends a mail that allows the administrator to reset their password.") fun sendResetMail(project: String, email: String): Boolean { + val user = transaction { + Administrators.innerJoin(Projects).slice(Administrators.columns) + .select((Projects.project eq project) and (Administrators.email eq email)) + .single().let { AdministratorEntity.wrapRow(it) } + } + + val key = AdministratorsRepository.setNewPasswordResetKey(user) + + sendMail(email, generateResetMail(key)) + return true } + private fun generateResetMail(key: String): String { + return """ + Guten Tag. + + Sie haben angefragt, Ihr Passwort zurückzusetzen. + Sie können Ihr Passwort unter dem folgenden Link zurücksetzen: + http://localhost:3000/reset-password/$key + + Mit freundlichen Grüßen, + Ihr Ehrenamtskarten Team + """.trimIndent() + } + @GraphQLDescription("Reset the administrator's password") - fun resetPassword(project: String, email: String, passwordResetHash: String, newPassword: String): Boolean { + fun resetPassword(project: String, email: String, passwordResetKey: String, newPassword: String): Boolean { return true } } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/SendMail.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/SendMail.kt new file mode 100644 index 000000000..f9e7c3d8f --- /dev/null +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/SendMail.kt @@ -0,0 +1,5 @@ +package app.ehrenamtskarte.backend.mail + +fun sendMail(to: String, message: String) { + println("SENDING PSEUDO MAIL TO $to\n$message") +} diff --git a/specs/backend-api.graphql b/specs/backend-api.graphql index 0c1725c6e..082bbe840 100644 --- a/specs/backend-api.graphql +++ b/specs/backend-api.graphql @@ -89,7 +89,7 @@ type Mutation { "Deletes the application with specified id" deleteApplication(applicationId: Int!): Boolean! "Reset the administrator's password" - resetPassword(email: String!, newPassword: String!, passwordResetHash: String!, project: String!): Boolean! + resetPassword(email: String!, newPassword: String!, passwordResetKey: String!, project: String!): Boolean! "Sends a mail that allows the administrator to reset their password." sendResetMail(email: String!, project: String!): Boolean! "Signs in an administrator" From a3f95ac096f2c9755744c9f8e40e4bd4e9afe7d0 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Wed, 12 Oct 2022 02:14:51 +0200 Subject: [PATCH 05/12] Implement ResetPasswordController --- administration/src/App.tsx | 2 +- .../auth/ResetPasswordController.tsx | 87 ++++++++++++++++++- .../auth/validateNewPasswordInput.ts | 37 ++++++++ .../user-settings/UserSettingsController.tsx | 36 +------- .../src/graphql/auth/resetPassword.graphql | 4 +- 5 files changed, 125 insertions(+), 41 deletions(-) create mode 100644 administration/src/components/auth/validateNewPasswordInput.ts diff --git a/administration/src/App.tsx b/administration/src/App.tsx index 2473216a1..bc5a84a80 100644 --- a/administration/src/App.tsx +++ b/administration/src/App.tsx @@ -58,7 +58,7 @@ const App = () => ( } /> - } /> + } /> { - const { resetPasswordHash } = useParams() + const config = useContext(ProjectConfigContext) + const appToaster = useAppToaster() + const [email, setEmail] = useState('') + const [newPassword, setNewPassword] = useState('') + const [repeatNewPassword, setRepeatNewPassword] = useState('') + const { passwordResetKey } = useParams() + const navigate = useNavigate() - return
{resetPasswordHash}
+ const [resetPassword, { loading }] = useResetPasswordMutation({ + onCompleted: () => { + appToaster?.show({ intent: 'success', message: 'Ihr Passwort wurde erfolgreich zurückgesetzt.' }) + navigate('/') + }, + onError: () => + appToaster?.show({ + intent: 'danger', + message: 'Etwas ist schief gelaufen. Prüfen Sie Ihre Eingaben.', + }), + }) + + const submit = () => + resetPassword({ + variables: { + project: config.projectId, + email, + newPassword, + passwordResetKey: passwordResetKey!, + }, + }) + + const warnMessage = validateNewPasswordInput(newPassword, repeatNewPassword) + const isDirty = newPassword !== '' || repeatNewPassword !== '' + + return ( + + +

{config.name}

+

Verwaltung

+

Passwort zurücksetzen.

+

+ Hier können Sie ein neues Passwort wählen. Ein gültiges Passwort ist mindestens zwölf Zeichen lang, enthält + mindestens einen Klein- und einen Großbuchstaben sowie mindestens ein Sonderzeichen. +

+
{ + e.preventDefault() + submit() + }}> + + setEmail(e.target.value)} + type='email' + placeholder='erika.musterfrau@example.org' + /> + + + + {warnMessage === null || !isDirty ? null : {warnMessage}} +
+ Zurück zum Login +
+ +
+
+ ) } export default ResetPasswordController diff --git a/administration/src/components/auth/validateNewPasswordInput.ts b/administration/src/components/auth/validateNewPasswordInput.ts new file mode 100644 index 000000000..757cd248d --- /dev/null +++ b/administration/src/components/auth/validateNewPasswordInput.ts @@ -0,0 +1,37 @@ +const isUpperCase = (value: string) => { + return value === value.toUpperCase() && value !== value.toLowerCase() +} + +const isLowerCase = (value: string) => { + return value === value.toLowerCase() && value !== value.toUpperCase() +} + +const isNumeric = (value: string) => { + return !isNaN(parseInt(value)) +} + +const isSpecialChar = (value: string) => { + return !isUpperCase(value) && !isLowerCase(value) && !isNumeric(value) +} + +const getNumChars = (value: string, predicate: (char: string) => boolean): number => { + return value.split('').filter(predicate).length +} +const minPasswordLength = 12 + +const validateNewPasswordInput = (newPassword: string, repeatNewPassword: string) => { + if (newPassword.length < minPasswordLength) { + return `Ihr Passwort muss mindestens ${minPasswordLength} Zeichen lang sein (aktuell ${newPassword.length}).` + } else if (getNumChars(newPassword, isLowerCase) < 1) { + return 'Ihr Passwort muss mindestens einen Kleinbuchstaben enthalten.' + } else if (getNumChars(newPassword, isUpperCase) < 1) { + return 'Ihr Passwort muss mindestens einen Großbuchstaben enthalten.' + } else if (getNumChars(newPassword, isSpecialChar) < 1) { + return 'Ihr Passwort muss mindestens ein Sonderzeichen enthalten.' + } else if (newPassword !== repeatNewPassword) { + return 'Die Passwörter stimmen nicht überein.' + } + return null +} + +export default validateNewPasswordInput diff --git a/administration/src/components/user-settings/UserSettingsController.tsx b/administration/src/components/user-settings/UserSettingsController.tsx index b3f4bad0f..cf9298eee 100644 --- a/administration/src/components/user-settings/UserSettingsController.tsx +++ b/administration/src/components/user-settings/UserSettingsController.tsx @@ -4,29 +4,9 @@ import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext import { AuthContext } from '../../AuthProvider' import { useAppToaster } from '../AppToaster' import ChangePasswordForm from './ChangePasswordForm' - -const isUpperCase = (value: string) => { - return value === value.toUpperCase() && value !== value.toLowerCase() -} - -const isLowerCase = (value: string) => { - return value === value.toLowerCase() && value !== value.toUpperCase() -} - -const isNumeric = (value: string) => { - return !isNaN(parseInt(value)) -} - -const isSpecialChar = (value: string) => { - return !isUpperCase(value) && !isLowerCase(value) && !isNumeric(value) -} - -const getNumChars = (value: string, predicate: (char: string) => boolean): number => { - return value.split('').filter(predicate).length -} +import validatePasswordInput from "../auth/validateNewPasswordInput"; const UserSettingsController = () => { - const minPasswordLength = 12 const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [repeatNewPassword, setRepeatNewPassword] = useState('') @@ -67,19 +47,7 @@ const UserSettingsController = () => { const isDirty = newPassword !== '' || repeatNewPassword !== '' - let warnMessage: string | null = null - - if (newPassword.length < minPasswordLength) { - warnMessage = `Ihr Passwort muss mindestens ${minPasswordLength} Zeichen lang sein (aktuell ${newPassword.length}).` - } else if (getNumChars(newPassword, isLowerCase) < 1) { - warnMessage = 'Ihr Passwort muss mindestens einen Kleinbuchstaben enthalten.' - } else if (getNumChars(newPassword, isUpperCase) < 1) { - warnMessage = warnMessage = 'Ihr Passwort muss mindestens einen Großbuchstaben enthalten.' - } else if (getNumChars(newPassword, isSpecialChar) < 1) { - warnMessage = 'Ihr Passwort muss mindestens ein Sonderzeichen enthalten.' - } else if (newPassword !== repeatNewPassword) { - warnMessage = 'Die Passwörter stimmen nicht überein.' - } + let warnMessage: string | null = validatePasswordInput(newPassword, repeatNewPassword) const valid = warnMessage === null diff --git a/administration/src/graphql/auth/resetPassword.graphql b/administration/src/graphql/auth/resetPassword.graphql index 633517ae5..36ae76a9d 100644 --- a/administration/src/graphql/auth/resetPassword.graphql +++ b/administration/src/graphql/auth/resetPassword.graphql @@ -1,8 +1,8 @@ -mutation resetPassword($project: String!, $email: String!, $passwordResetHash: String!, $newPassword: String!) { +mutation resetPassword($project: String!, $email: String!, $passwordResetKey: String!, $newPassword: String!) { result: resetPassword( project: $project email: $email - passwordResetHash: $passwordResetHash + passwordResetKey: $passwordResetKey newPassword: $newPassword ) } From 0c16e87853fdccaf1375e6f8374db676f82697d5 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Wed, 12 Oct 2022 02:39:20 +0200 Subject: [PATCH 06/12] Add host to projects and use it in the Reset Password Mail --- .../repos/AdministratorsRepository.kt | 7 +--- .../schema/ResetPasswordMutationService.kt | 38 +++++++++++++------ .../backend/config/BackendConfiguration.kt | 2 +- .../backend/projects/database/Schema.kt | 2 + .../backend/projects/database/Setup.kt | 1 + backend/src/main/resources/config/config.yml | 3 ++ 6 files changed, 36 insertions(+), 17 deletions(-) diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt index 71e6a5487..8052169a5 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt @@ -15,7 +15,6 @@ import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.select -import org.jetbrains.exposed.sql.transactions.transaction import java.security.SecureRandom import java.time.LocalDateTime import java.util.Base64 @@ -81,10 +80,8 @@ object AdministratorsRepository { SecureRandom.getInstanceStrong().nextBytes(byteArray) byteArray.toString() val key = Base64.getUrlEncoder().encodeToString(byteArray) - transaction { - administrator.passwordResetKey = key - administrator.passwordResetKeyExpiry = LocalDateTime.now().plusDays(1) - } + administrator.passwordResetKey = key + administrator.passwordResetKeyExpiry = LocalDateTime.now().plusDays(1) return key } } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt index de86bec3d..1f116f1b7 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt @@ -4,45 +4,61 @@ import app.ehrenamtskarte.backend.auth.database.AdministratorEntity import app.ehrenamtskarte.backend.auth.database.Administrators import app.ehrenamtskarte.backend.auth.database.repos.AdministratorsRepository import app.ehrenamtskarte.backend.mail.sendMail +import app.ehrenamtskarte.backend.projects.database.ProjectEntity import app.ehrenamtskarte.backend.projects.database.Projects import com.expediagroup.graphql.generator.annotations.GraphQLDescription import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction +import java.time.LocalDateTime @Suppress("unused") class ResetPasswordMutationService { @GraphQLDescription("Sends a mail that allows the administrator to reset their password.") fun sendResetMail(project: String, email: String): Boolean { - val user = transaction { - Administrators.innerJoin(Projects).slice(Administrators.columns) + transaction { + val user = Administrators.innerJoin(Projects).slice(Administrators.columns) .select((Projects.project eq project) and (Administrators.email eq email)) .single().let { AdministratorEntity.wrapRow(it) } - } - - val key = AdministratorsRepository.setNewPasswordResetKey(user) - - sendMail(email, generateResetMail(key)) + val projectEntity = ProjectEntity.findById(user.projectId)!! + val key = AdministratorsRepository.setNewPasswordResetKey(user) + sendMail(email, generateResetMail(key, projectEntity.host)) + } return true } - private fun generateResetMail(key: String): String { + private fun generateResetMail(key: String, host: String): String { return """ Guten Tag. - Sie haben angefragt, Ihr Passwort zurückzusetzen. + Sie haben angefragt, Ihr Passwort für $host zurückzusetzen. Sie können Ihr Passwort unter dem folgenden Link zurücksetzen: - http://localhost:3000/reset-password/$key + https://$host/reset-password/$key Mit freundlichen Grüßen, - Ihr Ehrenamtskarten Team + Ihr Digitalfabrik Team """.trimIndent() } @GraphQLDescription("Reset the administrator's password") fun resetPassword(project: String, email: String, passwordResetKey: String, newPassword: String): Boolean { + val user = transaction { + Administrators.innerJoin(Projects).slice(Administrators.columns) + .select((Projects.project eq project) and (Administrators.email eq email)) + .single().let { AdministratorEntity.wrapRow(it) } + } + + if (user.passwordResetKeyExpiry!!.isBefore(LocalDateTime.now())) { + throw Exception("Password reset key has expired.") + } else if (user.passwordResetKey != passwordResetKey) { + throw Exception("Password reset keys do not match.") + } + + transaction { + AdministratorsRepository.changePassword(user, newPassword) + } return true } } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/config/BackendConfiguration.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/config/BackendConfiguration.kt index 9eea5586d..557023d69 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/config/BackendConfiguration.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/config/BackendConfiguration.kt @@ -17,7 +17,7 @@ val possibleBackendConfigurationFiles = data class PostgresConfig(val url: String, val user: String, val password: String) data class GeocodingConfig(val enabled: Boolean, val host: String) -data class ProjectConfig(val id: String, val importUrl: String, val pipelineName: String) +data class ProjectConfig(val id: String, val host: String, val importUrl: String, val pipelineName: String) data class ServerConfig(val dataDirectory: String, val host: String, val port: String) data class BackendConfiguration( diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/projects/database/Schema.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/projects/database/Schema.kt index ba3c98d07..f2ce75bb5 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/projects/database/Schema.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/projects/database/Schema.kt @@ -7,10 +7,12 @@ import org.jetbrains.exposed.dao.id.IntIdTable object Projects : IntIdTable() { val project = varchar("project", 50).uniqueIndex() + val host = varchar("host", 100) } class ProjectEntity(id: EntityID) : IntEntity(id) { companion object : IntEntityClass(Projects) var project by Projects.project + var host by Projects.host } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/projects/database/Setup.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/projects/database/Setup.kt index 97d7af74c..6a3a66575 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/projects/database/Setup.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/projects/database/Setup.kt @@ -17,6 +17,7 @@ fun setupDatabase(config: BackendConfiguration) { if (dbProjects.none { it.project == projectConfig.id }) { ProjectEntity.new { project = projectConfig.id + host = projectConfig.host } } } diff --git a/backend/src/main/resources/config/config.yml b/backend/src/main/resources/config/config.yml index 788dbf8d3..4a472fd68 100644 --- a/backend/src/main/resources/config/config.yml +++ b/backend/src/main/resources/config/config.yml @@ -12,11 +12,14 @@ server: dataDirectory: ./data projects: - id: bayern.ehrenamtskarte.app + host: bayern.ehrenamtskarte.app importUrl: https://www.lbe.bayern.de/engagement-anerkennen/ehrenamtskarte/akzeptanzstellen/app-daten.xml pipelineName: EhrenamtskarteBayern - id: nuernberg.sozialpass.app + host: nuernberg.sozialpass.app importUrl: https://example.com pipelineName: SozialpassNuernberg - id: showcase.entitlementcard.app + host: showcase.entitlementcard.app importUrl: https://example.com pipelineName: BerechtigungskarteShowcase From 3f109800ecce20ead50e5ea9b5881334acf9886d Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Wed, 12 Oct 2022 02:42:16 +0200 Subject: [PATCH 07/12] Add subject --- .../auth/webservice/schema/ResetPasswordMutationService.kt | 6 +++--- .../main/kotlin/app/ehrenamtskarte/backend/mail/SendMail.kt | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt index 1f116f1b7..51821048c 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt @@ -24,14 +24,14 @@ class ResetPasswordMutationService { val projectEntity = ProjectEntity.findById(user.projectId)!! val key = AdministratorsRepository.setNewPasswordResetKey(user) - sendMail(email, generateResetMail(key, projectEntity.host)) + sendMail(email, "Passwort Zurücksetzen", generateResetMailMessage(key, projectEntity.host)) } return true } - private fun generateResetMail(key: String, host: String): String { + private fun generateResetMailMessage(key: String, host: String): String { return """ - Guten Tag. + Guten Tag, Sie haben angefragt, Ihr Passwort für $host zurückzusetzen. Sie können Ihr Passwort unter dem folgenden Link zurücksetzen: diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/SendMail.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/SendMail.kt index f9e7c3d8f..8a0232705 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/SendMail.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/SendMail.kt @@ -1,5 +1,5 @@ package app.ehrenamtskarte.backend.mail -fun sendMail(to: String, message: String) { - println("SENDING PSEUDO MAIL TO $to\n$message") +fun sendMail(to: String, subject: String, message: String) { + println("SENDING PSEUDO MAIL TO $to\nSUBJECT: $subject\n$message") } From 43649af82d5416b6d5dd98e30c2a0ea5e654ab66 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Wed, 12 Oct 2022 02:50:29 +0200 Subject: [PATCH 08/12] Set passwordResetKey to null whenever the password changs. --- .../backend/auth/database/repos/AdministratorsRepository.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt index 8052169a5..163637185 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/database/repos/AdministratorsRepository.kt @@ -73,6 +73,8 @@ object AdministratorsRepository { } administrator.passwordHash = PasswordCrypto.hashPasswort(newPassword) + administrator.passwordResetKey = null + administrator.passwordResetKeyExpiry = null } fun setNewPasswordResetKey(administrator: AdministratorEntity): String { From 861e9d4e74b84b3d19c28bcb59ca98e766ce8185 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Wed, 12 Oct 2022 02:55:41 +0200 Subject: [PATCH 09/12] Run format --- administration/src/components/auth/ResetPasswordController.tsx | 2 +- .../src/components/user-settings/UserSettingsController.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/administration/src/components/auth/ResetPasswordController.tsx b/administration/src/components/auth/ResetPasswordController.tsx index 7b2e2c205..9d1f55a36 100644 --- a/administration/src/components/auth/ResetPasswordController.tsx +++ b/administration/src/components/auth/ResetPasswordController.tsx @@ -70,7 +70,7 @@ const ResetPasswordController = () => { {warnMessage === null || !isDirty ? null : {warnMessage}}
+ style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '10px' }}> Zurück zum Login