Setup password recovery through email

This commit is contained in:
IRBorisov 2024-02-25 20:55:30 +03:00
parent 76b5456162
commit 657f4fe11c
21 changed files with 472 additions and 29 deletions

View File

@ -85,6 +85,7 @@
"NPRO",
"NUMR",
"Opencorpora",
"passwordreset",
"perfectivity",
"PNCT",
"ponomarev",

View File

@ -9,11 +9,14 @@ For more specific TODOs see comments in code
- блок организации библиотеки моделей
- проектный модуль?
- обратная связь - система баг репортов
- система обработки ошибок backend
[Tech]
- multilevel modals / rework modal system
- rewrite custom password-reset
[deployment]
- logs collection
- status dashboard for servers
[security]
- password-reset leaks info of email being used
- do not use schemaID for access (prevent enumerating IDs access)

View File

@ -21,6 +21,12 @@ secrets:
file: ./secrets/django_key.txt
db_password:
file: ./secrets/db_password.txt
email_host:
file: ./secrets/email_host.txt
email_user:
file: ./secrets/email_user.txt
email_password:
file: ./secrets/email_password.txt
services:
frontend:
@ -28,7 +34,7 @@ services:
restart: always
depends_on:
- backend
build:
build:
context: ./rsconcept/frontend
args:
BUILD_TYPE: production
@ -36,7 +42,6 @@ services:
- 3000
command: serve -s /home/node -l 3000
backend:
container_name: portal-backend
restart: always
@ -45,20 +50,24 @@ services:
secrets:
- db_password
- django_key
- email_host
- email_user
- email_password
build:
context: ./rsconcept/backend
env_file: ./rsconcept/backend/.env.prod
environment:
SECRET_KEY: /run/secrets/django_key
DB_PASSWORD: /run/secrets/db_password
EMAIL_HOST: /run/secrets/email_host
EMAIL_HOST_USER: /run/secrets/email_user
EMAIL_HOST_PASSWORD: /run/secrets/email_password
expose:
- 8000
volumes:
- django_static_volume:/home/app/web/static
- django_media_volume:/home/app/web/media
command:
gunicorn -w 3 project.wsgi --bind 0.0.0.0:8000
command: gunicorn -w 3 project.wsgi --bind 0.0.0.0:8000
postgresql-db:
container_name: portal-db
@ -72,7 +81,6 @@ services:
volumes:
- postgres_volume:/var/lib/postgresql/data
certbot:
container_name: portal-certbot
restart: no
@ -81,7 +89,6 @@ services:
- cerbot_www_volume:/var/www/certbot/:rw
- cerbot_conf_volume:/etc/letsencrypt/:rw
nginx:
container_name: portal-router
restart: always
@ -94,7 +101,7 @@ services:
- 443:443
depends_on:
- backend
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"'''
volumes:
- django_static_volume:/var/www/static
- django_media_volume:/var/www/media

View File

@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static
MEDIA_ROOT=/home/app/web/media
# Email
EMAIL_HOST=localhost
EMAIL_PORT=1025
EMAIL_HOST_USER=False
EMAIL_HOST_PASSWORD=False
EMAIL_SSL=False
EMAIL_TLS=False
# Database settings
DB_ENGINE=django.db.backends.postgresql_psycopg2
DB_NAME=portal-db

View File

@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static
MEDIA_ROOT=/home/app/web/media
# Email
# EMAIL_HOST=
# EMAIL_HOST_USER=
# EMAIL_HOST_PASSWORD=
EMAIL_PORT=443
EMAIL_SSL=True
EMAIL_TLS=False
# Database settings
DB_ENGINE=django.db.backends.postgresql_psycopg2
DB_NAME=portal-db

View File

@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static
MEDIA_ROOT=/home/app/web/media
# Email
EMAIL_HOST=localhost
EMAIL_PORT=1025
EMAIL_HOST_USER=False
EMAIL_HOST_PASSWORD=False
EMAIL_SSL=False
EMAIL_TLS=False
# Database settings
DB_ENGINE=django.db.backends.postgresql_psycopg2
DB_NAME=portal-db

View File

