[#468] Prototype of OAuth2 scopes support. TwitterAPI scope restrictions.

This commit is contained in:
Ivan Tashkinov 2019-02-09 17:09:08 +03:00
parent 99fd199bda
commit 4ad843fb9d
9 changed files with 159 additions and 49 deletions

View file

@ -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

11
lib/pleroma/web/oauth.ex Normal file
View file

@ -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

View 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)
}

View 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)

View 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)

View 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)
# 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("/pleroma/friend_requests", TwitterAPI.Controller, :friend_requests)
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("/friends/ids", TwitterAPI.Controller, :friends_ids)
get("/friendships/no_retweets/ids", TwitterAPI.Controller, :empty_array)
post("/statuses/pin/:id", TwitterAPI.Controller, :pin)
post("/statuses/unpin/:id", TwitterAPI.Controller, :unpin)
get("/mutes/users/ids", TwitterAPI.Controller, :empty_array)
get("/qvitter/mutes", TwitterAPI.Controller, :raw_empty_array)
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)
get("/externalprofile/show", TwitterAPI.Controller, :external_profile)
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("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read)
end
post("/statusnet/media/upload", TwitterAPI.Controller, :upload)
post("/media/upload", TwitterAPI.Controller, :upload_json)
post("/media/metadata/create", TwitterAPI.Controller, :update_media)
scope [] do
pipe_through(:oauth_write)
post("/favorites/create/:id", TwitterAPI.Controller, :favorite)
post("/favorites/create", TwitterAPI.Controller, :favorite)
post("/favorites/destroy/:id", TwitterAPI.Controller, :unfavorite)
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("/qvitter/update_avatar", TwitterAPI.Controller, :update_avatar)
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("/friends/ids", TwitterAPI.Controller, :friends_ids)
get("/friendships/no_retweets/ids", TwitterAPI.Controller, :empty_array)
post("/statuses/pin/:id", TwitterAPI.Controller, :pin)
post("/statuses/unpin/:id", TwitterAPI.Controller, :unpin)
get("/mutes/users/ids", TwitterAPI.Controller, :empty_array)
get("/qvitter/mutes", TwitterAPI.Controller, :raw_empty_array)
post("/statusnet/media/upload", TwitterAPI.Controller, :upload)
post("/media/upload", TwitterAPI.Controller, :upload_json)
post("/media/metadata/create", TwitterAPI.Controller, :update_media)
get("/externalprofile/show", TwitterAPI.Controller, :external_profile)
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
scope [] do
pipe_through(:oauth_follow)
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

View 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 %>

View file

@ -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

View file

@ -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