diff --git a/apps/authenticator/lib/sessions.ex b/apps/authenticator/lib/sessions.ex index 82ebc7a..57f378b 100644 --- a/apps/authenticator/lib/sessions.ex +++ b/apps/authenticator/lib/sessions.ex @@ -1,5 +1,12 @@ defmodule Authenticator.Sessions do - @moduledoc false + @moduledoc """ + An sesssion is a representation of an succeded authentication by a subject. + + The session in this application stores most of the info needed to check if + an access_token still active or not. This can be done by checking two infos: + - The Session status has to be `active`; + - The session expiration cannot be higher than actual date (UTC); + """ use Authenticator.Domain, schema_model: Authenticator.Sessions.Schemas.Session diff --git a/apps/authenticator/lib/sign_in/application_attempts.ex b/apps/authenticator/lib/sign_in/application_attempts.ex new file mode 100644 index 0000000..37af928 --- /dev/null +++ b/apps/authenticator/lib/sign_in/application_attempts.ex @@ -0,0 +1,9 @@ +defmodule Authenticator.SignIn.ApplicationAttempts do + @moduledoc """ + A application attemp is a representation of an valid or not sign in attempt by the application; + + This is used in order to check if the application should be temporarilly blocked or not. + """ + + use Authenticator.Domain, schema_model: Authenticator.SignIn.Schemas.ApplicationAttempt +end diff --git a/apps/authenticator/lib/sign_in/commands/client_credentials.ex b/apps/authenticator/lib/sign_in/commands/client_credentials.ex index 0ea558d..12b270f 100644 --- a/apps/authenticator/lib/sign_in/commands/client_credentials.ex +++ b/apps/authenticator/lib/sign_in/commands/client_credentials.ex @@ -16,9 +16,11 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do require Logger alias Authenticator.Ports.ResourceManager, as: Port - alias Authenticator.Sessions + alias Authenticator.{Repo, Sessions} alias Authenticator.Sessions.Tokens.{AccessToken, ClientAssertion, RefreshToken} + alias Authenticator.SignIn.ApplicationAttempts alias Authenticator.SignIn.Inputs.ClientCredentials, as: Input + alias Ecto.Multi alias ResourceManager.Permissions.Scopes @behaviour Authenticator.SignIn.Commands.Behaviour @@ -39,11 +41,11 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do with {:app, {:ok, app}} <- {:app, Port.get_identity(%{client_id: client_id})}, {:flow_enabled?, true} <- {:flow_enabled?, "client_credentials" in app.grant_flows}, {:app_active?, true} <- {:app_active?, app.status == "active"}, - {:secret_matches?, true} <- {:secret_matches?, secret_matches?(app, input)}, {:valid_protocol?, true} <- {:valid_protocol?, app.protocol == "openid-connect"}, + {:secret_matches?, true} <- {:secret_matches?, secret_matches?(app, input)}, {:ok, access_token, claims} <- generate_access_token(app, scope), {:ok, refresh_token, _} <- generate_refresh_token(app, claims), - {:ok, _session} <- generate_session(claims) do + {:ok, _session} <- generate_and_save(input, claims) do {:ok, parse_response(access_token, refresh_token, claims)} else {:app, {:error, :not_found}} -> @@ -58,18 +60,15 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do Logger.info("Client application #{client_id} is not active") {:error, :unauthenticated} - {:secret_matches?, false} -> - Logger.info("Client application #{client_id} credential didn't matches") - {:error, :unauthenticated} - - {:confidential?, false} -> - Logger.info("Client application #{client_id} is not confidential") - {:error, :unauthenticated} - {:valid_protocol?, false} -> Logger.info("Client application #{client_id} protocol is not openid-connect") {:error, :unauthenticated} + {:secret_matches?, false} -> + Logger.info("Client application #{client_id} credential didn't matches") + generate_attempt(input, false) + {:error, :unauthenticated} + error -> Logger.error("Failed to run command becuase of unknow error", error: inspect(error)) error @@ -155,6 +154,30 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do end end + defp generate_and_save(input, claims) do + Multi.new() + |> Multi.run(:save_attempt, fn _repo, _changes -> generate_attempt(input, true) end) + |> Multi.run(:generate, fn _repo, _changes -> generate_session(claims) end) + |> Repo.transaction() + |> case do + {:ok, %{generate: session}} -> + Logger.info("Succeeds in creating session", id: session.id) + {:ok, session} + + {:error, step, reason, _changes} -> + Logger.error("Failed to create session in step #{inspect(step)}", reason: reason) + {:error, reason} + end + end + + defp generate_attempt(%{client_id: client_id, ip_address: ip_address}, success?) do + ApplicationAttempts.create(%{ + client_id: client_id, + was_successful: success?, + ip_address: ip_address + }) + end + defp generate_session(%{"jti" => jti, "sub" => sub, "exp" => exp} = claims) do Sessions.create(%{ jti: jti, diff --git a/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex b/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex index cf6e866..e2c4b33 100644 --- a/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex +++ b/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex @@ -16,12 +16,13 @@ defmodule Authenticator.SignIn.Inputs.ClientCredentials do @possible_grant_type ~w(client_credentials) @acceptable_assertion_types ~w(urn:ietf:params:oauth:client-assertion-type:jwt-bearer) - @required [:client_id, :grant_type, :scope] + @required [:client_id, :grant_type, :ip_address, :scope] @optional [:client_secret, :client_assertion, :client_assertion_type] embedded_schema do field :client_id, Ecto.UUID field :grant_type, :string field :scope, :string + field :ip_address, :string # Application credentials field :client_secret, :string diff --git a/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex b/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex index a54b01e..02ef511 100644 --- a/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex +++ b/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex @@ -18,7 +18,7 @@ defmodule Authenticator.SignIn.Inputs.ResourceOwner do @possible_grant_type ~w(password) @acceptable_assertion_types ~w(urn:ietf:params:oauth:client-assertion-type:jwt-bearer) - @required [:username, :password, :client_id, :scope, :grant_type] + @required [:username, :password, :client_id, :ip_address, :scope, :grant_type] @optional [:client_secret, :client_assertion, :client_assertion_type] embedded_schema do field :username, :string @@ -26,6 +26,7 @@ defmodule Authenticator.SignIn.Inputs.ResourceOwner do field :grant_type, :string field :scope, :string field :client_id, :string + field :ip_address, :string # Application credentials field :client_secret, :string diff --git a/apps/authenticator/lib/sign_in/commands/resource_owner.ex b/apps/authenticator/lib/sign_in/commands/resource_owner.ex index 5476413..bfae29d 100644 --- a/apps/authenticator/lib/sign_in/commands/resource_owner.ex +++ b/apps/authenticator/lib/sign_in/commands/resource_owner.ex @@ -19,9 +19,11 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do alias Authenticator.Crypto.Commands.{FakeVerifyHash, VerifyHash} alias Authenticator.Ports.ResourceManager, as: Port - alias Authenticator.Sessions + alias Authenticator.{Repo, Sessions} alias Authenticator.Sessions.Tokens.{AccessToken, ClientAssertion, RefreshToken} alias Authenticator.SignIn.Inputs.ResourceOwner, as: Input + alias Authenticator.SignIn.UserAttempts + alias Ecto.Multi alias ResourceManager.Permissions.Scopes @behaviour Authenticator.SignIn.Commands.Behaviour @@ -51,7 +53,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do {:pass_matches?, true} <- {:pass_matches?, VerifyHash.execute(user, input.password)}, {:ok, access_token, claims} <- generate_access_token(user, app, scope), {:ok, refresh_token, _} <- generate_refresh_token(app, claims), - {:ok, _session} <- generate_session(claims) do + {:ok, _session} <- generate_and_save(input, claims) do {:ok, parse_response(access_token, refresh_token, claims)} else {:app, {:error, :not_found}} -> @@ -96,6 +98,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do {:pass_matches?, false} -> Logger.info("User #{username} password do not match any credential") + generate_attempt(input, false) {:error, :unauthenticated} error -> @@ -185,6 +188,30 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do end end + defp generate_and_save(input, claims) do + Multi.new() + |> Multi.run(:save_attempt, fn _repo, _changes -> generate_attempt(input, true) end) + |> Multi.run(:generate, fn _repo, _changes -> generate_session(claims) end) + |> Repo.transaction() + |> case do + {:ok, %{generate: session}} -> + Logger.info("Succeeds in creating session", id: session.id) + {:ok, session} + + {:error, step, reason, _changes} -> + Logger.error("Failed to create session in step #{inspect(step)}", reason: reason) + {:error, reason} + end + end + + defp generate_attempt(%{username: username, ip_address: ip_address}, success?) do + UserAttempts.create(%{ + username: username, + was_successful: success?, + ip_address: ip_address + }) + end + defp generate_session(%{"jti" => jti, "sub" => sub, "exp" => exp} = claims) do Sessions.create(%{ jti: jti, diff --git a/apps/authenticator/lib/sign_in/schemas/application_attempt.ex b/apps/authenticator/lib/sign_in/schemas/application_attempt.ex new file mode 100644 index 0000000..cf15786 --- /dev/null +++ b/apps/authenticator/lib/sign_in/schemas/application_attempt.ex @@ -0,0 +1,50 @@ +defmodule Authenticator.SignIn.Schemas.ApplicationAttempt do + @moduledoc """ + Application login attempts. + + Every time a application sign in on API we save the login attempt in order + to create some rules to detect and prevant attacks. + """ + + use Authenticator.Schema + + import Ecto.Changeset + + @typedoc "Application attempt schema fields" + @type t :: %__MODULE__{ + id: binary(), + client_id: String.t(), + was_successful: boolean(), + ip_address: String.t(), + inserted_at: NaiveDateTime.t(), + updated_at: NaiveDateTime.t() + } + + @required_fields [:client_id, :was_successful, :ip_address] + schema "application_sign_in_attempt" do + field :client_id, :string + field :was_successful, :boolean + field :ip_address, :string + + timestamps() + end + + @doc false + def changeset_create(params) when is_map(params) do + %__MODULE__{} + |> cast(params, @required_fields) + |> validate_required(@required_fields) + end + + @doc false + def changeset_update(%__MODULE__{} = model, params) when is_map(params), + do: cast(model, params, @required_fields) + + ################# + # Custom filters + ################# + + defp custom_query(query, {:ids, ids}), do: where(query, [c], c.id in ^ids) + defp custom_query(query, {:created_after, date}), do: where(query, [c], c.inserted_at > ^date) + defp custom_query(query, {:created_before, date}), do: where(query, [c], c.inserted_at < ^date) +end diff --git a/apps/authenticator/lib/sign_in/schemas/user_attempt.ex b/apps/authenticator/lib/sign_in/schemas/user_attempt.ex new file mode 100644 index 0000000..2721269 --- /dev/null +++ b/apps/authenticator/lib/sign_in/schemas/user_attempt.ex @@ -0,0 +1,50 @@ +defmodule Authenticator.SignIn.Schemas.UserAttempt do + @moduledoc """ + User login attempts. + + Every time a user sign in on API we save the login attempt in order + to create some rules to detect and prevant attacks. + """ + + use Authenticator.Schema + + import Ecto.Changeset + + @typedoc "User attempt schema fields" + @type t :: %__MODULE__{ + id: binary(), + username: String.t(), + was_successful: boolean(), + ip_address: String.t(), + inserted_at: NaiveDateTime.t(), + updated_at: NaiveDateTime.t() + } + + @required_fields [:username, :was_successful, :ip_address] + schema "user_sign_in_attempt" do + field :username, :string + field :was_successful, :boolean + field :ip_address, :string + + timestamps() + end + + @doc false + def changeset_create(params) when is_map(params) do + %__MODULE__{} + |> cast(params, @required_fields) + |> validate_required(@required_fields) + end + + @doc false + def changeset_update(%__MODULE__{} = model, params) when is_map(params), + do: cast(model, params, @required_fields) + + ################# + # Custom filters + ################# + + defp custom_query(query, {:ids, ids}), do: where(query, [c], c.id in ^ids) + defp custom_query(query, {:created_after, date}), do: where(query, [c], c.inserted_at > ^date) + defp custom_query(query, {:created_before, date}), do: where(query, [c], c.inserted_at < ^date) +end diff --git a/apps/authenticator/lib/sign_in/user_attempts.ex b/apps/authenticator/lib/sign_in/user_attempts.ex new file mode 100644 index 0000000..0cda8da --- /dev/null +++ b/apps/authenticator/lib/sign_in/user_attempts.ex @@ -0,0 +1,9 @@ +defmodule Authenticator.SignIn.UserAttempts do + @moduledoc """ + A user attemp is a representation of an valid or not sign in attempt by the user; + + This is used in order to check if the user should be temporarilly blocked or not. + """ + + use Authenticator.Domain, schema_model: Authenticator.SignIn.Schemas.UserAttempt +end diff --git a/apps/authenticator/priv/repo/migrations/20201004125044_create_user_sign_in_attempt_table.exs b/apps/authenticator/priv/repo/migrations/20201004125044_create_user_sign_in_attempt_table.exs new file mode 100644 index 0000000..9eb3488 --- /dev/null +++ b/apps/authenticator/priv/repo/migrations/20201004125044_create_user_sign_in_attempt_table.exs @@ -0,0 +1,16 @@ +defmodule Authenticator.Repo.Migrations.CreateUserSignInAttemptTable do + use Ecto.Migration + + def change do + create_if_not_exists table(:user_sign_in_attempt, primary_key: false) do + add :id, :uuid, primary_key: true + add :username, :string, null: false + add :was_successful, :boolean, null: false + add :ip_address, :string, null: false + + timestamps() + end + + create_if_not_exists index(:user_sign_in_attempt, [:username, :was_successful, :ip_address]) + end +end diff --git a/apps/authenticator/priv/repo/migrations/20201004125050_create_application_sign_in_attempt_table.exs b/apps/authenticator/priv/repo/migrations/20201004125050_create_application_sign_in_attempt_table.exs new file mode 100644 index 0000000..e08e70a --- /dev/null +++ b/apps/authenticator/priv/repo/migrations/20201004125050_create_application_sign_in_attempt_table.exs @@ -0,0 +1,16 @@ +defmodule Authenticator.Repo.Migrations.CreateApplicationSignInAttemptTable do + use Ecto.Migration + + def change do + create_if_not_exists table(:application_sign_in_attempt, primary_key: false) do + add :id, :uuid, primary_key: true + add :client_id, :string, null: false + add :was_successful, :boolean, null: false + add :ip_address, :string, null: false + + timestamps() + end + + create_if_not_exists index(:application_sign_in_attempt, [:client_id, :was_successful, :ip_address]) + end +end diff --git a/apps/authenticator/test/authenticator/sign_in/application_attempts_test.exs b/apps/authenticator/test/authenticator/sign_in/application_attempts_test.exs new file mode 100644 index 0000000..6bc4606 --- /dev/null +++ b/apps/authenticator/test/authenticator/sign_in/application_attempts_test.exs @@ -0,0 +1,85 @@ +defmodule Authenticator.SignIn.ApplicationAttemptsTest do + use Authenticator.DataCase, async: true + + alias Authenticator.SignIn.ApplicationAttempts + alias Authenticator.SignIn.Schemas.ApplicationAttempt + + setup do + {:ok, application_attempt: insert!(:application_sign_in_attempt)} + end + + describe "#{ApplicationAttempts}.create/1" do + test "succeed if params are valid" do + params = %{ + client_id: Ecto.UUID.generate(), + was_successful: true, + ip_address: "45.232.192.12" + } + + assert {:ok, %ApplicationAttempt{id: id} = application_attempt} = + ApplicationAttempts.create(params) + + assert application_attempt == Repo.get(ApplicationAttempt, id) + end + + test "fails if params are invalid" do + assert {:error, + %{ + errors: [ + client_id: {"can't be blank", _}, + was_successful: {"can't be blank", _}, + ip_address: {"can't be blank", _} + ] + }} = ApplicationAttempts.create(%{}) + end + end + + describe "#{ApplicationAttempts}.update/2" do + test "succeed if params are valid", ctx do + assert {:ok, %ApplicationAttempt{id: id, was_successful: false} = application_attempt} = + ApplicationAttempts.update(ctx.application_attempt, %{was_successful: false}) + + assert application_attempt == Repo.get(ApplicationAttempt, id) + end + + test "fails if params are invalid", ctx do + assert {:error, %{errors: [was_successful: {"is invalid", _}]}} = + ApplicationAttempts.update(ctx.application_attempt, %{was_successful: 123}) + end + end + + describe "#{ApplicationAttempts}.get_by/1" do + test "succeed if params are valid", ctx do + assert %ApplicationAttempt{} = ApplicationAttempts.get_by(id: ctx.application_attempt.id) + end + + test "returns nil if nothing was found" do + assert nil == ApplicationAttempts.get_by(id: Ecto.UUID.generate()) + end + end + + describe "#{ApplicationAttempts}.list/1" do + test "succeed if params are valid", ctx do + assert [%ApplicationAttempt{}] = ApplicationAttempts.list(id: ctx.application_attempt.id) + end + + test "returns empty list if nothing was found" do + assert [] == ApplicationAttempts.list(id: Ecto.UUID.generate()) + end + end + + describe "#{ApplicationAttempts}.delete/1" do + test "succeed if params are valid", ctx do + assert {:ok, %ApplicationAttempt{id: id}} = + ApplicationAttempts.delete(ctx.application_attempt) + + assert nil == Repo.get(ApplicationAttempt, id) + end + + test "raises if application_attempt does not exist" do + assert_raise Ecto.NoPrimaryKeyValueError, fn -> + ApplicationAttempts.delete(%ApplicationAttempt{}) + end + end + end +end diff --git a/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs b/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs index b07e97b..90b7654 100644 --- a/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs +++ b/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs @@ -5,6 +5,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do alias Authenticator.Sessions.Schemas.Session alias Authenticator.Sessions.Tokens.{AccessToken, ClientAssertion, RefreshToken} alias Authenticator.SignIn.Commands.ClientCredentials, as: Command + alias Authenticator.SignIn.Schemas.ApplicationAttempt describe "#{Command}.execute/1" do test "succeeds and generates an access_token" do @@ -20,7 +21,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do grant_type: "client_credentials", scope: scope, client_id: client_id, - client_secret: app.secret + client_secret: app.secret, + ip_address: "45.232.192.12" } expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> @@ -51,6 +53,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do "typ" => ^typ }} = AccessToken.verify_and_validate(access_token) + assert %ApplicationAttempt{client_id: ^client_id} = Repo.one(ApplicationAttempt) assert %Session{jti: ^jti} = Repo.one(Session) end @@ -65,7 +68,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do "grant_type" => "client_credentials", "scope" => scope, "client_id" => client_id, - "client_secret" => app.secret + "client_secret" => app.secret, + "ip_address" => "45.232.192.12" } expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> @@ -95,6 +99,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do "typ" => ^typ }} = RefreshToken.verify_and_validate(refresh_token) + assert %ApplicationAttempt{client_id: ^client_id} = Repo.one(ApplicationAttempt) assert %Session{jti: ^jti} = Repo.one(Session) end @@ -116,7 +121,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do scope: scopes |> Enum.map(& &1.name) |> Enum.join(" "), client_id: app.client_id, client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - client_assertion: client_assertion + client_assertion: client_assertion, + ip_address: "45.232.192.12" } expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> @@ -153,7 +159,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do "scope" => scopes |> Enum.map(& &1.name) |> Enum.join(" "), "client_id" => app.client_id, "client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - "client_assertion" => client_assertion + "client_assertion" => client_assertion, + "ip_address" => "45.232.192.12" } expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> @@ -181,6 +188,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do client_assertion_type: {"can't be blank", [validation: :required]}, client_assertion: {"can't be blank", [validation: :required]}, client_id: {"can't be blank", [validation: :required]}, + ip_address: {"can't be blank", [validation: :required]}, scope: {"can't be blank", [validation: :required]} ] }} = Command.execute(%{grant_type: "client_credentials"}) @@ -191,7 +199,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do grant_type: "client_credentials", scope: "admin:read", client_id: Ecto.UUID.generate(), - client_secret: "my-secret" + client_secret: "my-secret", + ip_address: "45.232.192.12" } expect(ResourceManagerMock, :get_identity, fn _ -> {:error, :not_found} end) @@ -204,7 +213,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do grant_type: "client_credentials", scope: "admin:read", client_id: Ecto.UUID.generate(), - client_secret: "my-secret" + client_secret: "my-secret", + ip_address: "45.232.192.12" } expect(ResourceManagerMock, :get_identity, fn _ -> @@ -226,7 +236,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do grant_type: "client_credentials", scope: "admin:read", client_id: Ecto.UUID.generate(), - client_secret: "my-secret" + client_secret: "my-secret", + ip_address: "45.232.192.12" } expect(ResourceManagerMock, :get_identity, fn _ -> @@ -241,7 +252,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do grant_type: "client_credentials", scope: "admin:read", client_id: Ecto.UUID.generate(), - client_secret: Ecto.UUID.generate() + client_secret: Ecto.UUID.generate(), + ip_address: "45.232.192.12" } expect(ResourceManagerMock, :get_identity, fn _ -> @@ -256,7 +268,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do grant_type: "client_credentials", scope: "admin:read", client_id: Ecto.UUID.generate(), - client_secret: "my-secret" + client_secret: "my-secret", + ip_address: "45.232.192.12" } expect(ResourceManagerMock, :get_identity, fn _ -> diff --git a/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs b/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs index 5233cf5..2349eba 100644 --- a/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs +++ b/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs @@ -5,6 +5,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do alias Authenticator.Sessions.Schemas.Session alias Authenticator.Sessions.Tokens.{AccessToken, ClientAssertion, RefreshToken} alias Authenticator.SignIn.Commands.ResourceOwner, as: Command + alias Authenticator.SignIn.Schemas.UserAttempt describe "#{Command}.execute/1" do test "succeeds and generates an access_token" do @@ -14,15 +15,17 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do hash = RF.gen_hashed_password("MyPassw@rd234") password = RF.insert!(:password, user: user, password_hash: hash) + username = user.username subject_id = user.id client_id = app.client_id client_name = app.name scope = scopes |> Enum.map(& &1.name) |> Enum.join(" ") input = %{ - username: user.username, + username: username, password: "MyPassw@rd234", grant_type: "password", + ip_address: "45.232.192.12", scope: scope, client_id: client_id, client_secret: app.secret @@ -33,8 +36,8 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do {:ok, %{app | public_key: nil, scopes: scopes}} end) - expect(ResourceManagerMock, :get_identity, fn %{username: username} -> - assert user.username == username + expect(ResourceManagerMock, :get_identity, fn %{username: input_username} -> + assert username == input_username {:ok, %{user | password: password, scopes: scopes}} end) @@ -61,6 +64,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do "typ" => ^typ }} = AccessToken.verify_and_validate(access_token) + assert %UserAttempt{username: ^username} = Repo.one(UserAttempt) assert %Session{jti: ^jti} = Repo.one(Session) end @@ -71,13 +75,15 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do hash = RF.gen_hashed_password("MyPassw@rd234") password = RF.insert!(:password, user: user, password_hash: hash) + username = user.username client_id = app.client_id scope = scopes |> Enum.map(& &1.name) |> Enum.join(" ") input = %{ - "username" => user.username, + "username" => username, "password" => "MyPassw@rd234", "grant_type" => "password", + "ip_address" => "45.232.192.12", "scope" => scope, "client_id" => client_id, "client_secret" => app.secret @@ -88,8 +94,8 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do {:ok, %{app | public_key: nil, scopes: scopes}} end) - expect(ResourceManagerMock, :get_identity, fn %{username: username} -> - assert user.username == username + expect(ResourceManagerMock, :get_identity, fn %{username: input_username} -> + assert username == input_username {:ok, %{user | password: password, scopes: scopes}} end) @@ -115,6 +121,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do "typ" => ^typ }} = RefreshToken.verify_and_validate(refresh_token) + assert %UserAttempt{username: ^username} = Repo.one(UserAttempt) assert %Session{jti: ^jti} = Repo.one(Session) end @@ -138,6 +145,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do username: user.username, password: "MyPassw@rd234", grant_type: "password", + ip_address: "45.232.192.12", scope: scopes |> Enum.map(& &1.name) |> Enum.join(" "), client_id: app.client_id, client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", @@ -185,6 +193,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do "username" => user.username, "password" => "MyPassw@rd234", "grant_type" => "password", + "ip_address" => "45.232.192.12", "scope" => scopes |> Enum.map(& &1.name) |> Enum.join(" "), "client_id" => app.client_id, "client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", @@ -223,6 +232,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do username: {"can't be blank", [validation: :required]}, password: {"can't be blank", [validation: :required]}, client_id: {"can't be blank", [validation: :required]}, + ip_address: {"can't be blank", [validation: :required]}, scope: {"can't be blank", [validation: :required]} ] }} = Command.execute(%{grant_type: "password"}) @@ -234,6 +244,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do password: "MyPassw@rd234", grant_type: "password", scope: "admin:read", + ip_address: "45.232.192.12", client_id: Ecto.UUID.generate(), client_secret: "my-secret" } @@ -249,6 +260,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do password: "MyPassw@rd234", grant_type: "password", scope: "admin:read", + ip_address: "45.232.192.12", client_id: Ecto.UUID.generate(), client_secret: "my-secret" } @@ -266,6 +278,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do password: "MyPassw@rd234", grant_type: "password", scope: "admin:read", + ip_address: "45.232.192.12", client_id: Ecto.UUID.generate(), client_secret: "my-secret" } @@ -283,6 +296,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do password: "MyPassw@rd234", grant_type: "password", scope: "admin:read", + ip_address: "45.232.192.12", client_id: Ecto.UUID.generate(), client_secret: Ecto.UUID.generate() } @@ -300,6 +314,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do password: "MyPassw@rd234", grant_type: "password", scope: "admin:read", + ip_address: "45.232.192.12", client_id: Ecto.UUID.generate(), client_secret: "my-secret" } @@ -317,6 +332,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do password: "MyPassw@rd234", grant_type: "password", scope: "admin:read", + ip_address: "45.232.192.12", client_id: Ecto.UUID.generate(), client_secret: "my-secret" } @@ -336,6 +352,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do password: "MyPassw@rd234", grant_type: "password", scope: "admin:read", + ip_address: "45.232.192.12", client_id: app.client_id, client_secret: app.secret } @@ -358,6 +375,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do password: "MyPassw@rd234", grant_type: "password", scope: "admin:read", + ip_address: "45.232.192.12", client_id: app.client_id, client_secret: app.secret } @@ -382,6 +400,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do password: Ecto.UUID.generate(), grant_type: "password", scope: "admin:read", + ip_address: "45.232.192.12", client_id: app.client_id, client_secret: app.secret } diff --git a/apps/authenticator/test/authenticator/sign_in/user_attempts_test.exs b/apps/authenticator/test/authenticator/sign_in/user_attempts_test.exs new file mode 100644 index 0000000..b96b025 --- /dev/null +++ b/apps/authenticator/test/authenticator/sign_in/user_attempts_test.exs @@ -0,0 +1,81 @@ +defmodule Authenticator.SignIn.UserAttemptsTest do + use Authenticator.DataCase, async: true + + alias Authenticator.SignIn.Schemas.UserAttempt + alias Authenticator.SignIn.UserAttempts + + setup do + {:ok, user_attempt: insert!(:user_sign_in_attempt)} + end + + describe "#{UserAttempts}.create/1" do + test "succeed if params are valid" do + params = %{ + username: Ecto.UUID.generate(), + was_successful: true, + ip_address: "45.232.192.12" + } + + assert {:ok, %UserAttempt{id: id} = user_attempt} = UserAttempts.create(params) + assert user_attempt == Repo.get(UserAttempt, id) + end + + test "fails if params are invalid" do + assert {:error, + %{ + errors: [ + username: {"can't be blank", _}, + was_successful: {"can't be blank", _}, + ip_address: {"can't be blank", _} + ] + }} = UserAttempts.create(%{}) + end + end + + describe "#{UserAttempts}.update/2" do + test "succeed if params are valid", ctx do + assert {:ok, %UserAttempt{id: id, was_successful: false} = user_attempt} = + UserAttempts.update(ctx.user_attempt, %{was_successful: false}) + + assert user_attempt == Repo.get(UserAttempt, id) + end + + test "fails if params are invalid", ctx do + assert {:error, %{errors: [was_successful: {"is invalid", _}]}} = + UserAttempts.update(ctx.user_attempt, %{was_successful: 123}) + end + end + + describe "#{UserAttempts}.get_by/1" do + test "succeed if params are valid", ctx do + assert %UserAttempt{} = UserAttempts.get_by(id: ctx.user_attempt.id) + end + + test "returns nil if nothing was found" do + assert nil == UserAttempts.get_by(id: Ecto.UUID.generate()) + end + end + + describe "#{UserAttempts}.list/1" do + test "succeed if params are valid", ctx do + assert [%UserAttempt{}] = UserAttempts.list(id: ctx.user_attempt.id) + end + + test "returns empty list if nothing was found" do + assert [] == UserAttempts.list(id: Ecto.UUID.generate()) + end + end + + describe "#{UserAttempts}.delete/1" do + test "succeed if params are valid", ctx do + assert {:ok, %UserAttempt{id: id}} = UserAttempts.delete(ctx.user_attempt) + assert nil == Repo.get(UserAttempt, id) + end + + test "raises if user_attempt does not exist" do + assert_raise Ecto.NoPrimaryKeyValueError, fn -> + UserAttempts.delete(%UserAttempt{}) + end + end + end +end diff --git a/apps/authenticator/test/support/factory.ex b/apps/authenticator/test/support/factory.ex index c902f57..079e88d 100644 --- a/apps/authenticator/test/support/factory.ex +++ b/apps/authenticator/test/support/factory.ex @@ -4,6 +4,7 @@ defmodule Authenticator.Factory do alias Authenticator.Repo alias Authenticator.Sessions.Schemas.Session alias Authenticator.Sessions.Tokens.{AccessToken, RefreshToken} + alias Authenticator.SignIn.Schemas.{ApplicationAttempt, UserAttempt} @doc false def build(:session) do @@ -34,6 +35,22 @@ defmodule Authenticator.Factory do } end + def build(:user_sign_in_attempt) do + %UserAttempt{ + username: Ecto.UUID.generate(), + was_successful: true, + ip_address: "45.232.192.12" + } + end + + def build(:application_sign_in_attempt) do + %ApplicationAttempt{ + client_id: Ecto.UUID.generate(), + was_successful: true, + ip_address: "45.232.192.12" + } + end + @doc false def build(factory_name, attributes) when is_atom(factory_name) and is_list(attributes) do factory_name diff --git a/apps/rest_api/lib/controller.ex b/apps/rest_api/lib/controller.ex index 2144547..d3208a0 100644 --- a/apps/rest_api/lib/controller.ex +++ b/apps/rest_api/lib/controller.ex @@ -10,6 +10,14 @@ defmodule RestAPI.Controller do import Plug.Conn alias RestAPI.Router.Helpers, as: Routes + + @doc "Gets the remote_ip from connection and parses into a string" + @spec get_remote_ip(conn :: Plug.Conn.t()) :: String.t() + def get_remote_ip(conn) do + conn.remote_ip + |> :inet_parse.ntoa() + |> to_string() + end end end end diff --git a/apps/rest_api/lib/controllers/public/auth.ex b/apps/rest_api/lib/controllers/public/auth.ex index 3e09fa8..72870d0 100644 --- a/apps/rest_api/lib/controllers/public/auth.ex +++ b/apps/rest_api/lib/controllers/public/auth.ex @@ -19,18 +19,21 @@ defmodule RestAPI.Controllers.Public.Auth do @spec sign_in(conn :: Plug.Conn.t(), params :: map()) :: Plug.Conn.t() def sign_in(conn, %{"grant_type" => "password"} = params) do params + |> Map.put("ip_address", get_remote_ip(conn)) |> Commands.sign_in_resource_owner() |> parse_sign_in_response(conn) end def sign_in(conn, %{"grant_type" => "refresh_token"} = params) do params + |> Map.put("ip_address", get_remote_ip(conn)) |> Commands.sign_in_refresh_token() |> parse_sign_in_response(conn) end def sign_in(conn, %{"grant_type" => "client_credentials"} = params) do params + |> Map.put("ip_address", get_remote_ip(conn)) |> Commands.sign_in_client_credentials() |> parse_sign_in_response(conn) end