Skip to content

Commit 0f71aad

Browse files
lcpojryashin5
andauthored
feat: add sign in with two factor to resource owner flow (#58)
* feat: add sign in with two factor to resource owner flow * chore: add ports, commands and tests * chore: run format * fix: tests and preloads * Update apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs Co-authored-by: Yashin Santos <31665100+yashin5@users.noreply.github.com> * Update apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs Co-authored-by: Yashin Santos <31665100+yashin5@users.noreply.github.com> * Update apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs Co-authored-by: Yashin Santos <31665100+yashin5@users.noreply.github.com> * chore: minor refactors on function heads Co-authored-by: Yashin Santos <31665100+yashin5@users.noreply.github.com>
1 parent 9d01f45 commit 0f71aad

File tree

9 files changed

+239
-14
lines changed

9 files changed

+239
-14
lines changed

apps/authenticator/lib/ports/resource_manager.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ defmodule Authenticator.Ports.ResourceManager do
99
@doc "Delegates to ResourceManager.get_identity/1"
1010
@callback get_identity(input :: map()) :: possible_responses()
1111

12-
@doc "Authenticates the subject using Resource Owner Flow"
12+
@doc "Delegates to ResourceManager.valid_totp?/2"
13+
@callback valid_totp?(totp :: struct(), code :: String.t()) :: possible_responses()
14+
15+
@doc "Gets the subject identity by its username or client_id"
1316
@spec get_identity(input :: map()) :: possible_responses()
1417
def get_identity(input), do: implementation().get_identity(input)
1518

19+
@doc "Verifies if the given totp code matches the generated for the user"
20+
@spec valid_totp?(totp :: struct(), code :: String.t()) :: boolean()
21+
def valid_totp?(totp, code), do: implementation().valid_totp?(totp, code)
22+
1623
defp implementation do
1724
:authenticator
1825
|> Application.get_env(__MODULE__)

apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ defmodule Authenticator.SignIn.Inputs.ResourceOwner do
1919
@acceptable_assertion_types ~w(urn:ietf:params:oauth:client-assertion-type:jwt-bearer)
2020

2121
@required [:username, :password, :client_id, :ip_address, :scope, :grant_type]
22-
@optional [:client_secret, :client_assertion, :client_assertion_type]
22+
@optional [:otp, :client_secret, :client_assertion, :client_assertion_type]
2323
embedded_schema do
2424
field :username, :string
2525
field :password, :string
26+
field :otp, :string
2627
field :grant_type, :string
2728
field :scope, :string
2829
field :client_id, :string
@@ -40,6 +41,7 @@ defmodule Authenticator.SignIn.Inputs.ResourceOwner do
4041
|> cast(params, @required ++ @optional)
4142
|> validate_length(:username, min: 1)
4243
|> validate_length(:password, min: 1)
44+
|> validate_format(:otp, ~r(\d{4,6}))
4345
|> validate_inclusion(:grant_type, @possible_grant_type)
4446
|> validate_length(:scope, min: 1)
4547
|> validate_length(:client_id, min: 1)

apps/authenticator/lib/sign_in/commands/resource_owner.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do
110110
end
111111
end
112112

113-
defp run_public_authentication(user, app, input) do
114-
if VerifyHash.execute(user, input.password) do
113+
defp run_public_authentication(user, app, %{password: password, otp: otp} = input) do
114+
if VerifyHash.execute(user, password) and valid_totp?(user, otp) do
115115
user
116116
|> generate_tokens(app, input)
117117
|> parse_response()
@@ -122,6 +122,10 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do
122122
end
123123
end
124124

125+
defp valid_totp?(%{totp: nil}, nil), do: true
126+
defp valid_totp?(%{totp: nil}, code) when is_binary(code), do: false
127+
defp valid_totp?(%{totp: totp}, code) when is_binary(code), do: Port.valid_totp?(totp, code)
128+
125129
defp run_confidential_authentication(user, app, input) do
126130
with {:secret_matches?, true} <- {:secret_matches?, secret_matches?(app, input)},
127131
{:pass_matches?, true} <- {:pass_matches?, VerifyHash.execute(user, input.password)} do

apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs

Lines changed: 175 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,74 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do
3838

3939
expect(ResourceManagerMock, :get_identity, fn %{username: input_username} ->
4040
assert username == input_username
41-
{:ok, %{user | password: password, scopes: scopes}}
41+
{:ok, %{user | password: password, totp: nil, scopes: scopes}}
42+
end)
43+
44+
assert {:ok,
45+
%{
46+
access_token: access_token,
47+
refresh_token: nil,
48+
expires_in: 7200,
49+
token_type: typ
50+
}} = Command.execute(input)
51+
52+
assert {:ok,
53+
%{
54+
"aud" => ^client_id,
55+
"azp" => ^client_name,
56+
"exp" => _,
57+
"iat" => _,
58+
"iss" => "WatcherEx",
59+
"jti" => jti,
60+
"nbf" => _,
61+
"scope" => ^scope,
62+
"identity" => "user",
63+
"sub" => ^subject_id,
64+
"typ" => ^typ
65+
}} = AccessToken.verify_and_validate(access_token)
66+
67+
assert %UserAttempt{username: ^username} = Repo.one(UserAttempt)
68+
assert %Session{jti: ^jti} = Repo.one(Session)
69+
end
70+
71+
test "succeeds and generates an access_token validating totp" do
72+
scopes = RF.insert_list!(:scope, 3)
73+
user = RF.insert!(:user)
74+
totp = RF.insert!(:totp, user: user)
75+
app = RF.insert!(:client_application)
76+
hash = RF.gen_hashed_password("MyPassw@rd234")
77+
password = RF.insert!(:password, user: user, password_hash: hash)
78+
79+
username = user.username
80+
subject_id = user.id
81+
client_id = app.client_id
82+
client_name = app.name
83+
scope = scopes |> Enum.map(& &1.name) |> Enum.join(" ")
84+
85+
input = %{
86+
username: username,
87+
password: "MyPassw@rd234",
88+
otp: "1234",
89+
grant_type: "password",
90+
ip_address: "45.232.192.12",
91+
scope: scope,
92+
client_id: client_id,
93+
client_secret: app.secret
94+
}
95+
96+
expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} ->
97+
assert app.client_id == client_id
98+
{:ok, %{app | public_key: nil, scopes: scopes}}
99+
end)
100+
101+
expect(ResourceManagerMock, :get_identity, fn %{username: input_username} ->
102+
assert username == input_username
103+
{:ok, %{user | password: password, totp: totp, scopes: scopes}}
104+
end)
105+
106+
expect(ResourceManagerMock, :valid_totp?, fn %{id: totp_id}, "1234" ->
107+
assert totp.id == totp_id
108+
true
42109
end)
43110

