authentication: initial LDAP wiring (login, password change)

This commit is contained in:
Timothée Floure 2020-12-21 15:40:19 +01:00
parent f286e83f6e
commit d96d9005f5
Signed by: tfloure
GPG Key ID: 4502C902C00A1E12
16 changed files with 264 additions and 108 deletions

81
lib/recycledcloud/LDAP.ex Normal file
View File

@ -0,0 +1,81 @@
defmodule RecycledCloud.LDAP do
require Logger
require Exldap
use GenServer
# Every request to the LDAP backend go through this GenServer.
def start_link(_) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
def init(_) do
{:ok, connect()}
end
def handle_call({:pool, query}, _from, state) do
case state do
%{status: :ok, conn: ldap_conn} ->
result = internal_execute_query(query, ldap_conn)
if (result == {:error, :ldap_closed}) do
{:reply, result, connect()}
else
{:reply, result, state}
end
%{status: :error, conn: nil} ->
state = connect()
if (state.status == :ok) do
result = internal_execute_query(query, state.conn)
{:reply, result, state}
else
error = {:error, "Unable to contact backend"}
{:reply, error, state}
end
end
end
def handle_call({:single, query}, _from, state) do
single_state = connect()
case single_state do
%{status: :ok, conn: ldap_conn} ->
result = internal_execute_query(query, ldap_conn)
close(ldap_conn)
{:reply, result, state}
%{status: :error, conn: nil} ->
error = {:error, "Unable to contact backend"}
{:reply, error, state}
end
end
def terminate(reason, state) do
if (state.status == :ok), do: close(state.conn)
IO.inspect(reason)
#Logger.info "Terminating LDAP backend: #{reason}."
end
###
def execute(query), do: GenServer.call(__MODULE__, {:pool, query})
def execute_single(query), do: GenServer.call(__MODULE__, {:single, query})
###
defp internal_execute_query(query, ldap_conn), do: query.(ldap_conn)
defp connect do
case Exldap.connect do
{:ok, ldap_conn} ->
Logger.info "Successfuly connected to LDAP server."
%{status: :ok, conn: ldap_conn}
{:error, err} ->
Logger.warning "Failed to connect to LDAP server: #{err}. Authentication will not be possible."
%{status: :error, conn: nil}
end
end
defp close(ldap_conn) do
ldap_conn |> Exldap.close
Logger.debug("An LDAP connection was closed.")
end
end

View File

@ -6,40 +6,41 @@ defmodule RecycledCloud.Accounts do
import Ecto.Query, warn: false
alias RecycledCloud.Repo
alias RecycledCloud.Accounts.{User, UserToken, UserNotifier}
alias RecycledCloud.LDAP
## Database getters
@doc """
Gets a user by email.
Gets a user by username.
## Examples
iex> get_user_by_email("foo@example.com")
iex> get_user_by_username("foo")
%User{}
iex> get_user_by_email("unknown@example.com")
iex> get_user_by_username("unknown")
nil
"""
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
def get_user_by_username(username) when is_binary(username) do
User.get_by_username(username)
end
@doc """
Gets a user by email and password.
Gets a user by username and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
iex> get_user_by_username_and_password("foo", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
iex> get_user_by_username_and_password("foo", "invalid_password")
nil
"""
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
def get_user_by_username_and_password(username, password)
when is_binary(username) and is_binary(password) do
user = get_user_by_username(username)
if User.valid_password?(user, password), do: user
end
@ -180,7 +181,7 @@ defmodule RecycledCloud.Accounts do
"""
def change_user_password(user, attrs \\ %{}) do
User.password_changeset(user, attrs, hash_password: false)
User.password_changeset(user, attrs)
end
@doc """
@ -195,19 +196,24 @@ defmodule RecycledCloud.Accounts do
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, password, attrs) do
changeset =
user
|> User.password_changeset(attrs)
|> User.validate_current_password(password)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
def update_user_password(user, current_password, attrs) do
changeset = user
|> User.password_changeset(attrs)
|> User.validate_current_password(current_password)
case Ecto.Changeset.apply_action(changeset, :update) do
{:ok, _} ->
new_password = attrs["password"]
case User.set_password(user, new_password) do
{:ok, _} -> {:ok, user}
{:error, err} ->
changeset_errors = [current_password: {"Unknown error.", []}]
updated_changeset = changeset
|> Map.put(:action, :update)
|> Map.put(:errors, changeset_errors)
{:error, updated_changeset}
end
err ->
err
end
end