@ -6,3 +6,6 @@ class UsersConfig(AppConfig):
''' Application config. '''
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.users'
def ready(self):
import apps.users.signals # pylint: disable=unused-import,import-outside-toplevel

View File

@ -105,11 +105,6 @@ class ChangePasswordSerializer(serializers.Serializer):
new_password = serializers.CharField(required=True)
class ResetPasswordSerializer(serializers.Serializer):
''' Serializer: Change password. '''
email = serializers.EmailField(required=True)
class SignupSerializer(serializers.ModelSerializer):
''' Serializer: Create user profile. '''
id = serializers.IntegerField(read_only=True)

View File

@ -0,0 +1,37 @@
''' Signals: user events. '''
from django.core.mail import send_mail
from django.dispatch import receiver
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django_rest_passwordreset.signals import reset_password_token_created # type: ignore
_EMAIL_NOREPLY = 'noreply.portal@acconcept.ru'
_EMAIL_SUBJECT = 'Восстановление пароля КонцептПортал'
_EMAIL_TEMPLATE = 'password_reset_email.html'
_RECOVERY_URL = 'https://portal.acconcept.ru/password-change'
@receiver(reset_password_token_created)
def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs):
'''
Handles password reset tokens
When a token is created, an e-mail needs to be sent to the user
'''
context = {
'current_user': reset_password_token.user,
'username': reset_password_token.user.username,
'first_name': reset_password_token.user.first_name,
'email': reset_password_token.user.email,
'reset_password_url': f'{_RECOVERY_URL}?token={reset_password_token.key}'
}
email_html_message = render_to_string(_EMAIL_TEMPLATE, context)
email_plaintext_message = strip_tags(email_html_message)
send_mail(
subject=_EMAIL_SUBJECT,
message=email_plaintext_message,
from_email=_EMAIL_NOREPLY,
recipient_list=[context['email']],
html_message=email_html_message
)

View File

@ -97,8 +97,8 @@ class TestUserUserProfileAPIView(APITestCase):
self.assertEqual(response.data['last_name'], 'lastName')
def test_edit_profile(self):
newmail = 'newmail@gmail.com'
data = {'email': newmail}
new_mail = 'newmail@gmail.com'
data = {'email': new_mail}
response = self.client.patch(
'/users/api/profile',
data=data, format='json'
@ -112,7 +112,7 @@ class TestUserUserProfileAPIView(APITestCase):
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['username'], self.username)
self.assertEqual(response.data['email'], newmail)
self.assertEqual(response.data['email'], new_mail)
def test_change_password(self):
newpassword = 'pw2'
@ -150,6 +150,25 @@ class TestUserUserProfileAPIView(APITestCase):
self.assertTrue(self.client.login(username=self.user.username, password=newpassword))
self.assertFalse(self.client.login(username=self.user.username, password=self.password))
def test_password_reset_request(self):
data = {
'email': 'invalid@gmail.com'
}
response = self.client.post(
'/users/api/password-reset',
data=data, format='json'
)
self.assertEqual(response.status_code, 400)
data = {
'email': self.email
}
response = self.client.post(
'/users/api/password-reset',
data=data, format='json'
)
self.assertEqual(response.status_code, 200)
class TestSignupAPIView(APITestCase):
def setUp(self):

View File

