Merge branch 'twitter_oauth' into 'develop'

OAuth consumer (sign in / sign up with external provider)

See merge request pleroma/pleroma!923
This commit is contained in:
lambda 2019-04-08 11:41:50 +00:00
commit fd45cab9ec
24 changed files with 1052 additions and 126 deletions

View File

@ -395,6 +395,22 @@
base: System.get_env("LDAP_BASE") || "dc=example,dc=com", base: System.get_env("LDAP_BASE") || "dc=example,dc=com",
uid: System.get_env("LDAP_UID") || "cn" uid: System.get_env("LDAP_UID") || "cn"
oauth_consumer_strategies = String.split(System.get_env("OAUTH_CONSUMER_STRATEGIES") || "")
ueberauth_providers =
for strategy <- oauth_consumer_strategies do
strategy_module_name = "Elixir.Ueberauth.Strategy.#{String.capitalize(strategy)}"
strategy_module = String.to_atom(strategy_module_name)
{String.to_atom(strategy), {strategy_module, [callback_params: ["state"]]}}
end
config :ueberauth,
Ueberauth,
base_path: "/oauth",
providers: ueberauth_providers
config :pleroma, :auth, oauth_consumer_strategies: oauth_consumer_strategies
config :pleroma, Pleroma.Mailer, adapter: Swoosh.Adapters.Sendmail config :pleroma, Pleroma.Mailer, adapter: Swoosh.Adapters.Sendmail
config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, path: "/api/pleroma/app_metrics" config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, path: "/api/pleroma/app_metrics"

View File

@ -12,7 +12,6 @@
protocol_options: [max_request_line_length: 8192, max_header_value_length: 8192] protocol_options: [max_request_line_length: 8192, max_header_value_length: 8192]
], ],
protocol: "http", protocol: "http",
secure_cookie_flag: false,
debug_errors: true, debug_errors: true,
code_reloader: true, code_reloader: true,
check_origin: false, check_origin: false,

View File

@ -391,6 +391,17 @@ config :auto_linker,
] ]
``` ```
## Pleroma.ScheduledActivity
* `daily_user_limit`: the number of scheduled activities a user is allowed to create in a single day (Default: `25`)
* `total_user_limit`: the number of scheduled activities a user is allowed to create in total (Default: `300`)
* `enabled`: whether scheduled activities are sent to the job queue to be executed
## Pleroma.Web.Auth.Authenticator
* `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator
* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication
## :ldap ## :ldap
Use LDAP for user authentication. When a user logs in to the Pleroma Use LDAP for user authentication. When a user logs in to the Pleroma
@ -409,13 +420,61 @@ Pleroma account will be created with the same name as the LDAP user name.
* `base`: LDAP base, e.g. "dc=example,dc=com" * `base`: LDAP base, e.g. "dc=example,dc=com"
* `uid`: LDAP attribute name to authenticate the user, e.g. when "cn", the filter will be "cn=username,base" * `uid`: LDAP attribute name to authenticate the user, e.g. when "cn", the filter will be "cn=username,base"
## Pleroma.Web.Auth.Authenticator ## :auth
* `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator Authentication / authorization settings.
* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication
## Pleroma.ScheduledActivity * `auth_template`: authentication form template. By default it's `show.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/show.html.eex`.
* `oauth_consumer_template`: OAuth consumer mode authentication form template. By default it's `consumer.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex`.
* `oauth_consumer_strategies`: the list of enabled OAuth consumer strategies; by default it's set by OAUTH_CONSUMER_STRATEGIES environment variable.
* `daily_user_limit`: the number of scheduled activities a user is allowed to create in a single day (Default: `25`) # OAuth consumer mode
* `total_user_limit`: the number of scheduled activities a user is allowed to create in total (Default: `300`)
* `enabled`: whether scheduled activities are sent to the job queue to be executed OAuth consumer mode allows sign in / sign up via external OAuth providers (e.g. Twitter, Facebook, Google, Microsoft, etc.).
Implementation is based on Ueberauth; see the list of [available strategies](https://github.com/ueberauth/ueberauth/wiki/List-of-Strategies).
Note: each strategy is shipped as a separate dependency; in order to get the strategies, run `OAUTH_CONSUMER_STRATEGIES="..." mix deps.get`,
e.g. `OAUTH_CONSUMER_STRATEGIES="twitter facebook google microsoft" mix deps.get`.
The server should also be started with `OAUTH_CONSUMER_STRATEGIES="..." mix phx.server` in case you enable any strategies.
Note: each strategy requires separate setup (on external provider side and Pleroma side). Below are the guidelines on setting up most popular strategies.
* For Twitter, [register an app](https://developer.twitter.com/en/apps), configure callback URL to https://<your_host>/oauth/twitter/callback
* For Facebook, [register an app](https://developers.facebook.com/apps), configure callback URL to https://<your_host>/oauth/facebook/callback, enable Facebook Login service at https://developers.facebook.com/apps/<app_id>/fb-login/settings/
* For Google, [register an app](https://console.developers.google.com), configure callback URL to https://<your_host>/oauth/google/callback
* For Microsoft, [register an app](https://portal.azure.com), configure callback URL to https://<your_host>/oauth/microsoft/callback
Once the app is configured on external OAuth provider side, add app's credentials and strategy-specific settings (if any — e.g. see Microsoft below) to `config/prod.secret.exs`,
per strategy's documentation (e.g. [ueberauth_twitter](https://github.com/ueberauth/ueberauth_twitter)). Example config basing on environment variables:
```
# Twitter
config :ueberauth, Ueberauth.Strategy.Twitter.OAuth,
consumer_key: System.get_env("TWITTER_CONSUMER_KEY"),
consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET")
# Facebook
config :ueberauth, Ueberauth.Strategy.Facebook.OAuth,
client_id: System.get_env("FACEBOOK_APP_ID"),
client_secret: System.get_env("FACEBOOK_APP_SECRET"),
redirect_uri: System.get_env("FACEBOOK_REDIRECT_URI")
# Google
config :ueberauth, Ueberauth.Strategy.Google.OAuth,
client_id: System.get_env("GOOGLE_CLIENT_ID"),
client_secret: System.get_env("GOOGLE_CLIENT_SECRET"),
redirect_uri: System.get_env("GOOGLE_REDIRECT_URI")
# Microsoft
config :ueberauth, Ueberauth.Strategy.Microsoft.OAuth,
client_id: System.get_env("MICROSOFT_CLIENT_ID"),
client_secret: System.get_env("MICROSOFT_CLIENT_SECRET")
config :ueberauth, Ueberauth,
providers: [
microsoft: {Ueberauth.Strategy.Microsoft, [callback_params: []]}
]
```

View File

@ -57,4 +57,8 @@ def delete([parent_key | keys]) do
def delete(key) do def delete(key) do
Application.delete_env(:pleroma, key) Application.delete_env(:pleroma, key)
end end
def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], [])
def oauth_consumer_enabled?, do: oauth_consumer_strategies() != []
end end

View File

@ -0,0 +1,57 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Registration do
use Ecto.Schema
import Ecto.Changeset
alias Pleroma.Registration
alias Pleroma.Repo
alias Pleroma.User
@primary_key {:id, Pleroma.FlakeId, autogenerate: true}
schema "registrations" do
belongs_to(:user, User, type: Pleroma.FlakeId)
field(:provider, :string)
field(:uid, :string)
field(:info, :map, default: %{})
timestamps()
end
def nickname(registration, default \\ nil),
do: Map.get(registration.info, "nickname", default)
def email(registration, default \\ nil),
do: Map.get(registration.info, "email", default)
def name(registration, default \\ nil),
do: Map.get(registration.info, "name", default)
def description(registration, default \\ nil),
do: Map.get(registration.info, "description", default)
def changeset(registration, params \\ %{}) do
registration
|> cast(params, [:user_id, :provider, :uid, :info])
|> validate_required([:provider, :uid])
|> foreign_key_constraint(:user_id)
|> unique_constraint(:uid, name: :registrations_provider_uid_index)
end
def bind_to_user(registration, user) do
registration
|> changeset(%{user_id: (user && user.id) || nil})
|> Repo.update()
end
def get_by_provider_uid(provider, uid) do
Repo.get_by(Registration,
provider: to_string(provider),
uid: to_string(uid)
)
end
end

View File

@ -13,6 +13,7 @@ defmodule Pleroma.User do
alias Pleroma.Formatter alias Pleroma.Formatter
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Registration
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web alias Pleroma.Web
@ -55,6 +56,7 @@ defmodule Pleroma.User do
field(:bookmarks, {:array, :string}, default: []) field(:bookmarks, {:array, :string}, default: [])
field(:last_refreshed_at, :naive_datetime_usec) field(:last_refreshed_at, :naive_datetime_usec)
has_many(:notifications, Notification) has_many(:notifications, Notification)
has_many(:registrations, Registration)
embeds_one(:info, Pleroma.User.Info) embeds_one(:info, Pleroma.User.Info)
timestamps() timestamps()
@ -216,7 +218,7 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do
changeset = changeset =
struct struct
|> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation]) |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation])
|> validate_required([:email, :name, :nickname, :password, :password_confirmation]) |> validate_required([:name, :nickname, :password, :password_confirmation])
|> validate_confirmation(:password) |> validate_confirmation(:password)
|> unique_constraint(:email) |> unique_constraint(:email)
|> unique_constraint(:nickname) |> unique_constraint(:nickname)
@ -227,6 +229,13 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do
|> validate_length(:name, min: 1, max: 100) |> validate_length(:name, min: 1, max: 100)
|> put_change(:info, info_change) |> put_change(:info, info_change)
changeset =
if opts[:external] do
changeset
else
validate_required(changeset, [:email])
end
if changeset.valid? do if changeset.valid? do
hashed = Pbkdf2.hashpwsalt(changeset.changes[:password]) hashed = Pbkdf2.hashpwsalt(changeset.changes[:password])
ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]}) ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]})
@ -505,11 +514,10 @@ def get_by_nickname(nickname) do
end end
end end
def get_by_email(email), do: Repo.get_by(User, email: email)
def get_by_nickname_or_email(nickname_or_email) do def get_by_nickname_or_email(nickname_or_email) do
case user = Repo.get_by(User, nickname: nickname_or_email) do get_by_nickname(nickname_or_email) || get_by_email(nickname_or_email)
%User{} -> user
nil -> Repo.get_by(User, email: nickname_or_email)
end
end end
def get_cached_user_info(user) do def get_cached_user_info(user) do

