Wire/test registration and password reset

This commit is contained in:
Timothée Floure 2021-01-08 14:41:37 +01:00
parent 34dee38481
commit fa15a25044
Signed by: tfloure
GPG key ID: 4502C902C00A1E12
11 changed files with 102 additions and 61 deletions

View file

@ -9,7 +9,8 @@ use Mix.Config
config :recycledcloud, config :recycledcloud,
namespace: RecycledCloud, namespace: RecycledCloud,
ecto_repos: [RecycledCloud.Repo] ecto_repos: [RecycledCloud.Repo],
enable_registration: true
# Configures the endpoint # Configures the endpoint
config :recycledcloud, RecycledCloudWeb.Endpoint, config :recycledcloud, RecycledCloudWeb.Endpoint,

View file

@ -89,12 +89,9 @@ defmodule RecycledCloud.Accounts do
case User.register(user) do case User.register(user) do
{:ok, result} -> {:ok, result} {:ok, result} -> {:ok, result}
{:error, :entryAlreadyExists} -> {:error, :entryAlreadyExists} ->
{ err = changeset
:error, |> Ecto.Changeset.add_error(:username, "has already been taken")
Ecto.Changeset.add_error( |> Ecto.Changeset.apply_action(:update)
changeset, :username, "has already been taken"
)
}
end end
err -> err err -> err
end end
@ -216,7 +213,11 @@ defmodule RecycledCloud.Accounts do
Repo.delete_all(UserToken.user_and_contexts_query(user, :all)) Repo.delete_all(UserToken.user_and_contexts_query(user, :all))
{:ok, user} {:ok, user}
{:error, err} -> {:error, err} ->
changeset_errors = [current_password: {"Unknown error: #{err}", [err: inspect(err)]}] msg = {"Unknown error: #{err}", [err: inspect(err)]}
changeset_errors = [
current_password: msg,
password: msg
]
updated_changeset = changeset updated_changeset = changeset
|> Map.put(:action, :update) |> Map.put(:action, :update)
|> Map.put(:errors, changeset_errors) |> Map.put(:errors, changeset_errors)
@ -244,7 +245,7 @@ defmodule RecycledCloud.Accounts do
|> User.password_changeset(attrs) |> User.password_changeset(attrs)
|> User.validate_current_password(current_password) |> User.validate_current_password(current_password)
set_user_password(user, changeset, attrs[:password]) set_user_password(user, changeset, attrs["password"])
end end
## Session ## Session
@ -376,7 +377,7 @@ defmodule RecycledCloud.Accounts do
changeset = user changeset = user
|> User.password_changeset(attrs) |> User.password_changeset(attrs)
set_user_password(user, changeset, attrs[:password]) set_user_password(user, changeset, attrs["password"])
end end
## Keys ## Keys

View file

