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",
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 :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: "http",
secure_cookie_flag: false,
debug_errors: true,
code_reloader: true,
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
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"
* `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
* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication
Authentication / authorization settings.
## 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`)
* `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
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
Application.delete_env(:pleroma, key)
end
def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], [])
def oauth_consumer_enabled?, do: oauth_consumer_strategies() != []
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.Notification
alias Pleroma.Object
alias Pleroma.Registration
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web
@ -55,6 +56,7 @@ defmodule Pleroma.User do
field(:bookmarks, {:array, :string}, default: [])
field(:last_refreshed_at, :naive_datetime_usec)
has_many(:notifications, Notification)
has_many(:registrations, Registration)
embeds_one(:info, Pleroma.User.Info)
timestamps()
@ -216,7 +218,7 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do
changeset =
struct
|> 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)
|> unique_constraint(:email)
|> unique_constraint(:nickname)
@ -227,6 +229,13 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do
|> validate_length(:name, min: 1, max: 100)
|> put_change(:info, info_change)
changeset =
if opts[:external] do
changeset
else
validate_required(changeset, [:email])
end
if changeset.valid? do
hashed = Pbkdf2.hashpwsalt(changeset.changes[:password])
ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]})
@ -505,11 +514,10 @@ def get_by_nickname(nickname) do
end
end
def get_by_email(email), do: Repo.get_by(User, email: email)
def get_by_nickname_or_email(nickname_or_email) do
case user = Repo.get_by(User, nickname: nickname_or_email) do
%User{} -> user
nil -> Repo.get_by(User, email: nickname_or_email)
end
get_by_nickname(nickname_or_email) || get_by_email(nickname_or_email)
end
def get_cached_user_info(user) do

View file

@ -3,6 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Auth.Authenticator do
alias Pleroma.Registration
alias Pleroma.User
def implementation do
@ -12,14 +13,33 @@ def implementation do
)
end
@callback get_user(Plug.Conn.t()) :: {:ok, User.t()} | {:error, any()}
def get_user(plug), do: implementation().get_user(plug)
@callback get_user(Plug.Conn.t(), Map.t()) :: {:ok, User.t()} | {:error, any()}
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()
def handle_error(plug, error), do: implementation().handle_error(plug, error)
@callback auth_template() :: String.t() | nil
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

View file

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

View file

@ -4,13 +4,15 @@
defmodule Pleroma.Web.Auth.PleromaAuthenticator do
alias Comeonin.Pbkdf2
alias Pleroma.Registration
alias Pleroma.Repo
alias Pleroma.User
@behaviour Pleroma.Web.Auth.Authenticator
def get_user(%Plug.Conn{} = conn) do
def get_user(%Plug.Conn{} = _conn, params) do
{name, password} =
case conn.params do
case params do
%{"authorization" => %{"name" => name, "password" => password}} ->
{name, password}
@ -27,9 +29,69 @@ def get_user(%Plug.Conn{} = conn) do
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
error
end
def auth_template, do: nil
def oauth_consumer_template, do: nil
end

View file

@ -51,11 +51,22 @@ defmodule Pleroma.Web.Endpoint do
plug(Plug.MethodOverride)
plug(Plug.Head)
secure_cookies = Pleroma.Config.get([__MODULE__, :secure_cookie_flag])
cookie_name =
if Application.get_env(:pleroma, Pleroma.Web.Endpoint) |> Keyword.get(:secure_cookie_flag),
if secure_cookies,
do: "__Host-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,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@ -65,9 +76,8 @@ defmodule Pleroma.Web.Endpoint do
key: cookie_name,
signing_salt: {Pleroma.Config, :get, [[__MODULE__, :signing_salt], "CqaoopA2"]},
http_only: true,
secure:
Application.get_env(:pleroma, Pleroma.Web.Endpoint) |> Keyword.get(:secure_cookie_flag),
extra: "SameSite=Strict"
secure: secure_cookies,
extra: same_site
)
# 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
alias Pleroma.Web.OAuth.OAuthController
# No user/password
def call(conn, _) do
def call(conn, {:register, :generic_error}) 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
|> put_status(:unauthorized)
|> put_flash(:error, "Invalid Username/Password")

View file

@ -5,6 +5,7 @@
defmodule Pleroma.Web.OAuth.OAuthController do
use Pleroma.Web, :controller
alias Pleroma.Registration
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.Auth.Authenticator
@ -15,6 +16,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2]
if Pleroma.Config.oauth_consumer_enabled?(), do: plug(Ueberauth)
plug(:fetch_session)
plug(:fetch_flash)
@ -57,68 +60,67 @@ defp do_authorize(conn, params) do
})
end
def create_authorization(conn, %{
"authorization" =>
%{
"client_id" => client_id,
"redirect_uri" => redirect_uri
} = auth_params
}) do
with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn)},
%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)},
{:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
redirect_uri = redirect_uri(conn, redirect_uri)
cond do
redirect_uri == "urn:ietf:wg:oauth:2.0:oob" ->
render(conn, "results.html", %{
auth: auth
})
true ->
connector = if String.contains?(redirect_uri, "?"), do: "&", else: "?"
url = "#{redirect_uri}#{connector}"
url_params = %{:code => auth.token}
url_params =
if auth_params["state"] do
Map.put(url_params, :state, auth_params["state"])
else
url_params
end
url = "#{url}#{Plug.Conn.Query.encode(url_params)}"
redirect(conn, external: url)
end
def create_authorization(
conn,
%{"authorization" => auth_params} = params,
opts \\ []
) do
with {:ok, auth} <- do_create_authorization(conn, params, opts[:user]) do
after_create_authorization(conn, auth, auth_params)
else
{scopes_issue, _} when scopes_issue in [:unsupported_scopes, :missing_scopes] ->
# Per https://github.com/tootsuite/mastodon/blob/
# 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39
conn
|> put_flash(:error, "This action is outside the authorized scopes")
|> put_status(:unauthorized)
|> authorize(auth_params)
{:auth_active, false} ->
# Per https://github.com/tootsuite/mastodon/blob/
# 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76
conn
|> put_flash(:error, "Your login is missing a confirmed e-mail address")
|> put_status(:forbidden)
|> authorize(auth_params)
error ->
Authenticator.handle_error(conn, error)
handle_create_authorization_error(conn, error, auth_params)
end
end
def after_create_authorization(conn, auth, %{"redirect_uri" => redirect_uri} = auth_params) do
redirect_uri = redirect_uri(conn, redirect_uri)
if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" do
render(conn, "results.html", %{
auth: auth
})
else
connector = if String.contains?(redirect_uri, "?"), do: "&", else: "?"
url = "#{redirect_uri}#{connector}"
url_params = %{:code => auth.token}
url_params =
if auth_params["state"] do
Map.put(url_params, :state, auth_params["state"])
else
url_params
end
url = "#{url}#{Plug.Conn.Query.encode(url_params)}"
redirect(conn, external: url)
end
end
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/
# 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39
conn
|> put_flash(:error, "This action is outside the authorized scopes")
|> put_status(:unauthorized)
|> authorize(auth_params)
end
defp handle_create_authorization_error(conn, {:auth_active, false}, auth_params) do
# Per https://github.com/tootsuite/mastodon/blob/
# 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76
conn
|> put_flash(:error, "Your login is missing a confirmed e-mail address")
|> put_status(:forbidden)
|> authorize(auth_params)
end
defp handle_create_authorization_error(conn, error, _auth_params) do
Authenticator.handle_error(conn, error)
end
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"]),
@ -149,7 +151,7 @@ def token_exchange(
conn,
%{"grant_type" => "password"} = params
) 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),
{:auth_active, true} <- {:auth_active, User.auth_active?(user)},
{:user_active, true} <- {:user_active, !user.info.deactivated},
@ -211,6 +213,184 @@ def token_revoke(conn, %{"token" => token} = params) do
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
# decoding it. Investigate sometime.
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, 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

View file

@ -5,6 +5,11 @@
defmodule Pleroma.Web.Router do
use Pleroma.Web, :router
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
end
pipeline :oauth do
plug(:fetch_session)
plug(Pleroma.Plugs.OAuthPlug)
@ -213,6 +218,16 @@ defmodule Pleroma.Web.Router do
post("/authorize", OAuthController, :create_authorization)
post("/token", OAuthController, :token_exchange)
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
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 %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<h2>OAuth Authorization</h2>
<%= form_for @conn, o_auth_path(@conn, :authorize), [as: "authorization"], fn f -> %>
<div class="input">
<%= label f, :name, "Name or email" %>
@ -14,22 +16,16 @@
<%= label f, :password, "Password" %>
<%= password_input f, :password %>
</div>
<div class="scopes-input">
<%= label f, :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 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>
<%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f, scope_param: "authorization[scope][]"}) %>
<%= 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, :state, value: @state%>
<%= hidden_input f, :state, value: @state %>
<%= submit "Authorize" %>
<% 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.
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"},
{:plug_cowboy, "~> 2.0"},
@ -71,6 +77,7 @@ defp deps do
{:calendar, "~> 0.17.4"},
{:cachex, "~> 3.0.2"},
{:httpoison, "~> 1.2.0"},
{:poison, "~> 3.0", override: true},
{:tesla, "~> 1.2"},
{:jason, "~> 1.0"},
{:mogrify, "~> 0.6.1"},
@ -91,6 +98,7 @@ defp deps do
{:floki, "~> 0.20.0"},
{:ex_syslogger, github: "slashmili/ex_syslogger", tag: "1.4.0"},
{:timex, "~> 3.5"},
{:ueberauth, "~> 0.4"},
{:auto_linker,
git: "https://git.pleroma.social/pleroma/auto_linker.git",
ref: "479dd343f4e563ff91215c8275f3b5c67e032850"},
@ -103,7 +111,7 @@ defp deps do
{:prometheus_process_collector, "~> 1.4"},
{:recon, github: "ferd/recon", tag: "2.4.0"},
{:quack, "~> 0.1.1"}
]
] ++ oauth_deps
end
# 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"},
"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"},
"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"},
"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"},
@ -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"},
"gen_smtp": {:hex, :gen_smtp, "0.13.0", "11f08504c4bdd831dc520b8f84a1dce5ce624474a797394e7aafd3c29f5dcd25", [:rebar3], [], "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_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"},
@ -40,7 +40,7 @@
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "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"},
"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"},
@ -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"},
"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"},
"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"},
"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"},

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

View file

@ -80,14 +80,13 @@ test "allows to force-follow another user" do
user = insert(:user)
follower = insert(:user)
conn =
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/user/follow", %{
"follower" => follower.nickname,
"followed" => user.nickname
})
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/user/follow", %{
"follower" => follower.nickname,
"followed" => user.nickname
})
user = User.get_by_id(user.id)
follower = User.get_by_id(follower.id)
@ -104,14 +103,13 @@ test "allows to force-unfollow another user" do
User.follow(follower, user)
conn =
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/user/unfollow", %{
"follower" => follower.nickname,
"followed" => user.nickname
})
build_conn()
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/user/unfollow", %{
"follower" => follower.nickname,
"followed" => user.nickname
})
user = User.get_by_id(user.id)
follower = User.get_by_id(follower.id)

View file

@ -5,24 +5,330 @@
defmodule Pleroma.Web.OAuth.OAuthControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
import Mock
alias Pleroma.Registration
alias Pleroma.Repo
alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.Token
@session_opts [
store: :cookie,
key: "_test",
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
session_opts = [
store: :cookie,
key: "_test",
signing_salt: "cooldude"
]
[
app: insert(:oauth_app, redirect_uris: "https://redirect.url"),
conn:
build_conn()
|> Plug.Session.call(Plug.Session.init(session_opts))
|> Plug.Session.call(Plug.Session.init(@session_opts))
|> fetch_session()
]
end