@ -1,6 +1,9 @@
''' Routing: User profile and Authorization. '''
from django.urls import path
from django.urls import path, include
from . import views
from django_rest_passwordreset.views import reset_password_confirm, \
reset_password_request_token, \
reset_password_validate_token
urlpatterns = [
@ -11,4 +14,8 @@ urlpatterns = [
path('api/login', views.LoginAPIView.as_view()),
path('api/logout', views.LogoutAPIView.as_view()),
path('api/change-password', views.UpdatePassword.as_view()),
# django_rest_passwordreset APIs see https://pypi.org/project/django-rest-passwordreset/
path('api/password-reset', reset_password_request_token, name='reset-password-request'),
path('api/password-reset/validate', reset_password_validate_token, name='reset-password-validate'),
path('api/password-reset/confirm', reset_password_confirm, name='reset-password-confirm')
]

View File

@ -29,6 +29,18 @@ DEBUG = os.environ.get('DEBUG', True) in [True, 'True', '1']
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(';')
INTERNAL_IPS = ['127.0.0.1'] if DEBUG else []
# MAIL SETUP
EMAIL_HOST = os.environ.get('EMAIL_HOST', 'localhost')
EMAIL_PORT = os.environ.get('EMAIL_PORT', '1025')
EMAIL_USE_SSL = os.environ.get('EMAIL_SSL', False)
EMAIL_USE_TLS = os.environ.get('EMAIL_TLS', False)
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
EMAIL_BACKEND = \
'django.core.mail.backends.smtp.EmailBackend' \
if EMAIL_HOST != '' else \
'django.core.mail.backends.console.EmailBackend'
INSTALLED_APPS = [
'django.contrib.admin',
@ -40,6 +52,7 @@ INSTALLED_APPS = [
'django_filters',
'rest_framework',
'django_rest_passwordreset',
'corsheaders',
'apps.users',

View File

@ -9,6 +9,7 @@ pymorphy2==0.9.1
pymorphy2-dicts-ru==2.4.417127.4579844
pymorphy2-dicts-uk==2.4.1.1.1460299261
razdel==0.5.0
django-rest-passwordreset==1.4.0
psycopg2-binary==2.9.9
gunicorn==21.2.0

View File

@ -14,6 +14,7 @@ mypy
pylint
coverage
djangorestframework-stubs[compatible-mypy]
django-rest-passwordreset
psycopg2-binary
gunicorn

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<title>Восстановление пароля КонцептПортал</title>
</head>
<body>
<p>Здравствуйте, {{first_name}}</p>
<p>
Мы получили запрос на восстановление пароля для пользователя {{username}},
привязанного к данному email. Если Вы отправили указанный запрос, то
перейдите по ссылке для задания нового пароля:
</p>
<p><a href="{{ reset_password_url }}">Сбросить пароль</a></p>
<p>
Если Вы не отправляли запрос, то можете игнорировать данное сообщение.
</p>
<p>С уважением,</p>
<p>Команда КонцептПортал</p>
</body>
</html>

View File

@ -6,6 +6,7 @@ import LibraryPage from '@/pages/LibraryPage';
import LoginPage from '@/pages/LoginPage';
import ManualsPage from '@/pages/ManualsPage';
import NotFoundPage from '@/pages/NotFoundPage';
import PasswordChangePage from '@/pages/PasswordChangePage';
import RegisterPage from '@/pages/RegisterPage';
import RestorePasswordPage from '@/pages/RestorePasswordPage';
import RSFormPage from '@/pages/RSFormPage';
@ -35,6 +36,10 @@ export const Router = createBrowserRouter([
path: 'restore-password',
element: <RestorePasswordPage />
},
{
path: 'password-change',
element: <PasswordChangePage />
},
{
path: 'profile',
element: <UserProfilePage />

View File

@ -4,13 +4,23 @@ import { createContext, useCallback, useContext, useLayoutEffect, useState } fro
import { type ErrorData } from '@/components/InfoError';
import useLocalStorage from '@/hooks/useLocalStorage';
import { IUserLoginData } from '@/models/library';
import { IPasswordTokenData, IRequestPasswordData, IResetPasswordData, IUserLoginData } from '@/models/library';
import { ICurrentUser } from '@/models/library';
import { IUserSignupData } from '@/models/library';
import { IUserProfile } from '@/models/library';
import { IUserInfo } from '@/models/library';
import { IUserUpdatePassword } from '@/models/library';
import { type DataCallback, getAuth, patchPassword, postLogin, postLogout, postSignup } from '@/utils/backendAPI';
import {
type DataCallback,
getAuth,
patchPassword,
postLogin,
postLogout,
postRequestPasswordReset,
postResetPassword,
postSignup,
postValidatePasswordToken
} from '@/utils/backendAPI';
import { useUsers } from './UsersContext';
@ -20,6 +30,9 @@ interface IAuthContext {
logout: (callback?: DataCallback) => void;
signup: (data: IUserSignupData, callback?: DataCallback<IUserProfile>) => void;
updatePassword: (data: IUserUpdatePassword, callback?: () => void) => void;
requestPasswordReset: (data: IRequestPasswordData, callback?: () => void) => void;
validateToken: (data: IPasswordTokenData, callback?: () => void) => void;
resetPassword: (data: IResetPasswordData, callback?: () => void) => void;
loading: boolean;
error: ErrorData;
setError: (error: ErrorData) => void;
@ -118,12 +131,77 @@ export const AuthState = ({ children }: AuthStateProps) => {
[reload]
);
const requestPasswordReset = useCallback(
(data: IRequestPasswordData, callback?: () => void) => {
setError(undefined);
postRequestPasswordReset({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: () =>
reload(() => {
if (callback) callback();
})
});
},
[reload]
);
const validateToken = useCallback(
(data: IPasswordTokenData, callback?: () => void) => {
setError(undefined);
postValidatePasswordToken({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: () =>
reload(() => {
if (callback) callback();
})
});
},
[reload]
);
const resetPassword = useCallback(
(data: IResetPasswordData, callback?: () => void) => {
setError(undefined);
postResetPassword({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: () =>
reload(() => {
if (callback) callback();
})
});
},
[reload]
);
useLayoutEffect(() => {
reload();
}, [reload]);
return (
<AuthContext.Provider value={{ user, login, logout, signup, loading, error, setError, updatePassword }}>
<AuthContext.Provider
value={{
user,
login,
logout,
signup,
loading,
error,
setError,
updatePassword,
requestPasswordReset,
validateToken,
resetPassword
}}
>
{children}
</AuthContext.Provider>
);

View File

@ -29,6 +29,24 @@ export interface IUserLoginData extends Pick<IUser, 'username'> {
password: string;
}
/**
* Represents password reset data.
*/
export interface IResetPasswordData {
password: string;
token: string;
}
/**
* Represents password token data.
*/
export interface IPasswordTokenData extends Pick<IResetPasswordData, 'token'> {}
/**
* Represents password reset request data.
*/
export interface IRequestPasswordData extends Pick<IUser, 'email'> {}
/**
* Represents signup data, used to create new users.
*/

View File

@ -0,0 +1,117 @@
'use client';
import axios from 'axios';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import DataLoader from '@/components/DataLoader';
import InfoError, { ErrorData } from '@/components/InfoError';
import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput';
import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import useQueryStrings from '@/hooks/useQueryStrings';
import { IPasswordTokenData, IResetPasswordData } from '@/models/library';
import { classnames } from '@/utils/constants';
function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
if (axios.isAxiosError(error) && error.response && error.response.status === 404) {
return <div className='mt-6 text-sm select-text clr-text-warning'>Данная ссылка не действительна</div>;
} else {
return <InfoError error={error} />;
}
}
function PasswordChangePage() {
const router = useConceptNavigation();
const token = useQueryStrings().get('token');
const { validateToken, resetPassword, loading, error, setError } = useAuth();
const [isTokenValid, setIsTokenValid] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [newPasswordRepeat, setNewPasswordRepeat] = useState('');
const passwordColor = useMemo(() => {
if (!!newPassword && !!newPasswordRepeat && newPassword !== newPasswordRepeat) {
return 'clr-warning';
} else {
return 'clr-input';
}
}, [newPassword, newPasswordRepeat]);
const canSubmit = useMemo(() => {
return !!newPassword && !!newPasswordRepeat && newPassword === newPasswordRepeat;
}, [newPassword, newPasswordRepeat]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!loading) {
const data: IResetPasswordData = {
password: newPassword,
token: token!
};
resetPassword(data, () => {
router.replace('/');
router.push('/login');
});
}
}
useEffect(() => {
setError(undefined);
}, [newPassword, newPasswordRepeat, setError]);
useEffect(() => {
const data: IPasswordTokenData = {
token: token ?? ''
};
validateToken(data, () => setIsTokenValid(true));
}, [token, validateToken]);
if (error) {
return <ProcessError error={error} />;
}
return (
<DataLoader
id='password-change-page' //
isLoading={loading}
hasNoData={!isTokenValid}
>
<form className={clsx('w-[24rem]', 'px-6 mt-3', classnames.flex_col)} onSubmit={handleSubmit}>
<TextInput
id='new_password'
type='password'
allowEnter
label='Новый пароль'
colors={passwordColor}
value={newPassword}
onChange={event => {
setNewPassword(event.target.value);
}}
/>
<TextInput
id='new_password_repeat'
type='password'
allowEnter
label='Повторите новый'
colors={passwordColor}
value={newPasswordRepeat}
onChange={event => {
setNewPasswordRepeat(event.target.value);
}}
/>
<SubmitButton
text='Установить пароль'
className='self-center w-[12rem] mt-3'
loading={loading}
disabled={!canSubmit}
/>
{error ? <ProcessError error={error} /> : null}
</form>
</DataLoader>
);
}
export default PasswordChangePage;

View File

@ -1,14 +1,78 @@
'use client';
import axios from 'axios';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import AnimateFade from '@/components/AnimateFade';
import InfoError, { ErrorData } from '@/components/InfoError';
import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput';
import TextURL from '@/components/ui/TextURL';
import { urls } from '@/utils/constants';
import { useAuth } from '@/context/AuthContext';
import { IRequestPasswordData } from '@/models/library';
import { classnames } from '@/utils/constants';
function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
return (
<div className='mt-6 text-sm select-text clr-text-warning'>
На Портале отсутствует пользователь с таким email.
</div>
);
} else {
return <InfoError error={error} />;
}
}
function RestorePasswordPage() {
const { requestPasswordReset, loading, error, setError } = useAuth();
const [isCompleted, setIsCompleted] = useState(false);
const [email, setEmail] = useState('');
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!loading) {
const data: IRequestPasswordData = {
email: email
};
requestPasswordReset(data, () => setIsCompleted(true));
}
}
useEffect(() => {
setError(undefined);
}, [email, setError]);
return (
<AnimateFade className='py-3'>
<p>Автоматическое восстановление пароля не доступно.</p>
<p>
Возможно восстановление пароля через обращение на <TextURL href={urls.mail_portal} text='portal@acconcept.ru' />
</p>
<AnimateFade>
{!isCompleted ? (
<form className={clsx('w-[24rem]', 'px-6 mt-3', classnames.flex_col)} onSubmit={handleSubmit}>
<TextInput
id='email'
allowEnter
label='Электронная почта'
value={email}
onChange={event => setEmail(event.target.value)}
/>
<SubmitButton
text='Запросить пароль'
className='self-center w-[12rem] mt-3'
loading={loading}
disabled={!email}
/>
{error ? <ProcessError error={error} /> : null}
</form>
) : null}
{isCompleted ? (
<div className='flex flex-col items-center gap-1 mt-3'>
<p>На указанную почту отправлены инструкции по восстановлению пароля.</p>
<TextURL text='Войти в Портал' href='/login' />
<TextURL text='Начальная страница' href='/' />
</div>
) : null}
</AnimateFade>
);
}