View File

@ -3,6 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Auth.Authenticator do defmodule Pleroma.Web.Auth.Authenticator do
alias Pleroma.Registration
alias Pleroma.User alias Pleroma.User
def implementation do def implementation do
@ -12,14 +13,33 @@ def implementation do
) )
end end
@callback get_user(Plug.Conn.t()) :: {:ok, User.t()} | {:error, any()} @callback get_user(Plug.Conn.t(), Map.t()) :: {:ok, User.t()} | {:error, any()}
def get_user(plug), do: implementation().get_user(plug) def get_user(plug, params), do: implementation().get_user(plug, params)
@callback create_from_registration(Plug.Conn.t(), Map.t(), Registration.t()) ::
{:ok, User.t()} | {:error, any()}
def create_from_registration(plug, params, registration),
do: implementation().create_from_registration(plug, params, registration)
@callback get_registration(Plug.Conn.t(), Map.t()) ::
{:ok, Registration.t()} | {:error, any()}
def get_registration(plug, params),
do: implementation().get_registration(plug, params)
@callback handle_error(Plug.Conn.t(), any()) :: any() @callback handle_error(Plug.Conn.t(), any()) :: any()
def handle_error(plug, error), do: implementation().handle_error(plug, error) def handle_error(plug, error), do: implementation().handle_error(plug, error)
@callback auth_template() :: String.t() | nil @callback auth_template() :: String.t() | nil
def auth_template do def auth_template do
implementation().auth_template() || Pleroma.Config.get(:auth_template, "show.html") # Note: `config :pleroma, :auth_template, "..."` support is deprecated
implementation().auth_template() ||
Pleroma.Config.get([:auth, :auth_template], Pleroma.Config.get(:auth_template)) ||
"show.html"
end
@callback oauth_consumer_template() :: String.t() | nil
def oauth_consumer_template do
implementation().oauth_consumer_template() ||
Pleroma.Config.get([:auth, :oauth_consumer_template], "consumer.html")
end end
end end

View File

