14 |
--------------------------------------------------------------------------------
/lib/boreale_web/controller.ex:
--------------------------------------------------------------------------------
1 | defmodule BorealeWeb.Controller do
2 | import Plug.Conn
3 | alias Boreale.Storage
4 |
5 | def render(%{status: status} = conn, template, assigns \\ []) do
6 | body =
7 | Storage.templates_directory_path()
8 | |> Path.join(template)
9 | |> String.replace_suffix(".html", ".html.heex")
10 | |> EEx.eval_file(assigns)
11 |
12 | send_resp(conn, status || 200, body)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/boreale_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule BorealeWeb.Router do
2 | use Plug.Router
3 |
4 | alias BorealeWeb.Login.Controller, as: LoginController
5 |
6 | # Plug configuration
7 | plug Plug.SSL, hsts: false
8 | plug Plug.Logger, log: :debug
9 | plug Plug.Parsers, parsers: [:urlencoded]
10 |
11 | plug Boreale.Plug.InitSession
12 | plug Boreale.Plug.ParseRequest
13 |
14 | plug :match
15 | plug :dispatch
16 |
17 | get "/" do
18 | LoginController.index(conn)
19 | end
20 |
21 | match _ do
22 | send_resp(conn, 404, "Not found")
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/boreale/credentials.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Credentials do
2 | alias Boreale.Storage
3 |
4 | def user_allowed?(domain, session) do
5 | user_logged_in?(session) || domain_public?(domain)
6 | end
7 |
8 | def validate(username, password) do
9 | with {:ok, user} <- Storage.get_user(username),
10 | true <- Bcrypt.verify_pass(password, user.password_hash) do
11 | :ok
12 | end
13 | end
14 |
15 | defp user_logged_in?(session) do
16 | if username = Map.get(session, "username") do
17 | username = String.downcase(username)
18 | match?({:ok, _user}, Storage.get_user(username))
19 | end
20 | end
21 |
22 | defp domain_public?(domain) do
23 | domains = Storage.get_domains() |> Enum.map(& &1.host)
24 | domain in domains
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where third-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | boreale-*.tar
24 |
25 | # Temporary files, for example, from tests.
26 | /tmp/
27 |
28 | # Local environment variable files
29 | .env.local
30 |
--------------------------------------------------------------------------------
/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | defmodule Environment do
4 | def get(key), do: System.get_env(key)
5 |
6 | def get_integer(key) do
7 | case get(key) do
8 | value when is_bitstring(value) -> String.to_integer(value)
9 | _ -> nil
10 | end
11 | end
12 | end
13 |
14 | config :boreale, BorealeWeb.Router,
15 | cookie_name: Environment.get("COOKIE_NAME") || "_boreale_auth",
16 | encryption_salt: Environment.get("ENCRYPTION_SALT"),
17 | port: Environment.get_integer("PORT") || 4000,
18 | secret_key_base: Environment.get("SECRET_KEY_BASE"),
19 | signing_salt: Environment.get("SIGNING_SALT"),
20 | sso_domain_name: Environment.get("SSO_DOMAIN_NAME")
21 |
22 | config :boreale, BorealeWeb.Login.Controller,
23 | page_title: Environment.get("PAGE_TITLE") || "Boréale Authentication"
24 |
--------------------------------------------------------------------------------
/lib/boreale_web/plug/init_session.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Plug.InitSession do
2 | alias BorealeWeb.Router
3 |
4 | def init(opts), do: opts
5 |
6 | def call(conn, _opts) do
7 | conn
8 | |> put_secret_key_base()
9 | |> init_session_cookie()
10 | end
11 |
12 | defp put_secret_key_base(conn) do
13 | Map.put(conn, :secret_key_base, Application.get_env(:boreale, Router)[:secret_key_base])
14 | end
15 |
16 | defp init_session_cookie(conn) do
17 | opts =
18 | Plug.Session.init(
19 | store: :cookie,
20 | domain: Application.get_env(:boreale, Router)[:sso_domain_name],
21 | key: Application.get_env(:boreale, Router)[:cookie_name],
22 | signing_salt: Application.get_env(:boreale, Router)[:signing_salt],
23 | encryption_salt: Application.get_env(:boreale, Router)[:encryption_salt],
24 | secure: true
25 | )
26 |
27 | Plug.Session.call(conn, opts)
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Boreale.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :boreale,
7 | version: "1.3.1",
8 | elixir: "~> 1.13",
9 | start_permanent: Mix.env() == :prod,
10 | deps: deps(),
11 | releases: releases()
12 | ]
13 | end
14 |
15 | # Run "mix help compile.app" to learn about applications.
16 | def application do
17 | [
18 | extra_applications: [:logger],
19 | mod: {Boreale.Application, []}
20 | ]
21 | end
22 |
23 | # Run "mix help deps" to learn about dependencies.
24 | defp deps do
25 | [
26 | {:bcrypt_elixir, "~> 3.0"},
27 | {:credo, "~> 1.6"},
28 | {:plug_cowboy, "~> 2.5"}
29 | ]
30 | end
31 |
32 | defp releases do
33 | [
34 | boreale: [
35 | version: {:from_app, :boreale},
36 | applications: [boreale: :permanent],
37 | include_executables_for: [:unix],
38 | steps: [:assemble, :tar]
39 | ]
40 | ]
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/test/boreale/domains_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Boreale.DomainsTest do
2 | use Boreale.TestCase
3 |
4 | alias Boreale.{Domains, Storage}
5 |
6 | setup do
7 | _ = Domains.start_link([])
8 | :ok
9 | end
10 |
11 | @domain "public.com"
12 | describe "public?/1" do
13 | test "only public domains are public" do
14 | with_mock(Storage, read_dets: fn _, _ -> [[@domain, DateTime.utc_now()]] end) do
15 | Domains.sync()
16 | assert Domains.public?(@domain)
17 | refute Domains.public?("another.domain.com")
18 | end
19 | end
20 | end
21 |
22 | describe "sync/0" do
23 | test "updates itself on sync" do
24 | with_mock(Storage, read_dets: fn _, _ -> [] end) do
25 | Domains.sync()
26 | refute Domains.public?(@domain)
27 | end
28 |
29 | with_mock(Storage, read_dets: fn _, _ -> [[@domain, DateTime.utc_now()]] end) do
30 | Domains.sync()
31 | assert Domains.public?(@domain)
32 | refute Domains.public?("another.domain.com")
33 | end
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/boreale/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | alias Boreale.SSL
9 |
10 | require Logger
11 |
12 | @impl true
13 | def start(_type, _args) do
14 | SSL.maybe_generate_certificate()
15 |
16 | children = [
17 | {Plug.Cowboy,
18 | scheme: :https,
19 | plug: BorealeWeb.Router,
20 | port: port(),
21 | otp_app: :boreale,
22 | keyfile: SSL.get_key_path(),
23 | certfile: SSL.get_certificate_path()}
24 | ]
25 |
26 | # See https://hexdocs.pm/elixir/Supervisor.html
27 | # for other strategies and supported options
28 | opts = [strategy: :one_for_one, name: Boreale.Supervisor]
29 | {:ok, pid} = Supervisor.start_link(children, opts)
30 |
31 | Logger.info("Boreale server started on https://localhost:#{port()}")
32 |
33 | {:ok, pid}
34 | end
35 |
36 | defp port do
37 | Application.get_env(:boreale, BorealeWeb.Router)[:port]
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Anthony Jean
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/lib/boreale_web/plug/parse_request.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Plug.ParseRequest do
2 | import Plug.Conn
3 |
4 | def init(opts), do: opts
5 |
6 | def call(conn, _opts) do
7 | conn
8 | |> parse_params()
9 | |> fetch_session_cookie()
10 | |> get_forwarded_domain()
11 | end
12 |
13 | defp parse_params(conn) do
14 | case get_req_header(conn, "auth-form") do
15 | [] ->
16 | conn
17 | |> assign(:action, :index)
18 |
19 | [value] ->
20 | %{"username" => username, "password" => password} = URI.decode_query(value)
21 |
22 | conn
23 | |> assign(:action, :login)
24 | |> assign(:username, username)
25 | |> assign(:password, password)
26 | end
27 | end
28 |
29 | defp fetch_session_cookie(conn) do
30 | conn = fetch_session(conn)
31 | session = get_session(conn)
32 | assign(conn, :session, session)
33 | end
34 |
35 | defp get_forwarded_domain(conn) do
36 | domain =
37 | case get_req_header(conn, "x-forwarded-host") do
38 | [] -> nil
39 | [domain] -> domain
40 | end
41 |
42 | assign(conn, :domain, domain)
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/priv/templates/login.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%= title %>
9 |
10 |
11 |
12 |
13 | <%= body %>
14 |
15 |
16 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/lib/tasks/CLI/domains/domains_add.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Tasks.Cli.DomainsAdd do
2 | alias Boreale.Storage
3 | alias Boreale.Tasks.Cli
4 |
5 | def run(args) do
6 | args = Cli.Utils.args_to_map(args)
7 |
8 | case args do
9 | %{"--help" => _} ->
10 | Cli.Utils.print_help_for("domains add")
11 |
12 | %{} ->
13 | add_domain()
14 |
15 | _ ->
16 | IO.puts("boreale cli: domains add command does not take any arguments")
17 | IO.puts("See 'boreale cli domains add --help'")
18 | end
19 | end
20 |
21 | defp add_domain do
22 | domain = IO.gets("Public domain name (FQDN): ") |> String.trim()
23 |
24 | if String.length(domain) >= 0 do
25 | case insert_domain(domain) do
26 | {:ok} -> IO.puts("Public domain #{domain} has been added.")
27 | {:error, msg} -> IO.puts(msg)
28 | end
29 | else
30 | IO.puts("Domain can't be empty.")
31 | end
32 | end
33 |
34 | defp insert_domain(domain) do
35 | date_time = DateTime.utc_now()
36 | {:ok, table} = :dets.open_file(Storage.persisted_domains_filepath(), type: :set)
37 |
38 | created? = :dets.insert_new(table, {domain, date_time})
39 | :dets.close(table)
40 |
41 | if created?, do: {:ok}, else: {:error, "The public domain #{domain} already exists."}
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | #
2 | # Step 1 - build the OTP binary
3 | #
4 | FROM hexpm/elixir:1.13.3-erlang-24.2.2-alpine-3.15.0 AS otp-builder
5 |
6 | ENV MIX_ENV=prod
7 |
8 | WORKDIR /build
9 |
10 | # Install Alpine dependencies
11 | RUN apk update --no-cache && \
12 | apk upgrade --no-cache && \
13 | apk add --no-cache git build-base
14 |
15 | # Install Erlang dependencies
16 | RUN mix local.rebar --force && \
17 | mix local.hex --force
18 |
19 | # Install dependencies
20 | COPY mix.* ./
21 | RUN mix deps.get --only prod && \
22 | mix deps.compile
23 |
24 | # Compile codebase
25 | COPY config config
26 | COPY lib lib
27 | COPY priv priv
28 | RUN mix compile
29 |
30 | # Build OTP release
31 | RUN mix release
32 |
33 | #
34 | # Step 2 - build a lean runtime container
35 | #
36 | FROM alpine:3.15.0
37 |
38 | ARG APP_NAME
39 | ARG APP_VERSION
40 |
41 | ENV APP_NAME=${APP_NAME} \
42 | APP_VERSION=${APP_VERSION}
43 |
44 | # Install Alpine dependencies
45 | RUN apk update --no-cache && \
46 | apk upgrade --no-cache && \
47 | apk add --no-cache bash shadow su-exec openssl libgcc libstdc++
48 |
49 | WORKDIR /opt/boreale
50 |
51 | # Copy OTP binary from step 1
52 | COPY --from=otp-builder /build/_build/prod/${APP_NAME}-${APP_VERSION}.tar.gz .
53 | RUN tar -xvzf ${APP_NAME}-${APP_VERSION}.tar.gz && \
54 | rm ${APP_NAME}-${APP_VERSION}.tar.gz
55 |
56 | # Copy the entrypoint script
57 | COPY docker-entrypoint.sh /usr/local/bin
58 | RUN chmod a+x /usr/local/bin/docker-entrypoint.sh
59 |
60 | COPY boreale-cli.sh bin/boreale-cli
61 | RUN chmod u+x bin/boreale-cli
62 |
63 | # Create a non-root user
64 | RUN adduser -D boreale && chown -R boreale: /opt/boreale
65 |
66 | ENTRYPOINT ["docker-entrypoint.sh"]
67 | CMD ["start"]
68 |
--------------------------------------------------------------------------------
/test/boreale/users_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Boreale.UsersTest do
2 | use Boreale.TestCase
3 |
4 | alias Boreale.{Users, Storage}
5 |
6 | setup do
7 | _ = Users.start_link([])
8 | :ok
9 | end
10 |
11 | @valid_user "valid_user"
12 | @password "password"
13 | @valid_users [
14 | [@valid_user, @password, DateTime.utc_now()]
15 | ]
16 | describe "sync/0" do
17 | test "syncs with latest changes" do
18 | with_mock(Storage, read_dets: fn _, _ -> [] end) do
19 | Users.sync()
20 | refute Users.valid?(@valid_user, @password)
21 | end
22 |
23 | with_mock(Storage, read_dets: fn _, _ -> hash_user_passwords(@valid_users) end) do
24 | Users.sync()
25 | assert Users.valid?(@valid_user, @password)
26 | end
27 | end
28 | end
29 |
30 | describe "valid?/2" do
31 | test "validate users" do
32 | with_mock(Storage, read_dets: fn _, _ -> hash_user_passwords(@valid_users) end) do
33 | Users.sync()
34 | assert Users.valid?(@valid_user, @password)
35 | refute Users.valid?(@valid_user, "bad_password")
36 | refute Users.valid?("invalid_user", @password)
37 | end
38 | end
39 |
40 | test "return false if no users exists" do
41 | with_mock(Storage, read_dets: fn _, _ -> [] end) do
42 | Users.sync()
43 | refute Users.valid?(@valid_user, @password)
44 | refute Users.valid?(@valid_user, "bad_password")
45 | refute Users.valid?("invalid_user", @password)
46 | end
47 | end
48 | end
49 |
50 | defp hash_user_passwords(users) do
51 | Enum.map(users, fn [user, pw, date] ->
52 | [user, Bcrypt.hash_pwd_salt(pw), date]
53 | end)
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/boreale/ssl.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.SSL do
2 | @moduledoc """
3 | The cert is only used for completing the SSL connection between traefik and the cowboy server
4 | It does not need to be signed by an authority since the auth server is not meant to be accessed
5 | directly by an hostname.
6 | """
7 | require Logger
8 |
9 | alias Boreale.Storage
10 |
11 | @cert_file_name "cert.pem"
12 | @key_file_name "key.pem"
13 |
14 | @spec maybe_generate_certificate :: {:error, any()} | :ok
15 | def maybe_generate_certificate do
16 | if certificate_exists?() do
17 | :ok
18 | else
19 | generate_certificate()
20 | end
21 | end
22 |
23 | defp generate_certificate do
24 | case run_openssl_command() do
25 | {_, 0} ->
26 | :ok
27 |
28 | {stdout, _} ->
29 | Logger.error(stdout)
30 | {:error, stdout}
31 | end
32 | end
33 |
34 | defp run_openssl_command do
35 | System.cmd("openssl", [
36 | "req",
37 | "-new",
38 | "-newkey",
39 | "rsa:4096",
40 | "-days",
41 | "365",
42 | "-nodes",
43 | "-x509",
44 | "-subj",
45 | "/C=US/ST=Denial/L=Springfield/O=Dis/CN=localhost",
46 | "-keyout",
47 | get_key_path(),
48 | "-out",
49 | get_certificate_path()
50 | ])
51 | end
52 |
53 | defp certificate_exists?, do: File.exists?(get_certificate_path())
54 |
55 | @spec get_certificate_path :: String.t()
56 | def get_certificate_path do
57 | Path.join(Storage.user_directory_path(), @cert_file_name)
58 | end
59 |
60 | @spec get_key_path :: String.t()
61 | def get_key_path do
62 | Path.join(Storage.user_directory_path(), @key_file_name)
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/lib/tasks/CLI/cli.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Tasks.Cli do
2 | alias __MODULE__
3 |
4 | @moduledoc "Provides a CLI for basic configuration of Boreale"
5 | def run(string) when is_binary(string) do
6 | string
7 | |> String.split()
8 | |> run()
9 | end
10 |
11 | def run([]), do: Cli.Utils.print_general_help()
12 |
13 | def run([cmd | args]) do
14 | case cmd do
15 | "domains" ->
16 | domains(args)
17 |
18 | "users" ->
19 | users(args)
20 |
21 | "--help" ->
22 | Cli.Utils.print_general_help()
23 |
24 | _ ->
25 | IO.puts("boreale cli: '#{cmd}' is not a boreale cli command.")
26 | IO.puts("See 'boreale cli --help'")
27 | end
28 | end
29 |
30 | defp users([]) do
31 | Cli.Users.run()
32 | end
33 |
34 | defp users([cmd | args]) do
35 | case cmd do
36 | "add" ->
37 | Cli.UsersAdd.run(args)
38 |
39 | "remove" ->
40 | Cli.UsersRemove.run(args)
41 |
42 | "--help" ->
43 | Cli.Utils.print_help_for("users")
44 |
45 | _ ->
46 | IO.puts("boreale cli: 'users #{cmd}' is not a boreale cli command.")
47 | IO.puts("See 'boreale cli --help'")
48 | end
49 | end
50 |
51 | defp domains([]) do
52 | Cli.Domains.run()
53 | end
54 |
55 | defp domains([cmd | args]) do
56 | case cmd do
57 | "add" ->
58 | Cli.DomainsAdd.run(args)
59 |
60 | "remove" ->
61 | Cli.DomainsRemove.run(args)
62 |
63 | "--help" ->
64 | Cli.Utils.print_help_for("domains")
65 |
66 | _ ->
67 | IO.puts("boreale cli: 'domains #{cmd}' is not a boreale cli command.")
68 | IO.puts("See 'boreale cli --help'")
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/tasks/CLI/users/users_remove.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Tasks.Cli.UsersRemove do
2 | alias Boreale.Storage
3 | alias Boreale.Tasks.Cli
4 |
5 | def run(args) do
6 | args = Cli.Utils.args_to_map(args)
7 |
8 | case args do
9 | %{"--help" => _} ->
10 | Cli.Utils.print_help_for("users remove")
11 |
12 | %{} ->
13 | remove_user()
14 |
15 | _ ->
16 | IO.puts("boreale cli: users remove command does not take any arguments")
17 | IO.puts("See 'boreale cli users remove --help'")
18 | end
19 | end
20 |
21 | defp remove_user do
22 | users = Cli.Users.run()
23 |
24 | with {:users_not_empty} <- is_users_empty?(users),
25 | {:ok, username} <- get_user(users) do
26 | {:ok, table} = :dets.open_file(Storage.persisted_users_filepath(), type: :set)
27 |
28 | deleted? = :dets.delete(table, username)
29 | :dets.close(table)
30 |
31 | case deleted? do
32 | :ok -> IO.puts("User #{username} has been removed.")
33 | _ -> IO.puts("Error : User #{username} hasn't been removed.")
34 | end
35 | else
36 | {:no_user_with_id} -> IO.puts("Error: There is no authorized user with this ID.")
37 | {:users_empty} -> nil
38 | end
39 | end
40 |
41 | defp get_user(users) do
42 | users =
43 | Stream.map(users, fn {id, name, _} -> {id, name} end)
44 | |> Enum.into(%{})
45 |
46 | IO.puts("")
47 | id = IO.gets("Enter the ID of the user you wish to remove: ") |> String.trim()
48 |
49 | if users[id],
50 | do: {:ok, users[id]},
51 | else: {:no_user_with_id}
52 | end
53 |
54 | defp is_users_empty?(users) do
55 | case is_nil(users) do
56 | true -> {:users_empty}
57 | false -> {:users_not_empty}
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/tasks/CLI/utils.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Tasks.Cli.Utils do
2 | def args_to_map(args) do
3 | args
4 | |> Enum.map(fn x ->
5 | String.split(x, "=")
6 | |> List.to_tuple()
7 | |> case do
8 | {k, v} -> {k, v}
9 | {k} -> {k, nil}
10 | end
11 | end)
12 | |> Enum.into(%{})
13 | end
14 |
15 | @commands_help %{
16 | "users" => %{optionals: "", body: "List all users"},
17 | "users add" => %{optionals: "", body: "Add an authorized user"},
18 | "users remove" => %{optionals: "", body: "Remove an authorized user"},
19 | "domains" => %{optionals: "", body: "List all public domains"},
20 | "domains add" => %{optionals: "", body: "Add a domain to the public domains list"},
21 | "domains remove" => %{optionals: "", body: "Remove a domain from the public domains list"}
22 | }
23 |
24 | def print_help_for(cmd) do
25 | IO.puts("Usage: boreale cli #{cmd} #{@commands_help[cmd].optionals}")
26 | IO.puts("")
27 | IO.puts("#{@commands_help[cmd].body}")
28 | end
29 |
30 | def print_general_help do
31 | IO.puts("Usage: boreale cli COMMAND")
32 | IO.puts("")
33 | IO.puts("A CLI for managing boreale")
34 | IO.puts("")
35 | IO.puts("Commands:")
36 | IO.puts(" domains #{@commands_help["domains"].body}")
37 | IO.puts(" domains add #{@commands_help["domains add"].body}")
38 | IO.puts(" domains remove #{@commands_help["domains remove"].body}")
39 | IO.puts(" users #{@commands_help["users"].body}")
40 | IO.puts(" users add #{@commands_help["users add"].body}")
41 | IO.puts(" users remove #{@commands_help["users remove"].body}")
42 | IO.puts("")
43 | IO.puts("Run 'boreale cli COMMAND --help' for more information on a command.")
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/.github/workflows/cd.yaml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | push_to_registry:
9 | name: Push Docker image to Docker Hub
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Check out the repo
13 | uses: actions/checkout@v3
14 |
15 | - name: Extract app variables
16 | shell: bash
17 | run: |
18 | echo "app_name=$(grep -Eo 'app: :\w*' mix.exs | cut -d ':' -f 3)" >> $GITHUB_OUTPUT
19 | echo "app_version=$(grep -Eo 'version: "[0-9\.]*"' mix.exs | cut -d '"' -f 2)" >> $GITHUB_OUTPUT
20 | id: extract_variables
21 |
22 | - name: Set up QEMU
23 | uses: docker/setup-qemu-action@v2
24 |
25 | - name: Set up Docker Buildx
26 | uses: docker/setup-buildx-action@v2
27 |
28 | - name: Log in to Docker Hub
29 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
30 | with:
31 | username: ${{ secrets.DOCKER_USERNAME }}
32 | password: ${{ secrets.DOCKER_PASSWORD }}
33 |
34 | - name: Extract metadata (tags, labels) for Docker
35 | id: meta
36 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
37 | with:
38 | images: lewazo/boreale
39 |
40 | - name: Build and push Docker image
41 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
42 | with:
43 | context: .
44 | platforms: linux/amd64,linux/arm64
45 | build-args: |
46 | APP_NAME=${{ steps.extract_variables.outputs.app_name }}
47 | APP_VERSION=${{ steps.extract_variables.outputs.app_version }}
48 | push: true
49 | tags: ${{ steps.meta.outputs.tags }}
50 | labels: ${{ steps.meta.outputs.labels }}
51 |
--------------------------------------------------------------------------------
/lib/tasks/CLI/users/users.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Tasks.Cli.Users do
2 | alias Boreale.Storage
3 |
4 | def run do
5 | {:ok, table} = :dets.open_file(Storage.persisted_users_filepath(), type: :set)
6 |
7 | users =
8 | :dets.match(table, {:"$1", :"$2", :"$3"})
9 | |> Stream.map(fn x -> List.to_tuple(x) end)
10 | |> Enum.sort(fn {_, _, x}, {_, _, y} -> DateTime.compare(x, y) == :lt end)
11 | |> Enum.map_reduce(1, fn x, acc ->
12 | {Tuple.insert_at(x, 0, Integer.to_string(acc)), acc + 1}
13 | end)
14 | |> elem(0)
15 | |> Enum.map(fn {id, u, _, dt} -> {id, u, DateTime.to_string(dt)} end)
16 |
17 | :dets.close(table)
18 |
19 | if length(users) > 0 do
20 | print_table(users)
21 | users
22 | else
23 | IO.puts("There are no authorized users configured.")
24 | nil
25 | end
26 | end
27 |
28 | defp print_table(users) do
29 | rows = [["ID", "NAME", "CREATED AT (UTC)"]] ++ Enum.map(users, fn x -> Tuple.to_list(x) end)
30 | number_of_cols = length(Enum.at(rows, 0))
31 |
32 | lengths_of_longest_strings =
33 | Enum.reduce(0..(number_of_cols - 1), %{}, fn col, acc_col ->
34 | longest_str_for_col =
35 | Enum.reduce(rows, 0, fn row, acc_row ->
36 | str_length = String.length(Enum.at(row, col))
37 | if str_length > acc_row, do: str_length, else: acc_row
38 | end)
39 |
40 | Map.put(acc_col, col, longest_str_for_col)
41 | end)
42 |
43 | Enum.each(rows, fn row ->
44 | Stream.with_index(row)
45 | |> Enum.each(fn {col, idx} ->
46 | IO.write(col)
47 | spacing = lengths_of_longest_strings[idx] - String.length(col)
48 | Enum.each(0..(spacing + 2), fn _ -> IO.write(" ") end)
49 | end)
50 |
51 | IO.puts("")
52 | end)
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/tasks/CLI/domains/domains.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Tasks.Cli.Domains do
2 | alias Boreale.Storage
3 |
4 | def run do
5 | {:ok, table} = :dets.open_file(Storage.persisted_domains_filepath(), type: :set)
6 |
7 | domains =
8 | :dets.match(table, {:"$1", :"$2"})
9 | |> Stream.map(fn x -> List.to_tuple(x) end)
10 | |> Enum.sort(fn {_, x}, {_, y} -> DateTime.compare(x, y) == :lt end)
11 | |> Enum.map_reduce(1, fn x, acc ->
12 | {Tuple.insert_at(x, 0, Integer.to_string(acc)), acc + 1}
13 | end)
14 | |> elem(0)
15 | |> Enum.map(fn {id, d, dt} -> {id, d, DateTime.to_string(dt)} end)
16 |
17 | :dets.close(table)
18 |
19 | if length(domains) > 0 do
20 | print_table(domains)
21 | domains
22 | else
23 | IO.puts("There are no public domains configured.")
24 | nil
25 | end
26 | end
27 |
28 | defp print_table(domains) do
29 | rows =
30 | [["ID", "DOMAIN", "CREATED AT (UTC)"]] ++ Enum.map(domains, fn x -> Tuple.to_list(x) end)
31 |
32 | number_of_cols = length(Enum.at(rows, 0))
33 |
34 | lengths_of_longest_strings =
35 | Enum.reduce(0..(number_of_cols - 1), %{}, fn col, acc_col ->
36 | longest_str_for_col =
37 | Enum.reduce(rows, 0, fn row, acc_row ->
38 | str_length = String.length(Enum.at(row, col))
39 | if str_length > acc_row, do: str_length, else: acc_row
40 | end)
41 |
42 | Map.put(acc_col, col, longest_str_for_col)
43 | end)
44 |
45 | Enum.each(rows, fn row ->
46 | Stream.with_index(row)
47 | |> Enum.each(fn {col, idx} ->
48 | IO.write(col)
49 | spacing = lengths_of_longest_strings[idx] - String.length(col)
50 | Enum.each(0..(spacing + 2), fn _ -> IO.write(" ") end)
51 | end)
52 |
53 | IO.puts("")
54 | end)
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/tasks/CLI/domains/domains_remove.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Tasks.Cli.DomainsRemove do
2 | alias Boreale.Storage
3 | alias Boreale.Tasks.Cli
4 |
5 | def run(args) do
6 | args = Cli.Utils.args_to_map(args)
7 |
8 | case args do
9 | %{"--help" => _} ->
10 | Cli.Utils.print_help_for("domains remove")
11 |
12 | %{} ->
13 | remove_domain()
14 |
15 | _ ->
16 | IO.puts("boreale cli: domains remove command does not take any arguments")
17 | IO.puts("See 'boreale cli domains remove --help'")
18 | end
19 | end
20 |
21 | defp remove_domain do
22 | domains = Cli.Domains.run()
23 |
24 | with {:domains_not_empty} <- is_domains_empty?(domains),
25 | {:ok, domain_name} <- get_domain(domains) do
26 | {:ok, table} = :dets.open_file(Storage.persisted_domains_filepath(), type: :set)
27 |
28 | deleted? = :dets.delete(table, domain_name)
29 | :dets.close(table)
30 |
31 | case deleted? do
32 | :ok -> IO.puts("Public domain #{domain_name} has been removed.")
33 | _ -> IO.puts("Error : Public domain #{domain_name} hasn't been removed.")
34 | end
35 | else
36 | {:no_domain_with_id} -> IO.puts("Error: There is no public domain with this ID.")
37 | {:domains_empty} -> nil
38 | end
39 | end
40 |
41 | defp get_domain(domains) do
42 | domains =
43 | Stream.map(domains, fn {id, name, _} -> {id, name} end)
44 | |> Enum.into(%{})
45 |
46 | IO.puts("")
47 | id = IO.gets("Enter the ID of the public domain you wish to remove: ") |> String.trim()
48 |
49 | if domains[id],
50 | do: {:ok, domains[id]},
51 | else: {:no_domain_with_id}
52 | end
53 |
54 | defp is_domains_empty?(domains) do
55 | case is_nil(domains) do
56 | true -> {:domains_empty}
57 | false -> {:domains_not_empty}
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/examples/login.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | margin: 0;
4 | padding: 0;
5 | font-family: verdana, Arial, Helvetica;
6 | }
7 |
8 | .background-image, .content.blur{
9 | background-image: linear-gradient(
10 | rgba(9,36,64,0.7) 0%,
11 | rgba(9,36,64,0.1) 50%,
12 | rgba(9,36,64,0.7) 100%),
13 | url("https://images.pexels.com/photos/917494/pexels-photo-917494.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260");
14 | background-size: cover;
15 | background-attachment: fixed;
16 | }
17 |
18 | .background-image {
19 | display: flex;
20 | align-items: center;
21 | justify-content: center;
22 | min-height: 100vh;
23 | min-width: 100vw;
24 | }
25 |
26 | .content p {
27 | filter: blur(0px);
28 | }
29 |
30 | .content {
31 | width: 400px;
32 | height: 400px;
33 | padding :40px;
34 | }
35 |
36 | .content.blur {
37 | filter: blur(10px);
38 | position: absolute;
39 | z-index: 1;
40 | }
41 |
42 | .content.not-blur {
43 | background-color: rgba(0,0,0,0.13);
44 | z-index: 999;
45 | text-align: center;
46 | display: flex;
47 | flex-direction: column;
48 | align-items: center;
49 | justify-content: center;
50 | }
51 |
52 | .content.not-blur h1 {
53 | text-align: center;
54 | margin: 30px 0;
55 | font-size: 30px;
56 | color: #fff;
57 | }
58 |
59 | .content.not-blur form {
60 | display: flex;
61 | flex-direction: column;
62 | align-items: center;
63 | justify-content: center;
64 | width: 60%;
65 | min-width: 300px;
66 | max-width: 450px;
67 | }
68 |
69 | .content.not-blur form input {
70 | display: block;
71 | width: 300px;
72 | margin: 10px auto;
73 | padding: 15px;
74 | background: rgba(0,0,0,0.2);
75 | color: #fff;
76 | border: 0;
77 | }
78 |
79 | .content.not-blur form input::placeholder {
80 | color: #fff;
81 | }
82 |
83 | .content.not-blur form button {
84 | background: #ec901d;
85 | border: 0;
86 | color: #fff;
87 | padding: 10px;
88 | font-size: 20px;
89 | width: 300px;
90 | margin: 20px auto;
91 | display: block;
92 | cursor: pointer;
93 | }
94 |
--------------------------------------------------------------------------------
/lib/boreale_web/login/controller.ex:
--------------------------------------------------------------------------------
1 | defmodule BorealeWeb.Login.Controller do
2 | import BorealeWeb.Controller
3 | import Plug.Conn
4 |
5 | alias Boreale.{Credentials, Storage}
6 |
7 | @page_body_filename "login.html"
8 | @page_style_filename "login.css"
9 |
10 | def index(%{assigns: %{action: :index}} = conn) do
11 | %{domain: domain, session: session} = conn.assigns
12 |
13 | if Credentials.user_allowed?(domain, session) do
14 | send_resp(conn, 200, "authorized")
15 | else
16 | conn
17 | |> put_status(401)
18 | |> render("login.html", page_assigns())
19 | end
20 | end
21 |
22 | def index(%{assigns: %{action: :login}} = conn) do
23 | %{username: username, password: password} = conn.assigns
24 |
25 | username = String.downcase(username)
26 |
27 | case Credentials.validate(username, password) do
28 | :ok ->
29 | conn
30 | |> put_session("username", username)
31 | |> send_resp(300, "Multiple choices")
32 |
33 | _ ->
34 | conn
35 | |> clear_session()
36 | |> send_resp(401, "Wrong username or password.")
37 | end
38 | end
39 |
40 | defp page_assigns do
41 | [
42 | title: Application.get_env(:boreale, __MODULE__)[:page_title],
43 | body: get_page_body(),
44 | style: get_page_style()
45 | ]
46 | end
47 |
48 | defp get_page_body do
49 | get_file(@page_body_filename)
50 | end
51 |
52 | defp get_page_style do
53 | get_file(@page_style_filename)
54 | end
55 |
56 | defp get_file(filename) do
57 | user_filepath = Path.join(Storage.user_directory_path(), filename)
58 |
59 | case File.read(user_filepath) do
60 | {:ok, binary} ->
61 | binary
62 |
63 | _ ->
64 | path = Path.join(Storage.user_directory_path(), filename)
65 |
66 | if File.exists?(path) do
67 | File.read!(path)
68 | else
69 | Storage.default_directory_path()
70 | |> Path.join(filename)
71 | |> File.read!()
72 | end
73 | end
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/priv/default/login.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | margin: 0;
4 | padding: 0;
5 | font-family: verdana, Arial, Helvetica;
6 | }
7 |
8 | .background-image, .content.blur{
9 | background-image: linear-gradient(
10 | rgba(9,36,64,0.7) 0%,
11 | rgba(9,36,64,0.1) 50%,
12 | rgba(9,36,64,0.7) 100%),
13 | url("https://images.pexels.com/photos/917494/pexels-photo-917494.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260");
14 | background-size: cover;
15 | background-attachment: fixed;
16 | }
17 |
18 | .background-image {
19 | display: flex;
20 | align-items: center;
21 | justify-content: center;
22 | min-height: 100vh;
23 | min-width: 100vw;
24 | }
25 |
26 | .content p {
27 | filter: blur(0px);
28 | }
29 |
30 | .content {
31 | width: 400px;
32 | height: 400px;
33 | padding :40px;
34 | }
35 |
36 | .content.blur {
37 | filter: blur(10px);
38 | position: absolute;
39 | z-index: 1;
40 | }
41 |
42 | .content.not-blur {
43 | background-color: rgba(0,0,0,0.13);
44 | z-index: 999;
45 | text-align: center;
46 | display: flex;
47 | flex-direction: column;
48 | align-items: center;
49 | justify-content: center;
50 | }
51 |
52 | .content.not-blur h1 {
53 | text-align: center;
54 | margin: 30px 0;
55 | font-size: 30px;
56 | color: #fff;
57 | }
58 |
59 | .content.not-blur form {
60 | display: flex;
61 | flex-direction: column;
62 | align-items: center;
63 | justify-content: center;
64 | width: 60%;
65 | min-width: 300px;
66 | max-width: 450px;
67 | }
68 |
69 | .content.not-blur form input {
70 | display: block;
71 | width: 300px;
72 | margin: 10px auto;
73 | padding: 15px;
74 | background: rgba(0,0,0,0.2);
75 | color: #fff;
76 | border: 0;
77 | }
78 |
79 | .content.not-blur form input::placeholder {
80 | color: #fff;
81 | }
82 |
83 | .content.not-blur form button {
84 | background: #ec901d;
85 | border: 0;
86 | color: #fff;
87 | padding: 10px;
88 | font-size: 20px;
89 | width: 300px;
90 | margin: 20px auto;
91 | display: block;
92 | cursor: pointer;
93 | }
94 |
--------------------------------------------------------------------------------
/lib/boreale/storage.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Storage do
2 | alias Boreale.{Domain, User}
3 |
4 | @spec user_directory_path :: String.t()
5 | def user_directory_path do
6 | Application.get_env(:boreale, __MODULE__)[:user_directory]
7 | end
8 |
9 | @spec default_directory_path :: String.t()
10 | def default_directory_path do
11 | Application.get_env(:boreale, __MODULE__)[:default_directory]
12 | |> get_app_dir()
13 | end
14 |
15 | @spec templates_directory_path :: String.t()
16 | def templates_directory_path do
17 | Application.get_env(:boreale, __MODULE__)[:templates_directory]
18 | |> get_app_dir()
19 | end
20 |
21 | @spec persisted_users_filepath :: atom()
22 | def persisted_users_filepath do
23 | user_directory_path()
24 | |> Path.join("users.dets")
25 | |> String.to_atom()
26 | end
27 |
28 | @spec persisted_domains_filepath :: atom()
29 | def persisted_domains_filepath do
30 | user_directory_path()
31 | |> Path.join("domains.dets")
32 | |> String.to_atom()
33 | end
34 |
35 | @spec get_user(String.t()) :: {:ok, User.t()} | {:error, String.t()}
36 | def get_user(username) do
37 | case lookup_table(persisted_users_filepath(), username) do
38 | [dets_user] -> {:ok, User.dets_user_to_struct(dets_user)}
39 | _ -> {:error, "Username not found"}
40 | end
41 | end
42 |
43 | @spec get_domains() :: list(String.t())
44 | def get_domains do
45 | persisted_domains_filepath()
46 | |> list_table()
47 | |> Domain.dets_domain_to_struct()
48 | end
49 |
50 | defp get_app_dir(filepath) do
51 | Application.app_dir(:boreale, filepath)
52 | end
53 |
54 | defp lookup_table(filepath, key) do
55 | table = open_table(filepath)
56 | object = :dets.lookup(table, key)
57 | close_table(table)
58 | object
59 | end
60 |
61 | defp list_table(filepath) do
62 | table = open_table(filepath)
63 | objects = :dets.match(table, {:"$1", :"$2"})
64 | close_table(table)
65 | objects
66 | end
67 |
68 | defp open_table(filepath) do
69 | {:ok, table} = :dets.open_file(filepath, type: :set)
70 | table
71 | end
72 |
73 | defp close_table(table) do
74 | :dets.close(table)
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/tasks/CLI/users/users_add.ex:
--------------------------------------------------------------------------------
1 | defmodule Boreale.Tasks.Cli.UsersAdd do
2 | alias Boreale.Storage
3 | alias Boreale.Tasks.Cli
4 |
5 | def run(args) do
6 | args = Cli.Utils.args_to_map(args)
7 |
8 | case args do
9 | %{"--help" => _} ->
10 | Cli.Utils.print_help_for("users add")
11 |
12 | %{} ->
13 | add_user()
14 |
15 | _ ->
16 | IO.puts("boreale cli: users add command does not take any arguments")
17 | IO.puts("See 'boreale cli users add --help'")
18 | end
19 | end
20 |
21 | defp add_user do
22 | username = IO.gets("username: ") |> String.trim() |> String.downcase()
23 | password = password_prompt("password:") |> String.trim()
24 |
25 | if String.length(username) >= 3 and String.length(password) >= 6 do
26 | case insert_user({username, password}) do
27 | {:error, message} -> IO.puts(message)
28 | _ -> IO.puts("User #{username} has been added.")
29 | end
30 | else
31 | IO.puts("Username have to be at least three characters long.")
32 | IO.puts("Password have to be at least six characters long.")
33 | end
34 | end
35 |
36 | defp insert_user({username, password}) do
37 | date_time = DateTime.utc_now()
38 |
39 | {:ok, table} = :dets.open_file(Storage.persisted_users_filepath(), type: :set)
40 |
41 | hashed_password = Bcrypt.hash_pwd_salt(password)
42 | created? = :dets.insert_new(table, {username, hashed_password, date_time})
43 |
44 | :dets.close(table)
45 |
46 | if created? do
47 | :ok
48 | else
49 | {:error, "The user #{username} already exists."}
50 | end
51 | end
52 |
53 | # Password prompt that hides input by every 1ms
54 | # clearing the line with stderr
55 | #
56 | # taken from the hex repository
57 | # https://github.com/hexpm/hex/blob/ae70158bb7c96f2d95b15c5b64c1899f8188e2d8/lib/mix/tasks/hex.ex#L363
58 | defp password_prompt(prompt) do
59 | pid = spawn_link(fn -> clear_password(prompt) end)
60 | ref = make_ref()
61 | value = IO.gets(prompt <> " ")
62 |
63 | send(pid, {:done, self(), ref})
64 | receive do: ({:done, ^pid, ^ref} -> :ok)
65 |
66 | value
67 | end
68 |
69 | defp clear_password(prompt) do
70 | receive do
71 | {:done, parent, ref} ->
72 | send(parent, {:done, self(), ref})
73 | IO.write(:standard_error, "\e[2K\r")
74 | after
75 | 1 ->
76 | IO.write(:standard_error, "\e[2K\r#{prompt} ")
77 | clear_password(prompt)
78 | end
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Build configuration
2 | # -------------------
3 |
4 | APP_NAME = `grep -Eo 'app: :\w*' mix.exs | cut -d ':' -f 3`
5 | APP_VERSION = `grep -Eo 'version: "[0-9\.]*"' mix.exs | cut -d '"' -f 2`
6 | GIT_REVISION = `git rev-parse HEAD`
7 | DOCKER_IMAGE_TAG ?= 'latest'
8 | DOCKER_NAMESPACE ?= 'lewazo'
9 |
10 | # Introspection targets
11 | # ---------------------
12 |
13 | .PHONY: help
14 | help: header targets
15 |
16 | .PHONY: header
17 | header:
18 | @echo "\033[34mEnvironment\033[0m"
19 | @echo "\033[34m---------------------------------------------------------------\033[0m"
20 | @printf "\033[33m%-23s\033[0m" "APP_NAME"
21 | @printf "\033[35m%s\033[0m" $(APP_NAME)
22 | @echo ""
23 | @printf "\033[33m%-23s\033[0m" "APP_VERSION"
24 | @printf "\033[35m%s\033[0m" $(APP_VERSION)
25 | @echo ""
26 | @printf "\033[33m%-23s\033[0m" "GIT_REVISION"
27 | @printf "\033[35m%s\033[0m" $(GIT_REVISION)
28 | @echo ""
29 | @printf "\033[33m%-23s\033[0m" "DOCKER_IMAGE_TAG"
30 | @printf "\033[35m%s\033[0m" $(DOCKER_IMAGE_TAG)
31 | @echo ""
32 | @printf "\033[33m%-23s\033[0m" "DOCKER_NAMESPACE"
33 | @printf "\033[35m%s\033[0m" $(DOCKER_NAMESPACE)
34 | @echo "\n"
35 |
36 | .PHONY: targets
37 | targets:
38 | @echo "\033[34mTargets\033[0m"
39 | @echo "\033[34m---------------------------------------------------------------\033[0m"
40 | @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
41 |
42 | # Build targets
43 | # -------------
44 |
45 | .PHONY: prepare
46 | prepare: ## Install dependencies
47 | mix deps.get
48 |
49 | .PHONY: build
50 | build: ## Build the Docker image for the OTP release
51 | docker build --build-arg APP_NAME=$(APP_NAME) --build-arg APP_VERSION=$(APP_VERSION) --rm --tag $(DOCKER_NAMESPACE)/$(APP_NAME):$(APP_VERSION) .
52 |
53 | .PHONY: push
54 | push: ## Push the Docker image
55 | docker tag $(DOCKER_NAMESPACE)/$(APP_NAME):$(APP_VERSION) $(DOCKER_NAMESPACE)/$(APP_NAME):$(DOCKER_IMAGE_TAG)
56 | docker push $(DOCKER_NAMESPACE)/$(APP_NAME) --all-tags
57 |
58 | # Development targets
59 | # -------------------
60 |
61 | .PHONY: run
62 | run: ## Run the server inside an IEx shell
63 | iex -S mix phx.server
64 |
65 | .PHONY: dependencies
66 | dependencies: ## Install dependencies required by the application
67 | mix deps.get --force
68 |
69 | .PHONY: clean
70 | clean: ## Clean dependencies
71 | mix deps.clean --unused --unlock
72 |
73 | .PHONY: test
74 | test: ## Run the test suite
75 | mix test
76 |
77 | # Check, lint and format targets
78 | # ------------------------------
79 |
80 | .PHONY: check
81 | check: check-format check-unused-dependencies check-code-security
82 |
83 | .PHONY: check-code-security
84 | check-code-security:
85 | mix sobelow --config .sobelow-conf
86 |
87 | .PHONY: check-format
88 | check-format:
89 | mix format --dry-run --check-formatted
90 |
91 | .PHONY: check-unused-dependencies
92 | check-unused-dependencies:
93 | mix deps.unlock --check-unused
94 |
95 | .PHONY: format
96 | format: ## Format project files
97 | mix format
98 |
99 | .PHONY: lint
100 | lint: lint-elixir
101 |
102 | .PHONY: lint-elixir
103 | lint-elixir:
104 | mix compile --warnings-as-errors --force
105 | mix credo --strict
106 |
--------------------------------------------------------------------------------
/test/boreale/boreale_router_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Boreale.RouterTest do
2 | use Boreale.ConnCase
3 |
4 | describe "GET /" do
5 | setup do
6 | conn =
7 | :get
8 | |> conn("/")
9 | |> Map.put(:scheme, :https)
10 |
11 | %{conn: conn}
12 | end
13 |
14 | @public_domain "public.com"
15 | @domain_header "x-forwarded-host"
16 | test "renders login on any non-public domains", %{conn: conn} do
17 | with_mock(Boreale.Domains, domains_mock()) do
18 | conn =
19 | conn
20 | |> put_req_header(@domain_header, "private.com")
21 | |> Boreale.Router.call(%{})
22 |
23 | assert conn.status == 401
24 | assert conn.resp_body =~ "Login"
25 | end
26 | end
27 |
28 | test "redirects when domain is public", %{conn: conn} do
29 | with_mock(Boreale.Domains, domains_mock()) do
30 | conn =
31 | conn
32 | |> put_req_header(@domain_header, @public_domain)
33 | |> Boreale.Router.call(%{})
34 |
35 | assert conn.status == 200
36 | assert conn.resp_body == "authorized"
37 | end
38 | end
39 |
40 | test "redirects when authenticated", %{conn: conn} do
41 | with_mock(Boreale.Domains, domains_mock()) do
42 | conn =
43 | conn
44 | |> put_req_header(@domain_header, "private.com")
45 | |> init_test_session(%{"username" => "user@email.com"})
46 | |> Boreale.Router.call(%{})
47 |
48 | assert conn.status == 200
49 | assert conn.resp_body == "authorized"
50 | end
51 | end
52 | end
53 |
54 | describe "POST /" do
55 | setup do
56 | conn =
57 | :post
58 | |> conn("/")
59 | |> Map.put(:scheme, :https)
60 |
61 | %{conn: conn}
62 | end
63 |
64 | @auth_form "auth-form"
65 | @good_creds URI.encode_query(%{
66 | "action" => "login",
67 | "username" => "user@email.com",
68 | "password" => "secret"
69 | })
70 | test "login a user successfully", %{conn: conn} do
71 | with_mock(Boreale.Users, users_mock()) do
72 | conn =
73 | conn
74 | |> put_req_header(@domain_header, "private.com")
75 | |> put_req_header(@auth_form, @good_creds)
76 | |> Boreale.Router.call(%{})
77 |
78 | assert conn.status == 300
79 | end
80 | end
81 |
82 | @bad_creds URI.encode_query(%{
83 | "action" => "login",
84 | "username" => "user@email.com",
85 | "password" => "bad_password"
86 | })
87 | test "does not login a user with bad password", %{conn: conn} do
88 | with_mock(Boreale.Users, users_mock()) do
89 | conn =
90 | conn
91 | |> put_req_header(@domain_header, "private.com")
92 | |> put_req_header(@auth_form, @bad_creds)
93 | |> Boreale.Router.call(%{})
94 |
95 | assert conn.status == 401
96 | assert conn.resp_body =~ "Wrong username or password"
97 | end
98 | end
99 | end
100 |
101 | defp domains_mock, do: [public?: fn public -> public in [@public_domain] end]
102 |
103 | @users %{"user@email.com" => "secret"}
104 | defp users_mock do
105 | [
106 | valid?: fn username, password -> Map.get(@users, username) == password end
107 | ]
108 | end
109 | end
110 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.0", "851d16b901b6c94a0ceadd3470f2b58d9899007bf04a42e0e4b399e2dd6ab307", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "fe251accb51f1aa17d67009cb125bddf81fb056cacad71bfad7827a44652f4ee"},
3 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
4 | "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
5 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
6 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
7 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
8 | "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"},
9 | "elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"},
10 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
11 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
12 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
13 | "plug": {:hex, :plug, "1.13.3", "93b299039c21a8b82cc904d13812bce4ced45cf69153e8d35ca16ffb3e8c5d98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "98c8003e4faf7b74a9ac41bee99e328b08f069bf932747d4a7532e97ae837a17"},
14 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
15 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
16 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
17 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},
18 | }
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | A very lightweight authentication service for Traefik
5 |
6 |
7 |
8 | # Boréale
9 | Boréale is a tiny and simple self-hosted service to handle forward authentication for services behind [Traefik reverse proxy](https://github.com/containous/traefik).
10 |
11 | ## Features
12 | * Very lightweight, less than 50 MB.
13 | * User can use a custom login page.
14 | * SSO for all subdomains.
15 | * Has no external dependencies.
16 | * Easy management through the Boréale CLI.
17 | * Secure and encrypted cookie.
18 | * Easy to deploy.
19 | * Supports amd64 and arm64 architectures
20 |
21 | ## Why?
22 | Traefik currently supports some authentication schemes like basic auth and digest. While these works great, they are very limited, non-customizable and requires to log in each time you restart your browser.
23 |
24 | There exists many services similar to Boréale, but they either rely on external services like Google OAuth, LDAP, keycloak, etc, or pack many features that are more suitable for a large organization.
25 |
26 | The main goal of Boréale is to have a tiny self-contained solution that is more appropriate for a home server usage.
27 |
28 | ### Alternatives
29 | * [Authelia](https://github.com/clems4ever/authelia) If you are looking for a complete solution for organizations.
30 |
31 | * [Traefik Forward Auth](https://github.com/thomseddon/traefik-forward-auth) If you are looking for a Google OAuth-based authentication.
32 |
33 | ## Getting Started
34 | The recommended way of deploying Boréale is through the official Docker image.
35 |
36 | Pass all [required environment variables](#environment-variables) with the `-e` option or add them to a `.env` file as shown [in the examples](examples/).
37 |
38 | Mount a volume for the `data` directory, which is where the Boréale configurations will live in on the host.
39 |
40 | You can also use `docker compose` with the `docker-compose.yml` [example provided here](examples/).
41 |
42 | ```
43 | docker run \
44 | --name=boreale \
45 | --env-file \
46 | -p 5252:4000 \
47 | -v :/opt/app/data \
48 | lewazo/boreale
49 | ```
50 |
51 |
52 | ## Configuration
53 | Most of the Boréale configuration is done through its CLI. To use the CLI, follow the instructions below depending on your environement.
54 |
55 | #### docker compose
56 | When using docker compose, simply run `docker compose exec boreale bin/boreale-cli` in the same directory as your `docker-compose.yml` file.
57 |
58 | #### Docker CLI
59 | When using the Docker CLI, you first need to get the container's ID. Run `docker ps` and find the container running the `lewazo/boreale` image. Then, run `docker exec -it bin/boreale-cli`.
60 |
61 |
62 | ### Traefik
63 | In order for Traefik to forward the authentication to Boréale, there are some configurations that needs to be done.
64 |
65 | In the following snippets, edit `127.0.0.1` for the IP of the host that runs Boréale. Match the port with the one forwarded to the container.
66 |
67 | We use `insecureSkipVerify = true` so Traefik can trust our self-signed certificate. More info on that [here](#tls).
68 |
69 | #### For Traefik version below 2.0
70 | In your `traefik.toml`, add the following lines under `[entryPoints.https.tls]`.
71 | ```
72 | [entryPoints.https.auth.forward]
73 | address = "https://127.0.0.1:5252"
74 | [entryPoints.https.auth.forward.tls]
75 | insecureSkipVerify = true
76 | ```
77 |
78 | #### For Traefik version 2.0 and above
79 | In your dynamic configuration file, define a middleware for Boréale like the following :
80 |
81 | **Note :** Traefik v2.0 now allows the use of the YAML file format. The following configuration uses the YAML format because in my opinion it is easier than TOML to use. You can still use TOML if you wish.
82 |
83 | ```
84 | http:
85 | middlewares:
86 | boreale:
87 | forwardAuth:
88 | address: "https://127.0.0.1:5252"
89 | tls:
90 | insecureSkipVerify: true
91 | ```
92 |
93 | Then add the middleware to all the routers you wish to guard with Boréale. If you use a configuration with labels, then simply add this label to your routers `"traefik.http.routers..middlewares=boreale@file"`.
94 |
95 | ### SSO
96 | SSO (Single sign-on) can be achieved using the `domain` cookie attribute. If your services are setup by subdomains like `service1.domain.tld`, `service2.domain.tld`, then you can use the SSO feature. If you use completely different domains like `service1-domain.tld` and `service2-domain.tld` then this won't work because of the `same origin` policy.
97 |
98 | To enable SSO, set the `SSO_DOMAIN_NAME` environment variable to your root domain, e.g., `domain.tld`. This will make all `*.domain.tld` **and** `domain.tld` requests share the same cookie. So a user only has to login to one service. The user will then be authentified on every other services.
99 |
100 | Not setting the variable will disable SSO.
101 |
102 | ### Authorized users
103 | An authorized user is a user who's allowed to log in and access all the web services behind Traefik.
104 |
105 | To list all authorized users, use the CLI's `users` command.
106 |
107 | To add a new user, use the CLI's `users add` command.
108 |
109 | To delete a user, use the CLI's `users remove` command.
110 |
111 | ### Public domains
112 | **Note:** Starting with Traefik v2.0, adding public domains isn't necessary anymore because we can choose which routers forwards the auth to Boréale by adding or not the middleware to routers. For Traefik v1.x, this isn't possible because the configuration is global; all requests are forwarded to Boréale. Public domains are the only way to have a 'whitelist' for those versions.
113 |
114 | A public domain is a FQDN that is meant to access a public server, i.e., it shouldn't ask the user to authenticate when visiting this domain. This acts as a kind of whitelist for your domains.
115 |
116 | To list all public domains, use the CLI's `domains` command.
117 |
118 | To add a new public domain, use the CLI's `domains add` command.
119 |
120 | To delete a public domain, use the CLI's `domains remove` command.
121 |
122 | ### Environment variables
123 | These are the environment variables that should be set in your `.env` file or set in your environment.
124 |
125 | | Variable | Description | Default | Optional? |
126 | |-------------------|--------------------------------------------------------|------------------------|-----------|
127 | | COOKIE_NAME | The name for the authentication cookie | _boreale_auth | Optional |
128 | | ENCRYPTION_SALT | The key used for encrypting the cookie | *none* | Required |
129 | | GID | The group ID of your user on the host | 9001 | Optional |
130 | | PAGE_TITLE | The title of the login page | Boréale Authentication | Optional |
131 | | PORT | The listening HTTPS port (OTP release only) | 4000 | Optional |
132 | | SECRET_KEY_BASE | The key used for encryption (Must be 64 bytes long) | *none* | Required |
133 | | SIGNING_SALT | The key used for signing the cookie | *none* | Required |
134 | | SSO_DOMAIN_NAME | The root domain name. Check [here for more info](#sso) | *none* | Optional |
135 | | UID | The user ID of your user on the host | 9001 | Optional |
136 |
137 | ## Customization
138 | Boréale ships with a default login form, but using your own is very easy.
139 |
140 | Simply add a `login.html` and `login.css` inside the `data/` directory and it will be automatically used. The only constraints is to have `username` and `password` inputs and a form with the id `form`. Take a look at the [examples here](examples/) to see the code for the default login form. Make sure to set the `UID` and `GID` variables as the same as your host's user so that the container can read the files. You can run the `id` command on your host to get the uid and gid.
141 |
142 | The following screenshot shows the default login form.
143 | 
144 |
145 | ## Security
146 | ### TLS
147 | Boréale is meant to be accessed directly by forwarding the auth. As such, you **should not** add it as a backend in Traefik, i.e., you should not have a `boreale.yourdomain.tld` or anything.
148 |
149 | With this premise in mind, Boréale automatically creates a self-signed certificate in order to provide a complete HTTPS connection between Traefik and Boréale. This allows the server to set a `secure` cookie on the browser.
150 |
151 | Since Boréale is only accessed through Traefik's authentication, using a self-signed certificate is perfectly fine if you trust your private network.
152 |
153 | ### HSTS
154 | To protect your services from cookie hijacking and protocol downgrade attacks, you should have HSTS enabled. Since Traefik is the one that's terminating the connection, HSTS should be enabled on it rather than on Boréale.
155 |
156 | ## License
157 | The source code and binaries of Boréale is subject to the [MIT License]().
158 |
159 | The above logo is made by [perdanakun](https://www.iconfinder.com/perdanakun) and is available [here](https://www.iconfinder.com/icons/3405132/camp_forest_holidays_jungle_summer_vacation_icon) and subject to the [Creative Commons BY 3.0](https://creativecommons.org/licenses/by/3.0/) license.
160 |
--------------------------------------------------------------------------------