[#923] OAuth: prototype of sign in / sign up with Twitter.
authorIvan Tashkinov <ivant.business@gmail.com>
Fri, 15 Mar 2019 14:08:03 +0000 (17:08 +0300)
committerIvan Tashkinov <ivant.business@gmail.com>
Fri, 15 Mar 2019 14:08:03 +0000 (17:08 +0300)
config/config.exs
lib/pleroma/user.ex
lib/pleroma/web/auth/authenticator.ex
lib/pleroma/web/auth/pleroma_authenticator.ex
lib/pleroma/web/endpoint.ex
lib/pleroma/web/oauth/oauth_controller.ex
lib/pleroma/web/oauth/oauth_view.ex
lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/o_auth/o_auth/show.html.eex
priv/repo/migrations/20190315101315_add_auth_provider_and_auth_provider_uid_to_users.exs [new file with mode: 0644]

index 8c754cef355f9223cfc178376e476f0ae7367ecd..1ddc1bad1ad158c0da74abeebf54f3ed5f502c6a 100644 (file)
@@ -369,11 +369,15 @@ config :auto_linker,
     rel: false
   ]
 
+config :pleroma, :auth, oauth_consumer_enabled: false
+
 config :ueberauth,
        Ueberauth,
        base_path: "/oauth",
        providers: [
-         twitter: {Ueberauth.Strategy.Twitter, []}
+         twitter:
+           {Ueberauth.Strategy.Twitter,
+            [callback_params: ~w[client_id redirect_uri scope scopes]]}
        ]
 
 config :ueberauth, Ueberauth.Strategy.Twitter.OAuth,
index f49ede149eec5a1e5fbe085d1a10877d0ff24654..e17df8e34840e5f54299edd89e8021850aee9e37 100644 (file)
@@ -40,6 +40,8 @@ defmodule Pleroma.User do
     field(:email, :string)
     field(:name, :string)
     field(:nickname, :string)
+    field(:auth_provider, :string)
+    field(:auth_provider_uid, :string)
     field(:password_hash, :string)
     field(:password, :string, virtual: true)
     field(:password_confirmation, :string, virtual: true)
@@ -206,6 +208,36 @@ defmodule Pleroma.User do
     update_and_set_cache(password_update_changeset(user, data))
   end
 
+  # TODO: FIXME (WIP):
+  def oauth_register_changeset(struct, params \\ %{}) do
+    info_change = User.Info.confirmation_changeset(%User.Info{}, :confirmed)
+
+    changeset =
+      struct
+      |> cast(params, [:email, :nickname, :name, :bio, :auth_provider, :auth_provider_uid])
+      |> validate_required([:auth_provider, :auth_provider_uid])
+      |> unique_constraint(:email)
+      |> unique_constraint(:nickname)
+      |> validate_exclusion(:nickname, Pleroma.Config.get([Pleroma.User, :restricted_nicknames]))
+      |> validate_format(:email, @email_regex)
+      |> validate_length(:bio, max: 1000)
+      |> put_change(:info, info_change)
+
+    if changeset.valid? do
+      nickname = changeset.changes[:nickname]
+      ap_id = (nickname && User.ap_id(%User{nickname: nickname})) || nil
+      followers = User.ap_followers(%User{nickname: ap_id})
+
+      changeset
+      |> put_change(:ap_id, ap_id)
+      |> unique_constraint(:ap_id)
+      |> put_change(:following, [followers])
+      |> put_change(:follower_address, followers)
+    else
+      changeset
+    end
+  end
+
   def register_changeset(struct, params \\ %{}, opts \\ []) do
     confirmation_status =
       if opts[:confirmed] || !Pleroma.Config.get([:instance, :account_activation_required]) do
@@ -504,13 +536,19 @@ defmodule Pleroma.User do
       end
   end
 
+  def get_by_email(email), do: Repo.get_by(User, email: email)
+
   def get_by_nickname_or_email(nickname_or_email) do
