diff --git a/administration/src/App.tsx b/administration/src/App.tsx index cc3a3f829..bc5a84a80 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/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 f08887409..a04808fdc 100644 --- a/administration/src/components/auth/Login.tsx +++ b/administration/src/components/auth/Login.tsx @@ -1,18 +1,10 @@ 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' - -const Center = styled('div')` - display: flex; - flex-grow: 1; - flex-direction: column; - justify-content: center; - align-items: center; -` +import StandaloneCenter from '../StandaloneCenter' interface State { email: string @@ -36,8 +28,8 @@ const Login = (props: { onSignIn: (payload: SignInPayload) => void }) => { }) return ( -
- + +

{config.name}

Verwaltung

Login

@@ -50,7 +42,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..9d1f55a36 --- /dev/null +++ b/administration/src/components/auth/ResetPasswordController.tsx @@ -0,0 +1,89 @@ +import { Button, Callout, Card, Classes, FormGroup, H2, H3, H4, InputGroup } from '@blueprintjs/core' +import { useContext, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext' +import StandaloneCenter from '../StandaloneCenter' +import { useAppToaster } from '../AppToaster' +import { useResetPasswordMutation } from '../../generated/graphql' +import PasswordInput from '../PasswordInput' +import validateNewPasswordInput from './validateNewPasswordInput' + +const ResetPasswordController = () => { + 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() + + 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/ChangePasswordForm.tsx b/administration/src/components/user-settings/ChangePasswordForm.tsx index cdbf4901e..31fcac4ca 100644 --- a/administration/src/components/user-settings/ChangePasswordForm.tsx +++ b/administration/src/components/user-settings/ChangePasswordForm.tsx @@ -18,8 +18,8 @@ const ChangePasswordForm = (props: {

Passwort ändern

- Ein gültiges Passwort besteht aus mindestens zwölf Zeichen, einem Klein- und einem Großbuchstaben sowie einem - Sonderzeichen. + Ein gültiges Passwort ist mindestens zwölf Zeichen lang, enthält mindestens einen Klein- und einen + Großbuchstaben sowie mindestens ein Sonderzeichen.

{ diff --git a/administration/src/components/user-settings/UserSettingsController.tsx b/administration/src/components/user-settings/UserSettingsController.tsx index b3f4bad0f..412423e3d 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 new file mode 100644 index 000000000..36ae76a9d --- /dev/null +++ b/administration/src/graphql/auth/resetPassword.graphql @@ -0,0 +1,8 @@ +mutation resetPassword($project: String!, $email: String!, $passwordResetKey: String!, $newPassword: String!) { + result: resetPassword( + project: $project + email: $email + passwordResetKey: $passwordResetKey + 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) +} diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/EntryPoint.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/EntryPoint.kt index c80f08163..08269edb0 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/EntryPoint.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/EntryPoint.kt @@ -49,10 +49,11 @@ class Entry : CliktCommand() { } class GraphQLExport : CliktCommand(name = "graphql-export") { + private val config by requireObject() private val path by argument(help = "Export GraphQL schema. Given ") override fun run() { - val schema = GraphQLHandler().graphQLSchema.print() + val schema = GraphQLHandler(config).graphQLSchema.print() val file = File(path) file.writeText(schema) } 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..0eedfad5b 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,9 @@ 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 java.security.SecureRandom +import java.time.LocalDateTime +import java.util.Base64 object AdministratorsRepository { @@ -30,7 +33,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 @@ -69,5 +73,16 @@ object AdministratorsRepository { } administrator.passwordHash = PasswordCrypto.hashPasswort(newPassword) + administrator.passwordResetKey = null + administrator.passwordResetKeyExpiry = null + } + + fun setNewPasswordResetKey(administrator: AdministratorEntity): String { + val byteArray = ByteArray(64) + SecureRandom.getInstanceStrong().nextBytes(byteArray) + val key = Base64.getUrlEncoder().encodeToString(byteArray) + administrator.passwordResetKey = key + administrator.passwordResetKeyExpiry = LocalDateTime.now().plusDays(1) + return key } } 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..796c1c5fa --- /dev/null +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/auth/webservice/schema/ResetPasswordMutationService.kt @@ -0,0 +1,73 @@ +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.common.webservice.GraphQLContext +import app.ehrenamtskarte.backend.mail.sendMail +import app.ehrenamtskarte.backend.projects.database.Projects +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import graphql.schema.DataFetchingEnvironment +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(dfe: DataFetchingEnvironment, project: String, email: String): Boolean { + val projectConfig = dfe.getContext().backendConfiguration.projects.first { it.id == project } + 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, + "Passwort Zurücksetzen", + generateResetMailMessage(key, projectConfig.administrationName, projectConfig.administrationBaseUrl) + ) + } + return true + } + + private fun generateResetMailMessage( + key: String, + administrationName: String, + administrationBaseUrl: String + ): String { + return """ + Guten Tag, + + Sie haben angefragt, Ihr Passwort für $administrationName zurückzusetzen. + Sie können Ihr Passwort unter dem folgenden Link zurücksetzen: + $administrationBaseUrl/reset-password/$key + + Dieser Link ist 24 Stunden gültig. + + Mit freundlichen Grüßen, + Ihr Digitalfabrik Team + """.trimIndent() + } + + @GraphQLDescription("Reset the administrator's password") + fun resetPassword(project: String, email: String, passwordResetKey: String, newPassword: String): Boolean { + transaction { + val user = 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.") + } + + AdministratorsRepository.changePassword(user, newPassword) + } + 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/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/GraphQLContext.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/GraphQLContext.kt index 082225f77..8042c811c 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/GraphQLContext.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/GraphQLContext.kt @@ -1,6 +1,7 @@ package app.ehrenamtskarte.backend.common.webservice import app.ehrenamtskarte.backend.auth.webservice.JwtPayload +import app.ehrenamtskarte.backend.config.BackendConfiguration import com.expediagroup.graphql.generator.execution.GraphQLContext import jakarta.servlet.http.Part import java.io.File @@ -8,7 +9,8 @@ import java.io.File data class GraphQLContext( val applicationData: File, val jwtPayload: JwtPayload?, - val files: List + val files: List, + val backendConfiguration: BackendConfiguration ) : GraphQLContext { fun enforceSignedIn(): JwtPayload { diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/GraphQLHandler.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/GraphQLHandler.kt index 1e3261e38..354694af5 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/GraphQLHandler.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/GraphQLHandler.kt @@ -3,6 +3,7 @@ package app.ehrenamtskarte.backend.common.webservice import app.ehrenamtskarte.backend.application.webservice.applicationGraphQlParams import app.ehrenamtskarte.backend.auth.webservice.JwtService import app.ehrenamtskarte.backend.auth.webservice.authGraphQlParams +import app.ehrenamtskarte.backend.config.BackendConfiguration import app.ehrenamtskarte.backend.regions.webservice.regionsGraphQlParams import app.ehrenamtskarte.backend.stores.webservice.storesGraphQlParams import app.ehrenamtskarte.backend.verification.webservice.verificationGraphQlParams @@ -27,6 +28,7 @@ import java.io.IOException import java.util.concurrent.ExecutionException class GraphQLHandler( + private val backendConfiguration: BackendConfiguration, private val graphQLParams: GraphQLParams = storesGraphQlParams stitch verificationGraphQlParams stitch applicationGraphQlParams stitch regionsGraphQlParams stitch authGraphQlParams @@ -109,11 +111,17 @@ class GraphQLHandler( private fun getGraphQLContext(context: Context, files: List, applicationData: File) = try { - GraphQLContext(applicationData, JwtService.verifyRequest(context), files) + GraphQLContext(applicationData, JwtService.verifyRequest(context), files, backendConfiguration) } catch (e: Exception) { when (e) { is JWTDecodeException, is AlgorithmMismatchException, is SignatureVerificationException, - is InvalidClaimException, is TokenExpiredException -> GraphQLContext(applicationData, null, files) + is InvalidClaimException, is TokenExpiredException -> GraphQLContext( + applicationData, + null, + files, + backendConfiguration + ) + else -> throw e } } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/WebService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/WebService.kt index cae35b317..1be8e5ed9 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/WebService.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/WebService.kt @@ -50,7 +50,7 @@ class WebService { println("Server is running at http://$host:$port") println("Goto http://$host:$port/graphiql/ for a graphical editor") - val graphQLHandler = GraphQLHandler() + val graphQLHandler = GraphQLHandler(config) val mapStyleHandler = MapStyleHandler(config) val applicationHandler = ApplicationHandler(applicationData) 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 33674b24d..ea32af76e 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/config/BackendConfiguration.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/config/BackendConfiguration.kt @@ -18,7 +18,14 @@ val possibleBackendConfigurationFiles = data class PostgresConfig(val url: String, val user: String, val password: String) data class MapConfig(val baseUrl: 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 importUrl: String, + val pipelineName: String, + val administrationBaseUrl: String, + val administrationName: 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/mail/SendMail.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/mail/SendMail.kt new file mode 100644 index 000000000..8a0232705 --- /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, subject: String, message: String) { + println("SENDING PSEUDO MAIL TO $to\nSUBJECT: $subject\n$message") +} diff --git a/backend/src/main/resources/config/config.yml b/backend/src/main/resources/config/config.yml index 6665b66f5..c5b3f8296 100644 --- a/backend/src/main/resources/config/config.yml +++ b/backend/src/main/resources/config/config.yml @@ -17,9 +17,15 @@ projects: - id: bayern.ehrenamtskarte.app importUrl: https://www.lbe.bayern.de/engagement-anerkennen/ehrenamtskarte/akzeptanzstellen/app-daten.xml pipelineName: EhrenamtskarteBayern + administrationBaseUrl: https://bayern.ehrenamtskarte.app + administrationName: Ehrenamtskarte-Bayern-Verwaltung - id: nuernberg.sozialpass.app importUrl: https://example.com pipelineName: SozialpassNuernberg + administrationBaseUrl: https://nuernberg.sozialpass.app + administrationName: Sozialpass-Nürnberg-Verwaltung - id: showcase.entitlementcard.app importUrl: https://example.com pipelineName: BerechtigungskarteShowcase + administrationBaseUrl: https://showcase.entitlementcard.app + administrationName: Showcase-Entitlementcard-Verwaltung diff --git a/specs/backend-api.graphql b/specs/backend-api.graphql index b7a07fc55..082bbe840 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!, 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" signIn(authData: AuthDataInput!, project: String!): SignInPayload! }