forked from AkkomaGang/akkoma
[#468] Prototype of OAuth2 scopes support. TwitterAPI scope restrictions.
This commit is contained in:
parent
99fd199bda
commit
4ad843fb9d
9 changed files with 159 additions and 49 deletions
29
lib/pleroma/plugs/oauth_scopes_plug.ex
Normal file
29
lib/pleroma/plugs/oauth_scopes_plug.ex
Normal 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
11
lib/pleroma/web/oauth.ex
Normal 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
|
|
@ -6,12 +6,14 @@ defmodule Pleroma.Web.OAuth.Authorization do
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
|
|
||||||
alias Pleroma.{User, Repo}
|
alias Pleroma.{User, Repo}
|
||||||
|
alias Pleroma.Web.OAuth
|
||||||
alias Pleroma.Web.OAuth.{Authorization, App}
|
alias Pleroma.Web.OAuth.{Authorization, App}
|
||||||
|
|
||||||
import Ecto.{Changeset, Query}
|
import Ecto.{Changeset, Query}
|
||||||
|
|
||||||
schema "oauth_authorizations" do
|
schema "oauth_authorizations" do
|
||||||
field(:token, :string)
|
field(:token, :string)
|
||||||
|
field(:scope, :string)
|
||||||
field(:valid_until, :naive_datetime)
|
field(:valid_until, :naive_datetime)
|
||||||
field(:used, :boolean, default: false)
|
field(:used, :boolean, default: false)
|
||||||
belongs_to(:user, Pleroma.User, type: Pleroma.FlakeId)
|
belongs_to(:user, Pleroma.User, type: Pleroma.FlakeId)
|
||||||
|
@ -20,7 +22,8 @@ defmodule Pleroma.Web.OAuth.Authorization do
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
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()
|
token = :crypto.strong_rand_bytes(32) |> Base.url_encode64()
|
||||||
|
|
||||||
authorization = %Authorization{
|
authorization = %Authorization{
|
||||||
|
@ -28,6 +31,7 @@ def create_authorization(%App{} = app, %User{} = user) do
|
||||||
used: false,
|
used: false,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
app_id: app.id,
|
app_id: app.id,
|
||||||
|
scope: Enum.join(scopes, " "),
|
||||||
valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10)
|
valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,7 @@ def create_authorization(conn, %{
|
||||||
{:auth_active, true} <- {:auth_active, User.auth_active?(user)},
|
{:auth_active, true} <- {:auth_active, User.auth_active?(user)},
|
||||||
%App{} = app <- Repo.get_by(App, client_id: client_id),
|
%App{} = app <- Repo.get_by(App, client_id: client_id),
|
||||||
true <- redirect_uri in String.split(app.redirect_uris),
|
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.
|
# Special case: Local MastodonFE.
|
||||||
redirect_uri =
|
redirect_uri =
|
||||||
if redirect_uri == "." do
|
if redirect_uri == "." do
|
||||||
|
@ -81,8 +81,6 @@ def create_authorization(conn, %{
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO
|
|
||||||
# - proper scope handling
|
|
||||||
def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do
|
def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do
|
||||||
with %App{} = app <- get_app_from_request(conn, params),
|
with %App{} = app <- get_app_from_request(conn, params),
|
||||||
fixed_token = fix_padding(params["code"]),
|
fixed_token = fix_padding(params["code"]),
|
||||||
|
@ -96,7 +94,7 @@ def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do
|
||||||
refresh_token: token.refresh_token,
|
refresh_token: token.refresh_token,
|
||||||
created_at: DateTime.to_unix(inserted_at),
|
created_at: DateTime.to_unix(inserted_at),
|
||||||
expires_in: 60 * 10,
|
expires_in: 60 * 10,
|
||||||
scope: "read write follow"
|
scope: token.scope
|
||||||
}
|
}
|
||||||
|
|
||||||
json(conn, response)
|
json(conn, response)
|
||||||
|
@ -107,8 +105,6 @@ def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO
|
|
||||||
# - investigate a way to verify the user wants to grant read/write/follow once scope handling is done
|
|
||||||
def token_exchange(
|
def token_exchange(
|
||||||
conn,
|
conn,
|
||||||
%{"grant_type" => "password", "username" => name, "password" => password} = params
|
%{"grant_type" => "password", "username" => name, "password" => password} = params
|
||||||
|
@ -117,14 +113,14 @@ def token_exchange(
|
||||||
%User{} = user <- User.get_by_nickname_or_email(name),
|
%User{} = user <- User.get_by_nickname_or_email(name),
|
||||||
true <- Pbkdf2.checkpw(password, user.password_hash),
|
true <- Pbkdf2.checkpw(password, user.password_hash),
|
||||||
{:auth_active, true} <- {:auth_active, User.auth_active?(user)},
|
{: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
|
{:ok, token} <- Token.exchange_token(app, auth) do
|
||||||
response = %{
|
response = %{
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
access_token: token.token,
|
access_token: token.token,
|
||||||
refresh_token: token.refresh_token,
|
refresh_token: token.refresh_token,
|
||||||
expires_in: 60 * 10,
|
expires_in: 60 * 10,
|
||||||
scope: "read write follow"
|
scope: token.scope
|
||||||
}
|
}
|
||||||
|
|
||||||
json(conn, response)
|
json(conn, response)
|
||||||
|
|
|
@ -8,11 +8,13 @@ defmodule Pleroma.Web.OAuth.Token do
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias Pleroma.{User, Repo}
|
alias Pleroma.{User, Repo}
|
||||||
|
alias Pleroma.Web.OAuth
|
||||||
alias Pleroma.Web.OAuth.{Token, App, Authorization}
|
alias Pleroma.Web.OAuth.{Token, App, Authorization}
|
||||||
|
|
||||||
schema "oauth_tokens" do
|
schema "oauth_tokens" do
|
||||||
field(:token, :string)
|
field(:token, :string)
|
||||||
field(:refresh_token, :string)
|
field(:refresh_token, :string)
|
||||||
|
field(:scope, :string)
|
||||||
field(:valid_until, :naive_datetime)
|
field(:valid_until, :naive_datetime)
|
||||||
belongs_to(:user, Pleroma.User, type: Pleroma.FlakeId)
|
belongs_to(:user, Pleroma.User, type: Pleroma.FlakeId)
|
||||||
belongs_to(:app, App)
|
belongs_to(:app, App)
|
||||||
|
@ -23,17 +25,19 @@ defmodule Pleroma.Web.OAuth.Token do
|
||||||
def exchange_token(app, auth) do
|
def exchange_token(app, auth) do
|
||||||
with {:ok, auth} <- Authorization.use_token(auth),
|
with {:ok, auth} <- Authorization.use_token(auth),
|
||||||
true <- auth.app_id == app.id do
|
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
|
||||||
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()
|
token = :crypto.strong_rand_bytes(32) |> Base.url_encode64()
|
||||||
refresh_token = :crypto.strong_rand_bytes(32) |> Base.url_encode64()
|
refresh_token = :crypto.strong_rand_bytes(32) |> Base.url_encode64()
|
||||||
|
|
||||||
token = %Token{
|
token = %Token{
|
||||||
token: token,
|
token: token,
|
||||||
refresh_token: refresh_token,
|
refresh_token: refresh_token,
|
||||||
|
scope: Enum.join(scopes, " "),
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
app_id: app.id,
|
app_id: app.id,
|
||||||
valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10)
|
valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10)
|
||||||
|
|
|
@ -74,6 +74,18 @@ defmodule Pleroma.Web.Router do
|
||||||
plug(Pleroma.Plugs.EnsureUserKeyPlug)
|
plug(Pleroma.Plugs.EnsureUserKeyPlug)
|
||||||
end
|
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
|
pipeline :well_known do
|
||||||
plug(:accepts, ["json", "jrd+json", "xml", "xrd+xml"])
|
plug(:accepts, ["json", "jrd+json", "xml", "xrd+xml"])
|
||||||
end
|
end
|
||||||
|
@ -338,55 +350,67 @@ defmodule Pleroma.Web.Router do
|
||||||
get("/account/verify_credentials", TwitterAPI.Controller, :verify_credentials)
|
get("/account/verify_credentials", TwitterAPI.Controller, :verify_credentials)
|
||||||
post("/account/verify_credentials", TwitterAPI.Controller, :verify_credentials)
|
post("/account/verify_credentials", TwitterAPI.Controller, :verify_credentials)
|
||||||
|
|
||||||
post("/account/update_profile", TwitterAPI.Controller, :update_profile)
|
scope [] do
|
||||||
post("/account/update_profile_banner", TwitterAPI.Controller, :update_banner)
|
pipe_through(:oauth_read)
|
||||||
post("/qvitter/update_background_image", TwitterAPI.Controller, :update_background)
|
|
||||||
|
|
||||||
get("/statuses/home_timeline", TwitterAPI.Controller, :friends_timeline)
|
get("/statuses/home_timeline", TwitterAPI.Controller, :friends_timeline)
|
||||||
get("/statuses/friends_timeline", TwitterAPI.Controller, :friends_timeline)
|
get("/statuses/friends_timeline", TwitterAPI.Controller, :friends_timeline)
|
||||||
get("/statuses/mentions", TwitterAPI.Controller, :mentions_timeline)
|
get("/statuses/mentions", TwitterAPI.Controller, :mentions_timeline)
|
||||||
get("/statuses/mentions_timeline", TwitterAPI.Controller, :mentions_timeline)
|
get("/statuses/mentions_timeline", TwitterAPI.Controller, :mentions_timeline)
|
||||||
get("/statuses/dm_timeline", TwitterAPI.Controller, :dm_timeline)
|
get("/statuses/dm_timeline", TwitterAPI.Controller, :dm_timeline)
|
||||||
get("/qvitter/statuses/notifications", TwitterAPI.Controller, :notifications)
|
get("/qvitter/statuses/notifications", TwitterAPI.Controller, :notifications)
|
||||||
|
|
||||||
# XXX: this is really a pleroma API, but we want to keep the pleroma namespace clean
|
get("/pleroma/friend_requests", TwitterAPI.Controller, :friend_requests)
|
||||||
# for now.
|
|
||||||
post("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read)
|
|
||||||
|
|
||||||
post("/statuses/update", TwitterAPI.Controller, :status_update)
|
get("/friends/ids", TwitterAPI.Controller, :friends_ids)
|
||||||
post("/statuses/retweet/:id", TwitterAPI.Controller, :retweet)
|
get("/friendships/no_retweets/ids", TwitterAPI.Controller, :empty_array)
|
||||||
post("/statuses/unretweet/:id", TwitterAPI.Controller, :unretweet)
|
|
||||||
post("/statuses/destroy/:id", TwitterAPI.Controller, :delete_post)
|
|
||||||
|
|
||||||
post("/statuses/pin/:id", TwitterAPI.Controller, :pin)
|
get("/mutes/users/ids", TwitterAPI.Controller, :empty_array)
|
||||||
post("/statuses/unpin/:id", TwitterAPI.Controller, :unpin)
|
get("/qvitter/mutes", TwitterAPI.Controller, :raw_empty_array)
|
||||||
|
|
||||||
get("/pleroma/friend_requests", TwitterAPI.Controller, :friend_requests)
|
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("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read)
|
||||||
post("/friendships/destroy", TwitterAPI.Controller, :unfollow)
|
end
|
||||||
post("/blocks/create", TwitterAPI.Controller, :block)
|
|
||||||
post("/blocks/destroy", TwitterAPI.Controller, :unblock)
|
|
||||||
|
|
||||||
post("/statusnet/media/upload", TwitterAPI.Controller, :upload)
|
scope [] do
|
||||||
post("/media/upload", TwitterAPI.Controller, :upload_json)
|
pipe_through(:oauth_write)
|
||||||
post("/media/metadata/create", TwitterAPI.Controller, :update_media)
|
|
||||||
|
|
||||||
post("/favorites/create/:id", TwitterAPI.Controller, :favorite)
|
post("/account/update_profile", TwitterAPI.Controller, :update_profile)
|
||||||
post("/favorites/create", TwitterAPI.Controller, :favorite)
|
post("/account/update_profile_banner", TwitterAPI.Controller, :update_banner)
|
||||||
post("/favorites/destroy/:id", TwitterAPI.Controller, :unfavorite)
|
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)
|
post("/statuses/pin/:id", TwitterAPI.Controller, :pin)
|
||||||
get("/friendships/no_retweets/ids", TwitterAPI.Controller, :empty_array)
|
post("/statuses/unpin/:id", TwitterAPI.Controller, :unpin)
|
||||||
|
|
||||||
get("/mutes/users/ids", TwitterAPI.Controller, :empty_array)
|
post("/statusnet/media/upload", TwitterAPI.Controller, :upload)
|
||||||
get("/qvitter/mutes", TwitterAPI.Controller, :raw_empty_array)
|
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
|
end
|
||||||
|
|
||||||
pipeline :ap_relay do
|
pipeline :ap_relay do
|
||||||
|
|
|
@ -8,10 +8,12 @@
|
||||||
<%= label f, :password, "Password" %>
|
<%= label f, :password, "Password" %>
|
||||||
<%= password_input f, :password %>
|
<%= password_input f, :password %>
|
||||||
<br>
|
<br>
|
||||||
|
<%= label f, :scope, "Scopes" %>
|
||||||
|
<%= text_input f, :scope, value: @scope %>
|
||||||
|
<br>
|
||||||
<%= hidden_input f, :client_id, value: @client_id %>
|
<%= hidden_input f, :client_id, value: @client_id %>
|
||||||
<%= hidden_input f, :response_type, value: @response_type %>
|
<%= hidden_input f, :response_type, value: @response_type %>
|
||||||
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
|
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
|
||||||
<%= hidden_input f, :scope, value: @scope %>
|
|
||||||
<%= hidden_input f, :state, value: @state%>
|
<%= hidden_input f, :state, value: @state%>
|
||||||
<%= submit "Authorize" %>
|
<%= submit "Authorize" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in a new issue