44111
assert {:ok,
@@ -96,7 +163,72 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do
96163

97164
expect(ResourceManagerMock, :get_identity, fn %{username: input_username} ->
98165
assert username == input_username
99-
{:ok, %{user | password: password, scopes: scopes}}
166+
{:ok, %{user | password: password, totp: nil, scopes: scopes}}
167+
end)
168+
169+
assert {:ok,
170+
%{
171+
access_token: access_token,
172+
refresh_token: refresh_token,
173+
expires_in: 7200,
174+
token_type: typ
175+
}} = Command.execute(input)
176+
177+
assert {:ok, %{"jti" => jti}} = RefreshToken.verify_and_validate(access_token)
178+
179+
assert {:ok,
180+
%{
181+
"aud" => ^client_id,
182+
"ati" => ^jti,
183+
"exp" => _,
184+
"iat" => _,
185+
"iss" => "WatcherEx",
186+
"jti" => _,
187+
"nbf" => _,
188+
"typ" => ^typ
189+
}} = RefreshToken.verify_and_validate(refresh_token)
190+
191+
assert %UserAttempt{username: ^username} = Repo.one(UserAttempt)
192+
assert %Session{jti: ^jti} = Repo.one(Session)
193+
end
194+
195+
test "succeeds and generates a refresh_token validating totp" do
196+
scopes = RF.insert_list!(:scope, 3)
197+
%{username: username} = user = RF.insert!(:user)
198+
totp = RF.insert!(:totp, user: user)
199+
200+
%{client_id: client_id} =
201+
app = RF.insert!(:client_application, grant_flows: ["resource_owner", "refresh_token"])
202+
203+
hash = RF.gen_hashed_password("MyPassw@rd234")
204+
password = RF.insert!(:password, user: user, password_hash: hash)
205+
206+
scope = scopes |> Enum.map(& &1.name) |> Enum.join(" ")
207+
208+
input = %{
209+
"username" => username,
210+
"password" => "MyPassw@rd234",
211+
"otp" => "1234",
212+
"grant_type" => "password",
213+
"ip_address" => "45.232.192.12",
214+
"scope" => scope,
215+
"client_id" => client_id,
216+
"client_secret" => app.secret
217+
}
218+
219+
expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} ->
220+
assert app.client_id == client_id
221+
{:ok, %{app | public_key: nil, scopes: scopes}}
222+
end)
223+
224+
expect(ResourceManagerMock, :get_identity, fn %{username: input_username} ->
225+
assert username == input_username
226+
{:ok, %{user | password: password, totp: totp, scopes: scopes}}
227+
end)
228+
229+
expect(ResourceManagerMock, :valid_totp?, fn %{id: totp_id}, "1234" ->
230+
assert totp.id == totp_id
231+
true
100232
end)
101233

