diff --git a/CHANGELOG.md b/CHANGELOG.md index 10423c32..807b3f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add login interface [#77](https://github.com/azavea/iow-boundary-tool/pull/77) - Add reset password functionality [#79](https://github.com/azavea/iow-boundary-tool/pull/79) - Create Remaining Initial Django Models [#82](https://github.com/azavea/iow-boundary-tool/pull/82) +- Force users to reset password on first login [#83](https://github.com/azavea/iow-boundary-tool/pull/83) ### Changed diff --git a/src/app/src/constants.js b/src/app/src/constants.js index 477761f2..e8bfe1bb 100644 --- a/src/app/src/constants.js +++ b/src/app/src/constants.js @@ -43,5 +43,10 @@ export const API_URLS = { LOGIN: 'api/auth/login/', LOGOUT: 'api/auth/logout/', FORGOT: 'api/auth/password/reset/', - RESET: 'api/auth/password/reset/confirm/', + CONFIRM: 'api/auth/password/reset/confirm/', + RESET: 'confirm_password_reset/reset/', +}; + +export const API_STATUSES = { + REDIRECT: 302, }; diff --git a/src/app/src/pages/Login.js b/src/app/src/pages/Login.js index 305fc783..0043695e 100644 --- a/src/app/src/pages/Login.js +++ b/src/app/src/pages/Login.js @@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { Box, Button, Input, Text, VStack } from '@chakra-ui/react'; import { API } from '../api'; -import { API_URLS } from '../constants'; +import { API_URLS, API_STATUSES } from '../constants'; import { login } from '../store/authSlice'; import LoginForm from '../components/LoginForm'; @@ -25,6 +25,10 @@ export default function Login() { dispatch(login()); }) .catch(apiError => { + if (apiError.response?.status === API_STATUSES.REDIRECT) { + const dest = `/${API_URLS.RESET}${apiError.response.data.uid}/${apiError.response.data.token}/`; + navigate(dest); + } setErrorDetail(apiError.response?.data?.detail); }); }; diff --git a/src/app/src/pages/ResetPassword.js b/src/app/src/pages/ResetPassword.js index c65d2fdf..c3dc703f 100644 --- a/src/app/src/pages/ResetPassword.js +++ b/src/app/src/pages/ResetPassword.js @@ -45,7 +45,7 @@ export default function ResetPassword() { }; const forgotPasswordRequest = () => { - API.post(API_URLS.RESET, { + API.post(API_URLS.CONFIRM, { uid, token, new_password1: newPassword1, @@ -65,7 +65,7 @@ export default function ResetPassword() { // This will require sending a dummy password that will never be valid (too short) // in order to get a response including the token or uid fields. useEffect(() => { - API.post(API_URLS.RESET, { + API.post(API_URLS.CONFIRM, { uid, token, new_password1: '!', // too short diff --git a/src/django/api/management/commands/resetdb.py b/src/django/api/management/commands/resetdb.py index c89d0930..57c09390 100644 --- a/src/django/api/management/commands/resetdb.py +++ b/src/django/api/management/commands/resetdb.py @@ -20,15 +20,18 @@ def handle(self, *args, **options): User.objects.create_superuser( email="a1@azavea.com", password="password", + has_admin_generated_password=False, ) User.objects.create_user( email="v1@azavea.com", password="password", + has_admin_generated_password=False, role=Role.objects.get(pk=Roles.VALIDATOR), ) contributor = User.objects.create_user( email="c1@azavea.com", password="password", + has_admin_generated_password=False, role=Role.objects.get(pk=Roles.CONTRIBUTOR), ) contributor.utilities.add(test_utility) diff --git a/src/django/api/migrations/0013_user_has_admin_generated_password.py b/src/django/api/migrations/0013_user_has_admin_generated_password.py new file mode 100644 index 00000000..487b2ae5 --- /dev/null +++ b/src/django/api/migrations/0013_user_has_admin_generated_password.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-09-29 13:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0012_create_annotation'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='has_admin_generated_password', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/django/api/models/user.py b/src/django/api/models/user.py index 57a730c6..2790c686 100644 --- a/src/django/api/models/user.py +++ b/src/django/api/models/user.py @@ -59,6 +59,7 @@ class User(AbstractBaseUser, PermissionsMixin): is_staff = models.BooleanField(default=False) is_active = models.BooleanField(default=True) date_joined = models.DateTimeField(default=timezone.now) + has_admin_generated_password = models.BooleanField(default=True) role = models.ForeignKey( Role, diff --git a/src/django/api/serializers/__init__.py b/src/django/api/serializers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/django/api/serializers/auth.py b/src/django/api/serializers/auth.py new file mode 100644 index 00000000..b9190833 --- /dev/null +++ b/src/django/api/serializers/auth.py @@ -0,0 +1,11 @@ +from django.db import transaction +from dj_rest_auth.serializers import PasswordResetConfirmSerializer + + +class UserChosenPasswordResetConfirmSerializer(PasswordResetConfirmSerializer): + @transaction.atomic + def save(self): + self.user.has_admin_generated_password = False + self.user.save() + + return self.set_password_form.save() diff --git a/src/django/api/views/auth.py b/src/django/api/views/auth.py index 6d84012f..3a3d0b12 100644 --- a/src/django/api/views/auth.py +++ b/src/django/api/views/auth.py @@ -5,6 +5,9 @@ from dj_rest_auth.views import LoginView, LogoutView from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.tokens import default_token_generator +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode class Login(LoginView): @@ -21,6 +24,13 @@ def post(self, request, *args, **kwargs): if user is None: raise AuthenticationFailed("Unable to login with those credentials") + if user.has_admin_generated_password: + context = { + 'uid': urlsafe_base64_encode(force_bytes(user.pk)), + 'token': default_token_generator.make_token(user), + } + # Ask client to redirect to a password reset link + return Response(context, status=status.HTTP_302_FOUND) login(request, user) diff --git a/src/django/iow/settings.py b/src/django/iow/settings.py index e64bb7ea..eb7b643e 100644 --- a/src/django/iow/settings.py +++ b/src/django/iow/settings.py @@ -185,6 +185,10 @@ AUTH_USER_MODEL = 'api.User' +REST_AUTH_SERIALIZERS = { + 'PASSWORD_RESET_CONFIRM_SERIALIZER': 'api.serializers.auth.UserChosenPasswordResetConfirmSerializer', +} + # Logging # https://docs.djangoproject.com/en/3.2/topics/logging/