From f129d4137dcf0f66d6a317ad68a914dd9036031c Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Tue, 24 Sep 2019 18:08:33 +0200 Subject: [PATCH 1/2] [Backend] Allow to change your password Signed-off-by: Thomas Citharel --- lib/mobilizon/users/user.ex | 20 +- lib/mobilizon_web/resolvers/user.ex | 31 +++ lib/mobilizon_web/schema/user.ex | 7 + .../resolvers/user_resolver_test.exs | 189 ++++++++++++++++++ 4 files changed, 245 insertions(+), 2 deletions(-) diff --git a/lib/mobilizon/users/user.ex b/lib/mobilizon/users/user.ex index 981b9de4a..08bd65dc3 100644 --- a/lib/mobilizon/users/user.ex +++ b/lib/mobilizon/users/user.ex @@ -43,7 +43,9 @@ defmodule Mobilizon.Users.User do @registration_required_attrs [:email, :password] - @password_reset_required_attrs [:password, :reset_password_token, :reset_password_sent_at] + @password_change_required_attrs [:password] + @password_reset_required_attrs @password_change_required_attrs ++ + [:reset_password_token, :reset_password_sent_at] @confirmation_token_length 30 @@ -107,8 +109,22 @@ defmodule Mobilizon.Users.User do @doc false @spec password_reset_changeset(t, map) :: Ecto.Changeset.t() def password_reset_changeset(%__MODULE__{} = user, attrs) do + password_change_changeset(user, attrs, @password_reset_required_attrs) + end + + @doc """ + Changeset to change a password + + It checks the minimum requirements for a password and hashes it. + """ + @spec password_change_changeset(t, map) :: Ecto.Changeset.t() + def password_change_changeset( + %__MODULE__{} = user, + attrs, + required_attrs \\ @password_change_required_attrs + ) do user - |> cast(attrs, @password_reset_required_attrs) + |> cast(attrs, required_attrs) |> validate_length(:password, min: 6, max: 100, diff --git a/lib/mobilizon_web/resolvers/user.ex b/lib/mobilizon_web/resolvers/user.ex index 8ab4c842f..82deae829 100644 --- a/lib/mobilizon_web/resolvers/user.ex +++ b/lib/mobilizon_web/resolvers/user.ex @@ -7,6 +7,7 @@ defmodule MobilizonWeb.Resolvers.User do alias Mobilizon.Actors.Actor alias Mobilizon.Service.Users.{ResetPassword, Activation} alias Mobilizon.Users.User + alias Mobilizon.Storage.Repo import Mobilizon.Users.Guards @@ -238,4 +239,34 @@ defmodule MobilizonWeb.Resolvers.User do {:ok, participations} end end + + def change_password(_parent, %{old_password: old_password, new_password: new_password}, %{ + context: %{current_user: %User{password_hash: old_password_hash} = user} + }) do + with {:current_password, true} <- + {:current_password, Argon2.verify_pass(old_password, old_password_hash)}, + {:same_password, false} <- {:same_password, old_password == new_password}, + {:ok, %User{} = user} <- + user + |> User.password_change_changeset(%{ + "password" => new_password + }) + |> Repo.update() do + {:ok, user} + else + {:current_password, false} -> + {:error, "The current password is invalid"} + + {:same_password, true} -> + {:error, "The new password must be different"} + + {:error, %Ecto.Changeset{errors: [password: {"registration.error.password_too_short", _}]}} -> + {:error, + "The password you have chosen is too short. Please make sure your password contains at least 6 characters."} + end + end + + def change_password(_parent, _args, _resolution) do + {:error, "You need to be logged-in to change your password"} + end end diff --git a/lib/mobilizon_web/schema/user.ex b/lib/mobilizon_web/schema/user.ex index 1373b2d1c..9a2595138 100644 --- a/lib/mobilizon_web/schema/user.ex +++ b/lib/mobilizon_web/schema/user.ex @@ -159,5 +159,12 @@ defmodule MobilizonWeb.Schema.UserType do arg(:preferred_username, non_null(:string)) resolve(&User.change_default_actor/3) end + + @desc "Change an user password" + field :change_password, :user do + arg(:old_password, non_null(:string)) + arg(:new_password, non_null(:string)) + resolve(&User.change_password/3) + end end end diff --git a/test/mobilizon_web/resolvers/user_resolver_test.exs b/test/mobilizon_web/resolvers/user_resolver_test.exs index 5f483e9b8..fa8a662e7 100644 --- a/test/mobilizon_web/resolvers/user_resolver_test.exs +++ b/test/mobilizon_web/resolvers/user_resolver_test.exs @@ -833,4 +833,193 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do ] == actor2.preferred_username end end + + describe "Resolver: Change password for an user" do + @email "toto@tata.tld" + @old_password "p4ssw0rd" + @new_password "upd4t3d" + + test "change_password/3 with valid password", %{conn: conn} do + {:ok, %User{} = user} = Users.register(%{email: @email, password: @old_password}) + + # Hammer time ! + {:ok, %User{} = _user} = + Users.update_user(user, %{ + "confirmed_at" => Timex.shift(user.confirmation_sent_at, hours: -3), + "confirmation_sent_at" => nil, + "confirmation_token" => nil + }) + + mutation = """ + mutation { + login( + email: "#{@email}", + password: "#{@old_password}", + ) { + accessToken, + refreshToken, + user { + id + } + } + } + """ + + res = + conn + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert login = json_response(res, 200)["data"]["login"] + assert Map.has_key?(login, "accessToken") && not is_nil(login["accessToken"]) + + mutation = """ + mutation { + changePassword(old_password: "#{@old_password}", new_password: "#{@new_password}") { + id + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] == nil + assert json_response(res, 200)["data"]["changePassword"]["id"] == to_string(user.id) + + mutation = """ + mutation { + login( + email: "#{@email}", + password: "#{@new_password}", + ) { + accessToken, + refreshToken, + user { + id + } + } + } + """ + + res = + conn + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert login = json_response(res, 200)["data"]["login"] + assert Map.has_key?(login, "accessToken") && not is_nil(login["accessToken"]) + end + + test "change_password/3 with invalid password", %{conn: conn} do + {:ok, %User{} = user} = Users.register(%{email: @email, password: @old_password}) + + # Hammer time ! + + {:ok, %User{} = _user} = + Users.update_user(user, %{ + "confirmed_at" => Timex.shift(user.confirmation_sent_at, hours: -3), + "confirmation_sent_at" => nil, + "confirmation_token" => nil + }) + + mutation = """ + mutation { + changePassword(old_password: "invalid password", new_password: "#{@new_password}") { + id + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert hd(json_response(res, 200)["errors"])["message"] == "The current password is invalid" + end + + test "change_password/3 with same password", %{conn: conn} do + {:ok, %User{} = user} = Users.register(%{email: @email, password: @old_password}) + + # Hammer time ! + {:ok, %User{} = _user} = + Users.update_user(user, %{ + "confirmed_at" => Timex.shift(user.confirmation_sent_at, hours: -3), + "confirmation_sent_at" => nil, + "confirmation_token" => nil + }) + + mutation = """ + mutation { + changePassword(old_password: "#{@old_password}", new_password: "#{@old_password}") { + id + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert hd(json_response(res, 200)["errors"])["message"] == + "The new password must be different" + end + + test "change_password/3 with new password too short", %{conn: conn} do + {:ok, %User{} = user} = Users.register(%{email: @email, password: @old_password}) + + # Hammer time ! + {:ok, %User{} = _user} = + Users.update_user(user, %{ + "confirmed_at" => Timex.shift(user.confirmation_sent_at, hours: -3), + "confirmation_sent_at" => nil, + "confirmation_token" => nil + }) + + mutation = """ + mutation { + changePassword(old_password: "#{@old_password}", new_password: "new") { + id + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert hd(json_response(res, 200)["errors"])["message"] == + "The password you have chosen is too short. Please make sure your password contains at least 6 characters." + end + + test "change_password/3 without being authenticated", %{conn: conn} do + {:ok, %User{} = user} = Users.register(%{email: @email, password: @old_password}) + + # Hammer time ! + {:ok, %User{} = _user} = + Users.update_user(user, %{ + "confirmed_at" => Timex.shift(user.confirmation_sent_at, hours: -3), + "confirmation_sent_at" => nil, + "confirmation_token" => nil + }) + + mutation = """ + mutation { + changePassword(old_password: "#{@old_password}", new_password: "#{@new_password}") { + id + } + } + """ + + res = + conn + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert hd(json_response(res, 200)["errors"])["message"] == + "You need to be logged-in to change your password" + end + end end From 3806295e833ffc26bbe41c43498feecbaf867385 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Tue, 24 Sep 2019 18:47:35 +0200 Subject: [PATCH 2/2] Implement password change in basic user settings Signed-off-by: Thomas Citharel --- js/src/graphql/user.ts | 8 +++ js/src/router/user.ts | 8 +++ js/src/views/Account/MyAccount.vue | 17 +++++ js/src/views/User/PasswordChange.vue | 94 ++++++++++++++++++++++++++++ schema.graphql | 13 ++-- 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 js/src/views/User/PasswordChange.vue diff --git a/js/src/graphql/user.ts b/js/src/graphql/user.ts index 61afe904d..c67fc5bbc 100644 --- a/js/src/graphql/user.ts +++ b/js/src/graphql/user.ts @@ -25,6 +25,14 @@ mutation ValidateUser($token: String!) { } `; +export const CHANGE_PASSWORD = gql` + mutation ChangePassword($oldPassword: String!, $newPassword: String!) { + changePassword(oldPassword: $oldPassword, newPassword: $newPassword) { + id + } + } +`; + export const CURRENT_USER_CLIENT = gql` query { currentUser @client { diff --git a/js/src/router/user.ts b/js/src/router/user.ts index 6d652a341..8580982d6 100644 --- a/js/src/router/user.ts +++ b/js/src/router/user.ts @@ -7,6 +7,7 @@ import SendPasswordReset from '@/views/User/SendPasswordReset.vue'; import PasswordReset from '@/views/User/PasswordReset.vue'; import { beforeRegisterGuard } from '@/router/guards/register-guard'; import { RouteConfig } from 'vue-router'; +import PasswordChange from '@/views/User/PasswordChange.vue'; export enum UserRouteName { REGISTER = 'Register', @@ -16,6 +17,7 @@ export enum UserRouteName { PASSWORD_RESET = 'PasswordReset', VALIDATE = 'Validate', LOGIN = 'Login', + PASSWORD_CHANGE = 'PasswordChange', } export const userRoutes: RouteConfig[] = [ @@ -70,4 +72,10 @@ export const userRoutes: RouteConfig[] = [ props: true, meta: { requiredAuth: false }, }, + { + path: '/my-account/password', + name: UserRouteName.PASSWORD_CHANGE, + component: PasswordChange, + meta: { requiredAuth: true }, + }, ]; diff --git a/js/src/views/Account/MyAccount.vue b/js/src/views/Account/MyAccount.vue index d09514e45..c1f6fde27 100644 --- a/js/src/views/Account/MyAccount.vue +++ b/js/src/views/Account/MyAccount.vue @@ -1,5 +1,10 @@