-    case user = Repo.get_by(User, nickname: nickname_or_email) do
-      %User{} -> user
-      nil -> Repo.get_by(User, email: nickname_or_email)
-    end
+    get_by_nickname(nickname_or_email) || get_by_email(nickname_or_email)
   end
 
+  def get_by_auth_provider_uid(auth_provider, auth_provider_uid),
+    do:
+      Repo.get_by(User,
+        auth_provider: to_string(auth_provider),
+        auth_provider_uid: to_string(auth_provider_uid)
+      )
+
   def get_cached_user_info(user) do
     key = "user_info:#{user.id}"
     Cachex.fetch!(:user_cache, key, fn _ -> user_info(user) end)
index 82267c5952a6a9b94cfb36f55287150ffb5228f5..fa439d562669e685a4e995626e09e0bcb1a726e4 100644 (file)
@@ -12,8 +12,13 @@ defmodule Pleroma.Web.Auth.Authenticator do
     )
   end
 
-  @callback get_user(Plug.Conn.t()) :: {:ok, User.t()} | {:error, any()}
-  def get_user(plug), do: implementation().get_user(plug)
+  @callback get_user(Plug.Conn.t(), Map.t()) :: {:ok, User.t()} | {:error, any()}
+  def get_user(plug, params), do: implementation().get_user(plug, params)
+
+  @callback get_or_create_user_by_oauth(Plug.Conn.t(), Map.t()) ::
+              {:ok, User.t()} | {:error, any()}
+  def get_or_create_user_by_oauth(plug, params),
+    do: implementation().get_or_create_user_by_oauth(plug, params)
 
   @callback handle_error(Plug.Conn.t(), any()) :: any()
   def handle_error(plug, error), do: implementation().handle_error(plug, error)
index 3cc19af016446c08c9fc73a29751db746e226fae..fb04ef8dad4d3c3a446eb2ee567318afb4dbf53b 100644 (file)
@@ -8,9 +8,9 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do
 
   @behaviour Pleroma.Web.Auth.Authenticator
 
-  def get_user(%Plug.Conn{} = conn) do
-    %{"authorization" => %{"name" => name, "password" => password}} = conn.params
-
+  def get_user(%Plug.Conn{} = _conn, %{
+        "authorization" => %{"name" => name, "password" => password}
+      }) do
     with {_, %User{} = user} <- {:user, User.get_by_nickname_or_email(name)},
          {_, true} <- {:checkpw, Pbkdf2.checkpw(password, user.password_hash)} do
       {:ok, user}
@@ -20,6 +20,56 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do
     end
   end
 
+  def get_user(%Plug.Conn{} = _conn, _params), do: {:error, :missing_credentials}
+
+  def get_or_create_user_by_oauth(
+        %Plug.Conn{assigns: %{ueberauth_auth: %{provider: provider, uid: uid} = auth}},
+        _params
+      ) do
+    user = User.get_by_auth_provider_uid(provider, uid)
+
+    if user do
+      {:ok, user}
+    else
+      info = auth.info
+      email = info.email
+      nickname = info.nickname
+
+      # TODO: FIXME: connect to existing (non-oauth) account (need a UI flow for that) / generate a random nickname?
+      email =
+        if email && User.get_by_email(email) do
+          nil
+        else
+          email
+        end
+
+      nickname =
+        if nickname && User.get_by_nickname(nickname) do
+          nil
+        else
+          nickname
+        end
+
+      new_user =
+        User.oauth_register_changeset(
+          %User{},
+          %{
+            auth_provider: to_string(provider),
+            auth_provider_uid: to_string(uid),
+            name: info.name,
+            bio: info.description,
+            email: email,
+            nickname: nickname
+          }
+        )
+
+      Pleroma.Repo.insert(new_user)
+    end
+  end
+
+  def get_or_create_user_by_oauth(%Plug.Conn{} = _conn, _params),
+    do: {:error, :missing_credentials}
+
   def handle_error(%Plug.Conn{} = _conn, error) do
     error
   end