View File

@ -11,6 +11,9 @@ import {
ICurrentUser,
ILibraryItem,
ILibraryUpdateData,
IPasswordTokenData,
IRequestPasswordData,
IResetPasswordData as IResetPasswordData,
IUserInfo,
IUserLoginData,
IUserProfile,
@ -136,12 +139,36 @@ export function patchProfile(request: FrontExchange<IUserUpdateData, IUserProfil
export function patchPassword(request: FrontPush<IUserUpdatePassword>) {
AxiosPatch({
title: 'Update Password',
title: 'Update password',
endpoint: '/users/api/change-password',
request: request
});
}
export function postRequestPasswordReset(request: FrontPush<IRequestPasswordData>) {
AxiosPost({
title: 'Request password reset',
endpoint: '/users/api/password-reset',
request: request
});
}
export function postValidatePasswordToken(request: FrontPush<IPasswordTokenData>) {
AxiosPost({
title: 'Validate password token',
endpoint: '/users/api/password-reset/validate',
request: request
});
}
export function postResetPassword(request: FrontPush<IResetPasswordData>) {
AxiosPost({
title: 'Reset password',
endpoint: '/users/api/password-reset/confirm',
request: request
});
}
export function getActiveUsers(request: FrontPull<IUserInfo[]>) {
AxiosGet({
title: 'Active users list',