mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-09 21:16:26 +08:00
Password access (#187)
This commit is contained in:
parent
dee372c623
commit
a9c8e20775
12 changed files with 252 additions and 62 deletions
|
@ -66,4 +66,4 @@ config :phoenix, :stacktrace_depth, 20
|
||||||
# Initialize plugs at runtime for faster development compilation
|
# Initialize plugs at runtime for faster development compilation
|
||||||
config :phoenix, :plug_init_mode, :runtime
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
||||||
config :livebook, :token_authentication, false
|
config :livebook, authentication_mode: :disabled
|
||||||
|
|
|
@ -9,6 +9,8 @@ config :livebook, LivebookWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 8080]
|
||||||
# so limit the amount of information we show.
|
# so limit the amount of information we show.
|
||||||
config :logger, level: :info
|
config :logger, level: :info
|
||||||
|
|
||||||
|
config :livebook, authentication_mode: :token
|
||||||
|
|
||||||
# ## SSL Support
|
# ## SSL Support
|
||||||
#
|
#
|
||||||
# To get SSL working, you will need to add the `https` key
|
# To get SSL working, you will need to add the `https` key
|
||||||
|
|
|
@ -5,10 +5,21 @@ import Config
|
||||||
# config :livebook, :node, {:shortnames, "livebook"}
|
# config :livebook, :node, {:shortnames, "livebook"}
|
||||||
# config :livebook, :node, {:longnames, :"livebook@127.0.0.1"}
|
# config :livebook, :node, {:longnames, :"livebook@127.0.0.1"}
|
||||||
|
|
||||||
|
if password = System.get_env("LIVEBOOK_PASSWORD") do
|
||||||
|
config :livebook,
|
||||||
|
authentication_mode: :password,
|
||||||
|
password: password
|
||||||
|
end
|
||||||
|
|
||||||
if config_env() == :prod do
|
if config_env() == :prod do
|
||||||
# We don't need persistent session, so it's fine to just
|
# In order to persist sessions between deployments (desirable when using password authentication mode)
|
||||||
# generate a new key everytime the app starts
|
# allow to customize secret_key_base. Otherwise the secret will change every time app starts.
|
||||||
secret_key_base = :crypto.strong_rand_bytes(48) |> Base.encode64()
|
secret_key_base =
|
||||||
|
if secret = System.get_env("SECRET_KEY_BASE") do
|
||||||
|
secret
|
||||||
|
else
|
||||||
|
:crypto.strong_rand_bytes(48) |> Base.encode64()
|
||||||
|
end
|
||||||
|
|
||||||
config :livebook, LivebookWeb.Endpoint, secret_key_base: secret_key_base
|
config :livebook, LivebookWeb.Endpoint, secret_key_base: secret_key_base
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,7 +9,7 @@ config :livebook, LivebookWeb.Endpoint,
|
||||||
# Print only warnings and errors during test
|
# Print only warnings and errors during test
|
||||||
config :logger, level: :warn
|
config :logger, level: :warn
|
||||||
|
|
||||||
config :livebook, :token_authentication, false
|
config :livebook, :authentication_mode, :disabled
|
||||||
|
|
||||||
# Use longnames when running tests in CI, so that no host resolution is required,
|
# Use longnames when running tests in CI, so that no host resolution is required,
|
||||||
# see https://github.com/elixir-nx/livebook/pull/173#issuecomment-819468549
|
# see https://github.com/elixir-nx/livebook/pull/173#issuecomment-819468549
|
||||||
|
|
36
lib/livebook_web/controllers/auth_controller.ex
Normal file
36
lib/livebook_web/controllers/auth_controller.ex
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
defmodule LivebookWeb.AuthController do
|
||||||
|
use LivebookWeb, :controller
|
||||||
|
|
||||||
|
alias LivebookWeb.Helpers
|
||||||
|
|
||||||
|
def index(conn, _assigns) do
|
||||||
|
conn
|
||||||
|
|> set_authenticated()
|
||||||
|
|> ensure_authenticated()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_authenticated(conn) do
|
||||||
|
conn
|
||||||
|
|> assign(:authenticated, LivebookWeb.AuthPlug.authenticated?(conn))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_authenticated(%Plug.Conn{assigns: %{authenticated: true}} = conn) do
|
||||||
|
conn
|
||||||
|
|> redirect(to: "/")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_authenticated(conn) do
|
||||||
|
conn
|
||||||
|
|> put_view(LivebookWeb.ErrorView)
|
||||||
|
|> render("401.html")
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate(conn, %{"password" => password}) do
|
||||||
|
password = :crypto.hash(:sha256, password) |> Base.encode16()
|
||||||
|
cookie_key = Helpers.auth_cookie_key(conn, :password)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_resp_cookie(cookie_key, password, Helpers.auth_cookie_opts())
|
||||||
|
|> redirect(to: "/")
|
||||||
|
end
|
||||||
|
end
|
|
@ -68,4 +68,24 @@ defmodule LivebookWeb.Helpers do
|
||||||
|> String.split("\n")
|
|> String.split("\n")
|
||||||
|> Enum.map(&Phoenix.HTML.raw/1)
|
|> Enum.map(&Phoenix.HTML.raw/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns cookie key based on connection port and authentication type.
|
||||||
|
|
||||||
|
The user may run multiple Livebook instances on the same host
|
||||||
|
on different ports, so the cookie name should be scoped under port.
|
||||||
|
"""
|
||||||
|
@spec auth_cookie_key(Plug.Conn.t(), :token | :password) :: String.t()
|
||||||
|
def auth_cookie_key(conn, type) do
|
||||||
|
"#{conn.port}#{inspect(type)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns cookie options that should be used to sign authentication cookies.
|
||||||
|
"""
|
||||||
|
@spec auth_cookie_opts() :: Keyword.t()
|
||||||
|
def auth_cookie_opts() do
|
||||||
|
# max_age is set to 30 days in seconds
|
||||||
|
[sign: true, max_age: 2_592_000]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,45 +7,89 @@ defmodule LivebookWeb.AuthPlug do
|
||||||
|
|
||||||
@behaviour Plug
|
@behaviour Plug
|
||||||
|
|
||||||
|
alias LivebookWeb.Helpers
|
||||||
|
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
import Phoenix.Controller
|
import Phoenix.Controller
|
||||||
|
|
||||||
@cookie_opts [sign: true, max_age: 2_592_000]
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(opts), do: opts
|
def init(opts), do: opts
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def call(conn, _otps) do
|
def call(conn, _opts) do
|
||||||
case Application.get_env(:livebook, :token) do
|
case auth_mode() do
|
||||||
nil -> conn
|
:password ->
|
||||||
token -> token_authentication(conn, token)
|
password_authentication(conn)
|
||||||
|
|
||||||
|
:token ->
|
||||||
|
token_authentication(conn)
|
||||||
|
|
||||||
|
:disabled ->
|
||||||
|
conn
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp token_authentication(conn, token) do
|
@doc """
|
||||||
# The user may run multiple Livebook instances on the same host
|
Checks if given connection is already authenticated.
|
||||||
# on different ports, so we scope the cookie name under port
|
"""
|
||||||
token_cookie = "#{conn.port}:token"
|
@spec authenticated?(Plug.Conn.t()) :: boolean()
|
||||||
|
def authenticated?(conn, mode \\ auth_mode())
|
||||||
|
|
||||||
conn = fetch_cookies(conn, signed: [token_cookie])
|
def authenticated?(conn, mode) when mode in [:token, :password] do
|
||||||
|
secret = prepare_secret(mode)
|
||||||
|
|
||||||
param_token = Map.get(conn.query_params, "token")
|
key = Helpers.auth_cookie_key(conn, mode)
|
||||||
cookie_token = conn.cookies[token_cookie]
|
conn = fetch_cookies(conn, signed: [key])
|
||||||
|
cookie = conn.cookies[key]
|
||||||
|
|
||||||
|
is_binary(cookie) and Plug.Crypto.secure_compare(cookie, secret)
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticated?(_conn, _mode) do
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
defp password_authentication(conn) do
|
||||||
|
if authenticated?(conn) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> redirect(to: "/authenticate")
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp token_authentication(conn) do
|
||||||
|
token = prepare_secret(:token)
|
||||||
|
cookie_key = Helpers.auth_cookie_key(conn, :token)
|
||||||
|
token_param = Map.get(conn.query_params, "token")
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
is_binary(param_token) and Plug.Crypto.secure_compare(param_token, token) ->
|
is_binary(token_param) and Plug.Crypto.secure_compare(token_param, token) ->
|
||||||
conn
|
conn
|
||||||
|> put_resp_cookie(token_cookie, param_token, @cookie_opts)
|
|> put_resp_cookie(cookie_key, token_param, Helpers.auth_cookie_opts())
|
||||||
# Redirect to the same path without query params
|
# Redirect to the same path without query params
|
||||||
|> redirect(to: conn.request_path)
|
|> redirect(to: conn.request_path)
|
||||||
|> halt()
|
|> halt()
|
||||||
|
|
||||||
is_binary(cookie_token) and Plug.Crypto.secure_compare(cookie_token, token) ->
|
authenticated?(conn) ->
|
||||||
conn
|
conn
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
raise LivebookWeb.InvalidTokenError
|
raise LivebookWeb.InvalidTokenError
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp auth_mode() do
|
||||||
|
Application.fetch_env!(:livebook, :authentication_mode)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prepare_secret(mode) do
|
||||||
|
secret = Application.fetch_env!(:livebook, mode)
|
||||||
|
|
||||||
|
case mode do
|
||||||
|
:token -> secret
|
||||||
|
:password -> :crypto.hash(:sha256, secret) |> Base.encode16()
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,11 +9,15 @@ defmodule LivebookWeb.Router do
|
||||||
plug :put_root_layout, {LivebookWeb.LayoutView, :root}
|
plug :put_root_layout, {LivebookWeb.LayoutView, :root}
|
||||||
plug :protect_from_forgery
|
plug :protect_from_forgery
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :auth do
|
||||||
plug LivebookWeb.AuthPlug
|
plug LivebookWeb.AuthPlug
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/", LivebookWeb do
|
scope "/", LivebookWeb do
|
||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
pipe_through :auth
|
||||||
|
|
||||||
live "/", HomeLive, :page
|
live "/", HomeLive, :page
|
||||||
live "/home/sessions/:session_id/close", HomeLive, :close_session
|
live "/home/sessions/:session_id/close", HomeLive, :close_session
|
||||||
|
@ -26,4 +30,11 @@ defmodule LivebookWeb.Router do
|
||||||
|
|
||||||
live_dashboard "/dashboard", metrics: LivebookWeb.Telemetry
|
live_dashboard "/dashboard", metrics: LivebookWeb.Telemetry
|
||||||
end
|
end
|
||||||
|
|
||||||
|
scope "/authenticate", LivebookWeb do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
get "/", AuthController, :index
|
||||||
|
post "/", AuthController, :authenticate
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
53
lib/livebook_web/templates/error/401.html.eex
Normal file
53
lib/livebook_web/templates/error/401.html.eex
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<%= live_title_tag "Livebook" %>
|
||||||
|
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="h-screen flex items-center justify-center bg-gray-900">
|
||||||
|
<div class="flex flex-col space-y-4 items-center">
|
||||||
|
<a href="/">
|
||||||
|
<img src="/logo.png" height="128" width="128" alt="livebook" />
|
||||||
|
</a>
|
||||||
|
<div class="text-2xl text-gray-50">
|
||||||
|
Authentication required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= case Application.fetch_env!(:livebook, :authentication_mode) do %>
|
||||||
|
<% :token -> %>
|
||||||
|
<div class="max-w-2xl text-center text-gray-300">
|
||||||
|
Please check out the console for authentication URL
|
||||||
|
or type the token directly here.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-2xl text-gray-50 w-full pt-2">
|
||||||
|
<form method="get" class="flex flex-col space-y-4 items-center">
|
||||||
|
<input type="text" name="token" class="input" placeholder="Token" />
|
||||||
|
<button type="submit" class="button button-blue">
|
||||||
|
Authenticate
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% :password -> %>
|
||||||
|
<div class="max-w-2xl text-center text-gray-300">
|
||||||
|
Type password to access the Livebook.
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl text-gray-50 w-full pt-2">
|
||||||
|
<form method="post" class="flex flex-col space-y-4 items-center">
|
||||||
|
<input type="hidden" value="<%= Phoenix.Controller.get_csrf_token() %>" name="_csrf_token"/>
|
||||||
|
<input type="text" name="password" class="input" placeholder="Password" />
|
||||||
|
<button type="submit" class="button button-blue">
|
||||||
|
Authenticate
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,34 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<%= live_title_tag "Livebook" %>
|
|
||||||
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="h-screen flex items-center justify-center bg-gray-900">
|
|
||||||
<div class="flex flex-col space-y-4 items-center">
|
|
||||||
<a href="/">
|
|
||||||
<img src="/logo.png" height="128" width="128" alt="livebook" />
|
|
||||||
</a>
|
|
||||||
<div class="text-2xl text-gray-50">
|
|
||||||
Authentication required
|
|
||||||
</div>
|
|
||||||
<div class="max-w-2xl text-center text-gray-300">
|
|
||||||
Please check out the console for authentication URL
|
|
||||||
or type the token directly here.
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl text-gray-50 w-full pt-2">
|
|
||||||
<form method="get" class="flex flex-col space-y-4 items-center">
|
|
||||||
<input type="text" name="token" class="input" placeholder="Token" />
|
|
||||||
<button type="submit" class="button button-blue">
|
|
||||||
Authenticate
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,10 +1,6 @@
|
||||||
defmodule LivebookWeb.ErrorView do
|
defmodule LivebookWeb.ErrorView do
|
||||||
use LivebookWeb, :view
|
use LivebookWeb, :view
|
||||||
|
|
||||||
def render("401.html", %{reason: %LivebookWeb.InvalidTokenError{}} = assigns) do
|
|
||||||
render("401_token.html", assigns)
|
|
||||||
end
|
|
||||||
|
|
||||||
def template_not_found(_template, assigns) do
|
def template_not_found(_template, assigns) do
|
||||||
render("500.html", assigns)
|
render("500.html", assigns)
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,11 +2,20 @@ defmodule LivebookWeb.AuthPlugTest do
|
||||||
use LivebookWeb.ConnCase, async: false
|
use LivebookWeb.ConnCase, async: false
|
||||||
|
|
||||||
setup context do
|
setup context do
|
||||||
if context[:token] do
|
{type, value} =
|
||||||
Application.put_env(:livebook, :token, context[:token])
|
cond do
|
||||||
|
token = context[:token] -> {:token, token}
|
||||||
|
password = context[:password] -> {:password, password}
|
||||||
|
true -> {:disabled, ""}
|
||||||
|
end
|
||||||
|
|
||||||
|
unless type == :disabled do
|
||||||
|
Application.put_env(:livebook, :authentication_mode, type)
|
||||||
|
Application.put_env(:livebook, type, value)
|
||||||
|
|
||||||
on_exit(fn ->
|
on_exit(fn ->
|
||||||
Application.delete_env(:livebook, :token)
|
Application.put_env(:livebook, :authentication_mode, :disabled)
|
||||||
|
Application.delete_env(:livebook, type)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -64,4 +73,46 @@ defmodule LivebookWeb.AuthPlugTest do
|
||||||
assert conn.resp_body =~ "New notebook"
|
assert conn.resp_body =~ "New notebook"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "password authentication" do
|
||||||
|
@tag password: "grumpycat"
|
||||||
|
test "redirects to '/authenticate' if not already authenticated", %{conn: conn} do
|
||||||
|
conn = get(conn, "/")
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/authenticate"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag password: "grumpycat"
|
||||||
|
test "redirects to '/' on valid authentication", %{conn: conn} do
|
||||||
|
conn = post(conn, Routes.auth_path(conn, :authenticate), password: "grumpycat")
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/"
|
||||||
|
|
||||||
|
conn = get(conn, "/")
|
||||||
|
assert html_response(conn, 200) =~ "New notebook"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag password: "grumpycat"
|
||||||
|
test "redirects back to '/authenticate' on invalid password", %{conn: conn} do
|
||||||
|
conn = post(conn, Routes.auth_path(conn, :authenticate), password: "invalid password")
|
||||||
|
|
||||||
|
conn = get(conn, "/")
|
||||||
|
assert redirected_to(conn) == "/authenticate"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag password: "grumpycat"
|
||||||
|
test "persists authentication across requests using cookies", %{conn: conn} do
|
||||||
|
conn = post(conn, Routes.auth_path(conn, :authenticate), password: "grumpycat")
|
||||||
|
|
||||||
|
assert Map.has_key?(conn.resp_cookies, "80:password")
|
||||||
|
|
||||||
|
conn =
|
||||||
|
build_conn()
|
||||||
|
|> Plug.Test.recycle_cookies(conn)
|
||||||
|
|> get("/")
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
assert conn.resp_body =~ "New notebook"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Reference in a new issue