index d906db67d4a2c54e52a56284ebbccbb6faa36fd4..31ffdecc08de87377b69f717022b142f85466ff7 100644 (file)
@@ -57,10 +57,17 @@ defmodule Pleroma.Web.Endpoint do
       do: "__Host-pleroma_key",
       else: "pleroma_key"
 
+  same_site =
+    if Pleroma.Config.get([:auth, :oauth_consumer_enabled]) do
+      # Note: "SameSite=Strict" prevents sign in with external OAuth provider (no cookies during callback request)
+      "SameSite=Lax"
+    else
+      "SameSite=Strict"
+    end
+
   # The session will be stored in the cookie and signed,
   # this means its contents can be read but not tampered with.
   # Set :encryption_salt if you would also like to encrypt it.
-  # Note: "SameSite=Strict" would cause issues with Twitter OAuth
   plug(
     Plug.Session,
     store: :cookie,
@@ -68,7 +75,7 @@ defmodule Pleroma.Web.Endpoint do
     signing_salt: {Pleroma.Config, :get, [[__MODULE__, :signing_salt], "CqaoopA2"]},
     http_only: true,
     secure: secure_cookies,
-    extra: "SameSite=Lax"
+    extra: same_site
   )
 
   plug(Pleroma.Web.Router)
index 7b052cb366454c32b6e2e1b55b9e4ccf22565528..366085a576bcec466dce753a13de53d9692ce408 100644 (file)
@@ -15,20 +15,57 @@ defmodule Pleroma.Web.OAuth.OAuthController do
 
   import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2]
 
-  plug(Ueberauth)
+  if Pleroma.Config.get([:auth, :oauth_consumer_enabled]), do: plug(Ueberauth)
+
   plug(:fetch_session)
   plug(:fetch_flash)
 
   action_fallback(Pleroma.Web.OAuth.FallbackController)
 
-  def callback(%{assigns: %{ueberauth_failure: _failure}} = conn, _params) do
+  def request(conn, params) do
+    message =
+      if params["provider"] do
+        "Unsupported OAuth provider: #{params["provider"]}."
+      else
+        "Bad OAuth request."
+      end
+
     conn
-    |> put_flash(:error, "Failed to authenticate.")
+    |> put_flash(:error, message)
     |> redirect(to: "/")
   end
 
-  def callback(%{assigns: %{ueberauth_auth: _auth}} = _conn, _params) do
-    raise "Authenticated successfully. Sign up via OAuth is not yet implemented."
+  def callback(%{assigns: %{ueberauth_failure: failure}} = conn, %{"redirect_uri" => redirect_uri}) do
+    messages = for e <- Map.get(failure, :errors, []), do: e.message
+    message = Enum.join(messages, "; ")
+
+    conn
+    |> put_flash(:error, "Failed to authenticate: #{message}.")
+    |> redirect(external: redirect_uri(conn, redirect_uri))
+  end
+
+  def callback(
+        conn,
+        %{"client_id" => client_id, "redirect_uri" => redirect_uri} = params
+      ) do
+    with {:ok, user} <- Authenticator.get_or_create_user_by_oauth(conn, params) do
+      do_create_authorization(
+        conn,
+        %{
+          "authorization" => %{
+            "client_id" => client_id,
+            "redirect_uri" => redirect_uri,
+            "scope" => oauth_scopes(params, nil)
+          }
+        },
+        user
+      )
+    else
+      _ ->
+        conn
+        |> put_flash(:error, "Failed to set up user account.")
+        |> redirect(external: redirect_uri(conn, redirect_uri))
+    end
   end
 
   def authorize(conn, params) do
@@ -47,14 +84,21 @@ defmodule Pleroma.Web.OAuth.OAuthController do
     })
   end
 