@ -8,14 +8,19 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do
require Logger require Logger
@behaviour Pleroma.Web.Auth.Authenticator @behaviour Pleroma.Web.Auth.Authenticator
@base Pleroma.Web.Auth.PleromaAuthenticator
@connection_timeout 10_000 @connection_timeout 10_000
@search_timeout 10_000 @search_timeout 10_000
def get_user(%Plug.Conn{} = conn) do defdelegate get_registration(conn, params), to: @base
defdelegate create_from_registration(conn, params, registration), to: @base
def get_user(%Plug.Conn{} = conn, params) do
if Pleroma.Config.get([:ldap, :enabled]) do if Pleroma.Config.get([:ldap, :enabled]) do
{name, password} = {name, password} =
case conn.params do case params do
%{"authorization" => %{"name" => name, "password" => password}} -> %{"authorization" => %{"name" => name, "password" => password}} ->
{name, password} {name, password}
@ -29,14 +34,14 @@ def get_user(%Plug.Conn{} = conn) do
{:error, {:ldap_connection_error, _}} -> {:error, {:ldap_connection_error, _}} ->
# When LDAP is unavailable, try default authenticator # When LDAP is unavailable, try default authenticator
Pleroma.Web.Auth.PleromaAuthenticator.get_user(conn) @base.get_user(conn, params)
error -> error ->
error error
end end
else else
# Fall back to default authenticator # Fall back to default authenticator
Pleroma.Web.Auth.PleromaAuthenticator.get_user(conn) @base.get_user(conn, params)
end end
end end
@ -46,6 +51,8 @@ def handle_error(%Plug.Conn{} = _conn, error) do
def auth_template, do: nil def auth_template, do: nil
def oauth_consumer_template, do: nil
defp ldap_user(name, password) do defp ldap_user(name, password) do
ldap = Pleroma.Config.get(:ldap, []) ldap = Pleroma.Config.get(:ldap, [])
host = Keyword.get(ldap, :host, "localhost") host = Keyword.get(ldap, :host, "localhost")

View File

@ -4,13 +4,15 @@
defmodule Pleroma.Web.Auth.PleromaAuthenticator do defmodule Pleroma.Web.Auth.PleromaAuthenticator do
alias Comeonin.Pbkdf2 alias Comeonin.Pbkdf2
alias Pleroma.Registration
alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
@behaviour Pleroma.Web.Auth.Authenticator @behaviour Pleroma.Web.Auth.Authenticator
def get_user(%Plug.Conn{} = conn) do def get_user(%Plug.Conn{} = _conn, params) do
{name, password} = {name, password} =
case conn.params do case params do
%{"authorization" => %{"name" => name, "password" => password}} -> %{"authorization" => %{"name" => name, "password" => password}} ->
{name, password} {name, password}
@ -27,9 +29,69 @@ def get_user(%Plug.Conn{} = conn) do
end end
end end
def get_registration(
%Plug.Conn{assigns: %{ueberauth_auth: %{provider: provider, uid: uid} = auth}},
_params
) do
registration = Registration.get_by_provider_uid(provider, uid)
if registration do
{:ok, registration}
else
info = auth.info
Registration.changeset(%Registration{}, %{
provider: to_string(provider),
uid: to_string(uid),
info: %{
"nickname" => info.nickname,
"email" => info.email,
"name" => info.name,
"description" => info.description
}
})
|> Repo.insert()
end
end
def get_registration(%Plug.Conn{} = _conn, _params), do: {:error, :missing_credentials}
def create_from_registration(_conn, params, registration) do
nickname = value([params["nickname"], Registration.nickname(registration)])
email = value([params["email"], Registration.email(registration)])
name = value([params["name"], Registration.name(registration)]) || nickname
bio = value([params["bio"], Registration.description(registration)])
random_password = :crypto.strong_rand_bytes(64) |> Base.encode64()
with {:ok, new_user} <-
User.register_changeset(
%User{},
%{
email: email,
nickname: nickname,
name: name,
bio: bio,
password: random_password,
password_confirmation: random_password
},
external: true,
confirmed: true
)
|> Repo.insert(),
{:ok, _} <-
Registration.changeset(registration, %{user_id: new_user.id}) |> Repo.update() do
{:ok, new_user}
end
end
defp value(list), do: Enum.find(list, &(to_string(&1) != ""))
def handle_error(%Plug.Conn{} = _conn, error) do def handle_error(%Plug.Conn{} = _conn, error) do
error error
end end
def auth_template, do: nil def auth_template, do: nil
def oauth_consumer_template, do: nil
end end

View File

@ -51,11 +51,22 @@ defmodule Pleroma.Web.Endpoint do
plug(Plug.MethodOverride) plug(Plug.MethodOverride)
plug(Plug.Head) plug(Plug.Head)
secure_cookies = Pleroma.Config.get([__MODULE__, :secure_cookie_flag])
cookie_name = cookie_name =
if Application.get_env(:pleroma, Pleroma.Web.Endpoint) |> Keyword.get(:secure_cookie_flag), if secure_cookies,
do: "__Host-pleroma_key", do: "__Host-pleroma_key",
else: "pleroma_key" else: "pleroma_key"
same_site =
if Pleroma.Config.oauth_consumer_enabled?() do
# Note: "SameSite=Strict" prevents sign in with external OAuth provider
# (there would be no cookies during callback request from OAuth provider)
"SameSite=Lax"
else
"SameSite=Strict"
end
# The session will be stored in the cookie and signed, # The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with. # this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it. # Set :encryption_salt if you would also like to encrypt it.
@ -65,9 +76,8 @@ defmodule Pleroma.Web.Endpoint do
key: cookie_name, key: cookie_name,
signing_salt: {Pleroma.Config, :get, [[__MODULE__, :signing_salt], "CqaoopA2"]}, signing_salt: {Pleroma.Config, :get, [[__MODULE__, :signing_salt], "CqaoopA2"]},
http_only: true, http_only: true,
secure: secure: secure_cookies,
Application.get_env(:pleroma, Pleroma.Web.Endpoint) |> Keyword.get(:secure_cookie_flag), extra: same_site
extra: "SameSite=Strict"
) )
# Note: the plug and its configuration is compile-time this can't be upstreamed yet # Note: the plug and its configuration is compile-time this can't be upstreamed yet

View File

@ -6,8 +6,21 @@ defmodule Pleroma.Web.OAuth.FallbackController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Web.OAuth.OAuthController alias Pleroma.Web.OAuth.OAuthController
# No user/password def call(conn, {:register, :generic_error}) do
def call(conn, _) do conn
|> put_status(:internal_server_error)
|> put_flash(:error, "Unknown error, please check the details and try again.")
|> OAuthController.registration_details(conn.params)
end
def call(conn, {:register, _error}) do
conn
|> put_status(:unauthorized)
|> put_flash(:error, "Invalid Username/Password")
|> OAuthController.registration_details(conn.params)
end
def call(conn, _error) do
conn conn
|> put_status(:unauthorized) |> put_status(:unauthorized)
|> put_flash(:error, "Invalid Username/Password") |> put_flash(:error, "Invalid Username/Password")

View File

