Skip to content

Commit 4c31c12

Browse files
authored
fix(auth): prevent registration role escalation (#35)
1 parent 06359f4 commit 4c31c12

7 files changed

Lines changed: 94 additions & 55 deletions

File tree

lib/craftplan/accounts/user.ex

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,14 +158,12 @@ defmodule Craftplan.Accounts.User do
158158
create :register_with_password do
159159
description "Register a new user with a email and password."
160160

161+
skip_unknown_inputs [:role, "role"]
162+
161163
argument :email, :ci_string do
162164
allow_nil? false
163165
end
164166

165-
argument :role, :atom do
166-
default :customer
167-
end
168-
169167
argument :password, :string do
170168
description "The proposed password for the user, in plain text."
171169
allow_nil? false
@@ -182,7 +180,8 @@ defmodule Craftplan.Accounts.User do
182180
# Sets the email from the argument
183181
change set_attribute(:email, arg(:email))
184182

185-
change set_attribute(:role, arg(:role))
183+
# Public registration must never grant a privileged role.
184+
change set_attribute(:role, :customer)
186185

187186
# Hashes the provided password
188187
change HashPasswordChange
@@ -199,6 +198,32 @@ defmodule Craftplan.Accounts.User do
199198
end
200199
end
201200

201+
create :create_initial_admin_with_password do
202+
description "Create the initial administrator during instance setup."
203+
204+
argument :email, :ci_string do
205+
allow_nil? false
206+
end
207+
208+
argument :password, :string do
209+
description "The proposed password for the user, in plain text."
210+
allow_nil? false
211+
constraints min_length: 8
212+
sensitive? true
213+
end
214+
215+
argument :password_confirmation, :string do
216+
description "The proposed password for the user (again), in plain text."
217+
allow_nil? false
218+
sensitive? true
219+
end
220+
221+
change set_attribute(:email, arg(:email))
222+
change set_attribute(:role, :admin)
223+
change {HashPasswordChange, strategy_name: :password}
224+
validate {PasswordConfirmationValidation, strategy_name: :password}
225+
end
226+
202227
action :request_password_reset_with_password do
203228
description "Send password reset instructions to a user if they exist."
204229

lib/craftplan_web/live/setup_live.ex

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ defmodule CraftplanWeb.SetupLive do
1313
|> redirect(to: ~p"/sign-in")}
1414
else
1515
form =
16-
AshPhoenix.Form.for_create(User, :register_with_password,
16+
AshPhoenix.Form.for_create(User, :create_initial_admin_with_password,
1717
as: "user",
1818
authorize?: false
1919
)
@@ -77,8 +77,6 @@ defmodule CraftplanWeb.SetupLive do
7777
|> put_flash(:error, "Setup already complete.")
7878
|> redirect(to: ~p"/sign-in")}
7979
else
80-
user_params = Map.put(user_params, "role", "admin")
81-
8280
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params, authorize?: false) do
8381
{:ok, _user} ->
8482
{:noreply,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule CraftplanWeb.AuthRegistrationControllerTest do
2+
use CraftplanWeb.ConnCase, async: true
3+
4+
test "public registration cannot assign an administrator role", %{conn: conn} do
5+
email = "registration-role+#{System.unique_integer([:positive])}@test.com"
6+
7+
conn =
8+
post(conn, ~p"/auth/user/password/register", %{
9+
"user" => %{
10+
"email" => email,
11+
"password" => "Password12345!",
12+
"password_confirmation" => "Password12345!",
13+
"role" => "admin"
14+
}
15+
})
16+
17+
assert redirected_to(conn) == ~p"/manage/overview"
18+
assert {:ok, user} = Craftplan.Accounts.get_user_by_email(email, authorize?: false)
19+
assert user.role == :customer
20+
end
21+
end

test/craftplan_web/live/manage/settings_members_live_test.exs

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,10 @@ defmodule CraftplanWeb.SettingsMembersLiveTest do
44
import Phoenix.LiveViewTest
55

66
alias Ash.Error.Forbidden
7-
alias Craftplan.Accounts.User
7+
alias Craftplan.Test.AuthHelpers
88

99
defp create_staff_member!(email) do
10-
User
11-
|> Ash.Changeset.for_create(:register_with_password, %{
12-
email: email,
13-
role: :staff,
14-
password: "TestPassword123!",
15-
password_confirmation: "TestPassword123!"
16-
})
17-
|> Ash.create!(
18-
context: %{
19-
strategy: AshAuthentication.Strategy.Password,
20-
private: %{ash_authentication?: true}
21-
}
22-
)
10+
AuthHelpers.register_user!(email: email, role: :staff)
2311
end
2412

2513
describe "authorization" do
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
defmodule CraftplanWeb.SetupLiveTest do
2+
use CraftplanWeb.ConnCase, async: true
3+
4+
import Phoenix.LiveViewTest
5+
6+
test "creates the initial user as an administrator", %{conn: conn} do
7+
email = "initial-admin+#{System.unique_integer([:positive])}@test.com"
8+
9+
{:ok, view, _html} = live(conn, ~p"/setup")
10+
11+
view
12+
|> form("#setup-form", %{
13+
"user" => %{
14+
"email" => email,
15+
"password" => "Password12345!",
16+
"password_confirmation" => "Password12345!"
17+
}
18+
})
19+
|> render_submit()
20+
21+
assert_redirect(view, ~p"/sign-in")
22+
assert {:ok, user} = Craftplan.Accounts.get_user_by_email(email, authorize?: false)
23+
assert user.role == :admin
24+
end
25+
end

test/support/auth_helpers.ex

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ defmodule Craftplan.Test.AuthHelpers do
2020
User
2121
|> Ash.Changeset.for_create(:register_with_password, %{
2222
email: email,
23-
role: role,
2423
password: @default_password,
2524
password_confirmation: @default_password
2625
})
@@ -30,6 +29,7 @@ defmodule Craftplan.Test.AuthHelpers do
3029
private: %{ash_authentication?: true}
3130
}
3231
)
32+
|> assign_role!(role)
3333
end
3434