View File

@ -1,17 +1,63 @@
defmodule RecycledCloud.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
require Logger
require Exldap
alias RecycledCloud.{LDAP,Accounts}
alias RecycledCloud.Accounts.{User, UserToken, UserNotifier}
alias RecycledCloud.Repo
@derive {Inspect, except: [:password]}
schema "users" do
field :email, :string
field :username, :string
field :password, :string, virtual: true
field :hashed_password, :string
field :email, :string, virtual: true
field :confirmed_at, :naive_datetime
timestamps()
end
defp get_dn_for(%User{username: uid}) do
# FIXME
"uid=#{uid},ou=users,dc=recycled,dc=cloud"
end
def get_by_username(username) when is_binary(username) do
local_user = Repo.get_by(User, username: username)
if local_user do
Map.put(local_user, :email, "unknown@domain.tld")
else
query = fn ldap_conn -> Exldap.search_field(ldap_conn, :uid, username) end
case query |> LDAP.execute do
{:ok, []} -> nil
{:ok, result} ->
{:ok, entry} = result |> Enum.fetch(0)
Logger.info("Found #{entry.object_name} in directory. Syncing with \
local database.")
attributes = entry |> Map.get(:attributes) |> Enum.into(%{})
username = attributes
|> Map.get('uid')
|> Enum.at(0)
|> List.to_string
email = attributes
|> Map.get('email')
|> Enum.at(0)
|> List.to_string
case Accounts.register_user(%{username: username, email: email}) do
{:ok, user} -> user
{:error, _} -> nil
end
{:error, err} ->
Logger.warn("LDAP error: #{err}")
nil
end
end
end
@doc """
A user changeset for registration.
@ -31,9 +77,9 @@ defmodule RecycledCloud.Accounts.User do
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password])
|> cast(attrs, [:username, :password])
|> validate_email()
|> validate_password(opts)
#|> validate_password(opts)
end
defp validate_email(changeset) do
@ -41,31 +87,15 @@ defmodule RecycledCloud.Accounts.User do
|> validate_required([:email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160)
|> unsafe_validate_unique(:email, RecycledCloud.Repo)
|> unique_constraint(:email)
end
defp validate_password(changeset, opts) do
defp validate_password(changeset) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 80)
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
|> validate_length(:password, min: 6, max: 80)
#|> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
#|> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
#|> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
end
@doc """
@ -95,11 +125,11 @@ defmodule RecycledCloud.Accounts.User do
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
def password_changeset(user, attrs, opts \\ []) do
def password_changeset(user, attrs) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(opts)
|> validate_password()
end
@doc """
@ -112,17 +142,21 @@ defmodule RecycledCloud.Accounts.User do
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%RecycledCloud.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)
def valid_password?(user = %User{username: uid}, password)
when byte_size(password) > 0 do
dn = get_dn_for(user)
query = fn ldap_conn ->
Exldap.verify_credentials(ldap_conn, dn, password)
end
case query |> LDAP.execute_single do
:ok -> true
{:error, _} -> false
end
end
def valid_password?(_, _) do
Bcrypt.no_user_verify()
false
end
@ -136,4 +170,21 @@ defmodule RecycledCloud.Accounts.User do
add_error(changeset, :current_password, "is not valid")
end
end
def set_password(user, new_password) do
# Exldap does not properly implement `change_password`, hence we have to
# fallback on erlang's `:eldap`.
# See http://erlang.org/doc/man/eldap.html#modify-4
user_dn = get_dn_for(user)
query = fn ldap_conn ->
:eldap.modify_password(
ldap_conn,
String.to_charlist(user_dn),
String.to_charlist(new_password)
)
end
query |> LDAP.execute_single
end
end

View File

@ -14,7 +14,9 @@ defmodule RecycledCloud.Application do
# Start the PubSub system
{Phoenix.PubSub, name: RecycledCloud.PubSub},
# Start the Endpoint (http/https)
RecycledCloudWeb.Endpoint
RecycledCloudWeb.Endpoint,
# Starts LDAP connection pool
RecycledCloud.LDAP
# Start a worker by calling: RecycledCloud.Worker.start_link(arg)
# {RecycledCloud.Worker, arg}
]

View File

@ -7,24 +7,31 @@ defmodule RecycledCloudWeb.UserRegistrationController do
def new(conn, _params) do
changeset = Accounts.change_user_registration(%User{})
render(conn, "new.html", changeset: changeset)
conn
|> put_flash(:error, "Registration is disabled for the time being.")
|> render("new.html", changeset: changeset)
end
def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(conn, :confirm, &1)
)
changeset = Accounts.change_user_registration(%User{})
conn
|> put_flash(:info, "User created successfully.")
|> UserAuth.log_in_user(user)
conn
|> redirect(to: Routes.user_registration_path(conn, :new))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
#case Accounts.register_user(user_params) do
# {:ok, user} ->
# {:ok, _} =
# Accounts.deliver_user_confirmation_instructions(
# user,
# &Routes.user_confirmation_url(conn, :confirm, &1)
# )
# conn
# |> put_flash(:info, "User created successfully.")
# |> UserAuth.log_in_user(user)
# {:error, %Ecto.Changeset{} = changeset} ->
# render(conn, "new.html", changeset: changeset)
#end
end
end

View File

@ -9,8 +9,8 @@ defmodule RecycledCloudWeb.UserResetPasswordController do
render(conn, "new.html")
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
def create(conn, %{"user" => %{"username" => username}}) do
if user = Accounts.get_user_by_username(username) do
Accounts.deliver_user_reset_password_instructions(
user,
&Routes.user_reset_password_url(conn, :edit, &1)

View File

@ -9,12 +9,12 @@ defmodule RecycledCloudWeb.UserSessionController do
end
def create(conn, %{"user" => user_params}) do
%{"email" => email, "password" => password} = user_params
%{"username" => username, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do
if user = Accounts.get_user_by_username_and_password(username, password) do
UserAuth.log_in_user(conn, user, user_params)
else
render(conn, "new.html", error_message: "Invalid email or password")
render(conn, "new.html", error_message: "Invalid username or password")
end
end

View File

@ -2,7 +2,7 @@
<li><%= link "Home", to: Routes.page_path(@conn, :index) %></li>
<%= if @current_user do %>
<li><%= @current_user.email %></li>
<li>Logged in as <%= @current_user.username %></li>
<li><%= link "Settings", to: Routes.user_settings_path(@conn, :edit) %></li>
<li><%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete %></li>
<% else %>

View File

@ -12,8 +12,14 @@
<div class="row">
<div class="col-2" id="section-nav">
<%= render "_sidebar_nav.html", assigns %>
<img id="nav-logo" src="<%= Routes.static_path(@conn, "/images/cloud.svg") %>"></img>
<p class="nav-notice">
<small>
management v<%= Mix.Project.config[:version] %> | <%= Mix.env %> | <a href="https://code.recycled.cloud/e-Durable/management">sources</a>
</small>
</p>
</div>
<div class="col-10">
<div class="col-6">
<main role="main" class="container-fluid">
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>

View File

@ -7,20 +7,17 @@
</div>
<% end %>
<%= label f, :email %>
<%= email_input f, :email, required: true %>
<%= error_tag f, :email %>
<%= text_input f, :username, required: true, placeholder: "Username" %>
<%= error_tag f, :username %>
<%= label f, :password %>
<%= password_input f, :password, required: true %>
<br />
<%= password_input f, :password, required: true, placeholder: "Password" %>
<%= error_tag f, :password %>
<br />
<div>
<%= submit "Register" %>
</div>
<% end %>
<p>
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %> |
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
</p>

View File

@ -1,8 +1,7 @@
<h1>Forgot your password?</h1>
<%= form_for :user, Routes.user_reset_password_path(@conn, :create), fn f -> %>
<%= label f, :email %>
<%= email_input f, :email, required: true %>
<%= text_input f, :username, required: true, placeholder: "Username" %>
<div>
<%= submit "Send instructions to reset password" %>

View File

@ -11,7 +11,7 @@ if you do not already have one.</p>
</div>
<% end %>
<%= email_input f, :email, required: true, placeholder: "Email" %> <br />
<%= text_input f, :username, required: true, placeholder: "Username" %> <br />
<%= password_input f, :password, required: true, placeholder: "Password" %> <br />
<%= label f, :remember_me, "Keep me logged in for 60 days" %>
<%= checkbox f, :remember_me %>

View File

@ -1,4 +1,9 @@
<h1>Settings</h1>
<h1>Account settings</h1>
<p>
You are currently logged in as <i><%= @current_user.username %></i>, using <%
@current_user.email %> as primary contact method.
</p>
<h3>Change email</h3>
@ -11,12 +16,12 @@
<%= hidden_input f, :action, name: "action", value: "update_email" %>
<%= label f, :email %>
<%= email_input f, :email, required: true %>
<%= email_input f, :email, required: true, placeholder: "Email" %>
<%= error_tag f, :email %>
<%= label f, :current_password, for: "current_password_for_email" %>
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email" %>
<br />
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email", placeholder: "Current password" %>
<%= error_tag f, :current_password %>
<div>
@ -35,16 +40,17 @@
<%= hidden_input f, :action, name: "action", value: "update_password" %>
<%= label f, :password, "New password" %>
<%= password_input f, :password, required: true %>
<%= password_input f, :password, required: true, placeholder: "New password" %>
<%= error_tag f, :password %>
<%= label f, :password_confirmation, "Confirm new password" %>
<%= password_input f, :password_confirmation, required: true %>
<br />
<%= password_input f, :password_confirmation, required: true, placeholder: "New password confirmation" %>
<%= error_tag f, :password_confirmation %>
<%= label f, :current_password, for: "current_password_for_password" %>
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password" %>
<br />
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password", placeholder: "Current password" %>
<%= error_tag f, :current_password %>
<div>

View File

@ -46,6 +46,7 @@ defmodule RecycledCloud.MixProject do
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.0"},
{:phx_gen_auth, "~> 0.6", only: [:dev], runtime: false},
{:exldap, git: "https://github.com/Fnux/exldap.git"}
]
end

View File

@ -10,6 +10,7 @@
"ecto": {:hex, :ecto, "3.5.5", "48219a991bb86daba6e38a1e64f8cea540cded58950ff38fbc8163e062281a07", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "98dd0e5e1de7f45beca6130d13116eae675db59adfa055fb79612406acf6f6f1"},
"ecto_sql": {:hex, :ecto_sql, "3.5.3", "1964df0305538364b97cc4661a2bd2b6c89d803e66e5655e4e55ff1571943efd", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d2f53592432ce17d3978feb8f43e8dc0705e288b0890caf06d449785f018061c"},
"elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"},
"exldap": {:git, "https://github.com/Fnux/exldap.git", "fd4b5c9928d2e6c4d4ba0fef95f9456ec8b088b9", []},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},

View File

@ -5,13 +5,12 @@ defmodule RecycledCloud.Repo.Migrations.CreateUsersAuthTables do
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :username, :citext, null: false
add :confirmed_at, :naive_datetime
timestamps()
end
create unique_index(:users, [:email])
create unique_index(:users, [:username])
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false