@ -5,6 +5,7 @@
defmodule Pleroma.Web.OAuth.OAuthController do defmodule Pleroma.Web.OAuth.OAuthController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Registration
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.Auth.Authenticator alias Pleroma.Web.Auth.Authenticator
@ -15,6 +16,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2] import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2]
if Pleroma.Config.oauth_consumer_enabled?(), do: plug(Ueberauth)
plug(:fetch_session) plug(:fetch_session)
plug(:fetch_flash) plug(:fetch_flash)
@ -57,31 +60,27 @@ defp do_authorize(conn, params) do
}) })
end end
def create_authorization(conn, %{ def create_authorization(
"authorization" => conn,
%{ %{"authorization" => auth_params} = params,
"client_id" => client_id, opts \\ []
"redirect_uri" => redirect_uri ) do
} = auth_params with {:ok, auth} <- do_create_authorization(conn, params, opts[:user]) do
}) do after_create_authorization(conn, auth, auth_params)
with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn)}, else
%App{} = app <- Repo.get_by(App, client_id: client_id), error ->
true <- redirect_uri in String.split(app.redirect_uris), handle_create_authorization_error(conn, error, auth_params)
scopes <- oauth_scopes(auth_params, []), end
{:unsupported_scopes, []} <- {:unsupported_scopes, scopes -- app.scopes}, end
# Note: `scope` param is intentionally not optional in this context
{:missing_scopes, false} <- {:missing_scopes, scopes == []}, def after_create_authorization(conn, auth, %{"redirect_uri" => redirect_uri} = auth_params) do
{:auth_active, true} <- {:auth_active, User.auth_active?(user)},
{:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
redirect_uri = redirect_uri(conn, redirect_uri) redirect_uri = redirect_uri(conn, redirect_uri)
cond do if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" do
redirect_uri == "urn:ietf:wg:oauth:2.0:oob" ->
render(conn, "results.html", %{ render(conn, "results.html", %{
auth: auth auth: auth
}) })
else
true ->
connector = if String.contains?(redirect_uri, "?"), do: "&", else: "?" connector = if String.contains?(redirect_uri, "?"), do: "&", else: "?"
url = "#{redirect_uri}#{connector}" url = "#{redirect_uri}#{connector}"
url_params = %{:code => auth.token} url_params = %{:code => auth.token}
@ -97,26 +96,29 @@ def create_authorization(conn, %{
redirect(conn, external: url) redirect(conn, external: url)
end end
else end
{scopes_issue, _} when scopes_issue in [:unsupported_scopes, :missing_scopes] ->
defp handle_create_authorization_error(conn, {scopes_issue, _}, auth_params)
when scopes_issue in [:unsupported_scopes, :missing_scopes] do
# Per https://github.com/tootsuite/mastodon/blob/ # Per https://github.com/tootsuite/mastodon/blob/
# 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39 # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39
conn conn
|> put_flash(:error, "This action is outside the authorized scopes") |> put_flash(:error, "This action is outside the authorized scopes")
|> put_status(:unauthorized) |> put_status(:unauthorized)
|> authorize(auth_params) |> authorize(auth_params)
end
{:auth_active, false} -> defp handle_create_authorization_error(conn, {:auth_active, false}, auth_params) do
# Per https://github.com/tootsuite/mastodon/blob/ # Per https://github.com/tootsuite/mastodon/blob/
# 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76 # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76
conn conn
|> put_flash(:error, "Your login is missing a confirmed e-mail address") |> put_flash(:error, "Your login is missing a confirmed e-mail address")
|> put_status(:forbidden) |> put_status(:forbidden)
|> authorize(auth_params) |> authorize(auth_params)
error ->
Authenticator.handle_error(conn, error)
end end
defp handle_create_authorization_error(conn, error, _auth_params) do
Authenticator.handle_error(conn, error)
end end
def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do
@ -149,7 +151,7 @@ def token_exchange(
conn, conn,
%{"grant_type" => "password"} = params %{"grant_type" => "password"} = params
) do ) do
with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn)}, with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn, params)},
%App{} = app <- get_app_from_request(conn, params), %App{} = app <- get_app_from_request(conn, params),
{:auth_active, true} <- {:auth_active, User.auth_active?(user)}, {:auth_active, true} <- {:auth_active, User.auth_active?(user)},
{:user_active, true} <- {:user_active, !user.info.deactivated}, {:user_active, true} <- {:user_active, !user.info.deactivated},
@ -211,6 +213,184 @@ def token_revoke(conn, %{"token" => token} = params) do
end end
end end
@doc "Prepares OAuth request to provider for Ueberauth"
def prepare_request(conn, %{"provider" => provider} = params) do
scope =
oauth_scopes(params, [])
|> Enum.join(" ")
state =
params
|> Map.delete("scopes")
|> Map.put("scope", scope)
|> Poison.encode!()
params =
params
|> Map.drop(~w(scope scopes client_id redirect_uri))
|> Map.put("state", state)
# Handing the request to Ueberauth
redirect(conn, to: o_auth_path(conn, :request, provider, params))
end
def request(conn, params) do
message =
if params["provider"] do
"Unsupported OAuth provider: #{params["provider"]}."
else
"Bad OAuth request."
end
conn
|> put_flash(:error, message)
|> redirect(to: "/")
end
def callback(%{assigns: %{ueberauth_failure: failure}} = conn, params) do
params = callback_params(params)
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, params["redirect_uri"]))
end
def callback(conn, params) do
params = callback_params(params)
with {:ok, registration} <- Authenticator.get_registration(conn, params) do
user = Repo.preload(registration, :user).user
auth_params = Map.take(params, ~w(client_id redirect_uri scope scopes state))
if user do
create_authorization(
conn,
%{"authorization" => auth_params},
user: user
)
else
registration_params =
Map.merge(auth_params, %{
"nickname" => Registration.nickname(registration),
"email" => Registration.email(registration)
})
conn
|> put_session(:registration_id, registration.id)
|> registration_details(registration_params)
end
else
_ ->
conn
|> put_flash(:error, "Failed to set up user account.")
|> redirect(external: redirect_uri(conn, params["redirect_uri"]))
end
end
defp callback_params(%{"state" => state} = params) do
Map.merge(params, Poison.decode!(state))
end
def registration_details(conn, params) do
render(conn, "register.html", %{
client_id: params["client_id"],
redirect_uri: params["redirect_uri"],
state: params["state"],
scopes: oauth_scopes(params, []),
nickname: params["nickname"],
email: params["email"]
})
end
def register(conn, %{"op" => "connect"} = params) do
authorization_params = Map.put(params, "name", params["auth_name"])
create_authorization_params = %{"authorization" => authorization_params}
with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
%Registration{} = registration <- Repo.get(Registration, registration_id),
{_, {:ok, auth}} <-
{:create_authorization, do_create_authorization(conn, create_authorization_params)},
%User{} = user <- Repo.preload(auth, :user).user,
{:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do
conn
|> put_session_registration_id(nil)
|> after_create_authorization(auth, authorization_params)
else
{:create_authorization, error} ->
{:register, handle_create_authorization_error(conn, error, create_authorization_params)}
_ ->
{:register, :generic_error}
end
end
def register(conn, %{"op" => "register"} = params) do
with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
%Registration{} = registration <- Repo.get(Registration, registration_id),
{:ok, user} <- Authenticator.create_from_registration(conn, params, registration) do
conn
|> put_session_registration_id(nil)
|> create_authorization(
%{
"authorization" => %{
"client_id" => params["client_id"],
"redirect_uri" => params["redirect_uri"],
"scopes" => oauth_scopes(params, nil)
}
},
user: user
)
else
{:error, changeset} ->
message =
Enum.map(changeset.errors, fn {field, {error, _}} ->
"#{field} #{error}"
end)
|> Enum.join("; ")
message =
String.replace(
message,
"ap_id has already been taken",
"nickname has already been taken"
)
conn
|> put_status(:forbidden)
|> put_flash(:error, "Error: #{message}.")
|> registration_details(params)
_ ->
{:register, :generic_error}
end
end
defp do_create_authorization(
conn,
%{
"authorization" =>
%{
"client_id" => client_id,
"redirect_uri" => redirect_uri
} = auth_params
} = params,
user \\ nil
) 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, []),
{:unsupported_scopes, []} <- {:unsupported_scopes, scopes -- app.scopes},
# Note: `scope` param is intentionally not optional in this context
{:missing_scopes, false} <- {:missing_scopes, scopes == []},
{:auth_active, true} <- {:auth_active, User.auth_active?(user)} do
Authorization.create_authorization(app, user, scopes)
end
end
# XXX - for whatever reason our token arrives urlencoded, but Plug.Conn should be # XXX - for whatever reason our token arrives urlencoded, but Plug.Conn should be
# decoding it. Investigate sometime. # decoding it. Investigate sometime.
defp fix_padding(token) do defp fix_padding(token) do
@ -248,4 +428,9 @@ defp get_app_from_request(conn, params) do
defp redirect_uri(conn, "."), do: mastodon_api_url(conn, :login) defp redirect_uri(conn, "."), do: mastodon_api_url(conn, :login)
defp redirect_uri(_conn, redirect_uri), do: redirect_uri defp redirect_uri(_conn, redirect_uri), do: redirect_uri
defp get_session_registration_id(conn), do: get_session(conn, :registration_id)
defp put_session_registration_id(conn, registration_id),
do: put_session(conn, :registration_id, registration_id)
end end