102234
assert {:ok,
@@ -418,5 +550,46 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do
418550

419551
assert {:error, :unauthenticated} == Command.execute(input)
420552
end
553+
554+
test "fails if user totp do not match credential" do
555+
scopes = RF.insert_list!(:scope, 3)
556+
user = RF.insert!(:user)
557+
totp = RF.insert!(:totp, user: user)
558+
app = RF.insert!(:client_application)
559+
hash = RF.gen_hashed_password("MyPassw@rd234")
560+
password = RF.insert!(:password, user: user, password_hash: hash)
561+
562+
username = user.username
563+
client_id = app.client_id
564+
scope = scopes |> Enum.map(& &1.name) |> Enum.join(" ")
565+
566+
input = %{
567+
username: username,
568+
password: "MyPassw@rd234",
569+
otp: "4321",
570+
grant_type: "password",
571+
ip_address: "45.232.192.12",
572+
scope: scope,
573+
client_id: client_id,
574+
client_secret: app.secret
575+
}
576+
577+
expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} ->
578+
assert app.client_id == client_id
579+
{:ok, %{app | public_key: nil, scopes: scopes}}
580+
end)
581+
582+
expect(ResourceManagerMock, :get_identity, fn %{username: input_username} ->
583+
assert username == input_username
584+
{:ok, %{user | password: password, totp: totp, scopes: scopes}}
585+
end)
586+
587+
expect(ResourceManagerMock, :valid_totp?, fn %{id: totp_id}, "4321" ->
588+
assert totp.id == totp_id
589+
false
590+
end)
591+
592+
assert {:error, :unauthenticated} == Command.execute(input)
593+
end
421594
end
422595
end

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ defmodule ResourceManager.Identities.Commands.GetIdentity do
2727
input
2828
|> GetUser.cast_to_list()
2929
|> Users.get_by()
30-
|> Repo.preload([:password, :scopes])
30+
|> Repo.preload([:password, :totp, :scopes])
3131
|> case do
3232
%User{} = user ->
3333
Logger.info("User identity #{user.id} got with success")

apps/resource_manager/lib/identities/schemas/user.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ defmodule ResourceManager.Identities.Schemas.User do
1010

1111
import Ecto.Changeset
1212

13-
alias ResourceManager.Credentials.Schemas.Password
13+
alias ResourceManager.Credentials.Schemas.{Password, TOTP}
1414
alias ResourceManager.Permissions.Schemas.Scope
1515

1616
@typedoc "User schema fields"
@@ -37,6 +37,7 @@ defmodule ResourceManager.Identities.Schemas.User do
3737
field :blocked_until, :naive_datetime
3838

3939
has_one :password, Password
40+
has_one :totp, TOTP
4041
many_to_many :scopes, Scope, join_through: "users_scopes"
4142

4243
timestamps()

