├── config ├── prod.exs ├── dev.exs ├── config.exs └── runtime.exs ├── priv ├── data │ └── .gitignore ├── default │ ├── login.html │ └── login.css └── templates │ └── login.html.heex ├── lib ├── boreale.ex ├── boreale │ ├── domain.ex │ ├── user.ex │ ├── credentials.ex │ ├── application.ex │ ├── ssl.ex │ └── storage.ex ├── boreale_web │ ├── controller.ex │ ├── router.ex │ ├── plug │ │ ├── init_session.ex │ │ └── parse_request.ex │ └── login │ │ └── controller.ex └── tasks │ └── CLI │ ├── domains │ ├── domains_add.ex │ ├── domains.ex │ └── domains_remove.ex │ ├── cli.ex │ ├── users │ ├── users_remove.ex │ ├── users.ex │ └── users_add.ex │ └── utils.ex ├── .tool-versions ├── .vscode └── settings.json ├── test ├── test_helper.exs ├── support │ ├── test_case.ex │ └── conn_case.ex └── boreale │ ├── domains_test.exs │ ├── users_test.exs │ └── boreale_router_test.exs ├── screenshot.png ├── examples ├── .env ├── docker-compose.yml ├── login.html └── login.css ├── boreale-cli.sh ├── .formatter.exs ├── .dockerignore ├── docker-compose.yml ├── docker-entrypoint.sh ├── .gitignore ├── mix.exs ├── LICENSE ├── Dockerfile ├── .github └── workflows │ └── cd.yaml ├── Makefile ├── logo.svg ├── mix.lock └── README.md /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /priv/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /lib/boreale.ex: -------------------------------------------------------------------------------- 1 | defmodule Boreale do 2 | end 3 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.13.3-otp-24 2 | erlang 24.2.2 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Boreale.SSL.generate_cert() 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewazo/boreale/HEAD/screenshot.png -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :boreale, Boreale.Storage, user_directory: "priv/data" 4 | -------------------------------------------------------------------------------- /examples/.env: -------------------------------------------------------------------------------- 1 | COOKIE_NAME= 2 | ENCRYPTION_SALT= 3 | GID= 4 | PAGE_TITLE= 5 | PORT= 6 | SECRET_KEY_BASE= 7 | SIGNING_SALT= 8 | SSO_DOMAIN_NAME= 9 | UID= 10 | -------------------------------------------------------------------------------- /boreale-cli.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Runs the CLI inside a new app instance 4 | args="$*" 5 | /opt/boreale/bin/boreale eval "Boreale.Tasks.Cli.run(\"$args\")" 6 | -------------------------------------------------------------------------------- /test/support/test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Boreale.TestCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using do 5 | quote do 6 | use ExUnit.Case 7 | import Mock 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | locals_without_parens = [plug: 1, plug: 2] 3 | 4 | [ 5 | locals_without_parens: locals_without_parens, 6 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 7 | ] 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | .elixir_ls/ 3 | .git/ 4 | .vscode/ 5 | deps/ 6 | examples/ 7 | priv/data/ 8 | test/ 9 | 10 | .* 11 | docker-compose.yml 12 | LICENSE 13 | logo.svg 14 | Makefile 15 | README.md 16 | screenshot.png 17 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :boreale, Boreale.Storage, 4 | user_directory: "/opt/app/data", 5 | default_directory: "priv/default", 6 | templates_directory: "priv/templates" 7 | 8 | import_config "#{Mix.env()}.exs" 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | boreale: 5 | image: "lewazo/boreale:latest" 6 | ports: 7 | - "5251:4040" 8 | env_file: 9 | - .env.local 10 | volumes: 11 | - ./priv/data:/opt/app/data 12 | -------------------------------------------------------------------------------- /examples/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | boreale: 5 | image: "lewazo/boreale:latest" 6 | ports: 7 | - "5252:4000" 8 | env_file: 9 | - .env 10 | volumes: 11 | - ./data:/opt/app/data 12 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Boreale.ConnCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using do 5 | quote do 6 | use ExUnit.Case 7 | use Plug.Test 8 | import Mock 9 | import Boreale.ConnCase 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/boreale/domain.ex: -------------------------------------------------------------------------------- 1 | defmodule Boreale.Domain do 2 | alias __MODULE__ 3 | 4 | defstruct host: nil, created_at: nil 5 | 6 | def dets_domain_to_struct(domains) do 7 | Enum.map(domains, fn [host, created_at] -> 8 | %Domain{ 9 | host: host, 10 | created_at: created_at 11 | } 12 | end) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/boreale/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Boreale.User do 2 | alias __MODULE__ 3 | 4 | defstruct username: nil, password_hash: nil, created_at: nil 5 | 6 | def dets_user_to_struct({username, password_hash, created_at}) do 7 | %User{ 8 | username: username, 9 | password_hash: password_hash, 10 | created_at: created_at 11 | } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | USER_ID=${UID:-9001} 5 | GROUP_ID=${GID:-9001} 6 | 7 | groupmod -o -g "$GROUP_ID" boreale 8 | usermod -o -u "$USER_ID" boreale 9 | 10 | echo " 11 | User uid: $(id -u boreale) 12 | User gid: $(id -g boreale) 13 | ------------------------------------- 14 | " 15 | 16 | exec /sbin/su-exec $USER_ID "/opt/$APP_NAME/bin/$APP_NAME" "$@" 17 | -------------------------------------------------------------------------------- /examples/login.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 |
5 |
6 |
7 |

Login

8 | 9 | 10 | 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /priv/default/login.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 |
5 |
6 |
7 |

Login

8 | 9 | 10 | 11 |
12 |
13 |
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 | ![Boréale](screenshot.png) 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 | --------------------------------------------------------------------------------