Skip to content

Commit 38de225

Browse files
yashin5Yashin Santos
andauthored
feat: create admin endpoint (#33)
* feat: create admin endpoint * feat: add password strong validation * feat: use authentication in admin endpoint * chore: use password_allowed? Co-authored-by: Yashin Santos <yashinndsantos@gmail..com>
1 parent 55c8f37 commit 38de225

File tree

14 files changed

+350
-4
lines changed

14 files changed

+350
-4
lines changed

apps/resource_manager/lib/identities/commands/create_identity.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ defmodule ResourceManager.Identities.Commands.CreateIdentity do
8282
end
8383
end
8484

85+
def execute(%{"username" => _, "password_hash" => _} = params) do
86+
params
87+
|> CreateUser.cast_and_apply()
88+
|> case do
89+
{:ok, %CreateUser{} = input} -> execute(input)
90+
error -> error
91+
end
92+
end
93+
8594
def execute(%{username: _, password_hash: _} = params) do
8695
params
8796
|> CreateUser.cast_and_apply()

apps/rest_api/README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,27 @@ curl -X POST http://localhost:4000/api/v1/auth/protocol/openid-connect/logout \
133133

134134
**Response (204)**:
135135

136-
`No content`
136+
`No content`
137+
138+
### Create an user
139+
140+
**Request**:
141+
142+
```sh
143+
curl -X POST http://localhost:4000/admin/v1/users \
144+
-H "Content-Type: application/json" \
145+
-d '{"username":"yashu", "password":"lcpo", "scopes":["6a3a3771-9f56-4254-9497-927e441dacfc" "8a235ba0-a827-4593-92c9-6248bef4fa06"]}'
146+
```
147+
148+
**Response (201)**:
149+
150+
```json
151+
{
152+
"id":"0c5fb5a7-5d86-4b11-b4e3-facf925b3e9d",
153+
"inserted_at":"2020-10-04T13:23:45",
154+
"is_admin":false,
155+
"status":"active",
156+
"update_at":"2020-10-04T13:23:45",
157+
"username":"yashu"
158+
}
159+
```
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule RestAPI.Controller.Admin.User do
2+
@moduledoc false
3+
4+
use RestAPI.Controller, :controller
5+
6+
alias RestAPI.Ports.{Authenticator, ResourceManager}
7+
alias RestAPI.Views.Admin.User
8+
9+
action_fallback RestAPI.Controllers.Fallback
10+
11+
def create(conn, %{"password" => password} = params) do
12+
with true <- ResourceManager.password_allowed?(password),
13+
password_hash <- Authenticator.generate_hash(password, :argon2),
14+
params <-
15+
Map.merge(params, %{"password_hash" => password_hash, "password_algorithm" => "argon2"}),
16+
{:ok, response} <- ResourceManager.create_identity(params) do
17+
conn
18+
|> put_status(:created)
19+
|> put_view(User)
20+
|> render("create.json", response: response)
21+
else
22+
false ->
23+
{:error, 400, %{password: ["password is not strong enough"]}}
24+
25+
{:error, _any} = error ->
26+
error
27+
end
28+
end
29+
end

apps/rest_api/lib/controllers/fallback.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ defmodule RestAPI.Controllers.Fallback do
1414
|> render("400.json")
1515
end
1616

17+
def call(conn, {:error, status, response}) when status in [:bad_request, 400] do
18+
conn
19+
|> put_status(status)
20+
|> put_view(Default)
21+
|> render("400.json", response: response)
22+
end
23+
1724
def call(conn, {:error, :unauthorized}) do
1825
conn
1926
|> put_status(:unauthorized)
@@ -35,6 +42,13 @@ defmodule RestAPI.Controllers.Fallback do
3542
|> render("404.json")
3643
end
3744

45+
def call(conn, {:error, status, response}) when status in [:unprocessable_entity, 422] do
46+
conn
47+
|> put_status(status)
48+
|> put_view(Default)
49+
|> render("422.json", response: response)
50+
end
51+
3852
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
3953
conn
4054
|> put_status(:bad_request)

apps/rest_api/lib/ports/authenticator.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ defmodule RestAPI.Ports.Authenticator do
4242
@callback sign_out_all_sessions(subject_id :: String.t(), subject_type :: String.t()) ::
4343
{:ok, count :: integer()} | possible_logout_failures()
4444

45+
@doc "Delegates to Authenticator.generate_hash/2"
46+
@callback generate_hash(password :: String.t(), algorithm :: atom()) :: String.t()
47+
4548
@doc "Authenticates the subject using Resource Owner Flow"
4649
@spec sign_in_resource_owner(input :: map()) :: possible_sign_in_responses()
4750
def sign_in_resource_owner(input), do: implementation().sign_in_resource_owner(input)
@@ -71,6 +74,10 @@ defmodule RestAPI.Ports.Authenticator do
7174
{:ok, count :: integer()} | possible_logout_failures()
7275
def sign_out_all_sessions(sub, type), do: implementation().sign_out_all_sessions(sub, type)
7376

77+
@doc "Generate a hash using the given password and algorithm"
78+
@spec generate_hash(password :: String.t(), algorithm :: atom()) :: String.t()
79+
def generate_hash(password, algorithm), do: implementation().generate_hash(password, algorithm)
80+
7481
defp implementation do
7582
:rest_api
7683
|> Application.get_env(__MODULE__)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule RestAPI.Ports.ResourceManager do
2+
@moduledoc """
3+
Port to access Authenticator domain commands.
4+
"""
5+
6+
@typedoc "All possible create_identity responses"
7+
@type possible_create_identity_response ::
8+
{:ok, struct()} | {:error, Ecto.Changeset.t() | :invalid_params}
9+
10+
@doc "Delegates to ResourceManager.create_identity/1"
11+
@callback create_identity(input :: map()) :: possible_create_identity_response()
12+
13+
@doc "Delegates to ResourceManager.password_allowed?/1"
14+
@callback password_allowed?(password :: String.t()) :: boolean()
15+
16+
@doc "Create a new identity with it's credentials"
17+
@spec create_identity(input :: map()) :: possible_create_identity_response()
18+
def create_identity(input), do: implementation().create_identity(input)
19+
20+
@doc "Checks if the given password is strong enough to be used"
21+
@spec password_allowed?(password :: String.t()) :: boolean()
22+
def password_allowed?(password), do: implementation().password_allowed?(password)
23+
24+
defp implementation do
25+
:rest_api
26+
|> Application.get_env(__MODULE__)
27+
|> Keyword.get(:domain)
28+
end
29+
end

apps/rest_api/lib/routers/public.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,10 @@ defmodule RestAPI.Routers.Public do
2828
end
2929
end
3030
end
31+
32+
scope "/admin/v1", RestAPI.Controller.Admin do
33+
pipe_through :authenticated
34+
35+
resources("/users", User, except: [:new])
36+
end
3137
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
defmodule RestAPI.Views.Admin.User do
2+
@moduledoc false
3+
4+
use RestAPI.View
5+
6+
def render("create.json", %{response: response}) do
7+
%{
8+
id: response.id,
9+
username: response.username,
10+
status: response.status,
11+
is_admin: response.is_admin,
12+
inserted_at: response.inserted_at,
13+
update_at: response.updated_at
14+
}
15+
end
16+
end

apps/rest_api/lib/views/errors/default.ex

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ defmodule RestAPI.Views.Errors.Default do
33

44
use RestAPI.View
55

6+
def render("400.json", %{response: response}) do
7+
%{
8+
status: 400,
9+
error: "bad_request",
10+
detail: "The given params failed in validation",
11+
response: response
12+
}
13+
end
14+
615
def render("400.json", _assigns) do
716
%{
817
status: 400,
@@ -43,10 +52,19 @@ defmodule RestAPI.Views.Errors.Default do
4352
}
4453
end
4554

55+
def render("422.json", %{response: response}) do
56+
%{
57+
status: 422,
58+
detail: "The given params failed in validation",
59+
response: response,
60+
error: "unprocessable entity"
61+
}
62+
end
63+
4664
def render("changeset.json", %{response: response}) do
4765
%{
4866
status: 400,
49-
detail: "The given params are invalid",
67+
detail: "The given params failed in validation",
5068
response: Ecto.Changeset.traverse_errors(response, &translate_error/1),
5169
error: "bad_request"
5270
}

0 commit comments

Comments
 (0)