@ -54,15 +54,33 @@ defmodule RecycledCloud.Accounts.User do
end end
end end
defp get_last_ldap_uid() do
query = fn ldap_conn ->
LDAP.search(ldap_conn, :objectClass, 'posixAccount')
end
case query |> LDAP.execute do
{:ok, entries} ->
uids = Enum.map(entries, fn entry ->
entry.uidNumber |> Enum.at(0) |> List.to_integer
end)
{:ok, uids |> Enum.max()}
{:error, err} -> {:error, err}
end
end
defp create_ldap_user(%User{} = user) do defp create_ldap_user(%User{} = user) do
# FIXME: read dn template from conf, gid_number # TODO: read dn template from conf, gid_number
# FIXME: generate uidNumber
conf = Application.get_env(:recycledcloud, :ldap) conf = Application.get_env(:recycledcloud, :ldap)
basetree = conf |> Keyword.get(:base_dn) basetree = conf |> Keyword.get(:base_dn)
default_group_gid = conf |> Keyword.get(:default_group_gid, 1000)
dn = ("uid=" <> user.username <> ",ou=Users," <> basetree) |> String.to_charlist dn = ("uid=" <> user.username <> ",ou=Users," <> basetree) |> String.to_charlist
uid_number = 1234 {:ok, last_uid_number} = get_last_ldap_uid()
gid_number = 1000 uid_number = last_uid_number + 1
gid_number = default_group_gid
ldif = [ ldif = [
{'uid', [user.username]}, {'uid', [user.username]},
@ -222,12 +240,14 @@ defmodule RecycledCloud.Accounts.User do
end end
end end
def set_password(%User{} = user, new_password) def set_password(%User{dn: nil} = _user, _), do: {:error, "User DN must be set"}
when byte_size(new_password) > 0 do def set_password(_, nil), do: {:error, "Password cannot be nil"}
def set_password(%User{dn: dn} = _user, new_password) when
byte_size(new_password) > 0do
query = fn ldap_conn -> query = fn ldap_conn ->
:eldap.modify_password( :eldap.modify_password(
ldap_conn, ldap_conn,
to_charlist(user.dn), to_charlist(dn),
to_charlist(new_password) to_charlist(new_password)
) )
end end

View file

@ -5,33 +5,46 @@ defmodule RecycledCloudWeb.UserRegistrationController do
alias RecycledCloud.Accounts.User alias RecycledCloud.Accounts.User
alias RecycledCloudWeb.UserAuth alias RecycledCloudWeb.UserAuth
defp is_registration_enabled?() do
Application.get_env(:recycledcloud, :enable_registration)
end
def new(conn, _params) do def new(conn, _params) do
changeset = Accounts.change_user_registration(%User{}) changeset = Accounts.change_user_registration(%User{})
with_notice = unless is_registration_enabled?() do
conn conn
|> put_flash(:error, "Registration is disabled for the time being.") |> put_flash(:error, "Registration is disabled for the time being.")
else
conn
end
with_notice
|> render("new.html", changeset: changeset) |> render("new.html", changeset: changeset)
end end
def create(conn, %{"user" => user_params}) do def create(conn, %{"user" => user_params}) do
changeset = Accounts.change_user_registration(%User{}) changeset = Accounts.change_user_registration(%User{})
unless is_registration_enabled?() do
conn conn
|> redirect(to: Routes.user_registration_path(conn, :new)) |> redirect(to: Routes.user_registration_path(conn, :new))
else
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(conn, :confirm, &1)
)
#case Accounts.register_user(user_params) do conn
# {:ok, user} -> |> put_flash(:info, "User created successfully.")
# {:ok, _} = |> UserAuth.log_in_user(user)
# Accounts.deliver_user_confirmation_instructions(
# user,
# &Routes.user_confirmation_url(conn, :confirm, &1)
# )
# conn {:error, %Ecto.Changeset{} = changeset} ->
# |> put_flash(:info, "User created successfully.") render(conn, "new.html", changeset: changeset)
# |> UserAuth.log_in_user(user) end
end
# {:error, %Ecto.Changeset{} = changeset} ->
# render(conn, "new.html", changeset: changeset)
#end
end end
end end

View file

@ -21,7 +21,7 @@ defmodule RecycledCloudWeb.UserResetPasswordController do
conn conn
|> put_flash( |> put_flash(
:info, :info,
"If your email is in our system, you will receive instructions to reset your password shortly." "If your account is in our system, you will receive instructions to reset your password shortly."
) )
|> redirect(to: "/") |> redirect(to: "/")
end end
@ -33,7 +33,10 @@ defmodule RecycledCloudWeb.UserResetPasswordController do
# Do not log in the user after reset password to avoid a # Do not log in the user after reset password to avoid a
# leaked token giving the user access to the account. # leaked token giving the user access to the account.
def update(conn, %{"user" => user_params}) do def update(conn, %{"user" => user_params}) do
case Accounts.reset_user_password(conn.assigns.user, user_params) do # FIXME: understand why conn.assigns.user is not populated with LDAP
# attributes.
user = conn.assigns.user.id |> Accounts.User.get!()
case Accounts.reset_user_password(user, user_params) do
{:ok, _} -> {:ok, _} ->
conn conn
|> put_flash(:info, "Password reset successfully.") |> put_flash(:info, "Password reset successfully.")

View file

@ -12,6 +12,11 @@
<br /> <br />
<%= text_input f, :email, required: true, placeholder: "E-mail" %>
<%= error_tag f, :email %>
<br />
<%= password_input f, :password, required: true, placeholder: "Password" %> <%= password_input f, :password, required: true, placeholder: "Password" %>
<%= error_tag f, :password %> <%= error_tag f, :password %>

View file

@ -7,12 +7,12 @@
</div> </div>
<% end %> <% end %>
<%= label f, :password, "New password" %> <%= password_input f, :password, required: true, placeholder: "New password" %>
<%= password_input f, :password, required: true %>
<%= error_tag f, :password %> <%= error_tag f, :password %>
<%= label f, :password_confirmation, "Confirm new password" %> <br />
<%= password_input f, :password_confirmation, required: true %>
<%= password_input f, :password_confirmation, required: true, placeholder: "New password confirmation" %>
<%= error_tag f, :password_confirmation %> <%= error_tag f, :password_confirmation %>
<div> <div>

View file

@ -262,14 +262,14 @@ defmodule RecycledCloud.AccountsTest do
too_long = String.duplicate("db", 100) too_long = String.duplicate("db", 100)
{:error, changeset} = {:error, changeset} =
Accounts.update_user_password(user, valid_user_password(), %{password: too_long}) Accounts.update_user_password(user, valid_user_password(), %{"password" => too_long})
assert "should be at most 80 character(s)" in errors_on(changeset).password assert "should be at most 80 character(s)" in errors_on(changeset).password
end end
test "validates current password", %{user: user} do test "validates current password", %{user: user} do
{:error, changeset} = {:error, changeset} =
Accounts.update_user_password(user, "invalid", %{password: valid_user_password()}) Accounts.update_user_password(user, "invalid", %{"password" => valid_user_password()})
assert %{current_password: ["is not valid"]} = errors_on(changeset) assert %{current_password: ["is not valid"]} = errors_on(changeset)
end end
@ -277,7 +277,7 @@ defmodule RecycledCloud.AccountsTest do
test "updates the password", %{user: user} do test "updates the password", %{user: user} do
{:ok, user} = {:ok, user} =
Accounts.update_user_password(user, valid_user_password(), %{ Accounts.update_user_password(user, valid_user_password(), %{
password: "new valid password" "password" => "new valid password"
}) })
assert is_nil(user.password) assert is_nil(user.password)
@ -289,7 +289,7 @@ defmodule RecycledCloud.AccountsTest do
{:ok, _} = {:ok, _} =
Accounts.update_user_password(user, valid_user_password(), %{ Accounts.update_user_password(user, valid_user_password(), %{
password: "new valid password" "password" => "new valid password"
}) })
refute Repo.get_by(UserToken, user_id: user.id) refute Repo.get_by(UserToken, user_id: user.id)
@ -481,7 +481,7 @@ defmodule RecycledCloud.AccountsTest do
test "deletes all tokens for the given user", %{user: user} do test "deletes all tokens for the given user", %{user: user} do
_ = Accounts.generate_user_session_token(user) _ = Accounts.generate_user_session_token(user)
{:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) {:ok, _} = Accounts.reset_user_password(user, %{"password" => "new valid password"})
refute Repo.get_by(UserToken, user_id: user.id) refute Repo.get_by(UserToken, user_id: user.id)
end end
end end

View file

@ -22,22 +22,22 @@ defmodule RecycledCloudWeb.UserResetPasswordControllerTest do
test "sends a new reset password token", %{conn: conn, user: user} do test "sends a new reset password token", %{conn: conn, user: user} do
conn = conn =
post(conn, Routes.user_reset_password_path(conn, :create), %{ post(conn, Routes.user_reset_password_path(conn, :create), %{
"user" => %{"email" => user.email} "user" => %{"username" => user.username}
}) })
assert redirected_to(conn) == "/" assert redirected_to(conn) == "/"
assert get_flash(conn, :info) =~ "If your email is in our system" assert get_flash(conn, :info) =~ "If your account is in our system"
assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "reset_password" assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "reset_password"
end end
test "does not send reset password token if email is invalid", %{conn: conn} do test "does not send reset password token if username is invalid", %{conn: conn} do
conn = conn =
post(conn, Routes.user_reset_password_path(conn, :create), %{ post(conn, Routes.user_reset_password_path(conn, :create), %{
"user" => %{"email" => "unknown@example.com"} "user" => %{"username" => "unknown"}
}) })
assert redirected_to(conn) == "/" assert redirected_to(conn) == "/"
assert get_flash(conn, :info) =~ "If your email is in our system" assert get_flash(conn, :info) =~ "If your account is in our system"
assert Repo.all(Accounts.UserToken) == [] assert Repo.all(Accounts.UserToken) == []
end end
end end
@ -86,21 +86,21 @@ defmodule RecycledCloudWeb.UserResetPasswordControllerTest do
assert redirected_to(conn) == Routes.user_session_path(conn, :new) assert redirected_to(conn) == Routes.user_session_path(conn, :new)
refute get_session(conn, :user_token) refute get_session(conn, :user_token)
assert get_flash(conn, :info) =~ "Password reset successfully" assert get_flash(conn, :info) =~ "Password reset successfully"
assert Accounts.get_user_by_email_and_password(user.email, "new valid password") assert Accounts.get_user_by_username_and_password(user.username, "new valid password")
end end
test "does not reset password on invalid data", %{conn: conn, token: token} do test "does not reset password on invalid data", %{conn: conn, token: token} do
conn = conn =
put(conn, Routes.user_reset_password_path(conn, :update, token), %{ put(conn, Routes.user_reset_password_path(conn, :update, token), %{
"user" => %{ "user" => %{
"password" => "too short", "password" => "short",
"password_confirmation" => "does not match" "password_confirmation" => "does not match"
} }
}) })
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ "<h1>Reset password</h1>" assert response =~ "<h1>Reset password</h1>"
assert response =~ "should be at least 12 character(s)" assert response =~ "should be at least 6 character(s)"
assert response =~ "does not match password" assert response =~ "does not match password"
end end

View file

@ -10,7 +10,7 @@ defmodule RecycledCloudWeb.UserSettingsControllerTest do
test "renders settings page", %{conn: conn} do test "renders settings page", %{conn: conn} do
conn = get(conn, Routes.user_settings_path(conn, :edit)) conn = get(conn, Routes.user_settings_path(conn, :edit))
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ "<h1>Settings</h1>" assert response =~ "<h1>Account settings</h1>"
end end
test "redirects if user is not logged in" do test "redirects if user is not logged in" do
@ -70,7 +70,7 @@ defmodule RecycledCloudWeb.UserSettingsControllerTest do
assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
assert get_flash(conn, :info) =~ "A link to confirm your email" assert get_flash(conn, :info) =~ "A link to confirm your email"
assert Accounts.get_user_by_email(user.email) assert Accounts.get_user_by_username(user.username).email == user.email
end end
test "does not update email on invalid data", %{conn: conn} do test "does not update email on invalid data", %{conn: conn} do
@ -82,7 +82,6 @@ defmodule RecycledCloudWeb.UserSettingsControllerTest do
}) })
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ "<h1>Settings</h1>"
assert response =~ "must have the @ sign and no spaces" assert response =~ "must have the @ sign and no spaces"
assert response =~ "is not valid" assert response =~ "is not valid"
end end
@ -104,8 +103,7 @@ defmodule RecycledCloudWeb.UserSettingsControllerTest do
conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
assert get_flash(conn, :info) =~ "Email changed successfully" assert get_flash(conn, :info) =~ "Email changed successfully"
refute Accounts.get_user_by_email(user.email) assert Accounts.get_user_by_username(user.username).email == email
assert Accounts.get_user_by_email(email)
conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
@ -116,7 +114,7 @@ defmodule RecycledCloudWeb.UserSettingsControllerTest do
conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops")) conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops"))
assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired"
assert Accounts.get_user_by_email(user.email) assert Accounts.get_user_by_username(user.username)
end end
test "redirects if user is not logged in", %{token: token} do test "redirects if user is not logged in", %{token: token} do

View file

@ -23,7 +23,7 @@ defmodule RecycledCloud.LDAPTestEnvironment do
:ok -> :ok ->
# Wait for LDAP server to be populated. # Wait for LDAP server to be populated.
# FIXME: poll state instead of taking a nap! # FIXME: poll state instead of taking a nap!
Process.sleep(1000) Process.sleep(2000)
{:ok, port, container} {:ok, port, container}
:timeout -> {:error, :timeout} :timeout -> {:error, :timeout}