[#468] Prototype of OAuth2 scopes support. TwitterAPI scope restrictions.
authorIvan Tashkinov <ivantashkinov@gmail.com>
Sat, 9 Feb 2019 14:09:08 +0000 (17:09 +0300)
committerIvan Tashkinov <ivantashkinov@gmail.com>
Sat, 9 Feb 2019 14:09:08 +0000 (17:09 +0300)
lib/pleroma/plugs/oauth_scopes_plug.ex [new file with mode: 0644]
lib/pleroma/web/oauth.ex [new file with mode: 0644]
lib/pleroma/web/oauth/authorization.ex
lib/pleroma/web/oauth/oauth_controller.ex
lib/pleroma/web/oauth/token.ex
lib/pleroma/web/router.ex
lib/pleroma/web/templates/o_auth/o_auth/show.html.eex
priv/repo/migrations/20190208131753_add_scope_to_o_auth_entities.exs [new file with mode: 0644]
priv/repo/migrations/20190209123318_data_migration_populate_o_auth_scope.exs [new file with mode: 0644]

diff --git a/lib/pleroma/plugs/oauth_scopes_plug.ex b/lib/pleroma/plugs/oauth_scopes_plug.ex
new file mode 100644 (file)
index 0000000..a16adb0
--- /dev/null
@@ -0,0 +1,29 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Plugs.OAuthScopesPlug do
+  import Plug.Conn
+  alias Pleroma.Web.OAuth
+
+  @behaviour Plug
+
+  def init(%{required_scopes: _} = options), do: options
+
+  def call(%Plug.Conn{assigns: assigns} = conn, %{required_scopes: required_scopes}) do
+    token = assigns[:token]
+    granted_scopes = token && OAuth.parse_scopes(token.scope)
+
+    if is_nil(token) || required_scopes -- granted_scopes == [] do
+      conn
+    else
+      missing_scopes = required_scopes -- granted_scopes
+      error_message = "Insufficient permissions: #{Enum.join(missing_scopes, ", ")}."
+
+      conn
+      |> put_resp_content_type("application/json")
+      |> send_resp(403, Jason.encode!(%{error: error_message}))
+      |> halt()
+    end
+  end
+end
diff --git a/lib/pleroma/web/oauth.ex b/lib/pleroma/web/oauth.ex
new file mode 100644 (file)
index 0000000..44b8343
--- /dev/null
@@ -0,0 +1,11 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.OAuth do
+  def parse_scopes(scopes) do
+    scopes
+    |> to_string()
+    |> String.split([" ", ","])
+  end
+end
index f8c65602dda59cd682ed522f3e1abd8b8c951730..0fbaa902b408ecc8f86a6fd91b8c84ee8a7f8bc7 100644 (file)
@@ -6,12 +6,14 @@ defmodule Pleroma.Web.OAuth.Authorization do
   use Ecto.Schema
 
   alias Pleroma.{User, Repo}
+  alias Pleroma.Web.OAuth
   alias Pleroma.Web.OAuth.{Authorization, App}
 
   import Ecto.{Changeset, Query}
 
   schema "oauth_authorizations" do
     field(:token, :string)
+    field(:scope, :string)
     field(:valid_until, :naive_datetime)
     field(:used, :boolean, default: false)
     belongs_to(:user, Pleroma.User, type: Pleroma.FlakeId)
@@ -20,7 +22,8 @@ defmodule Pleroma.Web.OAuth.Authorization do
     timestamps()
   end
 
-  def create_authorization(%App{} = app, %User{} = user) do
+  def create_authorization(%App{} = app, %User{} = user, scope \\ nil) do
+    scopes = OAuth.parse_scopes(scope || app.scopes)
     token = :crypto.strong_rand_bytes(32) |> Base.url_encode64()
 
     authorization = %Authorization{
@@ -28,6 +31,7 @@ defmodule Pleroma.Web.OAuth.Authorization do
       used: false,
       user_id: user.id,
       app_id: app.id,
+      scope: Enum.join(scopes, " "),
       valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10)
     }
 
index 8ec963c79f557e2000e49e21ed1f8511dd3c891e..15345d4bac9f55899fd7221a519e55fb6403f4eb 100644 (file)
@@ -38,7 +38,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          {:auth_active, true} <- {:auth_active, User.auth_active?(user)},
          %App{} = app <- Repo.get_by(App, client_id: client_id),
          true <- redirect_uri in String.split(app.redirect_uris),
