Packages
apiac_auth_basic
1.0.0
An APIac authenticator plug for API authentication using the HTTP Basic scheme
Current section
Files
Jump to
Current section
Files
lib/apiac_auth_basic.ex
defmodule APIacAuthBasic do
@behaviour Plug
@behaviour APIac.Authenticator
use Bitwise
@moduledoc """
An `APIac.Authenticator` plug for API authentication using the HTTP `Basic` scheme
The HTTP `Basic` scheme simply consists in transmitting a client and its password
in the `Authorization` HTTP header. It is base64-encoded:
```http
GET /api/accounts HTTP/1.1
Host: example.com
Authorization: Basic Y2xpZW50X2lkOmNsaWVudF9wYXNzd29yZA==
Accept: */*
```
The decoded value of `Y2xpZW50X2lkOmNsaWVudF9wYXNzd29yZA==` is `client_id:client_password`
This scheme is also sometimes called *APIKey* by some API managers.
## Security considerations
The password is transmitted in cleartext form (base64 is not a encryption scheme).
Therefore, you should only use this scheme on encrypted connections (HTTPS).
## Plug options
- `realm`: a mandatory `String.t` that conforms to the HTTP quoted-string syntax,
however without the surrounding quotes (which will be added automatically when
needed). Defaults to `default_realm`
- `callback`: a function that will return the password of a client. When a
callback is configured, it takes precedence over the clients in the config
files, which will not be used. The returned value can be:
- A cleartext password (`String.t`)
- An `Expwd.Hashed{}` (hashed password)
- `nil` if the client is not known
- `set_error_response`: function called when authentication failed. Defaults to
`APIacAuthBasic.send_error_response/3`
- `error_response_verbosity`: one of `:debug`, `:normal` or `:minimal`.
Defaults to `:normal`
## Application configuration
`{client_id, client_secret}` pairs can be configured in you application configuration files. There will be compiled at **compile time**. If you need runtime configurability,
use the `callback` option instead.
Storing cleartext password requires special care, for instance: using *.secret.exs files, encrypted storage of these config files, etc. Consider using hashed password instead, such
as `%Expwd.Hashed{}`.
Pairs a to be set separately for each realm in the `clients` key, as following:
``` elixir
config :apiac_auth_basic,
clients: %{
# using Expwd Hashed portable password
"realm_a" => [
{"client_1", "expwd:sha256:lYOmCIZUR603rPiIN0agzBHFyZDw9xEtETfbe6Q1ubU"},
{"client_2", "expwd:sha256:mnAWHn1tSHEOCj6sMDIrB9BTRuD4yZkiLbjx9x2i3ug"},
{"client_3", "expwd:sha256:9RYrMJSmXJSN4CSJZtOX0Xs+vP94meTaSzGc+oFcwqM"},
{"client_4", "expwd:sha256:aCL154jd8bNw868cbsCUw3skHun1n6fGYhBiITSmREw"},
{"client_5", "expwd:sha256:xSE6MkeC+gW7R/lEZKxsWGDs1MlqEV4u693fCBNlV4g"}
],
"realm_b" => [
{"client_1", "expwd:sha256:lYOmCIZUR603rPiIN0agzBHFyZDw9xEtETfbe6Q1ubU"}
],
# UNSAFE: cleartext passwords set directly in the config file
"realm_c" => [
{"client_6", "cleartext password"},
{"client_7", "cleartext password again"}
]
}
```
"""
@default_realm "default_realm"
@typedoc """
The callback function returns an Expwd.Hashed.t or a client_secret (String.t) so as
to prevent developers to [unsecurely compare passwords](https://codahale.com/a-lesson-in-timing-attacks/).
Return `nil` if the client could not be gound for this realm
"""
@type callback_fun ::
(APIac.realm(), APIac.client() -> Expwd.Hashed.t() | client_secret | nil)
@type client_secret :: String.t()
@doc """
Plug initialization callback
"""
@impl Plug
@spec init(Plug.opts()) :: Plug.opts()
def init(opts) do
if is_binary(opts[:realm]) and not APIac.rfc7230_quotedstring?("\"#{opts[:realm]}\""),
do: raise("Invalid realm string (do not conform with RFC7230 quoted string)")
realm = if opts[:realm], do: opts[:realm], else: @default_realm
opts
|> Enum.into(%{})
|> Map.put_new(:realm, @default_realm)
|> Map.put_new(:clients, Application.get_env(:apiac_auth_basic, :clients)[realm] || [])
|> Map.put_new(:callback, nil)
|> Map.put_new(:set_error_response, &APIacAuthBasic.send_error_response/3)
|> Map.put_new(:error_response_verbosity, :normal)
end
@doc """
Plug pipeline callback
"""
@impl Plug
@spec call(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t()
def call(conn, %{} = opts) do
if APIac.authenticated?(conn) do
conn
else
do_call(conn, opts)
end
end
def do_call(conn, opts) do
with {:ok, conn, credentials} <- extract_credentials(conn, opts),
{:ok, conn} <- validate_credentials(conn, credentials, opts) do
conn
else
{:error, conn, %APIac.Authenticator.Unauthorized{} = error} ->
opts[:set_error_response].(conn, error, opts)
end
end
@doc """
`APIac.Authenticator` credential extractor callback
Returns the credentials under the form `{client_id, client_secret}` where both
variables are binaries
"""
@impl APIac.Authenticator
def extract_credentials(conn, _opts) do
parse_authz_header(conn)
end
defp parse_authz_header(conn) do
case Plug.Conn.get_req_header(conn, "authorization") do
# Only one header value should be returned
# (https://stackoverflow.com/questions/29282578/multiple-http-authorization-headers)
["Basic " <> auth_token] ->
# rfc7235 syntax allows multiple spaces before the base64 token
case Base.decode64(String.trim_leading(auth_token, "\s")) do
{:ok, decodedbinary} ->
# nothing indicates we should trim extra whitespaces (a passowrd could contain one for instance)
case String.split(decodedbinary, ":", trim: false) do
[client_id, client_secret] ->
if not ctl_char?(client_secret) and not ctl_char?(client_secret) do
{:ok, conn, {client_id, client_secret}}
else
{:error, conn,
%APIac.Authenticator.Unauthorized{
authenticator: __MODULE__,
reason: :invalid_client_id_or_client_secret
}}
end
_ ->
{:error, conn,
%APIac.Authenticator.Unauthorized{
authenticator: __MODULE__,
reason: :invalid_credential_format
}}
end
_ ->
{:error, conn,
%APIac.Authenticator.Unauthorized{
authenticator: __MODULE__,
reason: :invalid_credential_format
}}
end
_ ->
{:error, conn,
%APIac.Authenticator.Unauthorized{
authenticator: __MODULE__,
reason: :credentials_not_found
}}
end
end
defp ctl_char?(str) do
Regex.run(~r/[\x00-\x1F\x7F]/, str) != nil
end
@doc """
`APIac.Authenticator` credential validator callback
"""
@impl APIac.Authenticator
def validate_credentials(conn, {client_id, client_secret}, %{callback: callback} = opts)
when is_function(callback) do
case callback.(opts[:realm], client_id) do
nil ->
{:error, conn,
%APIac.Authenticator.Unauthorized{authenticator: __MODULE__, reason: :client_not_found}}
stored_client_secret ->
if Expwd.secure_compare(client_secret, stored_client_secret) == true do
conn =
conn
|> Plug.Conn.put_private(:apiac_authenticator, __MODULE__)
|> Plug.Conn.put_private(:apiac_client, client_id)
|> Plug.Conn.put_private(:apiac_realm, opts[:realm])
{:ok, conn}
else
{:error, conn,
%APIac.Authenticator.Unauthorized{
authenticator: __MODULE__,
reason: :invalid_client_secret
}}
end
end
end
@impl APIac.Authenticator
def validate_credentials(conn, {client_id, client_secret}, opts) do
case List.keyfind(opts[:clients], client_id, 0) do
nil ->
{:error, conn,
%APIac.Authenticator.Unauthorized{authenticator: __MODULE__, reason: :client_not_found}}
{_stored_client_id, stored_client_secret} ->
if Expwd.secure_compare(client_secret, stored_client_secret) == true do
conn =
conn
|> Plug.Conn.put_private(:apiac_authenticator, __MODULE__)
|> Plug.Conn.put_private(:apiac_client, client_id)
|> Plug.Conn.put_private(:apiac_realm, opts[:realm])
{:ok, conn}
else
{:error, conn,
%APIac.Authenticator.Unauthorized{
authenticator: __MODULE__,
reason: :invalid_client_secret
}}
end
end
end
@doc """
Implementation of the `APIac.Authenticator` callback
## Verbosity
The following elements in the HTTP response are set depending on the value
of the `:error_response_verbosity` option:
| Error response verbosity | HTTP Status | Headers | Body |
|:-------------------------:|--------------------|--------------------------------------------------------|---------------------------------------------------------|
| `:debug` | Unauthorized (401) | WWW-Authenticate with `Basic` scheme and `realm` param | `APIac.Authenticator.Unauthorized` exception's message |
| `:normal` | Unauthorized (401) | WWW-Authenticate with `Basic` scheme and `realm` param | |
| `:minimal` | Unauthorized (401) | | |
Note: the behaviour when the verbosity is `:minimal` may not be conformant
to the HTTP specification as at least one scheme should be returned in
the `WWW-Authenticate` header.
"""
@impl APIac.Authenticator
def send_error_response(conn, error, opts) do
case opts[:error_response_verbosity] do
:debug ->
conn
|> APIac.set_WWWauthenticate_challenge("Basic", %{"realm" => "#{opts[:realm]}"})
|> Plug.Conn.send_resp(:unauthorized, Exception.message(error))
|> Plug.Conn.halt()
:normal ->
conn
|> APIac.set_WWWauthenticate_challenge("Basic", %{"realm" => "#{opts[:realm]}"})
|> Plug.Conn.send_resp(:unauthorized, "")
|> Plug.Conn.halt()
:minimal ->
conn
|> Plug.Conn.send_resp(:unauthorized, "")
|> Plug.Conn.halt()
end
end
@doc """
Sets the HTTP `WWW-authenticate` header when no such a scheme is used for
authentication.
Sets the HTTP `WWW-Authenticate` header with the `Basic` scheme and the realm
name, when the `Basic` scheme was not used in the request. When this scheme is
used in the request, response will be sent by `#{__MODULE__}.send_error_response/3`.
This allows advertising that the `Basic` scheme is available, without stopping
the plug pipeline.
Raises a exception when the error response verbosity is set to `:minimal` since
it does not set the `WWW-Authenticate` header.
"""
@spec set_WWWauthenticate_header(
Plug.Conn.t(),
%APIac.Authenticator.Unauthorized{},
any()
) :: Plug.Conn.t()
def set_WWWauthenticate_header(_conn, _err, %{:error_response_verbosity => :minimal}) do
raise "#{__ENV__.function} not accepted when :error_response_verbosity is set to :minimal"
end
def set_WWWauthenticate_header(
conn,
%APIac.Authenticator.Unauthorized{reason: :credentials_not_found},
opts
) do
conn
|> APIac.set_WWWauthenticate_challenge("Basic", %{"realm" => "#{opts[:realm]}"})
end
def set_WWWauthenticate_header(conn, error, opts) do
send_error_response(conn, error, opts)
end
@doc """
Saves failure in a `Plug.Conn.t()`'s private field and returns the `conn`
See the `APIac.AuthFailureResponseData` module for more information.
"""
@spec save_authentication_failure_response(
Plug.Conn.t(),
%APIac.Authenticator.Unauthorized{},
any()
) :: Plug.Conn.t()
def save_authentication_failure_response(conn, error, opts) do
failure_response_data =
case opts[:error_response_verbosity] do
:debug ->
%APIac.AuthFailureResponseData{
module: __MODULE__,
reason: error.reason,
www_authenticate_header: {"Basic", %{"realm" => "#{opts[:realm]}"}},
status_code: :unauthorized,
body: Exception.message(error)
}
:normal ->
%APIac.AuthFailureResponseData{
module: __MODULE__,
reason: error.reason,
www_authenticate_header: {"Basic", %{"realm" => "#{opts[:realm]}"}},
status_code: :unauthorized,
body: ""
}
:minimal ->
%APIac.AuthFailureResponseData{
module: __MODULE__,
reason: error.reason,
www_authenticate_header: nil,
status_code: :unauthorized,
body: ""
}
end
APIac.AuthFailureResponseData.put(conn, failure_response_data)
end
end