From 2ac596b8c8e100c386c0ca2f3610b152695767eb Mon Sep 17 00:00:00 2001 From: Silas Date: Tue, 22 Feb 2022 00:16:51 -0500 Subject: [PATCH] fix file uploads, serve them properly, put behind auth wall, decrypt secret message in frontend --- .gitignore | 4 +- .vscode/settings.json | 3 + assets/js/app.js | 3 +- assets/js/definitions/index.d.ts | 7 +- assets/js/pages/AuthPage.tsx | 251 +++++++++++++++--- assets/js/pages/RevealPage.tsx | 61 ----- config/config.exs | 2 +- lib/entendu/links.ex | 7 +- lib/entendu/links/link.ex | 17 +- lib/entendu/user_from_auth.ex | 16 +- lib/entendu_web.ex | 8 + .../controllers/auth_controller.ex | 31 ++- .../controllers/file_not_found_controller.ex | 9 + .../controllers/link_controller.ex | 36 ++- lib/entendu_web/plugs/authorize_link.ex | 70 +++++ lib/entendu_web/router.ex | 14 + lib/entendu_web/templates/link/auth.html.eex | 8 +- lib/entendu_web/uploaders/encrypted_link.ex | 12 +- lib/entendu_web/views/error_view.ex | 9 + uploads/.gitkeep | 0 20 files changed, 420 insertions(+), 148 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 assets/js/pages/RevealPage.tsx create mode 100644 lib/entendu_web/controllers/file_not_found_controller.ex create mode 100644 lib/entendu_web/plugs/authorize_link.ex create mode 100644 uploads/.gitkeep diff --git a/.gitignore b/.gitignore index 07c8651..65b6e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ npm-debug.log /priv/static/ /priv/cert/ -dev.secret.exs \ No newline at end of file +dev.secret.exs +/uploads/* +!/uploads/.gitkeep \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..23fd35f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} \ No newline at end of file diff --git a/assets/js/app.js b/assets/js/app.js index 5fc04c8..84b7f9e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,7 +24,6 @@ import JustPage from './pages/JustPage'; import ForPage from './pages/ForPage'; import YouPage from './pages/YouPage'; import AuthPage from './pages/AuthPage'; -import RevealPage from './pages/RevealPage'; let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) @@ -44,5 +43,5 @@ liveSocket.connect() window.liveSocket = liveSocket window.Components = { - SplashPage, JustPage, ForPage, YouPage, AuthPage, RevealPage + SplashPage, JustPage, ForPage, YouPage, AuthPage } diff --git a/assets/js/definitions/index.d.ts b/assets/js/definitions/index.d.ts index 98200a5..a40653f 100644 --- a/assets/js/definitions/index.d.ts +++ b/assets/js/definitions/index.d.ts @@ -1,3 +1,4 @@ -interface Link { - id: string -} \ No newline at end of file +type IntendedUser = { + name: string; + emails: string[]; +}; diff --git a/assets/js/pages/AuthPage.tsx b/assets/js/pages/AuthPage.tsx index 4215761..82cff5d 100644 --- a/assets/js/pages/AuthPage.tsx +++ b/assets/js/pages/AuthPage.tsx @@ -1,54 +1,219 @@ -import { Button, CenteredContainer, GlobalStyle, Header2, Header3, Input, Label, Spacer, TextAlignWrapper } from "@intended/intended-ui"; -import React, { useEffect } from "react"; +import { + Button, + CenteredContainer, + GlobalStyle, + Header2, + Header3, + Input, + InputButtonWithIcon, + Label, + SpaceBetweenContainer, + Spacer, + TextAlignWrapper, + TextAreaParagraph, +} from "@intended/intended-ui"; +import HexMix from "../utils/hexmix"; +import React, { useEffect, useState } from "react"; type AuthPageProps = { - csrf: string, - service: string, - recipient: string + csrf: string; + service: string; + recipient: string; + user: IntendedUser | null; +}; + +interface Keys { + key: string; + iv: string; +} + +interface Link { + text: Blob | null; + file: Blob | null; } const AuthPage = (props: AuthPageProps) => { - const { service, recipient } = props; - // const [recipientInput, setRecipientInput] = useState(""); - // const [serviceSelect, setServiceSelect] = useState("github"); + const { service, recipient, user } = props; - // useEffect(() => { + const [secretFileUrl, _setsecretFileUrl] = useState("#"); + const [secretMessage, setSecretMessage] = useState("Decrypting..."); - // }, []) + useEffect(() => { + init().catch((reason) => { + alert(reason); + }); + }, []); - return ( - - - - - Someone sent you a secret - - Please verify your identity to reveal this message. - - - - - - - - - - - - - - - - - - + const init = async (): Promise => { + const keys: Keys | null = await retrieveKeys(); + const link: Link | null = await retrieveLink(); + if (link && keys) { + await decrypt(link, keys); + } + }; + + const retrieveLink = async (): Promise => { + const urlSegments = new URL(document.URL).pathname.split("/"); + const linkId = urlSegments.pop() || urlSegments.pop(); + if (!linkId) { + alert("Could not find intended link in URL"); + return null; + } + + const textResponse = await fetch(`/uploads/links/${linkId}/text`); + const textData = await textResponse.blob(); + const fileResponse = await fetch(`/uploads/links/${linkId}/file`); + const fileData = await fileResponse.blob(); + + return { + text: textData.size > 0 ? textData : null, + file: fileData.size > 0 ? fileData : null, + }; + }; + + const retrieveKeys = async (): Promise => { + const fragmentData = window.location.hash.split("."); + let key, iv; + + // remove the hash from fragment URI + fragmentData[0] = fragmentData[0].slice(1); + + if (fragmentData.length <= 1) { + key = sessionStorage.getItem("link_key"); + iv = sessionStorage.getItem("link_iv"); + } else { + key = fragmentData[0]; + iv = fragmentData[1]; + } + + if (key && iv) { + return { key: key, iv: iv }; + } else { + alert("No key found in fragment URI or session storage."); + return null; + } + }; + + const decrypt = async (link: Link, keys: Keys) => { + const convertedKey = HexMix.hexToUint8(keys.key); + const convertedIv = HexMix.hexToUint8(keys.iv); + const importedKey = await window.crypto.subtle.importKey( + "raw", + convertedKey, + "AES-GCM", + true, + ["encrypt", "decrypt"] ); -} + if (link?.text) { + const textFile = await link.text.arrayBuffer(); + const encodedText = await window.crypto.subtle.decrypt( + { + name: "AES-GCM", + length: 256, + iv: convertedIv, + }, + importedKey, + textFile + ); + // And voila + HexMix.arrayBufferToString(encodedText, (result: string) => { + setSecretMessage(result); + }); + } + if (link?.file) { + } + }; + + const renderHeader = (): JSX.Element => { + return ( +
+ Someone sent you a secret + {user ? ( + + Hello {user.name}, you are logged in {service} which has the + following verified emails: {user.emails.join(", ")} + + ) : ( + + Please verify your identity to reveal this message. + + )} +
+ ); + }; + + const renderAuth = (): JSX.Element => { + return ( + + + {renderHeader()} + + + + + + + + + + + + + + + + + ); + }; + + const renderReveal = (): JSX.Element => { + return ( + + + {renderHeader()} + + + + + + + + {secretMessage} + + + + + + + {}} + /> + + + + + + + ); + }; + + return ( + + + {user ? renderReveal() : renderAuth()} + + ); +}; export default AuthPage; diff --git a/assets/js/pages/RevealPage.tsx b/assets/js/pages/RevealPage.tsx deleted file mode 100644 index cb1d634..0000000 --- a/assets/js/pages/RevealPage.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Button, CenteredContainer, GlobalStyle, Header2, Header3, InputButtonWithIcon, Label, SpaceBetweenContainer, Spacer, TextAlignWrapper, TextAreaParagraph } from "@intended/intended-ui"; -import React, { useEffect, useState } from "react"; -// import HexMix from "../utils/hexmix"; -// const fragmentData = window.location.hash.split('.'); -// if (fragmentData.length <= 0) { -// alert("No key found in fragment URI"); -// return; -// } -// const key = HexMix.hexToUint8(fragmentData[0]); -// const iv = HexMix.hexToUint8(fragmentData[1]); - -// const importedKey = await window.crypto.subtle.importKey( -// 'raw', -// key, -// 'AES-GCM', -// true, -// ['encrypt', 'decrypt'] -// ); - - -const RevealPage = () => { -return ( - - - - - Someone sent you a secret - - Please verify your identity to reveal this message. - - - - - - - - - "Sup. What are you doing for lunch?" - - - - - - - {}} - /> - - - - - - ); -} - -export default RevealPage; diff --git a/config/config.exs b/config/config.exs index 6cf5b4a..4c256de 100644 --- a/config/config.exs +++ b/config/config.exs @@ -14,7 +14,7 @@ config :entendu, Entendu.Repo, migration_primary_key: [type: :uuid] # Configures the endpoint config :entendu, EntenduWeb.Endpoint, - url: [host: "dev.intended.link"], + url: [host: "intended.link"], secret_key_base: "6PqoqDqHzsXs6pcm/QoI48rR0paD0gxubXBaR6j/b1fJNgL6Fawn5JPl82N/M2NR", render_errors: [view: EntenduWeb.ErrorView, accepts: ~w(html json), layout: false], pubsub_server: Entendu.PubSub, diff --git a/lib/entendu/links.ex b/lib/entendu/links.ex index 6e9de59..02f29bc 100644 --- a/lib/entendu/links.ex +++ b/lib/entendu/links.ex @@ -51,9 +51,10 @@ defmodule Entendu.Links do """ def create_link(attrs \\ %{}) do - %Link{} - |> Link.changeset(attrs) - |> Repo.insert() + Ecto.Multi.new() + |> Ecto.Multi.insert(:link, Link.changeset(%Link{}, attrs)) + |> Ecto.Multi.update(:link_with_file, &Link.file_changeset(&1.link, attrs)) + |> Repo.transaction() end @doc """ diff --git a/lib/entendu/links/link.ex b/lib/entendu/links/link.ex index b016de5..0dd2fae 100644 --- a/lib/entendu/links/link.ex +++ b/lib/entendu/links/link.ex @@ -5,7 +5,17 @@ defmodule Entendu.Links.Link do import Ecto.Changeset @primary_key {:id, Ecto.UUID, autogenerate: true} - + @derive {Jason.Encoder, + only: [ + :burn_after_reading, + :expires, + :filename, + :filetype, + :text_content, + :file_content, + :recipient, + :service + ]} schema "links" do field :burn_after_reading, :boolean, default: false field :expires, :utc_datetime @@ -30,7 +40,10 @@ defmodule Entendu.Links.Link do :recipient, :service ]) - |> cast_attachments(attrs, [:text_content, :file_content]) + end + def file_changeset(link, attrs) do + link + |> cast_attachments(attrs, [:text_content, :file_content]) end end diff --git a/lib/entendu/user_from_auth.ex b/lib/entendu/user_from_auth.ex index a13bada..a94c71d 100644 --- a/lib/entendu/user_from_auth.ex +++ b/lib/entendu/user_from_auth.ex @@ -25,15 +25,21 @@ defmodule Entendu.UserFromAuth do nil end - defp emails_from_auth(%Auth{ extra: %Auth.Extra{ raw_info: %{ user: %{ "emails" => emails}}}}), do: emails + # github + defp emails_from_auth(%Auth{extra: %Auth.Extra{raw_info: %{user: %{"emails" => emails}}}}), + do: emails - defp emails_from_auth(%Auth{ info: %{ email: email }}), do: [email] + defp emails_from_auth(%Auth{info: %{email: email}}), do: [email] defp emails_from_auth(_auth), do: [] defp basic_info(auth) do - IO.inspect(auth) - %{id: auth.uid, name: name_from_auth(auth), avatar: avatar_from_auth(auth), emails: emails_from_auth(auth)} + %{ + id: auth.uid, + name: name_from_auth(auth), + avatar: avatar_from_auth(auth), + emails: emails_from_auth(auth) + } end defp name_from_auth(auth) do @@ -54,6 +60,6 @@ defmodule Entendu.UserFromAuth do def can_access?(recipient, emails) do emails - |> Enum.any?(&( &1["verified"] == true and &1["email"] == recipient)) + |> Enum.any?(&(&1["verified"] == true and &1["email"] == recipient)) end end diff --git a/lib/entendu_web.ex b/lib/entendu_web.ex index 8bf568b..807409d 100644 --- a/lib/entendu_web.ex +++ b/lib/entendu_web.ex @@ -39,6 +39,14 @@ defmodule EntenduWeb do import ReactPhoenix.ClientSide + def current_user(conn) do + Plug.Conn.get_session(conn, :current_user) + end + + def current_link(conn) do + Plug.Conn.get_session(conn, :current_link) + end + # Include shared imports and aliases for views unquote(view_helpers()) end diff --git a/lib/entendu_web/controllers/auth_controller.ex b/lib/entendu_web/controllers/auth_controller.ex index 31809a1..c439f77 100644 --- a/lib/entendu_web/controllers/auth_controller.ex +++ b/lib/entendu_web/controllers/auth_controller.ex @@ -8,6 +8,8 @@ defmodule EntenduWeb.AuthController do plug Ueberauth alias Entendu.UserFromAuth + alias EntenduWeb.LinkView + alias Entendu.EncryptedLink def delete(conn, _params) do conn @@ -23,27 +25,32 @@ defmodule EntenduWeb.AuthController do end def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do - # TODO: turn this into plug that only proceeds if current_link session var exists - %{ id: link_id, recipient: recipient } = get_session(conn, :current_link) + link = get_session(conn, :intended_link) - with {:ok, user} <- UserFromAuth.find_or_create(auth), - true <- UserFromAuth.can_access?(recipient, user.emails) do - # TODO: send over encrypted data that the frontend can decrypt - conn - |> put_session(:current_user, user) - |> configure_session(renew: true) - |> redirect(to: "/just/for/you/#{link_id}") + with %{id: link_id, recipient: recipient} <- link, + {:ok, user} <- UserFromAuth.find_or_create(auth), + true <- UserFromAuth.can_access?(recipient, user.emails) do + # TODO: send over encrypted data that the frontend can decrypt + conn + |> put_session(:current_user, user) + |> configure_session(renew: true) + |> redirect(to: "/just/for/you/#{link_id}") else + nil -> + conn + |> put_flash(:error, "Could not find link to authenticate against") + |> redirect(to: "/just/for/you/") + false -> conn - |> put_flash(:error, "#{recipient} was not found in your list of verified emails") - |> redirect(to: "/just/for/you/#{link_id}") + |> put_flash(:error, "#{link.recipient} was not found in your list of verified emails") + |> redirect(to: "/just/for/you/#{link.id}") {:error, reason} -> conn |> put_flash(:error, reason) - |> redirect(to: "/just/for/you/#{link_id}") + |> redirect(to: "/just/for/you/#{link.id}") end end end diff --git a/lib/entendu_web/controllers/file_not_found_controller.ex b/lib/entendu_web/controllers/file_not_found_controller.ex new file mode 100644 index 0000000..ed0e594 --- /dev/null +++ b/lib/entendu_web/controllers/file_not_found_controller.ex @@ -0,0 +1,9 @@ +defmodule EntenduWeb.FileNotFoundController do + use EntenduWeb, :controller + + def index(conn, _params) do + conn + |> put_status(404) + |> text("File Not Found") + end +end diff --git a/lib/entendu_web/controllers/link_controller.ex b/lib/entendu_web/controllers/link_controller.ex index 8692cb3..e6f33de 100644 --- a/lib/entendu_web/controllers/link_controller.ex +++ b/lib/entendu_web/controllers/link_controller.ex @@ -9,6 +9,11 @@ defmodule EntenduWeb.LinkController do alias Entendu.Links alias Links.Link alias EntenduWeb.FallbackController + alias Entendu.EncryptedLink + alias Entendu.UserFromAuth + alias EntenduWeb.Plugs.AuthorizeLink + + plug AuthorizeLink when action in [:text, :file] action_fallback(FallbackController) @@ -17,12 +22,9 @@ defmodule EntenduWeb.LinkController do end def just(conn, params) do - with {:ok, %Link{} = link} <- Links.create_link(params) do + with {:ok, %{link_with_file: %Link{} = link}} <- Links.create_link(params) do conn |> render("show_authorized.json", %{link: link}) - else - test -> - IO.inspect(test) end end @@ -32,7 +34,7 @@ defmodule EntenduWeb.LinkController do def for(conn, %{"link_id" => link_id, "recipient" => recipient, "service" => service}) do with %Link{} = link <- Links.get_link(link_id), - Links.update_link(link, %{ recipient: recipient, service: service}) do + Links.update_link(link, %{recipient: recipient, service: service}) do conn |> render("show_authorized.json", %{link: link}) end @@ -42,11 +44,29 @@ defmodule EntenduWeb.LinkController do render(conn, "you.html") end - def auth_page(conn, %{ "id" => link_id}) do + def auth_page(conn, %{"id" => link_id}) do with %Link{service: service, recipient: recipient} = link <- Links.get_link(link_id) do conn - |> put_session(:current_link, link) - |> render("auth.html", %{ service: service, recipient: recipient }) + |> put_session(:intended_link, link) + |> render("auth.html", %{intended_link: %{service: service, recipient: recipient}}) + end + end + + def text(conn, %{"id" => link_id}) do + with user = get_session(conn, :current_user), + %Link{recipient: recipient} = link <- Links.get_link(link_id), + true <- UserFromAuth.can_access?(recipient, user.emails) do + path = EncryptedLink.url({link.text_content, link}) + send_file(conn, 200, path) + end + end + + def file(conn, %{"id" => link_id}) do + with user = get_session(conn, :current_user), + %Link{recipient: recipient} = link <- Links.get_link(link_id), + true <- UserFromAuth.can_access?(recipient, user.emails) do + path = EncryptedLink.url({link.file_content, link}) + send_file(conn, 200, path) end end end diff --git a/lib/entendu_web/plugs/authorize_link.ex b/lib/entendu_web/plugs/authorize_link.ex new file mode 100644 index 0000000..0f6c683 --- /dev/null +++ b/lib/entendu_web/plugs/authorize_link.ex @@ -0,0 +1,70 @@ +defmodule EntenduWeb.Plugs.AuthorizeLink do + import Plug.Conn + use EntenduWeb, :controller + + alias Entendu.Repo + alias Entendu.UserFromAuth + alias Entendu.Links + alias Entendu.Links.Link + alias EntenduWeb.FallbackController + alias EntenduWeb.ErrorView + + def init(_params) do + end + + def call(conn, params) do + %{params: %{"path" => [_, link_id, _]}} = conn + user = get_session(conn, :current_user) + + if !user do + conn + |> put_status(403) + |> put_view(EntenduWeb.ErrorView) + |> render("error_code.json", message: "Unauthorized", code: 403) + |> halt + else + with {:ok, user} <- get_user_from_path(conn), + %Link{recipient: recipient} = link <- Links.get_link(link_id), + true <- UserFromAuth.can_access?(recipient, user.emails) do + conn + |> assign(:link, link) + else + nil -> + conn + |> put_status(404) + |> put_view(EntenduWeb.ErrorView) + |> render("error_code.json", message: "Link could not be found", code: 404) + |> halt + + false -> + conn + |> put_status(403) + |> put_view(EntenduWeb.ErrorView) + |> render("error_code.json", message: "Unauthorized", code: 403) + |> halt + + {:error, reason} -> + conn + |> put_status(422) + |> put_view(EntenduWeb.ErrorView) + |> render("error_code.json", message: reason, code: 422) + |> halt + end + end + end + + defp get_user_from_path(%{params: %{"path" => [_, link_id, _]}} = conn) do + get_session(conn, :current_user) + |> get_user_from_path() + end + + defp get_user_from_path(nil) do + {:error, "User not authenticated"} + end + + defp get_user_from_path(%{id: _, name: _, emails: _} = user) do + {:ok, user} + end + + defp get_user_from_path(_), do: {:error, "Link does not exist"} +end diff --git a/lib/entendu_web/router.ex b/lib/entendu_web/router.ex index ddcb2c0..86b8624 100644 --- a/lib/entendu_web/router.ex +++ b/lib/entendu_web/router.ex @@ -1,6 +1,8 @@ defmodule EntenduWeb.Router do use EntenduWeb, :router + alias EntenduWeb.Plugs.AuthorizeLink + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -14,6 +16,11 @@ defmodule EntenduWeb.Router do plug :accepts, ["json"] end + pipeline :authorized_links do + plug AuthorizeLink + plug Plug.Static, at: "/uploads", from: Path.expand('./uploads'), gzip: false + end + scope "/", EntenduWeb do pipe_through :browser @@ -24,6 +31,8 @@ defmodule EntenduWeb.Router do post "/just/for", LinkController, :for get "/just/for/you", LinkController, :you_page get "/just/for/you/:id", LinkController, :auth_page + get "/links/:id/text", LinkController, :text + get "/links/:id/file", LinkController, :file end scope "/auth", EntenduWeb do @@ -34,6 +43,11 @@ defmodule EntenduWeb.Router do delete "/logout", AuthController, :delete end + scope "/uploads", EntenduWeb do + pipe_through [:browser, :authorized_links] + get "/*path", FileNotFoundController, :index + end + # Other scopes may use custom stacks. # scope "/api", EntenduWeb do # pipe_through :api diff --git a/lib/entendu_web/templates/link/auth.html.eex b/lib/entendu_web/templates/link/auth.html.eex index c52b8ad..0609ed3 100644 --- a/lib/entendu_web/templates/link/auth.html.eex +++ b/lib/entendu_web/templates/link/auth.html.eex @@ -1 +1,7 @@ -<%= react_component("Components.AuthPage", %{ csrf: Plug.CSRFProtection.get_csrf_token(), service: @service, recipient: @recipient, user: @current_user }) %> +<%= react_component("Components.AuthPage", %{ + csrf: Plug.CSRFProtection.get_csrf_token(), + service: @intended_link.service, + recipient: @intended_link.recipient, + user: current_user(@conn), + link: current_link(@conn) +}) %> diff --git a/lib/entendu_web/uploaders/encrypted_link.ex b/lib/entendu_web/uploaders/encrypted_link.ex index dcc11ee..f88b2e3 100644 --- a/lib/entendu_web/uploaders/encrypted_link.ex +++ b/lib/entendu_web/uploaders/encrypted_link.ex @@ -31,14 +31,14 @@ defmodule Entendu.EncryptedLink do # end # Override the persisted filenames: - # def filename(version, _) do - # version - # end + def filename(_version, {_file, %{filename: filename}}) do + if filename, do: filename, else: "text" + end # Override the storage directory: - # def storage_dir(version, {file, scope}) do - # "uploads/user/avatars/#{scope.id}" - # end + def storage_dir(version, {_file, scope}) do + "uploads/links/#{scope.id}" + end # Provide a default URL if there hasn't been a file uploaded # def default_url(version, scope) do diff --git a/lib/entendu_web/views/error_view.ex b/lib/entendu_web/views/error_view.ex index 280d7ae..4a272cd 100644 --- a/lib/entendu_web/views/error_view.ex +++ b/lib/entendu_web/views/error_view.ex @@ -13,4 +13,13 @@ defmodule EntenduWeb.ErrorView do def template_not_found(template, _assigns) do Phoenix.Controller.status_message_from_template(template) end + + def render("error_code.json", %{message: message} = params) do + code = Map.get(params, :code, "") + + %{ + message: message, + code: code + } + end end diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..e69de29