3535
@doc """
@@ -60,4 +60,15 @@ defmodule Craftplan.Test.AuthHelpers do
6060
end
6161

6262
defp unique_email(role), do: "#{role}+#{System.unique_integer([:positive])}@local"
63+
64+
defp assign_role!(user, :customer), do: user
65+
66+
defp assign_role!(user, role) do
67+
updated_user =
68+
user
69+
|> Ash.Changeset.for_update(:update_role, %{role: role})
70+
|> Ash.update!(authorize?: false)
71+
72+
%{updated_user | __metadata__: user.__metadata__}
73+
end
6374
end

test/support/data_case.ex

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ defmodule Craftplan.DataCase do
1616

1717
use ExUnit.CaseTemplate
1818

19-
alias AshAuthentication.Strategy.Password
20-
alias Craftplan.Accounts.User
19+
alias Craftplan.Test.AuthHelpers
2120
alias Ecto.Adapters.SQL.Sandbox
2221

2322
using do
@@ -64,41 +63,13 @@ defmodule Craftplan.DataCase do
6463
Create a staff user for use as an actor in tests that require authorization.
6564
"""
6665
def staff_actor do
67-
email = "staff+#{System.unique_integer([:positive])}@local"
68-
69-
User
70-
|> Ash.Changeset.for_create(:register_with_password, %{
71-
email: email,
72-
password: "Passw0rd!!",
73-
password_confirmation: "Passw0rd!!",
74-
role: :staff
75-
})
76-
|> Ash.create!(
77-
context: %{
78-
strategy: Password,
79-
private: %{ash_authentication?: true}
80-
}
81-
)
66+
AuthHelpers.register_user!(role: :staff)
8267
end
8368

8469
@doc """
8570
Create or fetch an admin user for tests requiring elevated privileges.
8671
"""
8772
def admin_actor do
88-
email = "admin+#{System.unique_integer([:positive])}@local"
89-
90-
User
91-
|> Ash.Changeset.for_create(:register_with_password, %{
92-
email: email,
93-
password: "Passw0rd!!",
94-
password_confirmation: "Passw0rd!!",
95-
role: :admin
96-
})
97-
|> Ash.create!(
98-
context: %{
99-
strategy: Password,
100-
private: %{ash_authentication?: true}
101-
}
102-
)
73+
AuthHelpers.register_user!(role: :admin)
10374
end
10475
end

0 commit comments

Comments
 (0)