View File

@ -5,6 +5,11 @@
defmodule Pleroma.Web.Router do defmodule Pleroma.Web.Router do
use Pleroma.Web, :router use Pleroma.Web, :router
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
end
pipeline :oauth do pipeline :oauth do
plug(:fetch_session) plug(:fetch_session)
plug(Pleroma.Plugs.OAuthPlug) plug(Pleroma.Plugs.OAuthPlug)
@ -213,6 +218,16 @@ defmodule Pleroma.Web.Router do
post("/authorize", OAuthController, :create_authorization) post("/authorize", OAuthController, :create_authorization)
post("/token", OAuthController, :token_exchange) post("/token", OAuthController, :token_exchange)
post("/revoke", OAuthController, :token_revoke) post("/revoke", OAuthController, :token_revoke)
get("/registration_details", OAuthController, :registration_details)
scope [] do
pipe_through(:browser)
get("/prepare_request", OAuthController, :prepare_request)
get("/:provider", OAuthController, :request)
get("/:provider/callback", OAuthController, :callback)
post("/register", OAuthController, :register)
end
end end
scope "/api/v1", Pleroma.Web.MastodonAPI do scope "/api/v1", Pleroma.Web.MastodonAPI do

View File

@ -0,0 +1,13 @@
<div class="scopes-input">
<%= label @form, :scope, "Permissions" %>
<div class="scopes">
<%= for scope <- @available_scopes do %>
<%# Note: using hidden input with `unchecked_value` in order to distinguish user's empty selection from `scope` param being omitted %>
<div class="scope">
<%= checkbox @form, :"scope_#{scope}", value: scope in @scopes && scope, checked_value: scope, unchecked_value: "", name: assigns[:scope_param] || "scope[]" %>
<%= label @form, :"scope_#{scope}", String.capitalize(scope) %>
</div>
<% end %>
</div>
</div>

View File

@ -0,0 +1,13 @@
<h2>Sign in with external provider</h2>
<%= form_for @conn, o_auth_path(@conn, :prepare_request), [method: "get"], fn f -> %>
<%= render @view_module, "_scopes.html", Map.put(assigns, :form, f) %>
<%= hidden_input f, :client_id, value: @client_id %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :state, value: @state %>
<%= for strategy <- Pleroma.Config.oauth_consumer_strategies() do %>
<%= submit "Sign in with #{String.capitalize(strategy)}", name: "provider", value: strategy %>
<% end %>
<% end %>

View File

@ -0,0 +1,43 @@
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<h2>Registration Details</h2>
<p>If you'd like to register a new account, please provide the details below.</p>
<%= form_for @conn, o_auth_path(@conn, :register), [], fn f -> %>
<div class="input">
<%= label f, :nickname, "Nickname" %>
<%= text_input f, :nickname, value: @nickname %>
</div>
<div class="input">
<%= label f, :email, "Email" %>
<%= text_input f, :email, value: @email %>
</div>
<%= submit "Proceed as new user", name: "op", value: "register" %>
<p>Alternatively, sign in to connect to existing account.</p>
<div class="input">
<%= label f, :auth_name, "Name or email" %>
<%= text_input f, :auth_name %>
</div>
<div class="input">
<%= label f, :password, "Password" %>
<%= password_input f, :password %>
</div>
<%= submit "Proceed as existing user", name: "op", value: "connect" %>
<%= hidden_input f, :client_id, value: @client_id %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :scope, value: Enum.join(@scopes, " ") %>
<%= hidden_input f, :state, value: @state %>
<% end %>

View File

@ -4,7 +4,9 @@
<%= if get_flash(@conn, :error) do %> <%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %> <% end %>
<h2>OAuth Authorization</h2> <h2>OAuth Authorization</h2>
<%= form_for @conn, o_auth_path(@conn, :authorize), [as: "authorization"], fn f -> %> <%= form_for @conn, o_auth_path(@conn, :authorize), [as: "authorization"], fn f -> %>
<div class="input"> <div class="input">
<%= label f, :name, "Name or email" %> <%= label f, :name, "Name or email" %>
@ -14,22 +16,16 @@
<%= label f, :password, "Password" %> <%= label f, :password, "Password" %>
<%= password_input f, :password %> <%= password_input f, :password %>
</div> </div>
<div class="scopes-input">
<%= label f, :scope, "Permissions" %> <%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f, scope_param: "authorization[scope][]"}) %>
<div class="scopes">
<%= for scope <- @available_scopes do %>
<%# Note: using hidden input with `unchecked_value` in order to distinguish user's empty selection from `scope` param being omitted %>
<div class="scope">
<%= checkbox f, :"scope_#{scope}", value: scope in @scopes && scope, checked_value: scope, unchecked_value: "", name: "authorization[scope][]" %>
<%= label f, :"scope_#{scope}", String.capitalize(scope) %>
</div>
<% end %>
</div>
</div>
<%= 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, :state, value: @state%> <%= hidden_input f, :state, value: @state %>
<%= submit "Authorize" %> <%= submit "Authorize" %>
<% end %> <% end %>
<%= if Pleroma.Config.oauth_consumer_enabled?() do %>
<%= render @view_module, Pleroma.Web.Auth.Authenticator.oauth_consumer_template(), assigns %>
<% end %>

