@@ -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 |
@@ -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 | |||
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) | |||
} | |||
@@ -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) | |||
@@ -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) | |||
@@ -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 | |||
@@ -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 %> |
@@ -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 |