-
Notifications
You must be signed in to change notification settings - Fork 0
implement telegram auth #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: wik-v2
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| TELEGRAM_BOT_TOKEN= | ||
| TELEGRAM_BOT_USERNAME= | ||
| DEV_USER_ID= |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,3 +49,4 @@ devenv.local.yaml | |
| # pre-commit | ||
| .pre-commit-config.yaml | ||
| Session.vim | ||
| .env | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+113
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unrestricted
🤖 Prompt for AI Agents |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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""" | ||
| <div | ||
| id="telegram-login" | ||
| phx-hook="TelegramLogin" | ||
| phx-update="ignore" | ||
| class={@class} | ||
| data-bot-username={@bot_username} | ||
| data-request-access={@request_access} | ||
| data-size={@size} | ||
| /> | ||
| """ | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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` | ||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment fragment on line 12. Line 12 has a stray comment fragment: 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+34
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 🐛 Proposed fix {:error, error} ->
Logger.error("""
Error in AuthController.Telegram.authorize_url()
#{inspect(error)}
""")
- nil
+ conn
+ |> put_flash(:error, "Failed to initiate Telegram login.")
+ |> redirect(to: "/sign-in")
end📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| end | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| def callback(conn, params) do | ||||||||||||||||||||||||||||||||||||||
| return_to = params["return_to"] | ||||||||||||||||||||||||||||||||||||||
| params = params |> Map.delete("return_to") | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+44
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Open redirect vulnerability via unvalidated
Validate that 🔒 Proposed fix def callback(conn, params) do
- return_to = params["return_to"]
+ return_to = validated_return_to(params["return_to"])
params = params |> Map.delete("return_to")Add a private helper: defp validated_return_to("/" <> _ = path) do
case URI.parse(path) do
%URI{host: nil} -> path
_ -> "/"
end
end
defp validated_return_to(_), do: "/"Also applies to: 80-82, 92-94 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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""" | ||||||||||||||||
| <div> | ||||||||||||||||
| <Components.Telegram.Widgets.login /> | ||||||||||||||||
| <div :if={Mix.env() == :dev} class="text-center mt-4"> | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🐛 Proposed fix defmodule WikWeb.SignInLive do
+ `@dev_mode` Application.compile_env(:wik, :dev_routes, false)
+
# ...
def render(assigns) do
+ assigns = assign(assigns, :dev_mode, `@dev_mode`)
+
~H"""
<div>
<Components.Telegram.Widgets.login />
- <div :if={Mix.env() == :dev} class="text-center mt-4">
+ <div :if={`@dev_mode`} class="text-center mt-4">
<.link class="btn btn-primary" navigate="/dev/login">dev login</.link>
</div>
</div>
"""
end🤖 Prompt for AI Agents |
||||||||||||||||
| <.link class="btn btn-primary" navigate="/dev/login">dev login</.link> | ||||||||||||||||
| </div> | ||||||||||||||||
| </div> | ||||||||||||||||
| """ | ||||||||||||||||
| end | ||||||||||||||||
|
|
||||||||||||||||
| @impl true | ||||||||||||||||
| def mount(_params, _session, socket) do | ||||||||||||||||
| dbg("🔴") | ||||||||||||||||
| dbg(socket.assigns) | ||||||||||||||||
| {:ok, socket} | ||||||||||||||||
|
Comment on lines
+22
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove Lines 23–24 are debug artifacts that should not ship to production. 🐛 Proposed fix def mount(_params, _session, socket) do
- dbg("🔴")
- dbg(socket.assigns)
{:ok, socket}
end📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| end | ||||||||||||||||
| end | ||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
to_stringreturns""for email-only users.Kernel.to_string(nil)returns"", so any user who signed in via magic link (without Telegram) will render as an empty string. This is a regression from the previous behavior that derived the display name from the email.🐛 Proposed fix — fallback to email
defimpl String.Chars do def to_string(user) do - Kernel.to_string(user.tg_username) + Kernel.to_string(user.tg_username || user.email || user.id) end end📝 Committable suggestion
🤖 Prompt for AI Agents