10
mix.exs
View File

@ -54,6 +54,12 @@ defp elixirc_paths(_), do: ["lib"]
# #
# Type `mix help deps` for examples and options. # Type `mix help deps` for examples and options.
defp deps do defp deps do
oauth_strategies = String.split(System.get_env("OAUTH_CONSUMER_STRATEGIES") || "")
oauth_deps =
for s <- oauth_strategies,
do: {String.to_atom("ueberauth_#{s}"), ">= 0.0.0"}
[ [
{:phoenix, "~> 1.4.1"}, {:phoenix, "~> 1.4.1"},
{:plug_cowboy, "~> 2.0"}, {:plug_cowboy, "~> 2.0"},
@ -71,6 +77,7 @@ defp deps do
{:calendar, "~> 0.17.4"}, {:calendar, "~> 0.17.4"},
{:cachex, "~> 3.0.2"}, {:cachex, "~> 3.0.2"},
{:httpoison, "~> 1.2.0"}, {:httpoison, "~> 1.2.0"},
{:poison, "~> 3.0", override: true},
{:tesla, "~> 1.2"}, {:tesla, "~> 1.2"},
{:jason, "~> 1.0"}, {:jason, "~> 1.0"},
{:mogrify, "~> 0.6.1"}, {:mogrify, "~> 0.6.1"},
@ -91,6 +98,7 @@ defp deps do
{:floki, "~> 0.20.0"}, {:floki, "~> 0.20.0"},
{:ex_syslogger, github: "slashmili/ex_syslogger", tag: "1.4.0"}, {:ex_syslogger, github: "slashmili/ex_syslogger", tag: "1.4.0"},
{:timex, "~> 3.5"}, {:timex, "~> 3.5"},
{:ueberauth, "~> 0.4"},
{:auto_linker, {:auto_linker,
git: "https://git.pleroma.social/pleroma/auto_linker.git", git: "https://git.pleroma.social/pleroma/auto_linker.git",
ref: "479dd343f4e563ff91215c8275f3b5c67e032850"}, ref: "479dd343f4e563ff91215c8275f3b5c67e032850"},
@ -103,7 +111,7 @@ defp deps do
{:prometheus_process_collector, "~> 1.4"}, {:prometheus_process_collector, "~> 1.4"},
{:recon, github: "ferd/recon", tag: "2.4.0"}, {:recon, github: "ferd/recon", tag: "2.4.0"},
{:quack, "~> 0.1.1"} {:quack, "~> 0.1.1"}
] ] ++ oauth_deps
end end
# Aliases are shortcuts or tasks specific to the current project. # Aliases are shortcuts or tasks specific to the current project.

View File

