diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5649661883..24f31b75ac 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -516,6 +516,8 @@ jobs: postgresql_version: "15" - name: Run DB migrations run: mix ecto.migrate + - name: Run DB seed + run: mix run priv/repo/seeds.exs - name: Run trento detached run: mix phx.server & - name: Install photofinish diff --git a/assets/js/lib/network/socket.js b/assets/js/lib/network/socket.js index 21eb8522a3..45c65d77df 100644 --- a/assets/js/lib/network/socket.js +++ b/assets/js/lib/network/socket.js @@ -2,6 +2,7 @@ // eslint-disable-next-line import { Socket } from 'phoenix'; import { logMessage, logError } from '@lib/log'; +import { getAccessTokenFromStore } from '@lib/auth'; export const joinChannel = (channel) => { channel @@ -12,7 +13,9 @@ export const joinChannel = (channel) => { }; export const initSocketConnection = () => { - const socket = new Socket('/socket', {}); + const socket = new Socket('/socket', { + params: { access_token: getAccessTokenFromStore() }, + }); socket.connect(); return socket; diff --git a/config/config.exs b/config/config.exs index 7eaa452b8f..a5e9d297d0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -76,6 +76,7 @@ config :trento, event_stores: [Trento.EventStore] config :trento, :pow, user: Trento.Users.User, repo: Trento.Repo, + users_context: Trento.Users, web_module: TrentoWeb, extensions: [PowPersistentSession], controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks @@ -168,8 +169,8 @@ config :trento, :jwt_authentication, issuer: "https://github.com/trento-project/web", app_audience: "trento_app", api_key_audience: "trento_api_key", - # Seconds, 10 minutes - access_token_expiration: 600, + # Seconds, 3 minutes + access_token_expiration: 180, # Seconds, 6 hours refresh_token_expiration: 21600 diff --git a/lib/trento/release.ex b/lib/trento/release.ex index 0394a26448..1a604eb6c4 100644 --- a/lib/trento/release.ex +++ b/lib/trento/release.ex @@ -4,7 +4,6 @@ defmodule Trento.Release do installed. """ - alias Pow.Ecto.Schema.Password alias Trento.Settings.ApiKeySettings @app :trento @@ -71,15 +70,19 @@ defmodule Trento.Release do admin_user = System.get_env("ADMIN_USER", "admin") admin_password = System.get_env("ADMIN_PASSWORD", "adminpassword") + admin_email = System.get_env("ADMIN_EMAIL", "admin@trento.suse.com") %Trento.Users.User{} |> Trento.Users.User.changeset(%{ username: admin_user, password: admin_password, - confirm_password: admin_password + confirm_password: admin_password, + email: admin_email, + enabled: true, + fullname: "Trento Default Admin" }) |> Trento.Repo.insert!( - on_conflict: [set: [password_hash: Password.pbkdf2_hash(admin_password)]], + on_conflict: [set: [password_hash: Argon2.hash_pwd_salt(admin_password)]], conflict_target: :username ) end diff --git a/lib/trento/users.ex b/lib/trento/users.ex new file mode 100644 index 0000000000..19f675169b --- /dev/null +++ b/lib/trento/users.ex @@ -0,0 +1,78 @@ +defmodule Trento.Users do + @moduledoc """ + The Users context. + """ + + use Pow.Ecto.Context, + repo: Trento.Repo, + user: Trento.Users.User + + import Ecto.Query, warn: false + alias Trento.Repo + + alias Trento.Users.User + + @impl true + @doc """ + get_by function overrides the one defined in Pow.Ecto.Context, + we retrieve the user by username as traditional Pow flow but we also exclude + deleted and locked users + """ + def get_by(clauses) do + username = clauses[:username] + + User + |> where([u], is_nil(u.deleted_at) and is_nil(u.locked_at) and u.username == ^username) + |> Repo.one() + end + + def list_users do + User + |> where([u], is_nil(u.deleted_at)) + |> Repo.all() + end + + def get_user(id) do + case User + |> where([u], is_nil(u.deleted_at) and u.id == ^id) + |> Repo.one() do + nil -> {:error, :not_found} + user -> {:ok, user} + end + end + + def create_user(attrs \\ %{}) do + %User{} + |> User.changeset(attrs) + |> Repo.insert() + end + + def update_user(%User{id: 1}, _), do: {:error, :operation_not_permitted} + + def update_user(%User{locked_at: nil} = user, %{enabled: false} = attrs) do + do_update(user, Map.put(attrs, :locked_at, DateTime.utc_now())) + end + + def update_user(%User{locked_at: locked_at} = user, %{enabled: false} = attrs) + when not is_nil(locked_at) do + do_update(user, attrs) + end + + def update_user(%User{} = user, attrs) do + do_update(user, Map.put(attrs, :locked_at, nil)) + end + + def delete_user(%User{id: 1}), do: {:error, :operation_not_permitted} + + def delete_user(%User{} = user) do + user + |> User.delete_changeset(%{deleted_at: DateTime.utc_now()}) + |> Repo.update() + end + + defp do_update(user, attrs) do + user + |> User.update_changeset(attrs) + |> Repo.update() + end +end diff --git a/lib/trento/users/user.ex b/lib/trento/users/user.ex index 952b3d8042..f5c8a9d1d1 100644 --- a/lib/trento/users/user.ex +++ b/lib/trento/users/user.ex @@ -2,16 +2,28 @@ defmodule Trento.Users.User do @moduledoc false use Ecto.Schema + import Ecto.Changeset use Pow.Ecto.Schema, - user_id_field: :username + user_id_field: :username, + password_hash_methods: {&Argon2.hash_pwd_salt/1, &Argon2.verify_pass/2} use Pow.Extension.Ecto.Schema, extensions: [PowPersistentSession] + alias EctoCommons.EmailValidator + + @sequences ["01234567890", "abcdefghijklmnopqrstuvwxyz"] + @max_sequential_chars 3 + schema "users" do pow_user_fields() + field :email, :string + field :fullname, :string + field :deleted_at, :utc_datetime_usec + field :locked_at, :utc_datetime_usec + timestamps(type: :utc_datetime_usec) end @@ -19,5 +31,83 @@ defmodule Trento.Users.User do user |> pow_changeset(attrs) |> pow_extension_changeset(attrs) + |> validate_password() + |> custom_fields_changeset(attrs) + end + + def update_changeset(user, attrs) do + user + |> pow_password_changeset(attrs) + |> pow_extension_changeset(attrs) + |> validate_password() + |> custom_fields_changeset(attrs) + |> lock_changeset(attrs) + end + + def delete_changeset(%__MODULE__{username: username} = user, %{deleted_at: deleted_at} = attrs) do + user + |> cast(attrs, [:deleted_at]) + |> validate_required(:deleted_at) + |> put_change(:username, "#{username}__#{deleted_at}") + end + + defp lock_changeset(user, attrs) do + cast(user, attrs, [:locked_at]) + end + + defp validate_password(changeset) do + changeset + |> validate_no_repetitive_characters() + |> validate_no_sequential_characters() + end + + defp validate_no_repetitive_characters(changeset) do + Ecto.Changeset.validate_change(changeset, :password, fn :password, password -> + case repetitive_characters?(password) do + true -> [password: "has repetitive characters"] + false -> [] + end + end) + end + + defp repetitive_characters?(password) when is_binary(password) do + password + |> String.to_charlist() + |> repetitive_characters?() + end + + defp repetitive_characters?([c, c, c | _rest]), do: true + defp repetitive_characters?([_c | rest]), do: repetitive_characters?(rest) + defp repetitive_characters?([]), do: false + + defp validate_no_sequential_characters(changeset) do + Ecto.Changeset.validate_change(changeset, :password, fn :password, password -> + case sequential_characters?(password) do + true -> [password: "has sequential characters"] + false -> [] + end + end) + end + + defp sequential_characters?(password) do + Enum.any?(@sequences, &sequential_characters?(password, &1)) + end + + defp sequential_characters?(password, sequence) do + max = String.length(sequence) - 1 - @max_sequential_chars + + Enum.any?(0..max, fn x -> + pattern = String.slice(sequence, x, @max_sequential_chars + 1) + + String.contains?(password, pattern) + end) + end + + defp custom_fields_changeset(user, attrs) do + user + |> cast(attrs, [:email, :fullname]) + |> validate_required([:email, :fullname]) + |> EmailValidator.validate_email(:email, checks: [:pow]) + |> unique_constraint(:email) end end diff --git a/lib/trento_web/channels/user_channel.ex b/lib/trento_web/channels/user_channel.ex new file mode 100644 index 0000000000..751a70b5f0 --- /dev/null +++ b/lib/trento_web/channels/user_channel.ex @@ -0,0 +1,33 @@ +defmodule TrentoWeb.UserChannel do + @moduledoc """ + User channel, each user is subscribed to his channel, + to receive personal broadcasts + + Users can't join other users channel + """ + require Logger + use TrentoWeb, :channel + + @impl true + def join( + "users:" <> user_id, + _payload, + %{assigns: %{current_user_id: current_user_id}} = socket + ) do + user_id = String.to_integer(user_id) + + if user_id == current_user_id do + {:ok, socket} + else + Logger.error( + "Could not join user channel, requested user id: #{user_id}, authenticated user id: #{current_user_id}" + ) + + {:error, :unauthorized} + end + end + + def join("users:" <> _user_id, _payload, _socket) do + {:error, :user_not_logged} + end +end diff --git a/lib/trento_web/channels/user_socket.ex b/lib/trento_web/channels/user_socket.ex index e6d1c11769..7e20cdb649 100644 --- a/lib/trento_web/channels/user_socket.ex +++ b/lib/trento_web/channels/user_socket.ex @@ -1,6 +1,9 @@ defmodule TrentoWeb.UserSocket do use Phoenix.Socket + require Logger + alias TrentoWeb.Auth.AccessToken + # A Socket handler # # It's possible to control the websocket connection and @@ -9,6 +12,7 @@ defmodule TrentoWeb.UserSocket do ## Channels channel "monitoring:*", TrentoWeb.MonitoringChannel + channel "users:*", TrentoWeb.UserChannel # Socket params are passed from the client and can # be used to verify and authenticate a user. After @@ -22,8 +26,20 @@ defmodule TrentoWeb.UserSocket do # See `Phoenix.Token` documentation for examples in # performing token verification on connect. @impl true - def connect(_params, socket, _connect_info) do - {:ok, socket} + def connect(%{"access_token" => access_token}, socket, _connect_info) do + case AccessToken.verify_and_validate(access_token) do + {:ok, %{"sub" => user_id}} -> + {:ok, assign(socket, :current_user_id, user_id)} + + {:error, reason} -> + Logger.error("Could not authenticate user socket: #{inspect(reason)}") + {:error, reason} + end + end + + def connect(_, _, _) do + Logger.error("Could not authenticate user socket: missing auth token") + {:error, :missing_auth_token} end # Socket id's are topics that allow you to identify all sockets for a given user: @@ -37,5 +53,5 @@ defmodule TrentoWeb.UserSocket do # # Returning `nil` makes this socket anonymous. @impl true - def id(_socket), do: nil + def id(socket), do: "user_socket:#{socket.assigns.current_user_id}" end diff --git a/lib/trento_web/controllers/fallback_controller.ex b/lib/trento_web/controllers/fallback_controller.ex index 240ce0ee76..f50584f262 100644 --- a/lib/trento_web/controllers/fallback_controller.ex +++ b/lib/trento_web/controllers/fallback_controller.ex @@ -131,6 +131,13 @@ defmodule TrentoWeb.FallbackController do |> render(:"422", reason: "Connection with software updates provider failed.") end + def call(conn, {:error, :operation_not_permitted}) do + conn + |> put_status(:forbidden) + |> put_view(ErrorView) + |> render(:"403") + end + def call(conn, {:error, [error | _]}), do: call(conn, {:error, error}) def call(conn, {:error, _}) do diff --git a/lib/trento_web/controllers/session_controller.ex b/lib/trento_web/controllers/session_controller.ex index dda056e13b..3202230647 100644 --- a/lib/trento_web/controllers/session_controller.ex +++ b/lib/trento_web/controllers/session_controller.ex @@ -89,11 +89,15 @@ defmodule TrentoWeb.SessionController do title: "TrentoUser", type: :object, example: %{ - username: "admin" + username: "admin", + id: 1 }, properties: %{ username: %Schema{ type: :string + }, + id: %Schema{ + type: :integer } } }} diff --git a/lib/trento_web/controllers/v1/user_controller.ex b/lib/trento_web/controllers/v1/user_controller.ex new file mode 100644 index 0000000000..d2c71a0c89 --- /dev/null +++ b/lib/trento_web/controllers/v1/user_controller.ex @@ -0,0 +1,128 @@ +defmodule TrentoWeb.V1.UserController do + use TrentoWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias Trento.Users + alias Trento.Users.User + alias TrentoWeb.OpenApi.V1.Schema + alias TrentoWeb.OpenApi.V1.Schema.UnprocessableEntity + + alias TrentoWeb.OpenApi.V1.Schema.User.{ + UserCollection, + UserCreationRequest, + UserItem, + UserUpdateRequest + } + + plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true + action_fallback TrentoWeb.FallbackController + + operation :index, + summary: "Gets the list of users in the system", + tags: ["User Management"], + responses: [ + ok: {"List of users in the system", "application/json", UserCollection} + ] + + def index(conn, _params) do + users = Users.list_users() + render(conn, "index.json", users: users) + end + + operation :create, + summary: "Create a new User", + tags: ["User Management"], + request_body: {"UserCreationRequest", "application/json", UserCreationRequest}, + responses: [ + created: {"User saved successfully", "application/json", UserItem}, + unprocessable_entity: UnprocessableEntity.response() + ] + + def create(%{body_params: body_params} = conn, _) do + with user_params <- decode_body(body_params), + {:ok, %User{} = user} <- Users.create_user(user_params) do + conn + |> put_status(:created) + |> render("show.json", user: user) + end + end + + operation :show, + summary: "Show the details of a user", + tags: ["User Management"], + parameters: [ + id: [ + in: :path, + required: true, + type: %OpenApiSpex.Schema{type: :integer} + ] + ], + responses: [ + ok: {"UserItem", "application/json", UserItem}, + not_found: Schema.NotFound.response(), + unprocessable_entity: Schema.UnprocessableEntity.response() + ] + + def show(conn, %{id: id}) do + with {:ok, user} <- Users.get_user(id) do + render(conn, "show.json", user: user) + end + end + + operation :update, + summary: "Update an existing user", + tags: ["User Management"], + parameters: [ + id: [ + in: :path, + required: true, + type: %OpenApiSpex.Schema{type: :integer} + ] + ], + request_body: {"UserUpdateRequest", "application/json", UserUpdateRequest}, + responses: [ + created: {"User updated successfully", "application/json", UserItem}, + unprocessable_entity: UnprocessableEntity.response(), + forbidden: Schema.Forbidden.response() + ] + + def update(%{body_params: body_params} = conn, %{id: id}) do + with {:ok, user} <- Users.get_user(id), + {:ok, %User{} = user} <- Users.update_user(user, body_params), + :ok <- broadcast_update_or_locked_user(user) do + render(conn, "show.json", user: user) + end + end + + operation :delete, + summary: "Delete a user", + tags: ["User Management"], + parameters: [ + id: [ + in: :path, + required: true, + type: %OpenApiSpex.Schema{type: :integer} + ] + ], + responses: [ + not_found: Schema.NotFound.response(), + forbidden: Schema.Forbidden.response() + ] + + def delete(conn, %{id: id}) do + with {:ok, user} <- Users.get_user(id), + {:ok, %User{}} <- Users.delete_user(user), + :ok <- TrentoWeb.Endpoint.broadcast("users:#{id}", "user_deleted", %{}) do + send_resp(conn, :no_content, "") + end + end + + defp broadcast_update_or_locked_user(%User{id: id, locked_at: nil}), + do: TrentoWeb.Endpoint.broadcast("users:#{id}", "user_updated", %{}) + + defp broadcast_update_or_locked_user(%User{id: id}), + do: TrentoWeb.Endpoint.broadcast("users:#{id}", "user_locked", %{}) + + defp decode_body(body) when is_struct(body), do: Map.from_struct(body) + defp decode_body(body), do: body +end diff --git a/lib/trento_web/openapi/v1/schema/forbidden.ex b/lib/trento_web/openapi/v1/schema/forbidden.ex new file mode 100644 index 0000000000..76d284bfc7 --- /dev/null +++ b/lib/trento_web/openapi/v1/schema/forbidden.ex @@ -0,0 +1,38 @@ +defmodule TrentoWeb.OpenApi.V1.Schema.Forbidden do + @moduledoc """ + 403 - Forbidden + """ + require OpenApiSpex + + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Forbidden", + type: :object, + additionalProperties: false, + properties: %{ + errors: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + detail: %Schema{ + type: :string, + example: "The requested operation could not be performed." + }, + title: %Schema{type: :string, example: "Forbidden"} + } + } + } + } + }) + + def response do + Operation.response( + "Forbidden", + "application/json", + __MODULE__ + ) + end +end diff --git a/lib/trento_web/openapi/v1/schema/user.ex b/lib/trento_web/openapi/v1/schema/user.ex new file mode 100644 index 0000000000..6d67f5c201 --- /dev/null +++ b/lib/trento_web/openapi/v1/schema/user.ex @@ -0,0 +1,118 @@ +defmodule TrentoWeb.OpenApi.V1.Schema.User do + @moduledoc false + + require OpenApiSpex + alias OpenApiSpex.Schema + + defmodule UserCreationRequest do + @moduledoc false + + OpenApiSpex.schema(%{ + title: "UserCreationRequest", + description: "Request body to create a user", + type: :object, + additionalProperties: false, + properties: %{ + fullname: %Schema{type: :string, description: "User full name", nullable: false}, + email: %Schema{type: :string, description: "User email", nullable: false, format: :email}, + username: %Schema{type: :string, description: "User username", nullable: false}, + enabled: %Schema{ + type: :boolean, + description: "User enabled in the system", + nullable: false + }, + password: %Schema{type: :string, description: "User new password", nullable: false}, + password_confirmation: %Schema{ + type: :string, + description: "User new password, should be the same as password field", + nullable: false + } + }, + required: [:fullname, :email, :enabled, :password, :password_confirmation, :username] + }) + end + + defmodule UserUpdateRequest do + @moduledoc false + + @schema %Schema{ + title: "UserUpdateRequest", + description: "Request body to update a user", + type: :object, + additionalProperties: false, + properties: %{ + fullname: %Schema{type: :string, description: "User full name", nullable: false}, + email: %Schema{type: :string, description: "User email", nullable: false, format: :email}, + enabled: %Schema{ + type: :boolean, + description: "User enabled in the system", + nullable: false + }, + password: %Schema{type: :string, description: "User new password", nullable: false}, + password_confirmation: %Schema{ + type: :string, + description: "User new password, should be the same as password field", + nullable: false + } + } + } + + # see: https://github.com/open-api-spex/open_api_spex/issues/87 + # This is an alternative way of defining schemas without having to deal to default values + # when the struct is converted to plain parameters in controller + # by default all the struct values have nil values, so we can't distinguish between a parameter not passed + # or a parameter passed explicitly nil. + # this is an update request and we have to distinguish in PATCH verb values passed or not passed. + # Having a value not passed as nil by default is not practical nor correct. + # this means that in the controller the body params are passed as map, without converting from the struct + # and we have everything cast and validated BUT without the hassle of cannot distinguish between passed + # object keys or not and further conversions in the controller. + def schema, do: @schema + end + + defmodule UserItem do + @moduledoc false + + OpenApiSpex.schema(%{ + title: "UserItem", + description: "User entity in the system", + type: :object, + additionalProperties: false, + properties: %{ + id: %Schema{type: :integer, description: "User ID", nullable: false}, + fullname: %Schema{type: :string, description: "User full name", nullable: false}, + username: %Schema{type: :string, description: "User username", nullable: false}, + email: %Schema{type: :string, description: "User email", nullable: false, format: :email}, + enabled: %Schema{ + type: :boolean, + description: "User enabled in the system", + nullable: false + }, + created_at: %OpenApiSpex.Schema{ + type: :string, + format: :"date-time", + description: "Date of user creation", + nullable: false + }, + updated_at: %OpenApiSpex.Schema{ + type: :string, + format: :"date-time", + description: "Date of user last update", + nullable: true + } + }, + required: [:username, :id, :fullname, :email, :created_at] + }) + end + + defmodule UserCollection do + @moduledoc false + + OpenApiSpex.schema(%{ + title: "UserCollection", + description: "A collection of users in the system", + type: :array, + items: UserItem + }) + end +end diff --git a/lib/trento_web/plugs/app_jwt_auth_plug.ex b/lib/trento_web/plugs/app_jwt_auth_plug.ex index f030f6d193..6039d6c8a6 100644 --- a/lib/trento_web/plugs/app_jwt_auth_plug.ex +++ b/lib/trento_web/plugs/app_jwt_auth_plug.ex @@ -13,6 +13,8 @@ defmodule TrentoWeb.Plugs.AppJWTAuthPlug do require Logger alias Plug.Conn + alias Trento.Users + alias Trento.Users.User alias TrentoWeb.Auth.AccessToken alias TrentoWeb.Auth.RefreshToken @@ -58,22 +60,18 @@ defmodule TrentoWeb.Plugs.AppJWTAuthPlug do The refresh token should be verified and valid, a new access token will be issued with the same validity as other access tokens, for the sub of the refresh token. + + Deleted and locked users, are not allowed to generate a refresh token. """ @spec renew(Conn.t(), String.t()) :: {:ok, Conn.t()} | {:error, any} def renew(conn, refresh_token) do - case validate_refresh_token(refresh_token) do - {:ok, claims} -> - new_access_token = AccessToken.generate_access_token!(%{"sub" => claims["sub"]}) - - conn = - conn - |> Conn.put_private(:api_access_token, new_access_token) - |> Conn.put_private(:access_token_expiration, AccessToken.expires_in()) - - {:ok, conn} - + with {:ok, %{"sub" => user_id}} <- validate_refresh_token(refresh_token), + {:ok, user} <- Users.get_user(user_id), + {:ok, conn} <- attach_refresh_token_to_conn(conn, user) do + {:ok, conn} + else {:error, reason} -> - Logger.error("Invalid refresh token: #{inspect(reason)}") + Logger.error("Could not refresh the access token: #{inspect(reason)}") {:error, reason} end @@ -101,4 +99,25 @@ defmodule TrentoWeb.Plugs.AppJWTAuthPlug do @spec validate_refresh_token(binary()) :: {atom(), any()} defp validate_refresh_token(jwt_token), do: RefreshToken.verify_and_validate(jwt_token) + + defp attach_refresh_token_to_conn(conn, user) do + if user_allowed_to_renew?(user) do + new_access_token = AccessToken.generate_access_token!(%{"sub" => user.id}) + + conn = + conn + |> Conn.put_private(:api_access_token, new_access_token) + |> Conn.put_private(:access_token_expiration, AccessToken.expires_in()) + + {:ok, conn} + else + {:error, :user_not_allowed_to_renew} + end + end + + defp user_allowed_to_renew?(%User{deleted_at: deleted_at, locked_at: locked_at}) + when not is_nil(deleted_at) or not is_nil(locked_at), + do: false + + defp user_allowed_to_renew?(%User{}), do: true end diff --git a/lib/trento_web/router.ex b/lib/trento_web/router.ex index adfee85ff8..7d551f1051 100644 --- a/lib/trento_web/router.ex +++ b/lib/trento_web/router.ex @@ -147,6 +147,8 @@ defmodule TrentoWeb.Router do DatabaseController, :delete_database_instance + resources "/users", UserController, except: [:new, :edit] + scope "/settings" do get "/", SettingsController, :settings post "/accept_eula", SettingsController, :accept_eula diff --git a/lib/trento_web/views/error_view.ex b/lib/trento_web/views/error_view.ex index a5cafa281f..9be7b38c52 100644 --- a/lib/trento_web/views/error_view.ex +++ b/lib/trento_web/views/error_view.ex @@ -85,6 +85,17 @@ defmodule TrentoWeb.ErrorView do } end + def render("403.json", _) do + %{ + errors: [ + %{ + title: "Forbidden", + detail: "You can't perform the operation or access the resource." + } + ] + } + end + def template_not_found(template, _assigns) do %{ errors: [ diff --git a/lib/trento_web/views/session_view.ex b/lib/trento_web/views/session_view.ex index e7c5e4d575..30cfb4085e 100644 --- a/lib/trento_web/views/session_view.ex +++ b/lib/trento_web/views/session_view.ex @@ -9,9 +9,10 @@ defmodule TrentoWeb.SessionView do %{access_token: token, expires_in: expiration} end - def render("me.json", %{user: user}) do + def render("me.json", %{user: %{username: username, id: id}}) do %{ - username: user.username + username: username, + id: id } end end diff --git a/lib/trento_web/views/v1/user_view.ex b/lib/trento_web/views/v1/user_view.ex new file mode 100644 index 0000000000..e1efc0fea0 --- /dev/null +++ b/lib/trento_web/views/v1/user_view.ex @@ -0,0 +1,33 @@ +defmodule TrentoWeb.V1.UserView do + use TrentoWeb, :view + + def render("index.json", %{users: users}) do + render_many(users, __MODULE__, "user.json") + end + + def render("show.json", %{user: user}) do + render_one(user, __MODULE__, "user.json") + end + + def render("user.json", %{ + user: %{ + id: id, + fullname: fullname, + username: username, + email: email, + locked_at: locked_at, + inserted_at: created_at, + updated_at: updated_at + } + }) do + %{ + id: id, + fullname: fullname, + username: username, + email: email, + enabled: locked_at == nil, + created_at: created_at, + updated_at: updated_at + } + end +end diff --git a/mix.exs b/mix.exs index d755c32144..d107b76c1e 100644 --- a/mix.exs +++ b/mix.exs @@ -108,7 +108,9 @@ defmodule Trento.MixProject do # https://stackoverflow.com/questions/76562092/hi-i-had-created-elixir-project-with-phoenix-framework-there-is-yaml-file-when {:ecto, "~> 3.10", override: true}, # https://github.com/deadtrickster/ssl_verify_fun.erl/pull/27 - {:ssl_verify_fun, "~> 1.1", manager: :rebar3, override: true} + {:ssl_verify_fun, "~> 1.1", manager: :rebar3, override: true}, + {:argon2_elixir, "~> 4.0"}, + {:ecto_commons, "~> 0.3.4"} ] end diff --git a/mix.lock b/mix.lock index 54abe1abcc..07b6eed28d 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "amqp": {:hex, :amqp, "3.3.0", "056d9f4bac96c3ab5a904b321e70e78b91ba594766a1fc2f32afd9c016d9f43b", [:mix], [{:amqp_client, "~> 3.9", [hex: :amqp_client, repo: "hexpm", optional: false]}], "hexpm", "8d3ae139d2646c630d674a1b8d68c7f85134f9e8b2a1c3dd5621616994b10a8b"}, "amqp_client": {:hex, :amqp_client, "3.12.12", "e7065dc769e2ddec11b66422b131377e656bf656125e526b8fece72d8b4e6fe9", [:make, :rebar3], [{:credentials_obfuscation, "3.4.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:rabbit_common, "3.12.12", [hex: :rabbit_common, repo: "hexpm", optional: false]}], "hexpm", "8b4d591ae0dc8938dcbb49c77df9b19e63e7c3c92d69844babc4fcf7c3184a9d"}, + "argon2_elixir": {:hex, :argon2_elixir, "4.0.0", "7f6cd2e4a93a37f61d58a367d82f830ad9527082ff3c820b8197a8a736648941", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f9da27cf060c9ea61b1bd47837a28d7e48a8f6fa13a745e252556c14f9132c7f"}, "backoff": {:hex, :backoff, "1.1.6", "83b72ed2108ba1ee8f7d1c22e0b4a00cfe3593a67dbc792799e8cce9f42f796b", [:rebar3], [], "hexpm", "cf0cfff8995fb20562f822e5cc47d8ccf664c5ecdc26a684cbe85c225f9d7c39"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, @@ -8,6 +9,7 @@ "cloak": {:hex, :cloak, "1.1.2", "7e0006c2b0b98d976d4f559080fabefd81f0e0a50a3c4b621f85ceeb563e80bb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "940d5ac4fcd51b252930fd112e319ea5ae6ab540b722f3ca60a85666759b9585"}, "cloak_ecto": {:hex, :cloak_ecto, "1.2.0", "e86a3df3bf0dc8980f70406bcb0af2858bac247d55494d40bc58a152590bd402", [:mix], [{:cloak, "~> 1.1.1", [hex: :cloak, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "8bcc677185c813fe64b786618bd6689b1707b35cd95acaae0834557b15a0c62f"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, + "comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"}, "commanded": {:hex, :commanded, "1.4.2", "edc11a5faea8fbaf7afed3b8f422ad49758c12c19f960da9ea93696465a6d49d", [:mix], [{:backoff, "~> 1.1", [hex: :backoff, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "4e90854da0aab8294ec2206eea89658367c077625a340cb3e806b4cf712a61fa"}, "commanded_ecto_projections": {:hex, :commanded_ecto_projections, "1.3.0", "fed87fbeb99f83fc95fd0a146439bfe93728a85246fb779e4dd8d046a40c3659", [:mix], [{:commanded, "~> 1.2", [hex: :commanded, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.5", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.3", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "9a3e0405e5a4840185047c60a840653a25eed52f9f754aaaa10228592f9d2963"}, "commanded_eventstore_adapter": {:hex, :commanded_eventstore_adapter, "1.4.0", "231c94f269c981df4816521d9151dd7df7ed3034ff2fda24edae864967abadc7", [:mix], [{:commanded, "~> 1.4", [hex: :commanded, repo: "hexpm", optional: false]}, {:eventstore, "~> 1.3", [hex: :eventstore, repo: "hexpm", optional: false]}, {:jason, "~> 1.3", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "a01a0d3e485c646c3fc2a67d4c58c8d4dd20677071433f58d39ce2752521b1c1"}, @@ -22,7 +24,9 @@ "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"}, + "ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"}, "ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"}, + "elixir_make": {:hex, :elixir_make, "0.8.3", "d38d7ee1578d722d89b4d452a3e36bcfdc644c618f0d063b874661876e708683", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "5c99a18571a756d4af7a4d89ca75c28ac899e6103af6f223982f09ce44942cc9"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, @@ -30,6 +34,7 @@ "eventstore_dashboard": {:git, "https://github.com/commanded/eventstore-dashboard.git", "c3171a26a1ec209ff1bda84648bff0681dc5f12f", []}, "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, + "ex_phone_number": {:hex, :ex_phone_number, "0.4.4", "8e994abe583496a3308cf56af013dca9b47a0424b0a9940af41cb0d66b848dd3", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "a59875692ec57b3392959a7740f3e9a5cb08da88bcaee4efd480c770f5bb0f2c"}, "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, "expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, @@ -49,6 +54,7 @@ "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"}, "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, + "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.3", "d684f4bac8690e70b06eb52dad65d26de2eefa44cd19d64a8095e1417df7c8fd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b78dc853d2e670ff6390b605d807263bf606da3c82be37f9d7f68635bd886fc9"}, @@ -82,6 +88,7 @@ "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "recon": {:hex, :recon, "2.5.3", "739107b9050ea683c30e96de050bc59248fd27ec147696f79a8797ff9fa17153", [:mix, :rebar3], [], "hexpm", "6c6683f46fd4a1dfd98404b9f78dcabc7fcd8826613a89dcb984727a8c3099d7"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, "swoosh": {:hex, :swoosh, "1.14.3", "949e6bf6dd469449238a94ec6f19ec10b63fc8753de7f3ebe3d3aeaf772f4c6b", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6c565103fc8f086bdd96e5c948660af8e20922b7a90a75db261f06a34f805c8b"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, diff --git a/priv/repo/migrations/20240416091937_add_email_and_fullname_to_user.exs b/priv/repo/migrations/20240416091937_add_email_and_fullname_to_user.exs new file mode 100644 index 0000000000..edab8307e7 --- /dev/null +++ b/priv/repo/migrations/20240416091937_add_email_and_fullname_to_user.exs @@ -0,0 +1,10 @@ +defmodule Trento.Repo.Migrations.AddEmailAndFullnameToUser do + use Ecto.Migration + + def change do + alter table(:users) do + add :email, :string + add :fullname, :string + end + end +end diff --git a/priv/repo/migrations/20240416094612_add_deleted_at_to_users.exs b/priv/repo/migrations/20240416094612_add_deleted_at_to_users.exs new file mode 100644 index 0000000000..1887d249a6 --- /dev/null +++ b/priv/repo/migrations/20240416094612_add_deleted_at_to_users.exs @@ -0,0 +1,9 @@ +defmodule Trento.Repo.Migrations.AddDeletedAtToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :deleted_at, :utc_datetime_usec + end + end +end diff --git a/priv/repo/migrations/20240416143044_unique_email_for_users.exs b/priv/repo/migrations/20240416143044_unique_email_for_users.exs new file mode 100644 index 0000000000..6b4c3e3b3b --- /dev/null +++ b/priv/repo/migrations/20240416143044_unique_email_for_users.exs @@ -0,0 +1,7 @@ +defmodule Trento.Repo.Migrations.UniqueEmailForUsers do + use Ecto.Migration + + def change do + create unique_index(:users, [:email]) + end +end diff --git a/priv/repo/migrations/20240418081549_add_locked_timestamp_to_users.exs b/priv/repo/migrations/20240418081549_add_locked_timestamp_to_users.exs new file mode 100644 index 0000000000..4b8b38ac5f --- /dev/null +++ b/priv/repo/migrations/20240418081549_add_locked_timestamp_to_users.exs @@ -0,0 +1,9 @@ +defmodule Trento.Repo.Migrations.AddLockedTimestampToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :locked_at, :utc_datetime_usec + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 7d96dba18b..01f93bd91e 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -14,9 +14,15 @@ |> Trento.Users.User.changeset(%{ username: "admin", password: "adminpassword", - confirm_password: "adminpassword" + confirm_password: "adminpassword", + fullname: "Trento Admin", + email: "admin@trento.suse.com", + enabled: true }) -|> Trento.Repo.insert!(on_conflict: :nothing) +|> Trento.Repo.insert!( + on_conflict: [set: [password_hash: Argon2.hash_pwd_salt("adminpassword")]], + conflict_target: :username +) %Trento.Settings.ApiKeySettings{} |> Trento.Settings.ApiKeySettings.changeset(%{ diff --git a/test/support/factory.ex b/test/support/factory.ex index 10ce3988fa..59aea6c130 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -21,6 +21,8 @@ defmodule Trento.Factory do SbdDevice } + alias Trento.Users.User + alias Trento.Hosts.ValueObjects.{ SaptuneStatus, SlesSubscription @@ -883,4 +885,18 @@ defmodule Trento.Factory do to_package_id: "#{RandomElixir.random_between(0, 1000)}" } end + + def user_factory do + password = Faker.Pokemon.name() + + %User{ + email: Faker.Internet.email(), + fullname: Faker.Pokemon.name(), + password: password, + password_hash: Argon2.hash_pwd_salt(password), + username: Faker.Pokemon.name(), + deleted_at: nil, + locked_at: nil + } + end end diff --git a/test/trento/users/user_test.exs b/test/trento/users/user_test.exs new file mode 100644 index 0000000000..cdbc14cd68 --- /dev/null +++ b/test/trento/users/user_test.exs @@ -0,0 +1,41 @@ +defmodule Trento.Users.UsersTest do + use ExUnit.Case + use Trento.DataCase + + alias Trento.Users.User + + describe "validation" do + test "changeset/2 validates email and fullname fields are required" do + changeset = User.changeset(%User{}, %{}) + + assert changeset.errors[:fullname] == {"can't be blank", [validation: :required]} + assert changeset.errors[:email] == {"can't be blank", [validation: :required]} + end + + test "changeset/2 validates the email field" do + changeset = User.changeset(%User{}, %{"email" => "invalid"}) + + assert changeset.errors[:email] == {"is not a valid email", [validation: :email]} + end + + test "changeset/2 validates repetitive and sequential password" do + changeset = User.changeset(%User{}, %{"password" => "secret1222"}) + assert changeset.errors[:password] == {"has repetitive characters", []} + + changeset = User.changeset(%User{}, %{"password" => "secret1223"}) + refute changeset.errors[:password] + + changeset = User.changeset(%User{}, %{"password" => "secret1234"}) + assert changeset.errors[:password] == {"has sequential characters", []} + + changeset = User.changeset(%User{}, %{"password" => "secret1235"}) + refute changeset.errors[:password] + + changeset = User.changeset(%User{}, %{"password" => "secretefgh"}) + assert changeset.errors[:password] == {"has sequential characters", []} + + changeset = User.changeset(%User{}, %{"password" => "secretafgh"}) + refute changeset.errors[:password] + end + end +end diff --git a/test/trento/users_test.exs b/test/trento/users_test.exs new file mode 100644 index 0000000000..c6ca74c5f2 --- /dev/null +++ b/test/trento/users_test.exs @@ -0,0 +1,195 @@ +defmodule Trento.UsersTest do + use Trento.DataCase + + alias Trento.Users + alias Trento.Users.User + import Trento.Factory + + describe "users" do + test "list_users returns all users except the deleted ones" do + %{id: user_id} = insert(:user) + insert(:user, deleted_at: DateTime.utc_now()) + users = Users.list_users() + assert [%User{id: ^user_id}] = users + assert length(users) == 1 + end + + test "get_user returns a user when the user exist" do + %{id: user_id} = insert(:user) + + assert {:ok, %User{id: ^user_id}} = Users.get_user(user_id) + end + + test "get_user returns an error when a user does not exist" do + %{id: user_id} = insert(:user, deleted_at: DateTime.utc_now()) + + assert {:error, :not_found} = Users.get_user(user_id) + end + + test "create_user with valid data creates a user" do + assert {:ok, %User{} = user} = + Users.create_user(%{ + username: "username", + password: "some password", + email: "test@trento.com", + fullname: "some fullname", + confirm_password: "some password" + }) + + assert user.password == nil + assert user.fullname == "some fullname" + assert user.email == "test@trento.com" + assert user.username == "username" + end + + test "create_user should return an error if the email has already been taken" do + user_already_existing = insert(:user) + + assert {:error, changeset} = + Users.create_user(%{ + username: "username", + password: "some password", + email: user_already_existing.email, + fullname: "some fullname", + confirm_password: "some password" + }) + + assert changeset.errors[:email] == + {"has already been taken", + [{:constraint, :unique}, {:constraint_name, "users_email_index"}]} + end + + test "create_user with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = + Users.create_user(%{ + username: "username", + email: "test@trento.com", + fullname: "some fullname" + }) + end + + test "update_user/2 with valid data updates the user" do + user = insert(:user) + {:ok, user} = Users.get_user(user.id) + + assert {:ok, %User{} = user} = + Users.update_user(user, %{ + fullname: "some updated fullname", + email: "newemail@test.com" + }) + + assert user.fullname == "some updated fullname" + assert user.email == "newemail@test.com" + end + + test "update_user/2 will not update user username" do + user = insert(:user) + {:ok, fetched_user} = Users.get_user(user.id) + + assert {:ok, %User{} = user} = + Users.update_user(fetched_user, %{ + fullname: "some updated fullname", + email: "newemail@test.com", + username: "newusername" + }) + + assert user.fullname == "some updated fullname" + assert user.email == "newemail@test.com" + assert user.username == fetched_user.username + end + + test "update_user/2 with invalid data does not update the user" do + user = insert(:user) + {:ok, user} = Users.get_user(user.id) + + assert {:error, changeset} = + Users.update_user(user, %{ + email: "invalid", + password: "novalid", + password_confirmation: "novalid" + }) + + assert changeset.errors[:email] == {"is not a valid email", [validation: :email]} + + assert changeset.errors[:password] == + {"should be at least %{count} character(s)", + [count: 8, validation: :length, kind: :min, type: :string]} + end + + test "update_user/2 returns error if the email has already been taken" do + user = insert(:user) + user2 = insert(:user) + {:ok, user} = Users.get_user(user.id) + + assert {:error, changeset} = + Users.update_user(user, %{ + email: user2.email + }) + + assert changeset.errors[:email] == + {"has already been taken", + [{:constraint, :unique}, {:constraint_name, "users_email_index"}]} + end + + test "update_user/2 does not update deleted_at" do + user = insert(:user) + {:ok, user} = Users.get_user(user.id) + assert {:ok, %User{} = user} = Users.update_user(user, %{deleted_at: DateTime.utc_now()}) + assert user.deleted_at == nil + end + + test "update_user/2 lock the user if enable false is passed as attribute and the user is not locked" do + user = insert(:user) + {:ok, user} = Users.get_user(user.id) + assert {:ok, %User{} = user} = Users.update_user(user, %{enabled: false}) + refute user.locked_at == nil + end + + test "update_user/2 does no lock the user if enable true is passed as attribute and the user is not locked" do + user = insert(:user) + {:ok, user} = Users.get_user(user.id) + assert {:ok, %User{} = user} = Users.update_user(user, %{enabled: true}) + assert user.locked_at == nil + end + + test "update_user/2 does not relock the user if enable false is passed as attribute and the user is already locked" do + user = insert(:user) + {:ok, user} = Users.get_user(user.id) + + assert {:ok, %User{locked_at: locked_at} = user} = + Users.update_user(user, %{enabled: false}) + + assert {:ok, %User{} = updated_user} = Users.update_user(user, %{enabled: false}) + assert updated_user.locked_at == locked_at + end + + test "update_user/2 does unlock the user if enable true is passed as attribute and the user is locked" do + user = insert(:user) + {:ok, user} = Users.get_user(user.id) + assert {:ok, %User{} = user} = Users.update_user(user, %{enabled: false}) + + assert {:ok, %User{} = updated_user} = Users.update_user(user, %{enabled: true}) + assert updated_user.locked_at == nil + end + + test "update_user/2 does not update user with id 1" do + assert {:error, :operation_not_permitted} = + Users.update_user(%User{id: 1}, %{fullname: "new fullname"}) + end + + test "delete_user/2 does not delete user with id 1" do + assert {:error, :operation_not_permitted} = Users.delete_user(%User{id: 1}) + end + + test "delete_user/1 deletes the user" do + %{id: user_id, username: original_username} = user = insert(:user) + assert {:ok, %User{}} = Users.delete_user(user) + assert {:error, :not_found} == Users.get_user(user.id) + + %User{deleted_at: deleted_at, username: username} = Trento.Repo.get_by!(User, id: user_id) + + refute deleted_at == nil + refute username == original_username + end + end +end diff --git a/test/trento_web/auth/access_token_test.exs b/test/trento_web/auth/access_token_test.exs index efd2c42f0d..4f832d5c1a 100644 --- a/test/trento_web/auth/access_token_test.exs +++ b/test/trento_web/auth/access_token_test.exs @@ -26,7 +26,7 @@ defmodule TrentoWeb.AccessTokenTest do describe "generate_access_token!/1" do test "should generate and sign a jwt token with the default claims correctly set" do - expected_expiry = @test_timestamp + 600 + expected_expiry = @test_timestamp + 180 token = AccessToken.generate_access_token!(%{}) {:ok, claims} = Joken.peek_claims(token) diff --git a/test/trento_web/channels/user_channel_test.exs b/test/trento_web/channels/user_channel_test.exs new file mode 100644 index 0000000000..d4f419b98c --- /dev/null +++ b/test/trento_web/channels/user_channel_test.exs @@ -0,0 +1,22 @@ +defmodule TrentoWeb.UserChannelTest do + use TrentoWeb.ChannelCase + + test "socket users can only join their channel" do + assert {:ok, _, _socket} = + TrentoWeb.UserSocket + |> socket("user_id", %{current_user_id: 876}) + |> subscribe_and_join(TrentoWeb.UserChannel, "users:876") + + assert {:error, :unauthorized} = + TrentoWeb.UserSocket + |> socket("user_id", %{current_user_id: 788}) + |> subscribe_and_join(TrentoWeb.UserChannel, "users:876") + end + + test "non logged users cannot join a user channel" do + assert {:error, :user_not_logged} = + TrentoWeb.UserSocket + |> socket("user_id", %{}) + |> subscribe_and_join(TrentoWeb.UserChannel, "users:8989") + end +end diff --git a/test/trento_web/controllers/session_controller_test.exs b/test/trento_web/controllers/session_controller_test.exs index 97c069166a..f141c5032b 100644 --- a/test/trento_web/controllers/session_controller_test.exs +++ b/test/trento_web/controllers/session_controller_test.exs @@ -6,6 +6,7 @@ defmodule TrentoWeb.SessionControllerTest do import Mox import OpenApiSpex.TestAssertions + alias TrentoWeb.Auth.RefreshToken alias TrentoWeb.OpenApi.V1.ApiSpec setup [:set_mox_from_context, :verify_on_exit!] @@ -16,7 +17,9 @@ defmodule TrentoWeb.SessionControllerTest do |> Trento.Users.User.changeset(%{ username: "admin", password: "testpassword", - confirm_password: "testpassword" + confirm_password: "testpassword", + email: "test@trento.com", + fullname: "Full Name" }) |> Trento.Repo.insert!() @@ -26,22 +29,80 @@ defmodule TrentoWeb.SessionControllerTest do end describe "refresh endpoint" do + test "should return unauthorized when the refresh token is valid but the user has been deleted", + %{ + conn: conn, + user: user + } do + {:ok, _} = Trento.Users.delete_user(user) + + expect( + Joken.CurrentTime.Mock, + :current_time, + 5, + fn -> + 1_671_641_814 + end + ) + + refresh_token = RefreshToken.generate_and_sign!(%{"sub" => user.id}) + + conn = + post(conn, "/api/session/refresh", %{ + "refresh_token" => refresh_token + }) + + resp = json_response(conn, 401) + + assert %{"errors" => [%{"detail" => "Invalid refresh token.", "title" => "Unauthorized"}]} = + resp + end + + test "should return unauthorized when the refresh token is valid but the user has been locked", + %{ + conn: conn, + user: user + } do + {:ok, _} = Trento.Users.update_user(user, %{enabled: false}) + + expect( + Joken.CurrentTime.Mock, + :current_time, + 5, + fn -> + 1_671_641_814 + end + ) + + refresh_token = RefreshToken.generate_and_sign!(%{"sub" => user.id}) + + conn = + post(conn, "/api/session/refresh", %{ + "refresh_token" => refresh_token + }) + + resp = json_response(conn, 401) + + assert %{"errors" => [%{"detail" => "Invalid refresh token.", "title" => "Unauthorized"}]} = + resp + end + test "should return refreshed credentials for the user when the refresh token is valid", %{ conn: conn, - api_spec: api_spec + api_spec: api_spec, + user: user } do - refresh_token = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0cmVudG9fYXBwIiwiZXhwIjoxNjcxNjYzNDE0LCJpYXQiOjE2NzE2NDE4MTQsImlzcyI6Imh0dHBzOi8vZ2l0aHViLmNvbS90cmVudG8tcHJvamVjdC93ZWIiLCJqdGkiOiIyc3BpM3MwZnM2ZmpwcTl2dWswMDA1ZTEiLCJuYmYiOjE2NzE2NDE4MTQsInN1YiI6MSwidHlwIjoiUmVmcmVzaCJ9.73ajWvgUml4F4Ml5rACyUeAlipknOUdQFy6t8tYZf5Y" - expect( Joken.CurrentTime.Mock, :current_time, - 5, + 8, fn -> 1_671_641_814 end ) + refresh_token = RefreshToken.generate_and_sign!(%{"sub" => user.id}) + conn = post(conn, "/api/session/refresh", %{ "refresh_token" => refresh_token @@ -146,7 +207,8 @@ defmodule TrentoWeb.SessionControllerTest do |> json_response(200) |> assert_schema("TrentoUser", api_spec) - assert %{username: "admin"} = resp + user_id = user.id + assert %{username: "admin", id: ^user_id} = resp end end @@ -175,6 +237,60 @@ defmodule TrentoWeb.SessionControllerTest do |> assert_schema("Credentials", api_spec) end + test "should return unauthorized response when the credentials are of a deleted user", %{ + conn: conn, + user: user + } do + {:ok, _} = Trento.Users.delete_user(user) + + expect( + Joken.CurrentTime.Mock, + :current_time, + 0, + fn -> + 1_671_715_992 + end + ) + + conn = + post(conn, "/api/session", %{ + "username" => "admin", + "password" => "tespassword" + }) + + resp = json_response(conn, 401) + + assert %{"errors" => [%{"detail" => "Invalid credentials.", "title" => "Unauthorized"}]} = + resp + end + + test "should return unauthorized response when the credentials are of a locked user", %{ + conn: conn, + user: user + } do + {:ok, _} = Trento.Users.update_user(user, %{enabled: false}) + + expect( + Joken.CurrentTime.Mock, + :current_time, + 0, + fn -> + 1_671_715_992 + end + ) + + conn = + post(conn, "/api/session", %{ + "username" => "admin", + "password" => "tespassword" + }) + + resp = json_response(conn, 401) + + assert %{"errors" => [%{"detail" => "Invalid credentials.", "title" => "Unauthorized"}]} = + resp + end + test "should return unauthorized response when the credentials are invalid", %{ conn: conn } do diff --git a/test/trento_web/controllers/v1/user_controller_test.exs b/test/trento_web/controllers/v1/user_controller_test.exs new file mode 100644 index 0000000000..7d34b8a98f --- /dev/null +++ b/test/trento_web/controllers/v1/user_controller_test.exs @@ -0,0 +1,217 @@ +defmodule TrentoWeb.V1.UserControllerTest do + use TrentoWeb.ConnCase, async: true + + import OpenApiSpex.TestAssertions + import Phoenix.ChannelTest + import TrentoWeb.ChannelCase + import Trento.Factory + + alias TrentoWeb.OpenApi.V1.ApiSpec + + @endpoint TrentoWeb.Endpoint + + setup %{conn: conn} do + api_spec = ApiSpec.spec() + + {:ok, conn: put_req_header(conn, "accept", "application/json"), api_spec: api_spec} + end + + describe "index" do + test "lists all users", %{conn: conn, api_spec: api_spec} do + %{id: user_one_id} = insert(:user) + %{id: user_two_id} = insert(:user) + + conn = get(conn, "/api/v1/users") + + resp = + conn + |> json_response(200) + |> assert_schema("UserCollection", api_spec) + + assert [%{id: ^user_one_id}, %{id: ^user_two_id}] = resp + end + end + + describe "create user" do + test "should create the user when parameters are valid", %{conn: conn, api_spec: api_spec} do + valid_params = %{ + fullname: Faker.Person.name(), + email: Faker.Internet.email(), + username: Faker.Pokemon.name(), + enabled: true, + password: "testpassword89", + password_confirmation: "testpassword89" + } + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/users", valid_params) + |> json_response(:created) + |> assert_schema("UserItem", api_spec) + end + + test "should not create the user when request parameters are not valid", %{ + conn: conn, + api_spec: api_spec + } do + invalid_request_params = %{ + fullname: Faker.Person.name(), + email: Faker.Internet.email(), + username: Faker.Pokemon.name(), + password: "testpassword89", + password_confirmation: "testpassword89" + } + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/users", invalid_request_params) + |> json_response(:unprocessable_entity) + |> assert_schema("UnprocessableEntity", api_spec) + end + + test "should not create the user when request parameters are valid but error are returned during creation", + %{conn: conn, api_spec: api_spec} do + %{email: already_taken_email} = insert(:user) + + valid_params = %{ + fullname: Faker.Person.name(), + email: already_taken_email, + username: Faker.Pokemon.name(), + enabled: true, + password: "testpassword89", + password_confirmation: "notequal" + } + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/users", valid_params) + |> json_response(:unprocessable_entity) + |> assert_schema("UnprocessableEntity", api_spec) + end + end + + describe "update user" do + test "should not update an existing user if the body is empty in a patch operation", %{ + conn: conn, + api_spec: api_spec + } do + %{id: id, updated_at: updated_at} = insert(:user) + + {:ok, _, _} = + TrentoWeb.UserSocket + |> socket("user_id", %{current_user_id: id}) + |> subscribe_and_join(TrentoWeb.UserChannel, "users:#{id}") + + resp = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/users/#{id}", %{}) + |> json_response(:ok) + |> assert_schema("UserItem", api_spec) + + assert resp.updated_at == updated_at + + assert_broadcast "user_updated", %{}, 1000 + end + + test "should return 404 if the user does not exists", %{ + conn: conn, + api_spec: api_spec + } do + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/users/8789578945574", %{}) + |> json_response(:not_found) + |> assert_schema("NotFound", api_spec) + end + + test "should not update the user if parameters are valid but an error is returned from update operation", + %{conn: conn, api_spec: api_spec} do + %{email: already_taken_email} = insert(:user) + %{id: id} = insert(:user) + + valid_params = %{ + email: already_taken_email + } + + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/users/#{id}", valid_params) + |> json_response(:unprocessable_entity) + |> assert_schema("UnprocessableEntity", api_spec) + end + + test "should not update the user if parameters are not valid", %{ + conn: conn, + api_spec: api_spec + } do + %{id: id} = insert(:user) + + invalid_params = %{ + enabled: "invalid" + } + + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/users/#{id}", invalid_params) + |> json_response(:unprocessable_entity) + |> assert_schema("UnprocessableEntity", api_spec) + end + + test "should update the user if parameters are valid", %{conn: conn, api_spec: api_spec} do + %{id: id, email: email, fullname: fullname} = insert(:user) + + {:ok, _, _} = + TrentoWeb.UserSocket + |> socket("user_id", %{current_user_id: id}) + |> subscribe_and_join(TrentoWeb.UserChannel, "users:#{id}") + + valid_params = %{ + fullname: Faker.Person.name(), + email: Faker.Internet.email(), + enabled: false, + password: "testpassword89", + password_confirmation: "testpassword89" + } + + resp = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/users/#{id}", valid_params) + |> json_response(:ok) + |> assert_schema("UserItem", api_spec) + + refute resp.fullname == fullname + refute resp.enabled == true + refute resp.email == email + + assert_broadcast "user_locked", %{}, 1000 + end + end + + describe "delete user" do + test "should not delete a user when the user is not found", %{conn: conn, api_spec: api_spec} do + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/v1/users/8908409480") + |> json_response(:not_found) + |> assert_schema("NotFound", api_spec) + end + + test "should delete a user when the user is found", %{conn: conn} do + %{id: id} = insert(:user) + + {:ok, _, _} = + TrentoWeb.UserSocket + |> socket("user_id", %{current_user_id: id}) + |> subscribe_and_join(TrentoWeb.UserChannel, "users:#{id}") + + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/v1/users/#{id}") + |> response(:no_content) + + assert_broadcast "user_deleted", %{}, 1000 + end + end +end diff --git a/test/trento_web/plugs/app_jwt_auth_plug_test.exs b/test/trento_web/plugs/app_jwt_auth_plug_test.exs index 662fe2f267..898a013504 100644 --- a/test/trento_web/plugs/app_jwt_auth_plug_test.exs +++ b/test/trento_web/plugs/app_jwt_auth_plug_test.exs @@ -9,6 +9,8 @@ defmodule TrentoWeb.Plugs.AppJWTAuthPlugTest do RefreshToken } + alias Trento.Users + alias Trento.Users.User alias TrentoWeb.Plugs.AppJWTAuthPlug import Mox @@ -38,23 +40,65 @@ defmodule TrentoWeb.Plugs.AppJWTAuthPlugTest do end describe "renew/2" do + setup do + password = "themightypassword8897" + + {:ok, user} = + Users.create_user(%{ + email: Faker.Internet.email(), + fullname: Faker.Pokemon.name(), + password: password, + password_confirmation: password, + username: Faker.Pokemon.name() + }) + + %{user: user} + end + test "should renew a token and put it in the conn private if the refresh token is valid", %{ - conn: conn + conn: conn, + user: user } do - valid_refresh = RefreshToken.generate_refresh_token!(%{"sub" => 1}) + valid_refresh = RefreshToken.generate_refresh_token!(%{"sub" => user.id}) {:ok, res_conn} = AppJWTAuthPlug.renew(conn, valid_refresh) assert %{ private: %{ api_access_token: new_access_token, - access_token_expiration: 600 + access_token_expiration: 180 } } = res_conn assert {:ok, %{"typ" => "Bearer"}} = Joken.peek_claims(new_access_token) end + test "should not renew a token if the token is valid but the associated user is not found", %{ + conn: conn + } do + valid_refresh = RefreshToken.generate_refresh_token!(%{"sub" => Faker.Address.zip_code()}) + + {:error, :not_found} = AppJWTAuthPlug.renew(conn, valid_refresh) + end + + test "should not renew a token if the token is valid but the associated user is deleted", %{ + conn: conn, + user: user + } do + {:ok, %User{} = user} = Trento.Users.delete_user(user) + valid_refresh = RefreshToken.generate_refresh_token!(%{"sub" => user.id}) + {:error, :not_found} = AppJWTAuthPlug.renew(conn, valid_refresh) + end + + test "should not renew a token if the token is valid but the associated user is locked", %{ + conn: conn, + user: user + } do + {:ok, %User{} = user} = Trento.Users.update_user(user, %{enabled: false}) + valid_refresh = RefreshToken.generate_refresh_token!(%{"sub" => user.id}) + {:error, :user_not_allowed_to_renew} = AppJWTAuthPlug.renew(conn, valid_refresh) + end + test "should return an error if the refresh token is malformed", %{conn: conn} do {:error, _reason} = AppJWTAuthPlug.renew(conn, "invalid") end @@ -95,7 +139,7 @@ defmodule TrentoWeb.Plugs.AppJWTAuthPlugTest do assert %{ private: %{ api_access_token: _jwt, - access_token_expiration: 600, + access_token_expiration: 180, api_refresh_token: _refresh } } = res_conn