-  def create_authorization(conn, %{
-        "authorization" =>
-          %{
-            "client_id" => client_id,
-            "redirect_uri" => redirect_uri
-          } = auth_params
-      }) do
-    with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn)},
+  def create_authorization(conn, params), do: do_create_authorization(conn, params, nil)
+
+  defp do_create_authorization(
+         conn,
+         %{
+           "authorization" =>
+             %{
+               "client_id" => client_id,
+               "redirect_uri" => redirect_uri
+             } = auth_params
+         } = params,
+         user
+       ) do
+    with {_, {:ok, %User{} = user}} <-
+           {:get_user, (user && {:ok, user}) || Authenticator.get_user(conn, params)},
          %App{} = app <- Repo.get_by(App, client_id: client_id),
          true <- redirect_uri in String.split(app.redirect_uris),
          scopes <- oauth_scopes(auth_params, []),
@@ -63,13 +107,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          {:missing_scopes, false} <- {:missing_scopes, scopes == []},
          {:auth_active, true} <- {:auth_active, User.auth_active?(user)},
          {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
-      redirect_uri =
-        if redirect_uri == "." do
-          # Special case: Local MastodonFE
-          mastodon_api_url(conn, :login)
-        else
-          redirect_uri
-        end
+      redirect_uri = redirect_uri(conn, redirect_uri)
 
       cond do
         redirect_uri == "urn:ietf:wg:oauth:2.0:oob" ->
@@ -225,4 +263,9 @@ defmodule Pleroma.Web.OAuth.OAuthController do
       nil
     end
   end
+
+  # Special case: Local MastodonFE
+  defp redirect_uri(conn, "."), do: mastodon_api_url(conn, :login)
+
+  defp redirect_uri(_conn, redirect_uri), do: redirect_uri
 end
index 1450b5a8db4d6cc25fc8b37db936e72c97320336..9b37a91c5e7f8bd25c6d885a2b5f97d58bd73b46 100644 (file)
@@ -5,5 +5,4 @@
 defmodule Pleroma.Web.OAuth.OAuthView do
   use Pleroma.Web, :view
   import Phoenix.HTML.Form
-  import Phoenix.HTML.Link
 end
diff --git a/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex
new file mode 100644 (file)
index 0000000..e7251bc
--- /dev/null
@@ -0,0 +1,14 @@
+<h2>External OAuth Authorization</h2>
+<%= form_for @conn, o_auth_path(@conn, :request, :twitter), [method: "get"], fn f -> %>
+  <div class="scopes-input">
+  <%= label f, :scope, "Permissions" %>
+  <div class="scopes">
+    <%= text_input f, :scope, value: Enum.join(@available_scopes, " ") %>
+  </div>
+  </div>
+
+  <%= hidden_input f, :client_id, value: @client_id %>
+  <%= hidden_input f, :redirect_uri, value: @redirect_uri %>
+  <%= hidden_input f, :state, value: @state%>
+  <%= submit "Sign in with Twitter" %>
+<% end %>
index d465f06b13ff084c32477446d628c1f10cd7eefe..2fa7837fc439a322a53c7f813768e851b1024295 100644 (file)
@@ -36,7 +36,7 @@
 <%= submit "Authorize" %>
 <% end %>
 
-<br>
-<%= link to: "/oauth/twitter", class: "alert alert-info" do %>
-  Sign in with Twitter
-<% end %>
\ No newline at end of file
+<%= if Pleroma.Config.get([:auth, :oauth_consumer_enabled]) do %>
+  <br>
+  <%= render @view_module, "consumer.html", assigns %>
+<% end %>
diff --git a/priv/repo/migrations/20190315101315_add_auth_provider_and_auth_provider_uid_to_users.exs b/priv/repo/migrations/20190315101315_add_auth_provider_and_auth_provider_uid_to_users.exs
new file mode 100644 (file)
index 0000000..90947f8
--- /dev/null
@@ -0,0 +1,12 @@
+defmodule Pleroma.Repo.Migrations.AddAuthProviderAndAuthProviderUidToUsers do
+  use Ecto.Migration
+
+  def change do
+    alter table(:users) do
+      add :auth_provider, :string
+      add :auth_provider_uid, :string
+    end
+
+    create unique_index(:users, [:auth_provider, :auth_provider_uid])
+  end
+end