@ -5,7 +5,7 @@
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"cachex": {:hex, :cachex, "3.0.2", "1351caa4e26e29f7d7ec1d29b53d6013f0447630bbf382b4fb5d5bad0209f203", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"}, "cachex": {:hex, :cachex, "3.0.2", "1351caa4e26e29f7d7ec1d29b53d6013f0447630bbf382b4fb5d5bad0209f203", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"},
"calendar": {:hex, :calendar, "0.17.4", "22c5e8d98a4db9494396e5727108dffb820ee0d18fed4b0aa8ab76e4f5bc32f1", [:mix], [{:tzdata, "~> 0.5.8 or ~> 0.1.201603", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, "calendar": {:hex, :calendar, "0.17.4", "22c5e8d98a4db9494396e5727108dffb820ee0d18fed4b0aa8ab76e4f5bc32f1", [:mix], [{:tzdata, "~> 0.5.8 or ~> 0.1.201603", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
"certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"},
"comeonin": {:hex, :comeonin, "4.1.1", "c7304fc29b45b897b34142a91122bc72757bc0c295e9e824999d5179ffc08416", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"}, "comeonin": {:hex, :comeonin, "4.1.1", "c7304fc29b45b897b34142a91122bc72757bc0c295e9e824999d5179ffc08416", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
@ -28,7 +28,7 @@
"floki": {:hex, :floki, "0.20.4", "be42ac911fece24b4c72f3b5846774b6e61b83fe685c2fc9d62093277fb3bc86", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}, {:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "floki": {:hex, :floki, "0.20.4", "be42ac911fece24b4c72f3b5846774b6e61b83fe685c2fc9d62093277fb3bc86", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}, {:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"},
"gen_smtp": {:hex, :gen_smtp, "0.13.0", "11f08504c4bdd831dc520b8f84a1dce5ce624474a797394e7aafd3c29f5dcd25", [:rebar3], [], "hexpm"}, "gen_smtp": {:hex, :gen_smtp, "0.13.0", "11f08504c4bdd831dc520b8f84a1dce5ce624474a797394e7aafd3c29f5dcd25", [:rebar3], [], "hexpm"},
"gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"},
"hackney": {:hex, :hackney, "1.14.3", "b5f6f5dcc4f1fba340762738759209e21914516df6be440d85772542d4a5e412", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm"}, "html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm"},
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"},
"httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
@ -40,7 +40,7 @@
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
"mochiweb": {:hex, :mochiweb, "2.15.0", "e1daac474df07651e5d17cc1e642c4069c7850dc4508d3db7263a0651330aacc", [:rebar3], [], "hexpm"}, "mochiweb": {:hex, :mochiweb, "2.15.0", "e1daac474df07651e5d17cc1e642c4069c7850dc4508d3db7263a0651330aacc", [:rebar3], [], "hexpm"},
"mock": {:hex, :mock, "0.3.1", "994f00150f79a0ea50dc9d86134cd9ebd0d177ad60bd04d1e46336cdfdb98ff9", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, "mock": {:hex, :mock, "0.3.1", "994f00150f79a0ea50dc9d86134cd9ebd0d177ad60bd04d1e46336cdfdb98ff9", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"},
"mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm"}, "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm"},
@ -75,6 +75,7 @@
"timex": {:hex, :timex, "3.5.0", "b0a23167da02d0fe4f1a4e104d1f929a00d348502b52432c05de875d0b9cffa5", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, "timex": {:hex, :timex, "3.5.0", "b0a23167da02d0fe4f1a4e104d1f929a00d348502b52432c05de875d0b9cffa5", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
"trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"tzdata": {:hex, :tzdata, "0.5.17", "50793e3d85af49736701da1a040c415c97dc1caf6464112fd9bd18f425d3053b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "tzdata": {:hex, :tzdata, "0.5.17", "50793e3d85af49736701da1a040c415c97dc1caf6464112fd9bd18f425d3053b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"ueberauth": {:hex, :ueberauth, "0.6.1", "9e90d3337dddf38b1ca2753aca9b1e53d8a52b890191cdc55240247c89230412", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
"unsafe": {:hex, :unsafe, "1.0.0", "7c21742cd05380c7875546b023481d3a26f52df8e5dfedcb9f958f322baae305", [:mix], [], "hexpm"}, "unsafe": {:hex, :unsafe, "1.0.0", "7c21742cd05380c7875546b023481d3a26f52df8e5dfedcb9f958f322baae305", [:mix], [], "hexpm"},
"web_push_encryption": {:hex, :web_push_encryption, "0.2.1", "d42cecf73420d9dc0053ba3299cc8c8d6ff2be2487d67ca2a57265868e4d9a98", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "web_push_encryption": {:hex, :web_push_encryption, "0.2.1", "d42cecf73420d9dc0053ba3299cc8c8d6ff2be2487d67ca2a57265868e4d9a98", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},

View File

@ -0,0 +1,18 @@
defmodule Pleroma.Repo.Migrations.CreateRegistrations do
use Ecto.Migration
def change do
create table(:registrations, primary_key: false) do
add :id, :uuid, primary_key: true
add :user_id, references(:users, type: :uuid, on_delete: :delete_all)
add :provider, :string
add :uid, :string
add :info, :map, default: %{}
timestamps()
end
create unique_index(:registrations, [:provider, :uid])
create unique_index(:registrations, [:user_id, :provider, :uid])
end
end

View File

@ -0,0 +1,59 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.RegistrationTest do
use Pleroma.DataCase
import Pleroma.Factory
alias Pleroma.Registration
alias Pleroma.Repo
describe "generic changeset" do
test "requires :provider, :uid" do
registration = build(:registration, provider: nil, uid: nil)
cs = Registration.changeset(registration, %{})
refute cs.valid?
assert [
provider: {"can't be blank", [validation: :required]},
uid: {"can't be blank", [validation: :required]}
] == cs.errors
end
test "ensures uniqueness of [:provider, :uid]" do
registration = insert(:registration)
registration2 = build(:registration, provider: registration.provider, uid: registration.uid)
cs = Registration.changeset(registration2, %{})
assert cs.valid?
assert {:error,
%Ecto.Changeset{
errors: [
uid:
{"has already been taken",
[constraint: :unique, constraint_name: "registrations_provider_uid_index"]}
]
}} = Repo.insert(cs)
# Note: multiple :uid values per [:user_id, :provider] are intentionally allowed
cs2 = Registration.changeset(registration2, %{uid: "available.uid"})
assert cs2.valid?
assert {:ok, _} = Repo.insert(cs2)
cs3 = Registration.changeset(registration2, %{provider: "provider2"})
assert cs3.valid?
assert {:ok, _} = Repo.insert(cs3)
end
test "allows `nil` :user_id (user-unbound registration)" do
registration = build(:registration, user_id: nil)
cs = Registration.changeset(registration, %{})
assert cs.valid?
assert {:ok, _} = Repo.insert(cs)
end
end
end

View File

@ -275,4 +275,20 @@ def scheduled_activity_factory do
params: build(:note) |> Map.from_struct() |> Map.get(:data) params: build(:note) |> Map.from_struct() |> Map.get(:data)
} }
end end
def registration_factory do
user = insert(:user)
%Pleroma.Registration{
user: user,
provider: "twitter",
uid: "171799000",
info: %{
"name" => "John Doe",
"email" => "john@doe.com",
"nickname" => "johndoe",
"description" => "My bio"
}
}
end
end end

View File

@ -80,7 +80,6 @@ test "allows to force-follow another user" do
user = insert(:user) user = insert(:user)
follower = insert(:user) follower = insert(:user)
conn =
build_conn() build_conn()
|> assign(:user, admin) |> assign(:user, admin)
|> put_req_header("accept", "application/json") |> put_req_header("accept", "application/json")
@ -104,7 +103,6 @@ test "allows to force-unfollow another user" do
User.follow(follower, user) User.follow(follower, user)
conn =
build_conn() build_conn()
|> assign(:user, admin) |> assign(:user, admin)
|> put_req_header("accept", "application/json") |> put_req_header("accept", "application/json")

View File

@ -5,24 +5,330 @@
defmodule Pleroma.Web.OAuth.OAuthControllerTest do defmodule Pleroma.Web.OAuth.OAuthControllerTest do
use Pleroma.Web.ConnCase use Pleroma.Web.ConnCase
import Pleroma.Factory import Pleroma.Factory
import Mock
alias Pleroma.Registration
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token
describe "GET /oauth/authorize" do @session_opts [
setup do
session_opts = [
store: :cookie, store: :cookie,
key: "_test", key: "_test",
signing_salt: "cooldude" signing_salt: "cooldude"
] ]
describe "in OAuth consumer mode, " do
setup do
oauth_consumer_strategies_path = [:auth, :oauth_consumer_strategies]
oauth_consumer_strategies = Pleroma.Config.get(oauth_consumer_strategies_path)
Pleroma.Config.put(oauth_consumer_strategies_path, ~w(twitter facebook))
on_exit(fn ->
Pleroma.Config.put(oauth_consumer_strategies_path, oauth_consumer_strategies)
end)
[
app: insert(:oauth_app),
conn:
build_conn()
|> Plug.Session.call(Plug.Session.init(@session_opts))
|> fetch_session()
]
end
test "GET /oauth/authorize renders auth forms, including OAuth consumer form", %{
app: app,
conn: conn
} do
conn =
get(
conn,
"/oauth/authorize",
%{
"response_type" => "code",
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"scope" => "read"
}
)
assert response = html_response(conn, 200)
assert response =~ "Sign in with Twitter"
assert response =~ o_auth_path(conn, :prepare_request)
end
test "GET /oauth/prepare_request encodes parameters as `state` and redirects", %{
app: app,
conn: conn
} do
conn =
get(
conn,
"/oauth/prepare_request",
%{
"provider" => "twitter",
"scope" => "read follow",
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"state" => "a_state"
}
)
assert response = html_response(conn, 302)
redirect_query = URI.parse(redirected_to(conn)).query
assert %{"state" => state_param} = URI.decode_query(redirect_query)
assert {:ok, state_components} = Poison.decode(state_param)
expected_client_id = app.client_id
expected_redirect_uri = app.redirect_uris
assert %{
"scope" => "read follow",
"client_id" => ^expected_client_id,
"redirect_uri" => ^expected_redirect_uri,
"state" => "a_state"
} = state_components
end
test "with user-bound registration, GET /oauth/<provider>/callback redirects to `redirect_uri` with `code`",
%{app: app, conn: conn} do
registration = insert(:registration)
state_params = %{
"scope" => Enum.join(app.scopes, " "),
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"state" => ""
}
with_mock Pleroma.Web.Auth.Authenticator,
get_registration: fn _, _ -> {:ok, registration} end do
conn =
get(
conn,
"/oauth/twitter/callback",
%{
"oauth_token" => "G-5a3AAAAAAAwMH9AAABaektfSM",
"oauth_verifier" => "QZl8vUqNvXMTKpdmUnGejJxuHG75WWWs",
"provider" => "twitter",
"state" => Poison.encode!(state_params)
}
)
assert response = html_response(conn, 302)
assert redirected_to(conn) =~ ~r/#{app.redirect_uris}\?code=.+/
end
end
test "with user-unbound registration, GET /oauth/<provider>/callback renders registration_details page",
%{app: app, conn: conn} do
registration = insert(:registration, user: nil)
state_params = %{
"scope" => "read write",
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"state" => "a_state"
}
with_mock Pleroma.Web.Auth.Authenticator,
get_registration: fn _, _ -> {:ok, registration} end do
conn =
get(
conn,
"/oauth/twitter/callback",
%{
"oauth_token" => "G-5a3AAAAAAAwMH9AAABaektfSM",
"oauth_verifier" => "QZl8vUqNvXMTKpdmUnGejJxuHG75WWWs",
"provider" => "twitter",
"state" => Poison.encode!(state_params)
}
)
assert response = html_response(conn, 200)
assert response =~ ~r/name="op" type="submit" value="register"/
assert response =~ ~r/name="op" type="submit" value="connect"/
assert response =~ Registration.email(registration)
assert response =~ Registration.nickname(registration)
end
end
test "on authentication error, GET /oauth/<provider>/callback redirects to `redirect_uri`", %{
app: app,
conn: conn
} do
state_params = %{
"scope" => Enum.join(app.scopes, " "),
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"state" => ""
}
conn =
conn
|> assign(:ueberauth_failure, %{errors: [%{message: "(error description)"}]})
|> get(
"/oauth/twitter/callback",
%{
"oauth_token" => "G-5a3AAAAAAAwMH9AAABaektfSM",
"oauth_verifier" => "QZl8vUqNvXMTKpdmUnGejJxuHG75WWWs",
"provider" => "twitter",
"state" => Poison.encode!(state_params)
}
)
assert response = html_response(conn, 302)
assert redirected_to(conn) == app.redirect_uris
assert get_flash(conn, :error) == "Failed to authenticate: (error description)."
end
test "GET /oauth/registration_details renders registration details form", %{
app: app,
conn: conn
} do
conn =
get(
conn,
"/oauth/registration_details",
%{
"scopes" => app.scopes,
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"state" => "a_state",
"nickname" => nil,
"email" => "john@doe.com"
}
)
assert response = html_response(conn, 200)
assert response =~ ~r/name="op" type="submit" value="register"/
assert response =~ ~r/name="op" type="submit" value="connect"/
end
test "with valid params, POST /oauth/register?op=register redirects to `redirect_uri` with `code`",
%{
app: app,
conn: conn
} do
registration = insert(:registration, user: nil, info: %{"nickname" => nil, "email" => nil})
conn =
conn
|> put_session(:registration_id, registration.id)
|> post(
"/oauth/register",
%{
"op" => "register",
"scopes" => app.scopes,
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"state" => "a_state",
"nickname" => "availablenick",
"email" => "available@email.com"
}
)
assert response = html_response(conn, 302)
assert redirected_to(conn) =~ ~r/#{app.redirect_uris}\?code=.+/
end
test "with invalid params, POST /oauth/register?op=register renders registration_details page",
%{
app: app,
conn: conn
} do
another_user = insert(:user)
registration = insert(:registration, user: nil, info: %{"nickname" => nil, "email" => nil})
params = %{
"op" => "register",
"scopes" => app.scopes,
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"state" => "a_state",
"nickname" => "availablenickname",
"email" => "available@email.com"
}
for {bad_param, bad_param_value} <-
[{"nickname", another_user.nickname}, {"email", another_user.email}] do
bad_params = Map.put(params, bad_param, bad_param_value)
conn =
conn
|> put_session(:registration_id, registration.id)
|> post("/oauth/register", bad_params)
assert html_response(conn, 403) =~ ~r/name="op" type="submit" value="register"/
assert get_flash(conn, :error) == "Error: #{bad_param} has already been taken."
end
end
test "with valid params, POST /oauth/register?op=connect redirects to `redirect_uri` with `code`",
%{
app: app,
conn: conn
} do
user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("testpassword"))
registration = insert(:registration, user: nil)
conn =
conn
|> put_session(:registration_id, registration.id)
|> post(
"/oauth/register",
%{
"op" => "connect",
"scopes" => app.scopes,
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"state" => "a_state",
"auth_name" => user.nickname,
"password" => "testpassword"
}
)
assert response = html_response(conn, 302)
assert redirected_to(conn) =~ ~r/#{app.redirect_uris}\?code=.+/
end
test "with invalid params, POST /oauth/register?op=connect renders registration_details page",
%{
app: app,
conn: conn
} do
user = insert(:user)
registration = insert(:registration, user: nil)
params = %{
"op" => "connect",
"scopes" => app.scopes,
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"state" => "a_state",
"auth_name" => user.nickname,
"password" => "wrong password"
}
conn =
conn
|> put_session(:registration_id, registration.id)
|> post("/oauth/register", params)
assert html_response(conn, 401) =~ ~r/name="op" type="submit" value="connect"/
assert get_flash(conn, :error) == "Invalid Username/Password"
end
end
describe "GET /oauth/authorize" do
setup do
[ [
app: insert(:oauth_app, redirect_uris: "https://redirect.url"), app: insert(:oauth_app, redirect_uris: "https://redirect.url"),
conn: conn:
build_conn() build_conn()
|> Plug.Session.call(Plug.Session.init(session_opts)) |> Plug.Session.call(Plug.Session.init(@session_opts))
|> fetch_session() |> fetch_session()
] ]
end end