apps/resource_manager/lib/resource_manager.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule ResourceManager do
44
"""
55

66
alias ResourceManager.Credentials.Commands.PasswordIsAllowed
7+
alias ResourceManager.Credentials.TOTPs
78
alias ResourceManager.Identities.Commands.{CreateClientApplication, CreateUser, GetIdentity}
89
alias ResourceManager.Permissions.Commands.{ConsentScope, RemoveScope}
910

@@ -24,4 +25,7 @@ defmodule ResourceManager do
2425

2526
@doc "Delegates to #{PasswordIsAllowed}.execute/1"
2627
defdelegate password_allowed?(password), to: PasswordIsAllowed, as: :execute
28+
29+
@doc "Delegates to #{TOTP}.valid_code?/2"
30+
defdelegate valid_totp?(totp, code), to: TOTPs, as: :valid_code?
2731
end

apps/resource_manager/test/resource_manager/identity/commands/get_identity_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ defmodule ResourceManager.Identities.Commands.GetIdentityTest do
1919
}
2020

2121
assert {:ok, %User{} = user} = GetIdentity.execute(input)
22-
assert user == User |> Repo.one() |> Repo.preload([:password, :scopes])
22+
assert user == User |> Repo.one() |> Repo.preload([:password, :scopes, :totp])
2323
end
2424

2525
test "succeeds in getting client application identity if params are valid", ctx do

apps/rest_api/test/controllers/public/auth_test.exs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,28 @@ defmodule RestAPI.Controllers.Public.AuthTest do
3131
|> json_response(200)
3232
end
3333

34+
test "suceeds in Resource Owner Flow with totp if params are valid", %{conn: conn} do
35+
params = %{
36+
"username" => "my-username",
37+
"password" => "my-password",
38+
"otp" => "1234",
39+
"grant_type" => "password",
40+
"scope" => "admin:read admin:write",
41+
"client_id" => "2e455bb1-0604-4812-9756-36f7ab23b8d9",
42+
"client_secret" => "w3MehAvgztbMYpnhneVLQhkoZbxAXBGUCFe"
43+
}
44+
45+
expect(AuthenticatorMock, :sign_in_resource_owner, fn _input ->
46+
{:ok, success_payload()}
47+
end)
48+
49+
assert %{"access_token" => _, "refresh_token" => _, "token_type" => _, "expires_in" => _} =
50+
conn
51+
|> put_req_header("content-type", @content_type)
52+
|> post(@token_endpoint, params)
53+
|> json_response(200)
54+
end
55+
3456
test "suceeds in Refresh Token Flow if params are valid", %{conn: conn} do
3557
params = %{
3658
"refresh_token" => "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
@@ -73,18 +95,22 @@ defmodule RestAPI.Controllers.Public.AuthTest do
7395
end)
7496

7597
assert %{
98+
"detail" => "The given params failed in validation",
99+
"error" => "bad_request",
100+
"status" => 400,
76101
"response" => %{
77102
"scope" => ["can't be blank"],
78103
"client_id" => ["can't be blank"],
79104
"client_assertion" => ["can't be blank"],
80105
"client_assertion_type" => ["can't be blank"],
81106
"password" => ["can't be blank"],
82-
"username" => ["can't be blank"]
107+
"username" => ["can't be blank"],
108+
"otp" => ["is invalid"]
83109
}
84-
} =
110+
} ==
85111
conn
86112
|> put_req_header("content-type", @content_type)
87-
|> post(@token_endpoint, %{"grant_type" => "password"})
113+
|> post(@token_endpoint, %{"grant_type" => "password", "otp" => 123})
88114
|> json_response(400)
89115
end
90116

@@ -93,7 +119,12 @@ defmodule RestAPI.Controllers.Public.AuthTest do
93119
RefreshToken.cast_and_apply(input)
94120
end)
95121

96-
assert %{"response" => %{"refresh_token" => ["can't be blank"]}} =
122+
assert %{
123+
"detail" => "The given params failed in validation",
124+
"error" => "bad_request",
125+
"status" => 400,
126+
"response" => %{"refresh_token" => ["can't be blank"]}
127+
} ==
97128
conn
98129
|> put_req_header("content-type", @content_type)
99130
|> post(@token_endpoint, %{"grant_type" => "refresh_token"})
@@ -106,13 +137,16 @@ defmodule RestAPI.Controllers.Public.AuthTest do
106137
end)
107138

108139
assert %{
140+
"detail" => "The given params failed in validation",
141+
"error" => "bad_request",
142+
"status" => 400,
109143
"response" => %{
110144
"scope" => ["can't be blank"],
111145
"client_id" => ["can't be blank"],
112146
"client_assertion" => ["can't be blank"],
113147
"client_assertion_type" => ["can't be blank"]
114148
}
115-
} =
149+
} ==
116150
conn
117151
|> put_req_header("content-type", @content_type)
118152
|> post(@token_endpoint, %{"grant_type" => "client_credentials"})

0 commit comments

Comments
 (0)