From 78468768927bd35b944491026139673a156dc984 Mon Sep 17 00:00:00 2001 From: Daniel Reszka Date: Sat, 7 Feb 2026 12:51:33 +0100 Subject: [PATCH] implement telegram auth --- .env.example | 3 + .gitignore | 1 + assets/js/app.js | 2 + assets/js/hooks/telegram_login.js | 24 +++ lib/wik/accounts/user.ex | 46 ++++- lib/wik_web/components/telegram/widgets.ex | 30 ++++ .../controllers/auth_controller/dev.ex | 36 ++++ .../controllers/auth_controller/telegram.ex | 97 ++++++++++ lib/wik_web/live/sign_in_live.ex | 27 +++ lib/wik_web/router.ex | 23 ++- .../20260207004623_migrate_resources7_dev.exs | 39 ++++ .../20260207004946_migrate_resources8_dev.exs | 21 +++ .../repo/users/20260207004623_dev.json | 168 ++++++++++++++++++ .../repo/users/20260207004946_dev.json | 168 ++++++++++++++++++ 14 files changed, 671 insertions(+), 14 deletions(-) create mode 100644 .env.example create mode 100644 assets/js/hooks/telegram_login.js create mode 100644 lib/wik_web/components/telegram/widgets.ex create mode 100644 lib/wik_web/controllers/auth_controller/dev.ex create mode 100644 lib/wik_web/controllers/auth_controller/telegram.ex create mode 100644 lib/wik_web/live/sign_in_live.ex create mode 100644 priv/repo/migrations/20260207004623_migrate_resources7_dev.exs create mode 100644 priv/repo/migrations/20260207004946_migrate_resources8_dev.exs create mode 100644 priv/resource_snapshots/repo/users/20260207004623_dev.json create mode 100644 priv/resource_snapshots/repo/users/20260207004946_dev.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c6b553b --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +TELEGRAM_BOT_TOKEN= +TELEGRAM_BOT_USERNAME= +DEV_USER_ID= diff --git a/.gitignore b/.gitignore index b9cd48a..0a74500 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ devenv.local.yaml # pre-commit .pre-commit-config.yaml Session.vim +.env diff --git a/assets/js/app.js b/assets/js/app.js index 8894288..8d0b8b8 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -29,6 +29,7 @@ import MilkdownEditor from "./hooks/milkdown.js"; import { LayoutStickyToolbar } from "./hooks/layout-sticky-toolbar.js"; import Wikimap from "./hooks/wikimap.js"; import Wikitree from "./hooks/wikitree.js"; +import { TelegramLoginHook } from "./hooks/telegram_login.js" const csrfToken = document .querySelector("meta[name='csrf-token']") @@ -43,6 +44,7 @@ const liveSocket = new LiveSocket("/live", Socket, { LayoutStickyToolbar, Wikimap, Wikitree, + TelegramLogin: TelegramLoginHook, }, }); diff --git a/assets/js/hooks/telegram_login.js b/assets/js/hooks/telegram_login.js new file mode 100644 index 0000000..c27149b --- /dev/null +++ b/assets/js/hooks/telegram_login.js @@ -0,0 +1,24 @@ +export const TelegramLoginHook = { + mounted() { + if (this.el.dataset.inited === "1") return + this.el.dataset.inited = "1" + + const bot = this.el.dataset.botUsername + const req = this.el.dataset.requestAccess || "write" + const size = this.el.dataset.size || "large" + + const { pathname, search } = window.location + const returnTo = `${pathname}${search || ""}` // <- path-only + const authUrl = `/auth/telegram/callback?return_to=${encodeURIComponent(returnTo)}` + + const s = document.createElement("script") + s.async = true + s.src = "https://telegram.org/js/telegram-widget.js?22" + s.dataset.telegramLogin = bot + s.dataset.authUrl = authUrl + s.dataset.requestAccess = req + s.dataset.size = size + + this.el.appendChild(s) + }, +} diff --git a/lib/wik/accounts/user.ex b/lib/wik/accounts/user.ex index 698c962..084c9e3 100644 --- a/lib/wik/accounts/user.ex +++ b/lib/wik/accounts/user.ex @@ -8,8 +8,7 @@ defmodule Wik.Accounts.User do defimpl String.Chars do def to_string(user) do - [username, _] = Kernel.to_string(user.email) |> String.split("@", parts: 2) - username + Kernel.to_string(user.tg_username) end end @@ -62,7 +61,7 @@ defmodule Wik.Accounts.User do create :create do primary? true - accept [:email] + accept [:email, :tg_id, :tg_first_name, :tg_last_name, :tg_username, :tg_photo_url] end create :sign_in_with_magic_link do @@ -87,7 +86,7 @@ defmodule Wik.Accounts.User do action :request_magic_link do argument :email, :ci_string do - allow_nil? false + allow_nil? true end run AshAuthentication.Strategy.MagicLink.Request @@ -111,13 +110,49 @@ defmodule Wik.Accounts.User do ) ) end + + policy action_type(:create) do + authorize_if always() + end end attributes do uuid_primary_key :id attribute :email, :ci_string do - allow_nil? false + allow_nil? true + public? true + end + + attribute :confirmed_at, :utc_datetime_usec + + attribute :role, :atom do + public? true + constraints one_of: [:user, :admin, :moderator] + default :user + end + + attribute :tz, :string do + public? true + end + + attribute :tg_id, :string do + public? true + end + + attribute :tg_first_name, :string do + public? true + end + + attribute :tg_last_name, :string do + public? true + end + + attribute :tg_username, :string do + public? true + end + + attribute :tg_photo_url, :string do public? true end end @@ -136,5 +171,6 @@ defmodule Wik.Accounts.User do identities do identity :unique_email, [:email] + identity :unique_tg_id, [:tg_id] end end diff --git a/lib/wik_web/components/telegram/widgets.ex b/lib/wik_web/components/telegram/widgets.ex new file mode 100644 index 0000000..3805608 --- /dev/null +++ b/lib/wik_web/components/telegram/widgets.ex @@ -0,0 +1,30 @@ +defmodule WikWeb.Components.Telegram.Widgets do + use Phoenix.Component + + # Optional attrs if you want to override defaults + attr :class, :string, default: nil + attr :request_access, :string, default: "write" + attr :size, :string, default: "large", values: ~w(small medium large) + + def login(assigns) do + bot_username = + System.get_env("TELEGRAM_BOT_USERNAME") || + raise "TELEGRAM_BOT_USERNAME not set" + + assigns = + assigns + |> assign(:bot_username, bot_username) + + ~H""" +
+ """ + end +end diff --git a/lib/wik_web/controllers/auth_controller/dev.ex b/lib/wik_web/controllers/auth_controller/dev.ex new file mode 100644 index 0000000..b0a6335 --- /dev/null +++ b/lib/wik_web/controllers/auth_controller/dev.ex @@ -0,0 +1,36 @@ +defmodule WikWeb.AuthController.Dev do + use WikWeb, :controller + require Ash.Query + require Logger + alias AshAuthentication.Jwt + alias AshAuthentication.Plug.Helpers, as: AuthHelpers + + def login(conn, _params) do + return_to = "/" + + id = System.get_env("DEV_USER_ID", "000000000") + + img = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcBAMAAACAI8KnAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpSKVDlYQcQhSneyioo61CkWoEGqFVh1Mrp/QpCFJcXEUXAsOfixWHVycdXVwFQTBDxB3wUnRRUr8X1JoEePBcT/e3XvcvQOERoWpZlcMUDXLSCXiYia7KgZeISCEAYxgRmamPidJSXiOr3v4+HoX5Vne5/4cfbm8yQCfSBxjumERbxBPb1o6533iMCvJOeJz4nGDLkj8yHXF5TfORYcFnhk20ql54jCxWOxgpYNZyVCJp4gjOVWjfCHjco7zFme1UmOte/IXBvPayjLXaQ4jgUUsQYIIBTWUUYGFKK0aKSZStB/38A85folcCrnKYORYQBUqZMcP/ge/uzULkxNuUjAOdL/Y9scoENgFmnXb/j627eYJ4H8GrrS2v9oAZj9Jr7e1yBEQ2gYurtuasgdc7gCDT7psyI7kpykUCsD7GX1TFui/BXrX3N5a+zh9ANLUVfIGODgExoqUve7x7p7O3v490+rvB903ctGnToVWAAAAIVBMVEVKHBxKHD5vKl68TqDKcrS8oE5OvLyFvE6eynK62Zv36fNZSEXTAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAAuIwAALiMBeKU/dgAAAAd0SU1FB+kCDxUqHL67UiAAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAZklEQVQY02OYiQDT0mYy4OROSw1NQ+e6IIBbmgsBrrGxCULAGJ3LYGzitWoVkOm1aolLBzbuQkEg11GKMLcDL7cc2d5yIrjOxiBg4l6OyS0vBjKVjJWUjc2xcSECxiAeMVwEQOMCAM+GiLYSTcf8AAAAAElFTkSuQmCC" + + attrs = %{ + tg_id: id, + tg_first_name: "Testuser", + tg_last_name: "Testuser", + tg_username: "Testuser", + tg_photo_url: img + } + + db_user = + Wik.Accounts.User + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create!(upsert?: true, upsert_identity: :unique_tg_id) + + {:ok, token, _claims} = Jwt.token_for_user(db_user) + db_user = Ash.Resource.set_metadata(db_user, %{token: token}) + + conn + |> AuthHelpers.store_in_session(db_user) + |> redirect(to: return_to) + end +end diff --git a/lib/wik_web/controllers/auth_controller/telegram.ex b/lib/wik_web/controllers/auth_controller/telegram.ex new file mode 100644 index 0000000..0ea9459 --- /dev/null +++ b/lib/wik_web/controllers/auth_controller/telegram.ex @@ -0,0 +1,97 @@ +defmodule WikWeb.AuthController.Telegram do + use WikWeb, :controller + require Ash.Query + require Logger + alias AshAuthentication.Jwt + alias AshAuthentication.Plug.Helpers, as: AuthHelpers + alias Assent.Strategy.Telegram + + defp config do + [ + bot_token: System.get_env("TELEGRAM_BOT_TOKEN"), + # `, may be one of `:login_widget` or `:web_mini_app` + authorization_channel: :login_widget, + origin: "" + # return_to: "/" + # client_id: "REPLACE_WITH_CLIENT_ID", + # client_secret: "REPLACE_WITH_CLIENT_SECRET", + # redirect_uri: "http://localhost:4000/auth/telegram /callback" + ] + end + + def request(conn) do + config() + |> Telegram.authorize_url() + |> case do + {:ok, %{url: url, session_params: session_params}} -> + conn = put_session(conn, :session_params, session_params) + + # Redirect end-user to Telegram to authorize access to their account + conn + |> put_resp_header("location", url) + |> send_resp(302, "") + + {:error, error} -> + Logger.error(""" + Error in AuthController.Telegram.authorize_url() + #{inspect(error)} + """) + + nil + end + end + + def callback(conn, params) do + return_to = params["return_to"] + params = params |> Map.delete("return_to") + session_params = get_session(conn, :session_params) + + config() + # Session params should be added to the config so the strategy can use them + |> Keyword.put(:session_params, session_params) + |> Telegram.callback(params) + |> case do + {:ok, %{user: user_from_telegram}} -> + attrs = %{ + tg_id: user_from_telegram["sub"] |> to_string(), + tg_first_name: user_from_telegram["given_name"], + tg_last_name: user_from_telegram["family_name"], + tg_username: user_from_telegram["preferred_username"], + tg_photo_url: user_from_telegram["picture"] + } + + db_user = + Wik.Accounts.User + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create!(upsert?: true, upsert_identity: :unique_tg_id) + + {:ok, token, _claims} = Jwt.token_for_user(db_user) + db_user = Ash.Resource.set_metadata(db_user, %{token: token}) + + # current_user = %{ + # id: db_user.id, + # first_name: db_user.telegram_first_name, + # last_name: db_user.telegram_last_name, + # username: db_user.telegram_username, + # photo_url: db_user.telegram_photo_url, + # role: db_user.role + # } + + conn + |> AuthHelpers.store_in_session(db_user) + |> redirect(to: return_to) + + {:error, error} -> + error_id = :rand.uniform(10_000) + + Logger.error(""" + Error #{error_id} in AuthController.Telegram.callback() + #{inspect(error)} + """) + + conn + |> put_flash(:error, "Authentication failed. Error id: #{error_id}") + |> redirect(to: return_to) + end + end +end diff --git a/lib/wik_web/live/sign_in_live.ex b/lib/wik_web/live/sign_in_live.ex new file mode 100644 index 0000000..d96affb --- /dev/null +++ b/lib/wik_web/live/sign_in_live.ex @@ -0,0 +1,27 @@ +defmodule WikWeb.SignInLive do + @moduledoc """ + """ + + use WikWeb, :live_view + alias WikWeb.Components.RealtimeToast + alias WikWeb.Components + + @impl true + def render(assigns) do + ~H""" +
+ +
+ <.link class="btn btn-primary" navigate="/dev/login">dev login +
+
+ """ + end + + @impl true + def mount(_params, _session, socket) do + dbg("🔴") + dbg(socket.assigns) + {:ok, socket} + end +end diff --git a/lib/wik_web/router.ex b/lib/wik_web/router.ex index 97c54bf..9cd1837 100644 --- a/lib/wik_web/router.ex +++ b/lib/wik_web/router.ex @@ -34,18 +34,19 @@ defmodule WikWeb.Router do scope "/", WikWeb do pipe_through :browser + get "/auth/telegram/callback", AuthController.Telegram, :callback auth_routes AuthController, Wik.Accounts.User, path: "/auth" sign_out_route AuthController - # Remove these if you'd like to use your own authentication views - sign_in_route register_path: "/register", - reset_path: "/reset", - auth_routes_prefix: "/auth", - on_mount: [{WikWeb.LiveUserAuth, :live_no_user}], - overrides: [ - WikWeb.AuthOverrides, - Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI - ] + # # Remove these if you'd like to use your own authentication views + # sign_in_route register_path: "/register", + # reset_path: "/reset", + # auth_routes_prefix: "/auth", + # on_mount: [{WikWeb.LiveUserAuth, :live_no_user}], + # overrides: [ + # WikWeb.AuthOverrides, + # Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI + # ] # Remove this if you do not want to use the reset password feature reset_route auth_routes_prefix: "/auth", @@ -64,6 +65,8 @@ defmodule WikWeb.Router do auth_routes_prefix: "/auth", overrides: [WikWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI] ) + + live "/sign-in", SignInLive, :show end scope "/", WikWeb do @@ -125,6 +128,8 @@ defmodule WikWeb.Router do scope "/dev" do pipe_through :browser + get "/login", WikWeb.AuthController.Dev, :login + live "/telegram", WikWeb.TelegramLive.BotUpdates, :index live_dashboard "/dashboard", metrics: WikWeb.Telemetry forward "/mailbox", Plug.Swoosh.MailboxPreview end diff --git a/priv/repo/migrations/20260207004623_migrate_resources7_dev.exs b/priv/repo/migrations/20260207004623_migrate_resources7_dev.exs new file mode 100644 index 0000000..83b3716 --- /dev/null +++ b/priv/repo/migrations/20260207004623_migrate_resources7_dev.exs @@ -0,0 +1,39 @@ +defmodule Wik.Repo.Migrations.MigrateResources7 do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:users) do + add :confirmed_at, :utc_datetime_usec + add :role, :text, default: "user" + add :tz, :text + add :tg_id, :text + add :tg_first_name, :text + add :tg_last_name, :text + add :tg_username, :text + add :tg_photo_url, :text + end + + create unique_index(:users, [:tg_id], name: "users_unique_tg_id_index") + end + + def down do + drop_if_exists unique_index(:users, [:tg_id], name: "users_unique_tg_id_index") + + alter table(:users) do + remove :tg_photo_url + remove :tg_username + remove :tg_last_name + remove :tg_first_name + remove :tg_id + remove :tz + remove :role + remove :confirmed_at + end + end +end diff --git a/priv/repo/migrations/20260207004946_migrate_resources8_dev.exs b/priv/repo/migrations/20260207004946_migrate_resources8_dev.exs new file mode 100644 index 0000000..ecfcb75 --- /dev/null +++ b/priv/repo/migrations/20260207004946_migrate_resources8_dev.exs @@ -0,0 +1,21 @@ +defmodule Wik.Repo.Migrations.MigrateResources8 do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:users) do + modify :email, :citext, null: true + end + end + + def down do + alter table(:users) do + modify :email, :citext, null: false + end + end +end diff --git a/priv/resource_snapshots/repo/users/20260207004623_dev.json b/priv/resource_snapshots/repo/users/20260207004623_dev.json new file mode 100644 index 0000000..059aec4 --- /dev/null +++ b/priv/resource_snapshots/repo/users/20260207004623_dev.json @@ -0,0 +1,168 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "citext" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "confirmed_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "\"user\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "role", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tz", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tg_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tg_first_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tg_last_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tg_username", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tg_photo_url", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "2086A57017F4D2E182152346DB33E6D24B2CB3967EB4ABEB7759C6D54A8F7E21", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_tg_id_index", + "keys": [ + { + "type": "atom", + "value": "tg_id" + } + ], + "name": "unique_tg_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Wik.Repo", + "schema": null, + "table": "users" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/users/20260207004946_dev.json b/priv/resource_snapshots/repo/users/20260207004946_dev.json new file mode 100644 index 0000000..6b188f4 --- /dev/null +++ b/priv/resource_snapshots/repo/users/20260207004946_dev.json @@ -0,0 +1,168 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "citext" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "confirmed_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "\"user\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "role", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tz", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tg_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tg_first_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tg_last_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tg_username", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tg_photo_url", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "A737867823207BFA1CAEE2E7F3CBFA4F2344AE2E4AE09CF3F890A2337266A5BE", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_tg_id_index", + "keys": [ + { + "type": "atom", + "value": "tg_id" + } + ], + "name": "unique_tg_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Wik.Repo", + "schema": null, + "table": "users" +} \ No newline at end of file