-         {:ok, auth} <- Authorization.create_authorization(app, user) do
+         {:ok, auth} <- Authorization.create_authorization(app, user, params["scope"]) do
       # Special case: Local MastodonFE.
       redirect_uri =
         if redirect_uri == "." do
@@ -81,8 +81,6 @@ defmodule Pleroma.Web.OAuth.OAuthController do
     end
   end
 
-  # TODO
-  # - proper scope handling
   def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do
     with %App{} = app <- get_app_from_request(conn, params),
          fixed_token = fix_padding(params["code"]),
@@ -96,7 +94,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
         refresh_token: token.refresh_token,
         created_at: DateTime.to_unix(inserted_at),
         expires_in: 60 * 10,
-        scope: "read write follow"
+        scope: token.scope
       }
 
       json(conn, response)
@@ -107,8 +105,6 @@ defmodule Pleroma.Web.OAuth.OAuthController do
     end
   end
 
-  # TODO
-  # - investigate a way to verify the user wants to grant read/write/follow once scope handling is done
   def token_exchange(
         conn,
         %{"grant_type" => "password", "username" => name, "password" => password} = params
@@ -117,14 +113,14 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          %User{} = user <- User.get_by_nickname_or_email(name),
          true <- Pbkdf2.checkpw(password, user.password_hash),
          {:auth_active, true} <- {:auth_active, User.auth_active?(user)},
-         {:ok, auth} <- Authorization.create_authorization(app, user),
+         {:ok, auth} <- Authorization.create_authorization(app, user, params["scope"]),
          {:ok, token} <- Token.exchange_token(app, auth) do
       response = %{
         token_type: "Bearer",
         access_token: token.token,
         refresh_token: token.refresh_token,
         expires_in: 60 * 10,
-        scope: "read write follow"
+        scope: token.scope
       }
 
       json(conn, response)
index 4e01b123b2ff3589f1b146a424c9ecd6d6624d71..61f43ed5a9aba7e2c13624bd42e8d7e2beed05f9 100644 (file)
@@ -8,11 +8,13 @@ defmodule Pleroma.Web.OAuth.Token do
   import Ecto.Query
 
   alias Pleroma.{User, Repo}
+  alias Pleroma.Web.OAuth
   alias Pleroma.Web.OAuth.{Token, App, Authorization}
 
   schema "oauth_tokens" do
     field(:token, :string)
     field(:refresh_token, :string)
+    field(:scope, :string)
     field(:valid_until, :naive_datetime)
     belongs_to(:user, Pleroma.User, type: Pleroma.FlakeId)
     belongs_to(:app, App)
@@ -23,17 +25,19 @@ defmodule Pleroma.Web.OAuth.Token do
   def exchange_token(app, auth) do
     with {:ok, auth} <- Authorization.use_token(auth),
          true <- auth.app_id == app.id do
-      create_token(app, Repo.get(User, auth.user_id))
+      create_token(app, Repo.get(User, auth.user_id), auth.scope)
     end
   end
 
-  def create_token(%App{} = app, %User{} = user) do
+  def create_token(%App{} = app, %User{} = user, scope \\ nil) do
+    scopes = OAuth.parse_scopes(scope || app.scopes)
     token = :crypto.strong_rand_bytes(32) |> Base.url_encode64()
     refresh_token = :crypto.strong_rand_bytes(32) |> Base.url_encode64()
 
     token = %Token{
       token: token,
       refresh_token: refresh_token,
+      scope: Enum.join(scopes, " "),
       user_id: user.id,
       app_id: app.id,
       valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10)
index 7f606ac404683c389a7328f991cbaf1d1e69ff87..1316d7f98f755da3ecff24e4f299b8d47dce44d5 100644 (file)
@@ -74,6 +74,18 @@ defmodule Pleroma.Web.Router do
     plug(Pleroma.Plugs.EnsureUserKeyPlug)
   end
 
+  pipeline :oauth_read do
+    plug(Pleroma.Plugs.OAuthScopesPlug, %{required_scopes: ["read"]})
+  end
+
+  pipeline :oauth_write do
+    plug(Pleroma.Plugs.OAuthScopesPlug, %{required_scopes: ["write"]})
+  end
+
+  pipeline :oauth_follow do
+    plug(Pleroma.Plugs.OAuthScopesPlug, %{required_scopes: ["follow"]})
+  end
+
   pipeline :well_known do
     plug(:accepts, ["json", "jrd+json", "xml", "xrd+xml"])
   end
@@ -338,55 +350,67 @@ defmodule Pleroma.Web.Router do
     get("/account/verify_credentials", TwitterAPI.Controller, :verify_credentials)
     post("/account/verify_credentials", TwitterAPI.Controller, :verify_credentials)
 
-    post("/account/update_profile", TwitterAPI.Controller, :update_profile)
-    post("/account/update_profile_banner", TwitterAPI.Controller, :update_banner)
-    post("/qvitter/update_background_image", TwitterAPI.Controller, :update_background)
+    scope [] do
+      pipe_through(:oauth_read)
+
+      get("/statuses/home_timeline", TwitterAPI.Controller, :friends_timeline)
+      get("/statuses/friends_timeline", TwitterAPI.Controller, :friends_timeline)
+      get("/statuses/mentions", TwitterAPI.Controller, :mentions_timeline)
+      get("/statuses/mentions_timeline", TwitterAPI.Controller, :mentions_timeline)
+      get("/statuses/dm_timeline", TwitterAPI.Controller, :dm_timeline)
+      get("/qvitter/statuses/notifications", TwitterAPI.Controller, :notifications)
 
-    get("/statuses/home_timeline", TwitterAPI.Controller, :friends_timeline)
-    get("/statuses/friends_timeline", TwitterAPI.Controller, :friends_timeline)
-    get("/statuses/mentions", TwitterAPI.Controller, :mentions_timeline)
-    get("/statuses/mentions_timeline", TwitterAPI.Controller, :mentions_timeline)
-    get("/statuses/dm_timeline", TwitterAPI.Controller, :dm_timeline)
-    get("/qvitter/statuses/notifications", TwitterAPI.Controller, :notifications)
+      get("/pleroma/friend_requests", TwitterAPI.Controller, :friend_requests)
 
-    # XXX: this is really a pleroma API, but we want to keep the pleroma namespace clean
-    #      for now.
-    post("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read)
+      get("/friends/ids", TwitterAPI.Controller, :friends_ids)
+      get("/friendships/no_retweets/ids", TwitterAPI.Controller, :empty_array)
 
-    post("/statuses/update", TwitterAPI.Controller, :status_update)
-    post("/statuses/retweet/:id", TwitterAPI.Controller, :retweet)
-    post("/statuses/unretweet/:id", TwitterAPI.Controller, :unretweet)
-    post("/statuses/destroy/:id", TwitterAPI.Controller, :delete_post)
+      get("/mutes/users/ids", TwitterAPI.Controller, :empty_array)
+      get("/qvitter/mutes", TwitterAPI.Controller, :raw_empty_array)
 
-    post("/statuses/pin/:id", TwitterAPI.Controller, :pin)
-    post("/statuses/unpin/:id", TwitterAPI.Controller, :unpin)
+      get("/externalprofile/show", TwitterAPI.Controller, :external_profile)
 
-    get("/pleroma/friend_requests", TwitterAPI.Controller, :friend_requests)
-    post("/pleroma/friendships/approve", TwitterAPI.Controller, :approve_friend_request)
-    post("/pleroma/friendships/deny", TwitterAPI.Controller, :deny_friend_request)
+      post("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read)
+    end
+
+    scope [] do
+      pipe_through(:oauth_write)
 
-    post("/friendships/create", TwitterAPI.Controller, :follow)
-    post("/friendships/destroy", TwitterAPI.Controller, :unfollow)
-    post("/blocks/create", TwitterAPI.Controller, :block)
-    post("/blocks/destroy", TwitterAPI.Controller, :unblock)
+      post("/account/update_profile", TwitterAPI.Controller, :update_profile)
+      post("/account/update_profile_banner", TwitterAPI.Controller, :update_banner)
+      post("/qvitter/update_background_image", TwitterAPI.Controller, :update_background)
 
-    post("/statusnet/media/upload", TwitterAPI.Controller, :upload)
-    post("/media/upload", TwitterAPI.Controller, :upload_json)
-    post("/media/metadata/create", TwitterAPI.Controller, :update_media)
+      post("/statuses/update", TwitterAPI.Controller, :status_update)
+      post("/statuses/retweet/:id", TwitterAPI.Controller, :retweet)
+      post("/statuses/unretweet/:id", TwitterAPI.Controller, :unretweet)
+      post("/statuses/destroy/:id", TwitterAPI.Controller, :delete_post)
 
-    post("/favorites/create/:id", TwitterAPI.Controller, :favorite)
-    post("/favorites/create", TwitterAPI.Controller, :favorite)
-    post("/favorites/destroy/:id", TwitterAPI.Controller, :unfavorite)
+      post("/statuses/pin/:id", TwitterAPI.Controller, :pin)
+      post("/statuses/unpin/:id", TwitterAPI.Controller, :unpin)
 
-    post("/qvitter/update_avatar", TwitterAPI.Controller, :update_avatar)
+      post("/statusnet/media/upload", TwitterAPI.Controller, :upload)
+      post("/media/upload", TwitterAPI.Controller, :upload_json)
+      post("/media/metadata/create", TwitterAPI.Controller, :update_media)
 
-    get("/friends/ids", TwitterAPI.Controller, :friends_ids)
-    get("/friendships/no_retweets/ids", TwitterAPI.Controller, :empty_array)
+      post("/favorites/create/:id", TwitterAPI.Controller, :favorite)
+      post("/favorites/create", TwitterAPI.Controller, :favorite)
+      post("/favorites/destroy/:id", TwitterAPI.Controller, :unfavorite)
+
+      post("/qvitter/update_avatar", TwitterAPI.Controller, :update_avatar)
+    end
 
-    get("/mutes/users/ids", TwitterAPI.Controller, :empty_array)
-    get("/qvitter/mutes", TwitterAPI.Controller, :raw_empty_array)
+    scope [] do
+      pipe_through(:oauth_follow)
 
-    get("/externalprofile/show", TwitterAPI.Controller, :external_profile)
+      post("/pleroma/friendships/approve", TwitterAPI.Controller, :approve_friend_request)
+      post("/pleroma/friendships/deny", TwitterAPI.Controller, :deny_friend_request)
+
+      post("/friendships/create", TwitterAPI.Controller, :follow)
+      post("/friendships/destroy", TwitterAPI.Controller, :unfollow)
+
+      post("/blocks/create", TwitterAPI.Controller, :block)
+      post("/blocks/destroy", TwitterAPI.Controller, :unblock)
+    end
   end
 
   pipeline :ap_relay do
index de2241ec91dd283d38baf7d0d5db3b49d8c1d2ce..e1c0af975c8af8bb9358d3674f0f01c96161b473 100644 (file)
@@ -8,10 +8,12 @@
 <%= label f, :password, "Password" %>
 <%= password_input f, :password %>
 <br>
+<%= label f, :scope, "Scopes" %>
+<%= text_input f, :scope, value: @scope %>
+<br>
 <%= hidden_input f, :client_id, value: @client_id %>
 <%= hidden_input f, :response_type, value: @response_type %>
 <%= hidden_input f, :redirect_uri, value: @redirect_uri %>
-<%= hidden_input f, :scope, value: @scope %>
 <%= hidden_input f, :state, value: @state%>
 <%= submit "Authorize" %>
 <% end %>
diff --git a/priv/repo/migrations/20190208131753_add_scope_to_o_auth_entities.exs b/priv/repo/migrations/20190208131753_add_scope_to_o_auth_entities.exs
new file mode 100644 (file)
index 0000000..809e9ab
--- /dev/null
@@ -0,0 +1,11 @@
+defmodule Pleroma.Repo.Migrations.AddScopeToOAuthEntities do
+  use Ecto.Migration
+
+  def change do
+    for t <- [:oauth_authorizations, :oauth_tokens] do
+      alter table(t) do
+        add :scope, :string
+      end
+    end
+  end
+end
diff --git a/priv/repo/migrations/20190209123318_data_migration_populate_o_auth_scope.exs b/priv/repo/migrations/20190209123318_data_migration_populate_o_auth_scope.exs
new file mode 100644 (file)
index 0000000..722cd6c
--- /dev/null
@@ -0,0 +1,29 @@
+defmodule Pleroma.Repo.Migrations.DataMigrationPopulateOAuthScope do
+  use Ecto.Migration
+
+  require Ecto.Query
+
+  alias Ecto.Query
+  alias Pleroma.Repo
+  alias Pleroma.Web.OAuth
+  alias Pleroma.Web.OAuth.{App, Authorization, Token}
+
+  def up do
+    for app <- Repo.all(Query.from(app in App)) do
+      scopes = OAuth.parse_scopes(app.scopes)
+      scope = Enum.join(scopes, " ")
+
+      Repo.update_all(
+        Query.from(auth in Authorization, where: auth.app_id == ^app.id),
+        set: [scope: scope]
+      )
+
+      Repo.update_all(
+        Query.from(token in Token, where: token.app_id == ^app.id),
+        set: [scope: scope]
+      )
+    end
+  end
+
+  def down, do: :noop
+end