oauth consistency
This commit is contained in:
parent
7acf09beb8
commit
b5b4395e4a
18 changed files with 0 additions and 0 deletions
149
lib/pleroma/web/o_auth/app.ex
Normal file
149
lib/pleroma/web/o_auth/app.ex
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.App do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
alias Pleroma.Repo
|
||||
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
schema "apps" do
|
||||
field(:client_name, :string)
|
||||
field(:redirect_uris, :string)
|
||||
field(:scopes, {:array, :string}, default: [])
|
||||
field(:website, :string)
|
||||
field(:client_id, :string)
|
||||
field(:client_secret, :string)
|
||||
field(:trusted, :boolean, default: false)
|
||||
|
||||
has_many(:oauth_authorizations, Pleroma.Web.OAuth.Authorization, on_delete: :delete_all)
|
||||
has_many(:oauth_tokens, Pleroma.Web.OAuth.Token, on_delete: :delete_all)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@spec changeset(t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(struct, params) do
|
||||
cast(struct, params, [:client_name, :redirect_uris, :scopes, :website, :trusted])
|
||||
end
|
||||
|
||||
@spec register_changeset(t(), map()) :: Ecto.Changeset.t()
|
||||
def register_changeset(struct, params \\ %{}) do
|
||||
changeset =
|
||||
struct
|
||||
|> changeset(params)
|
||||
|> validate_required([:client_name, :redirect_uris, :scopes])
|
||||
|
||||
if changeset.valid? do
|
||||
changeset
|
||||
|> put_change(
|
||||
:client_id,
|
||||
:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
|
||||
)
|
||||
|> put_change(
|
||||
:client_secret,
|
||||
:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
|
||||
)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@spec create(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
|
||||
def create(params) do
|
||||
%__MODULE__{}
|
||||
|> register_changeset(params)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@spec update(pos_integer(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
|
||||
def update(id, params) do
|
||||
with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do
|
||||
app
|
||||
|> changeset(params)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets app by attrs or create new with attrs.
|
||||
And updates the scopes if need.
|
||||
"""
|
||||
@spec get_or_make(map(), list(String.t())) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
|
||||
def get_or_make(attrs, scopes) do
|
||||
with %__MODULE__{} = app <- Repo.get_by(__MODULE__, attrs) do
|
||||
update_scopes(app, scopes)
|
||||
else
|
||||
_e ->
|
||||
%__MODULE__{}
|
||||
|> register_changeset(Map.put(attrs, :scopes, scopes))
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
|
||||
defp update_scopes(%__MODULE__{} = app, []), do: {:ok, app}
|
||||
defp update_scopes(%__MODULE__{scopes: scopes} = app, scopes), do: {:ok, app}
|
||||
|
||||
defp update_scopes(%__MODULE__{} = app, scopes) do
|
||||
app
|
||||
|> change(%{scopes: scopes})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@spec search(map()) :: {:ok, [t()], non_neg_integer()}
|
||||
def search(params) do
|
||||
query = from(a in __MODULE__)
|
||||
|
||||
query =
|
||||
if params[:client_name] do
|
||||
from(a in query, where: a.client_name == ^params[:client_name])
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
if params[:client_id] do
|
||||
from(a in query, where: a.client_id == ^params[:client_id])
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
if Map.has_key?(params, :trusted) do
|
||||
from(a in query, where: a.trusted == ^params[:trusted])
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
from(u in query,
|
||||
limit: ^params[:page_size],
|
||||
offset: ^((params[:page] - 1) * params[:page_size])
|
||||
)
|
||||
|
||||
count = Repo.aggregate(__MODULE__, :count, :id)
|
||||
|
||||
{:ok, Repo.all(query), count}
|
||||
end
|
||||
|
||||
@spec destroy(pos_integer()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
|
||||
def destroy(id) do
|
||||
with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do
|
||||
Repo.delete(app)
|
||||
end
|
||||
end
|
||||
|
||||
@spec errors(Ecto.Changeset.t()) :: map()
|
||||
def errors(changeset) do
|
||||
Enum.reduce(changeset.errors, %{}, fn
|
||||
{:client_name, {error, _}}, acc ->
|
||||
Map.put(acc, :name, error)
|
||||
|
||||
{key, {error, _}}, acc ->
|
||||
Map.put(acc, key, error)
|
||||
end)
|
||||
end
|
||||
end
|
||||
95
lib/pleroma/web/o_auth/authorization.ex
Normal file
95
lib/pleroma/web/o_auth/authorization.ex
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.Authorization do
|
||||
use Ecto.Schema
|
||||
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User
|
||||
alias Pleroma.Web.OAuth.App
|
||||
alias Pleroma.Web.OAuth.Authorization
|
||||
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
schema "oauth_authorizations" do
|
||||
field(:token, :string)
|
||||
field(:scopes, {:array, :string}, default: [])
|
||||
field(:valid_until, :naive_datetime_usec)
|
||||
field(:used, :boolean, default: false)
|
||||
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
|
||||
belongs_to(:app, App)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@spec create_authorization(App.t(), User.t() | %{}, [String.t()] | nil) ::
|
||||
{:ok, Authorization.t()} | {:error, Changeset.t()}
|
||||
def create_authorization(%App{} = app, %User{} = user, scopes \\ nil) do
|
||||
%{
|
||||
scopes: scopes || app.scopes,
|
||||
user_id: user.id,
|
||||
app_id: app.id
|
||||
}
|
||||
|> create_changeset()
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@spec create_changeset(map()) :: Changeset.t()
|
||||
def create_changeset(attrs \\ %{}) do
|
||||
%Authorization{}
|
||||
|> cast(attrs, [:user_id, :app_id, :scopes, :valid_until])
|
||||
|> validate_required([:app_id, :scopes])
|
||||
|> add_token()
|
||||
|> add_lifetime()
|
||||
end
|
||||
|
||||
defp add_token(changeset) do
|
||||
token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
|
||||
put_change(changeset, :token, token)
|
||||
end
|
||||
|
||||
defp add_lifetime(changeset) do
|
||||
put_change(changeset, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10))
|
||||
end
|
||||
|
||||
@spec use_changeset(Authtorizatiton.t(), map()) :: Changeset.t()
|
||||
def use_changeset(%Authorization{} = auth, params) do
|
||||
auth
|
||||
|> cast(params, [:used])
|
||||
|> validate_required([:used])
|
||||
end
|
||||
|
||||
@spec use_token(Authorization.t()) ::
|
||||
{:ok, Authorization.t()} | {:error, Changeset.t()} | {:error, String.t()}
|
||||
def use_token(%Authorization{used: false, valid_until: valid_until} = auth) do
|
||||
if NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) < 0 do
|
||||
Repo.update(use_changeset(auth, %{used: true}))
|
||||
else
|
||||
{:error, "token expired"}
|
||||
end
|
||||
end
|
||||
|
||||
def use_token(%Authorization{used: true}), do: {:error, "already used"}
|
||||
|
||||
@spec delete_user_authorizations(User.t()) :: {integer(), any()}
|
||||
def delete_user_authorizations(%User{} = user) do
|
||||
user
|
||||
|> delete_by_user_query
|
||||
|> Repo.delete_all()
|
||||
end
|
||||
|
||||
def delete_by_user_query(%User{id: user_id}) do
|
||||
from(a in __MODULE__, where: a.user_id == ^user_id)
|
||||
end
|
||||
|
||||
@doc "gets auth for app by token"
|
||||
@spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found}
|
||||
def get_by_token(%App{id: app_id} = _app, token) do
|
||||
from(t in __MODULE__, where: t.app_id == ^app_id and t.token == ^token)
|
||||
|> Repo.find_resource()
|
||||
end
|
||||
end
|
||||
32
lib/pleroma/web/o_auth/fallback_controller.ex
Normal file
32
lib/pleroma/web/o_auth/fallback_controller.ex
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.FallbackController do
|
||||
use Pleroma.Web, :controller
|
||||
alias Pleroma.Web.OAuth.OAuthController
|
||||
|
||||
def call(conn, {:register, :generic_error}) do
|
||||
conn
|
||||
|> put_status(:internal_server_error)
|
||||
|> put_flash(
|
||||
:error,
|
||||
dgettext("errors", "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, dgettext("errors", "Invalid Username/Password"))
|
||||
|> OAuthController.registration_details(conn.params)
|
||||
end
|
||||
|
||||
def call(conn, _error) do
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> put_flash(:error, dgettext("errors", "Invalid Username/Password"))
|
||||
|> OAuthController.authorize(conn.params)
|
||||
end
|
||||
end
|
||||
98
lib/pleroma/web/o_auth/mfa_controller.ex
Normal file
98
lib/pleroma/web/o_auth/mfa_controller.ex
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.MFAController do
|
||||
@moduledoc """
|
||||
The model represents api to use Multi Factor authentications.
|
||||
"""
|
||||
|
||||
use Pleroma.Web, :controller
|
||||
|
||||
alias Pleroma.MFA
|
||||
alias Pleroma.Web.Auth.TOTPAuthenticator
|
||||
alias Pleroma.Web.OAuth.MFAView, as: View
|
||||
alias Pleroma.Web.OAuth.OAuthController
|
||||
alias Pleroma.Web.OAuth.OAuthView
|
||||
alias Pleroma.Web.OAuth.Token
|
||||
|
||||
plug(:fetch_session when action in [:show, :verify])
|
||||
plug(:fetch_flash when action in [:show, :verify])
|
||||
|
||||
@doc """
|
||||
Display form to input mfa code or recovery code.
|
||||
"""
|
||||
def show(conn, %{"mfa_token" => mfa_token} = params) do
|
||||
template = Map.get(params, "challenge_type", "totp")
|
||||
|
||||
conn
|
||||
|> put_view(View)
|
||||
|> render("#{template}.html", %{
|
||||
mfa_token: mfa_token,
|
||||
redirect_uri: params["redirect_uri"],
|
||||
state: params["state"]
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verification code and continue authorization.
|
||||
"""
|
||||
def verify(conn, %{"mfa" => %{"mfa_token" => mfa_token} = mfa_params} = _) do
|
||||
with {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token),
|
||||
{:ok, _} <- validates_challenge(user, mfa_params) do
|
||||
conn
|
||||
|> OAuthController.after_create_authorization(auth, %{
|
||||
"authorization" => %{
|
||||
"redirect_uri" => mfa_params["redirect_uri"],
|
||||
"state" => mfa_params["state"]
|
||||
}
|
||||
})
|
||||
else
|
||||
_ ->
|
||||
conn
|
||||
|> put_flash(:error, "Two-factor authentication failed.")
|
||||
|> put_status(:unauthorized)
|
||||
|> show(mfa_params)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verification second step of MFA (or recovery) and returns access token.
|
||||
|
||||
## Endpoint
|
||||
POST /oauth/mfa/challenge
|
||||
|
||||
params:
|
||||
`client_id`
|
||||
`client_secret`
|
||||
`mfa_token` - access token to check second step of mfa
|
||||
`challenge_type` - 'totp' or 'recovery'
|
||||
`code`
|
||||
|
||||
"""
|
||||
def challenge(conn, %{"mfa_token" => mfa_token} = params) do
|
||||
with {:ok, app} <- Token.Utils.fetch_app(conn),
|
||||
{:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token),
|
||||
{:ok, _} <- validates_challenge(user, params),
|
||||
{:ok, token} <- Token.exchange_token(app, auth) do
|
||||
json(conn, OAuthView.render("token.json", %{user: user, token: token}))
|
||||
else
|
||||
_error ->
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> json(%{error: "Invalid code"})
|
||||
end
|
||||
end
|
||||
|
||||
# Verify TOTP Code
|
||||
defp validates_challenge(user, %{"challenge_type" => "totp", "code" => code} = _) do
|
||||
TOTPAuthenticator.verify(code, user)
|
||||
end
|
||||
|
||||
# Verify Recovery Code
|
||||
defp validates_challenge(user, %{"challenge_type" => "recovery", "code" => code} = _) do
|
||||
TOTPAuthenticator.verify_recovery_code(user, code)
|
||||
end
|
||||
|
||||
defp validates_challenge(_, _), do: {:error, :unsupported_challenge_type}
|
||||
end
|
||||
17
lib/pleroma/web/o_auth/mfa_view.ex
Normal file
17
lib/pleroma/web/o_auth/mfa_view.ex
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.MFAView do
|
||||
use Pleroma.Web, :view
|
||||
import Phoenix.HTML.Form
|
||||
alias Pleroma.MFA
|
||||
|
||||
def render("mfa_response.json", %{token: token, user: user}) do
|
||||
%{
|
||||
error: "mfa_required",
|
||||
mfa_token: token.token,
|
||||
supported_challenge_types: MFA.supported_methods(user)
|
||||
}
|
||||
end
|
||||
end
|
||||
610
lib/pleroma/web/o_auth/o_auth_controller.ex
Normal file
610
lib/pleroma/web/o_auth/o_auth_controller.ex
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.OAuthController do
|
||||
use Pleroma.Web, :controller
|
||||
|
||||
alias Pleroma.Helpers.UriHelper
|
||||
alias Pleroma.Maps
|
||||
alias Pleroma.MFA
|
||||
alias Pleroma.Plugs.RateLimiter
|
||||
alias Pleroma.Registration
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User
|
||||
alias Pleroma.Web.Auth.Authenticator
|
||||
alias Pleroma.Web.ControllerHelper
|
||||
alias Pleroma.Web.OAuth.App
|
||||
alias Pleroma.Web.OAuth.Authorization
|
||||
alias Pleroma.Web.OAuth.MFAController
|
||||
alias Pleroma.Web.OAuth.MFAView
|
||||
alias Pleroma.Web.OAuth.OAuthView
|
||||
alias Pleroma.Web.OAuth.Scopes
|
||||
alias Pleroma.Web.OAuth.Token
|
||||
alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken
|
||||
alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken
|
||||
|
||||
require Logger
|
||||
|
||||
if Pleroma.Config.oauth_consumer_enabled?(), do: plug(Ueberauth)
|
||||
|
||||
plug(:fetch_session)
|
||||
plug(:fetch_flash)
|
||||
|
||||
plug(:skip_plug, [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug])
|
||||
|
||||
plug(RateLimiter, [name: :authentication] when action == :create_authorization)
|
||||
|
||||
action_fallback(Pleroma.Web.OAuth.FallbackController)
|
||||
|
||||
@oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob"
|
||||
|
||||
# Note: this definition is only called from error-handling methods with `conn.params` as 2nd arg
|
||||
def authorize(%Plug.Conn{} = conn, %{"authorization" => _} = params) do
|
||||
{auth_attrs, params} = Map.pop(params, "authorization")
|
||||
authorize(conn, Map.merge(params, auth_attrs))
|
||||
end
|
||||
|
||||
def authorize(%Plug.Conn{assigns: %{token: %Token{}}} = conn, %{"force_login" => _} = params) do
|
||||
if ControllerHelper.truthy_param?(params["force_login"]) do
|
||||
do_authorize(conn, params)
|
||||
else
|
||||
handle_existing_authorization(conn, params)
|
||||
end
|
||||
end
|
||||
|
||||
# Note: the token is set in oauth_plug, but the token and client do not always go together.
|
||||
# For example, MastodonFE's token is set if user requests with another client,
|
||||
# after user already authorized to MastodonFE.
|
||||
# So we have to check client and token.
|
||||
def authorize(
|
||||
%Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
|
||||
%{"client_id" => client_id} = params
|
||||
) do
|
||||
with %Token{} = t <- Repo.get_by(Token, token: token.token) |> Repo.preload(:app),
|
||||
^client_id <- t.app.client_id do
|
||||
handle_existing_authorization(conn, params)
|
||||
else
|
||||
_ -> do_authorize(conn, params)
|
||||
end
|
||||
end
|
||||
|
||||
def authorize(%Plug.Conn{} = conn, params), do: do_authorize(conn, params)
|
||||
|
||||
defp do_authorize(%Plug.Conn{} = conn, params) do
|
||||
app = Repo.get_by(App, client_id: params["client_id"])
|
||||
available_scopes = (app && app.scopes) || []
|
||||
scopes = Scopes.fetch_scopes(params, available_scopes)
|
||||
|
||||
scopes =
|
||||
if scopes == [] do
|
||||
available_scopes
|
||||
else
|
||||
scopes
|
||||
end
|
||||
|
||||
# Note: `params` might differ from `conn.params`; use `@params` not `@conn.params` in template
|
||||
render(conn, Authenticator.auth_template(), %{
|
||||
response_type: params["response_type"],
|
||||
client_id: params["client_id"],
|
||||
available_scopes: available_scopes,
|
||||
scopes: scopes,
|
||||
redirect_uri: params["redirect_uri"],
|
||||
state: params["state"],
|
||||
params: params
|
||||
})
|
||||
end
|
||||
|
||||
defp handle_existing_authorization(
|
||||
%Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
|
||||
%{"redirect_uri" => @oob_token_redirect_uri}
|
||||
) do
|
||||
render(conn, "oob_token_exists.html", %{token: token})
|
||||
end
|
||||
|
||||
defp handle_existing_authorization(
|
||||
%Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
|
||||
%{} = params
|
||||
) do
|
||||
app = Repo.preload(token, :app).app
|
||||
|
||||
redirect_uri =
|
||||
if is_binary(params["redirect_uri"]) do
|
||||
params["redirect_uri"]
|
||||
else
|
||||
default_redirect_uri(app)
|
||||
end
|
||||
|
||||
if redirect_uri in String.split(app.redirect_uris) do
|
||||
redirect_uri = redirect_uri(conn, redirect_uri)
|
||||
url_params = %{access_token: token.token}
|
||||
url_params = Maps.put_if_present(url_params, :state, params["state"])
|
||||
url = UriHelper.modify_uri_params(redirect_uri, url_params)
|
||||
redirect(conn, external: url)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, dgettext("errors", "Unlisted redirect_uri."))
|
||||
|> redirect(external: redirect_uri(conn, redirect_uri))
|
||||
end
|
||||
end
|
||||
|
||||
def create_authorization(
|
||||
%Plug.Conn{} = conn,
|
||||
%{"authorization" => _} = params,
|
||||
opts \\ []
|
||||
) do
|
||||
with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]),
|
||||
{:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do
|
||||
after_create_authorization(conn, auth, params)
|
||||
else
|
||||
error ->
|
||||
handle_create_authorization_error(conn, error, params)
|
||||
end
|
||||
end
|
||||
|
||||
def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{
|
||||
"authorization" => %{"redirect_uri" => @oob_token_redirect_uri}
|
||||
}) do
|
||||
# Enforcing the view to reuse the template when calling from other controllers
|
||||
conn
|
||||
|> put_view(OAuthView)
|
||||
|> render("oob_authorization_created.html", %{auth: auth})
|
||||
end
|
||||
|
||||
def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{
|
||||
"authorization" => %{"redirect_uri" => redirect_uri} = auth_attrs
|
||||
}) do
|
||||
app = Repo.preload(auth, :app).app
|
||||
|
||||
# An extra safety measure before we redirect (also done in `do_create_authorization/2`)
|
||||
if redirect_uri in String.split(app.redirect_uris) do
|
||||
redirect_uri = redirect_uri(conn, redirect_uri)
|
||||
url_params = %{code: auth.token}
|
||||
url_params = Maps.put_if_present(url_params, :state, auth_attrs["state"])
|
||||
url = UriHelper.modify_uri_params(redirect_uri, url_params)
|
||||
redirect(conn, external: url)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, dgettext("errors", "Unlisted redirect_uri."))
|
||||
|> redirect(external: redirect_uri(conn, redirect_uri))
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_create_authorization_error(
|
||||
%Plug.Conn{} = conn,
|
||||
{:error, scopes_issue},
|
||||
%{"authorization" => _} = 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, dgettext("errors", "This action is outside the authorized scopes"))
|
||||
|> put_status(:unauthorized)
|
||||
|> authorize(params)
|
||||
end
|
||||
|
||||
defp handle_create_authorization_error(
|
||||
%Plug.Conn{} = conn,
|
||||
{:account_status, :confirmation_pending},
|
||||
%{"authorization" => _} = params
|
||||
) do
|
||||
conn
|
||||
|> put_flash(:error, dgettext("errors", "Your login is missing a confirmed e-mail address"))
|
||||
|> put_status(:forbidden)
|
||||
|> authorize(params)
|
||||
end
|
||||
|
||||
defp handle_create_authorization_error(
|
||||
%Plug.Conn{} = conn,
|
||||
{:mfa_required, user, auth, _},
|
||||
params
|
||||
) do
|
||||
{:ok, token} = MFA.Token.create(user, auth)
|
||||
|
||||
data = %{
|
||||
"mfa_token" => token.token,
|
||||
"redirect_uri" => params["authorization"]["redirect_uri"],
|
||||
"state" => params["authorization"]["state"]
|
||||
}
|
||||
|
||||
MFAController.show(conn, data)
|
||||
end
|
||||
|
||||
defp handle_create_authorization_error(
|
||||
%Plug.Conn{} = conn,
|
||||
{:account_status, :password_reset_pending},
|
||||
%{"authorization" => _} = params
|
||||
) do
|
||||
conn
|
||||
|> put_flash(:error, dgettext("errors", "Password reset is required"))
|
||||
|> put_status(:forbidden)
|
||||
|> authorize(params)
|
||||
end
|
||||
|
||||
defp handle_create_authorization_error(
|
||||
%Plug.Conn{} = conn,
|
||||
{:account_status, :deactivated},
|
||||
%{"authorization" => _} = params
|
||||
) do
|
||||
conn
|
||||
|> put_flash(:error, dgettext("errors", "Your account is currently disabled"))
|
||||
|> put_status(:forbidden)
|
||||
|> authorize(params)
|
||||
end
|
||||
|
||||
defp handle_create_authorization_error(%Plug.Conn{} = conn, error, %{"authorization" => _}) do
|
||||
Authenticator.handle_error(conn, error)
|
||||
end
|
||||
|
||||
@doc "Renew access_token with refresh_token"
|
||||
def token_exchange(
|
||||
%Plug.Conn{} = conn,
|
||||
%{"grant_type" => "refresh_token", "refresh_token" => token} = _params
|
||||
) do
|
||||
with {:ok, app} <- Token.Utils.fetch_app(conn),
|
||||
{:ok, %{user: user} = token} <- Token.get_by_refresh_token(app, token),
|
||||
{:ok, token} <- RefreshToken.grant(token) do
|
||||
json(conn, OAuthView.render("token.json", %{user: user, token: token}))
|
||||
else
|
||||
_error -> render_invalid_credentials_error(conn)
|
||||
end
|
||||
end
|
||||
|
||||
def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"} = params) do
|
||||
with {:ok, app} <- Token.Utils.fetch_app(conn),
|
||||
fixed_token = Token.Utils.fix_padding(params["code"]),
|
||||
{:ok, auth} <- Authorization.get_by_token(app, fixed_token),
|
||||
%User{} = user <- User.get_cached_by_id(auth.user_id),
|
||||
{:ok, token} <- Token.exchange_token(app, auth) do
|
||||
json(conn, OAuthView.render("token.json", %{user: user, token: token}))
|
||||
else
|
||||
error ->
|
||||
handle_token_exchange_error(conn, error)
|
||||
end
|
||||
end
|
||||
|
||||
def token_exchange(
|
||||
%Plug.Conn{} = conn,
|
||||
%{"grant_type" => "password"} = params
|
||||
) do
|
||||
with {:ok, %User{} = user} <- Authenticator.get_user(conn),
|
||||
{:ok, app} <- Token.Utils.fetch_app(conn),
|
||||
requested_scopes <- Scopes.fetch_scopes(params, app.scopes),
|
||||
{:ok, token} <- login(user, app, requested_scopes) do
|
||||
json(conn, OAuthView.render("token.json", %{user: user, token: token}))
|
||||
else
|
||||
error ->
|
||||
handle_token_exchange_error(conn, error)
|
||||
end
|
||||
end
|
||||
|
||||
def token_exchange(
|
||||
%Plug.Conn{} = conn,
|
||||
%{"grant_type" => "password", "name" => name, "password" => _password} = params
|
||||
) do
|
||||
params =
|
||||
params
|
||||
|> Map.delete("name")
|
||||
|> Map.put("username", name)
|
||||
|
||||
token_exchange(conn, params)
|
||||
end
|
||||
|
||||
def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"} = _params) do
|
||||
with {:ok, app} <- Token.Utils.fetch_app(conn),
|
||||
{:ok, auth} <- Authorization.create_authorization(app, %User{}),
|
||||
{:ok, token} <- Token.exchange_token(app, auth) do
|
||||
json(conn, OAuthView.render("token.json", %{token: token}))
|
||||
else
|
||||
_error ->
|
||||
handle_token_exchange_error(conn, :invalid_credentails)
|
||||
end
|
||||
end
|
||||
|
||||
# Bad request
|
||||
def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
|
||||
|
||||
defp handle_token_exchange_error(%Plug.Conn{} = conn, {:mfa_required, user, auth, _}) do
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(build_and_response_mfa_token(user, auth))
|
||||
end
|
||||
|
||||
defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :deactivated}) do
|
||||
render_error(
|
||||
conn,
|
||||
:forbidden,
|
||||
"Your account is currently disabled",
|
||||
%{},
|
||||
"account_is_disabled"
|
||||
)
|
||||
end
|
||||
|
||||
defp handle_token_exchange_error(
|
||||
%Plug.Conn{} = conn,
|
||||
{:account_status, :password_reset_pending}
|
||||
) do
|
||||
render_error(
|
||||
conn,
|
||||
:forbidden,
|
||||
"Password reset is required",
|
||||
%{},
|
||||
"password_reset_required"
|
||||
)
|
||||
end
|
||||
|
||||
defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :confirmation_pending}) do
|
||||
render_error(
|
||||
conn,
|
||||
:forbidden,
|
||||
"Your login is missing a confirmed e-mail address",
|
||||
%{},
|
||||
"missing_confirmed_email"
|
||||
)
|
||||
end
|
||||
|
||||
defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :approval_pending}) do
|
||||
render_error(
|
||||
conn,
|
||||
:forbidden,
|
||||
"Your account is awaiting approval.",
|
||||
%{},
|
||||
"awaiting_approval"
|
||||
)
|
||||
end
|
||||
|
||||
defp handle_token_exchange_error(%Plug.Conn{} = conn, _error) do
|
||||
render_invalid_credentials_error(conn)
|
||||
end
|
||||
|
||||
def token_revoke(%Plug.Conn{} = conn, %{"token" => _token} = params) do
|
||||
with {:ok, app} <- Token.Utils.fetch_app(conn),
|
||||
{:ok, _token} <- RevokeToken.revoke(app, params) do
|
||||
json(conn, %{})
|
||||
else
|
||||
_error ->
|
||||
# RFC 7009: invalid tokens [in the request] do not cause an error response
|
||||
json(conn, %{})
|
||||
end
|
||||
end
|
||||
|
||||
def token_revoke(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
|
||||
|
||||
# Response for bad request
|
||||
defp bad_request(%Plug.Conn{} = conn, _) do
|
||||
render_error(conn, :internal_server_error, "Bad request")
|
||||
end
|
||||
|
||||
@doc "Prepares OAuth request to provider for Ueberauth"
|
||||
def prepare_request(%Plug.Conn{} = conn, %{
|
||||
"provider" => provider,
|
||||
"authorization" => auth_attrs
|
||||
}) do
|
||||
scope =
|
||||
auth_attrs
|
||||
|> Scopes.fetch_scopes([])
|
||||
|> Scopes.to_string()
|
||||
|
||||
state =
|
||||
auth_attrs
|
||||
|> Map.delete("scopes")
|
||||
|> Map.put("scope", scope)
|
||||
|> Jason.encode!()
|
||||
|
||||
params =
|
||||
auth_attrs
|
||||
|> 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(%Plug.Conn{} = conn, params) do
|
||||
message =
|
||||
if params["provider"] do
|
||||
dgettext("errors", "Unsupported OAuth provider: %{provider}.",
|
||||
provider: params["provider"]
|
||||
)
|
||||
else
|
||||
dgettext("errors", "Bad OAuth request.")
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
|
||||
def callback(%Plug.Conn{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,
|
||||
dgettext("errors", "Failed to authenticate: %{message}.", message: message)
|
||||
)
|
||||
|> redirect(external: redirect_uri(conn, params["redirect_uri"]))
|
||||
end
|
||||
|
||||
def callback(%Plug.Conn{} = conn, params) do
|
||||
params = callback_params(params)
|
||||
|
||||
with {:ok, registration} <- Authenticator.get_registration(conn) do
|
||||
auth_attrs = Map.take(params, ~w(client_id redirect_uri scope scopes state))
|
||||
|
||||
case Repo.get_assoc(registration, :user) do
|
||||
{:ok, user} ->
|
||||
create_authorization(conn, %{"authorization" => auth_attrs}, user: user)
|
||||
|
||||
_ ->
|
||||
registration_params =
|
||||
Map.merge(auth_attrs, %{
|
||||
"nickname" => Registration.nickname(registration),
|
||||
"email" => Registration.email(registration)
|
||||
})
|
||||
|
||||
conn
|
||||
|> put_session_registration_id(registration.id)
|
||||
|> registration_details(%{"authorization" => registration_params})
|
||||
end
|
||||
else
|
||||
error ->
|
||||
Logger.debug(inspect(["OAUTH_ERROR", error, conn.assigns]))
|
||||
|
||||
conn
|
||||
|> put_flash(:error, dgettext("errors", "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, Jason.decode!(state))
|
||||
end
|
||||
|
||||
def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs}) do
|
||||
render(conn, "register.html", %{
|
||||
client_id: auth_attrs["client_id"],
|
||||
redirect_uri: auth_attrs["redirect_uri"],
|
||||
state: auth_attrs["state"],
|
||||
scopes: Scopes.fetch_scopes(auth_attrs, []),
|
||||
nickname: auth_attrs["nickname"],
|
||||
email: auth_attrs["email"]
|
||||
})
|
||||
end
|
||||
|
||||
def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do
|
||||
with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
|
||||
%Registration{} = registration <- Repo.get(Registration, registration_id),
|
||||
{_, {:ok, auth, _user}} <-
|
||||
{:create_authorization, do_create_authorization(conn, 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, params)
|
||||
else
|
||||
{:create_authorization, error} ->
|
||||
{:register, handle_create_authorization_error(conn, error, params)}
|
||||
|
||||
_ ->
|
||||
{:register, :generic_error}
|
||||
end
|
||||
end
|
||||
|
||||
def register(%Plug.Conn{} = conn, %{"authorization" => _, "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, registration) do
|
||||
conn
|
||||
|> put_session_registration_id(nil)
|
||||
|> create_authorization(
|
||||
params,
|
||||
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, auth_attrs, user \\ nil)
|
||||
|
||||
defp do_create_authorization(
|
||||
%Plug.Conn{} = conn,
|
||||
%{
|
||||
"authorization" =>
|
||||
%{
|
||||
"client_id" => client_id,
|
||||
"redirect_uri" => redirect_uri
|
||||
} = auth_attrs
|
||||
},
|
||||
user
|
||||
) do
|
||||
with {_, {:ok, %User{} = user}} <-
|
||||
{:get_user, (user && {:ok, user}) || Authenticator.get_user(conn)},
|
||||
%App{} = app <- Repo.get_by(App, client_id: client_id),
|
||||
true <- redirect_uri in String.split(app.redirect_uris),
|
||||
requested_scopes <- Scopes.fetch_scopes(auth_attrs, app.scopes),
|
||||
{:ok, auth} <- do_create_authorization(user, app, requested_scopes) do
|
||||
{:ok, auth, user}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_create_authorization(%User{} = user, %App{} = app, requested_scopes)
|
||||
when is_list(requested_scopes) do
|
||||
with {:account_status, :active} <- {:account_status, User.account_status(user)},
|
||||
{:ok, scopes} <- validate_scopes(app, requested_scopes),
|
||||
{:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
|
||||
{:ok, auth}
|
||||
end
|
||||
end
|
||||
|
||||
# Note: intended to be a private function but opened for AccountController that logs in on signup
|
||||
@doc "If checks pass, creates authorization and token for given user, app and requested scopes."
|
||||
def login(%User{} = user, %App{} = app, requested_scopes) when is_list(requested_scopes) do
|
||||
with {:ok, auth} <- do_create_authorization(user, app, requested_scopes),
|
||||
{:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)},
|
||||
{:ok, token} <- Token.exchange_token(app, auth) do
|
||||
{:ok, token}
|
||||
end
|
||||
end
|
||||
|
||||
# Special case: Local MastodonFE
|
||||
defp redirect_uri(%Plug.Conn{} = conn, "."), do: auth_url(conn, :login)
|
||||
|
||||
defp redirect_uri(%Plug.Conn{}, redirect_uri), do: redirect_uri
|
||||
|
||||
defp get_session_registration_id(%Plug.Conn{} = conn), do: get_session(conn, :registration_id)
|
||||
|
||||
defp put_session_registration_id(%Plug.Conn{} = conn, registration_id),
|
||||
do: put_session(conn, :registration_id, registration_id)
|
||||
|
||||
defp build_and_response_mfa_token(user, auth) do
|
||||
with {:ok, token} <- MFA.Token.create(user, auth) do
|
||||
MFAView.render("mfa_response.json", %{token: token, user: user})
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_scopes(App.t(), map() | list()) ::
|
||||
{:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
|
||||
defp validate_scopes(%App{} = app, params) when is_map(params) do
|
||||
requested_scopes = Scopes.fetch_scopes(params, app.scopes)
|
||||
validate_scopes(app, requested_scopes)
|
||||
end
|
||||
|
||||
defp validate_scopes(%App{} = app, requested_scopes) when is_list(requested_scopes) do
|
||||
Scopes.validate(requested_scopes, app.scopes)
|
||||
end
|
||||
|
||||
def default_redirect_uri(%App{} = app) do
|
||||
app.redirect_uris
|
||||
|> String.split()
|
||||
|> Enum.at(0)
|
||||
end
|
||||
|
||||
defp render_invalid_credentials_error(conn) do
|
||||
render_error(conn, :bad_request, "Invalid credentials")
|
||||
end
|
||||
end
|
||||
30
lib/pleroma/web/o_auth/o_auth_view.ex
Normal file
30
lib/pleroma/web/o_auth/o_auth_view.ex
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.OAuthView do
|
||||
use Pleroma.Web, :view
|
||||
import Phoenix.HTML.Form
|
||||
|
||||
alias Pleroma.Web.OAuth.Token.Utils
|
||||
|
||||
def render("token.json", %{token: token} = opts) do
|
||||
response = %{
|
||||
token_type: "Bearer",
|
||||
access_token: token.token,
|
||||
refresh_token: token.refresh_token,
|
||||
expires_in: expires_in(),
|
||||
scope: Enum.join(token.scopes, " "),
|
||||
created_at: Utils.format_created_at(token)
|
||||
}
|
||||
|
||||
if user = opts[:user] do
|
||||
response
|
||||
|> Map.put(:me, user.ap_id)
|
||||
else
|
||||
response
|
||||
end
|
||||
end
|
||||
|
||||
defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600)
|
||||
end
|
||||
76
lib/pleroma/web/o_auth/scopes.ex
Normal file
76
lib/pleroma/web/o_auth/scopes.ex
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.Scopes do
|
||||
@moduledoc """
|
||||
Functions for dealing with scopes.
|
||||
"""
|
||||
|
||||
alias Pleroma.Plugs.OAuthScopesPlug
|
||||
|
||||
@doc """
|
||||
Fetch scopes from request params.
|
||||
|
||||
Note: `scopes` is used by Mastodon — supporting it but sticking to
|
||||
OAuth's standard `scope` wherever we control it
|
||||
"""
|
||||
@spec fetch_scopes(map() | struct(), list()) :: list()
|
||||
|
||||
def fetch_scopes(params, default) do
|
||||
parse_scopes(params["scope"] || params["scopes"] || params[:scopes], default)
|
||||
end
|
||||
|
||||
def parse_scopes(scopes, _default) when is_list(scopes) do
|
||||
Enum.filter(scopes, &(&1 not in [nil, ""]))
|
||||
end
|
||||
|
||||
def parse_scopes(scopes, default) when is_binary(scopes) do
|
||||
scopes
|
||||
|> to_list
|
||||
|> parse_scopes(default)
|
||||
end
|
||||
|
||||
def parse_scopes(_, default) do
|
||||
default
|
||||
end
|
||||
|
||||
@doc """
|
||||
Convert scopes string to list
|
||||
"""
|
||||
@spec to_list(binary()) :: [binary()]
|
||||
def to_list(nil), do: []
|
||||
|
||||
def to_list(str) do
|
||||
str
|
||||
|> String.trim()
|
||||
|> String.split(~r/[\s,]+/)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Convert scopes list to string
|
||||
"""
|
||||
@spec to_string(list()) :: binary()
|
||||
def to_string(scopes), do: Enum.join(scopes, " ")
|
||||
|
||||
@doc """
|
||||
Validates scopes.
|
||||
"""
|
||||
@spec validate(list() | nil, list()) ::
|
||||
{:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
|
||||
def validate(blank_scopes, _app_scopes) when blank_scopes in [nil, []],
|
||||
do: {:error, :missing_scopes}
|
||||
|
||||
def validate(scopes, app_scopes) do
|
||||
case OAuthScopesPlug.filter_descendants(scopes, app_scopes) do
|
||||
^scopes -> {:ok, scopes}
|
||||
_ -> {:error, :unsupported_scopes}
|
||||
end
|
||||
end
|
||||
|
||||
def contains_admin_scopes?(scopes) do
|
||||
scopes
|
||||
|> OAuthScopesPlug.filter_descendants(["admin"])
|
||||
|> Enum.any?()
|
||||
end
|
||||
end
|
||||
135
lib/pleroma/web/o_auth/token.ex
Normal file
135
lib/pleroma/web/o_auth/token.ex
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.Token do
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User
|
||||
alias Pleroma.Web.OAuth.App
|
||||
alias Pleroma.Web.OAuth.Authorization
|
||||
alias Pleroma.Web.OAuth.Token
|
||||
alias Pleroma.Web.OAuth.Token.Query
|
||||
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
schema "oauth_tokens" do
|
||||
field(:token, :string)
|
||||
field(:refresh_token, :string)
|
||||
field(:scopes, {:array, :string}, default: [])
|
||||
field(:valid_until, :naive_datetime_usec)
|
||||
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
|
||||
belongs_to(:app, App)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc "Gets token for app by access token"
|
||||
@spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found}
|
||||
def get_by_token(%App{id: app_id} = _app, token) do
|
||||
Query.get_by_app(app_id)
|
||||
|> Query.get_by_token(token)
|
||||
|> Repo.find_resource()
|
||||
end
|
||||
|
||||
@doc "Gets token for app by refresh token"
|
||||
@spec get_by_refresh_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found}
|
||||
def get_by_refresh_token(%App{id: app_id} = _app, token) do
|
||||
Query.get_by_app(app_id)
|
||||
|> Query.get_by_refresh_token(token)
|
||||
|> Query.preload([:user])
|
||||
|> Repo.find_resource()
|
||||
end
|
||||
|
||||
@spec exchange_token(App.t(), Authorization.t()) :: {:ok, Token.t()} | {:error, Changeset.t()}
|
||||
def exchange_token(app, auth) do
|
||||
with {:ok, auth} <- Authorization.use_token(auth),
|
||||
true <- auth.app_id == app.id do
|
||||
user = if auth.user_id, do: User.get_cached_by_id(auth.user_id), else: %User{}
|
||||
|
||||
create(
|
||||
app,
|
||||
user,
|
||||
%{scopes: auth.scopes}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp put_token(changeset) do
|
||||
changeset
|
||||
|> change(%{token: Token.Utils.generate_token()})
|
||||
|> validate_required([:token])
|
||||
|> unique_constraint(:token)
|
||||
end
|
||||
|
||||
defp put_refresh_token(changeset, attrs) do
|
||||
refresh_token = Map.get(attrs, :refresh_token, Token.Utils.generate_token())
|
||||
|
||||
changeset
|
||||
|> change(%{refresh_token: refresh_token})
|
||||
|> validate_required([:refresh_token])
|
||||
|> unique_constraint(:refresh_token)
|
||||
end
|
||||
|
||||
defp put_valid_until(changeset, attrs) do
|
||||
expires_in =
|
||||
Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), expires_in()))
|
||||
|
||||
changeset
|
||||
|> change(%{valid_until: expires_in})
|
||||
|> validate_required([:valid_until])
|
||||
end
|
||||
|
||||
@spec create(App.t(), User.t(), map()) :: {:ok, Token} | {:error, Changeset.t()}
|
||||
def create(%App{} = app, %User{} = user, attrs \\ %{}) do
|
||||
with {:ok, token} <- do_create(app, user, attrs) do
|
||||
if Pleroma.Config.get([:oauth2, :clean_expired_tokens]) do
|
||||
Pleroma.Workers.PurgeExpiredToken.enqueue(%{
|
||||
token_id: token.id,
|
||||
valid_until: DateTime.from_naive!(token.valid_until, "Etc/UTC"),
|
||||
mod: __MODULE__
|
||||
})
|
||||
end
|
||||
|
||||
{:ok, token}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_create(app, user, attrs) do
|
||||
%__MODULE__{user_id: user.id, app_id: app.id}
|
||||
|> cast(%{scopes: attrs[:scopes] || app.scopes}, [:scopes])
|
||||
|> validate_required([:scopes, :app_id])
|
||||
|> put_valid_until(attrs)
|
||||
|> put_token()
|
||||
|> put_refresh_token(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def delete_user_tokens(%User{id: user_id}) do
|
||||
Query.get_by_user(user_id)
|
||||
|> Repo.delete_all()
|
||||
end
|
||||
|
||||
def delete_user_token(%User{id: user_id}, token_id) do
|
||||
Query.get_by_user(user_id)
|
||||
|> Query.get_by_id(token_id)
|
||||
|> Repo.delete_all()
|
||||
end
|
||||
|
||||
def get_user_tokens(%User{id: user_id}) do
|
||||
Query.get_by_user(user_id)
|
||||
|> Query.preload([:app])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def is_expired?(%__MODULE__{valid_until: valid_until}) do
|
||||
NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0
|
||||
end
|
||||
|
||||
def is_expired?(_), do: false
|
||||
|
||||
defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600)
|
||||
end
|
||||
49
lib/pleroma/web/o_auth/token/query.ex
Normal file
49
lib/pleroma/web/o_auth/token/query.ex
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.Token.Query do
|
||||
@moduledoc """
|
||||
Contains queries for OAuth Token.
|
||||
"""
|
||||
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
@type query :: Ecto.Queryable.t() | Token.t()
|
||||
|
||||
alias Pleroma.Web.OAuth.Token
|
||||
|
||||
@spec get_by_refresh_token(query, String.t()) :: query
|
||||
def get_by_refresh_token(query \\ Token, refresh_token) do
|
||||
from(q in query, where: q.refresh_token == ^refresh_token)
|
||||
end
|
||||
|
||||
@spec get_by_token(query, String.t()) :: query
|
||||
def get_by_token(query \\ Token, token) do
|
||||
from(q in query, where: q.token == ^token)
|
||||
end
|
||||
|
||||
@spec get_by_app(query, String.t()) :: query
|
||||
def get_by_app(query \\ Token, app_id) do
|
||||
from(q in query, where: q.app_id == ^app_id)
|
||||
end
|
||||
|
||||
@spec get_by_id(query, String.t()) :: query
|
||||
def get_by_id(query \\ Token, id) do
|
||||
from(q in query, where: q.id == ^id)
|
||||
end
|
||||
|
||||
@spec get_by_user(query, String.t()) :: query
|
||||
def get_by_user(query \\ Token, user_id) do
|
||||
from(q in query, where: q.user_id == ^user_id)
|
||||
end
|
||||
|
||||
@spec preload(query, any) :: query
|
||||
def preload(query \\ Token, assoc_preload \\ [])
|
||||
|
||||
def preload(query, assoc_preload) when is_list(assoc_preload) do
|
||||
from(q in query, preload: ^assoc_preload)
|
||||
end
|
||||
|
||||
def preload(query, _assoc_preload), do: query
|
||||
end
|
||||
58
lib/pleroma/web/o_auth/token/strategy/refresh_token.ex
Normal file
58
lib/pleroma/web/o_auth/token/strategy/refresh_token.ex
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.Token.Strategy.RefreshToken do
|
||||
@moduledoc """
|
||||
Functions for dealing with refresh token strategy.
|
||||
"""
|
||||
|
||||
alias Pleroma.Config
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.Web.OAuth.Token
|
||||
alias Pleroma.Web.OAuth.Token.Strategy.Revoke
|
||||
|
||||
@doc """
|
||||
Will grant access token by refresh token.
|
||||
"""
|
||||
@spec grant(Token.t()) :: {:ok, Token.t()} | {:error, any()}
|
||||
def grant(token) do
|
||||
access_token = Repo.preload(token, [:user, :app])
|
||||
|
||||
result =
|
||||
Repo.transaction(fn ->
|
||||
token_params = %{
|
||||
app: access_token.app,
|
||||
user: access_token.user,
|
||||
scopes: access_token.scopes
|
||||
}
|
||||
|
||||
access_token
|
||||
|> revoke_access_token()
|
||||
|> create_access_token(token_params)
|
||||
end)
|
||||
|
||||
case result do
|
||||
{:ok, {:error, reason}} -> {:error, reason}
|
||||
{:ok, {:ok, token}} -> {:ok, token}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp revoke_access_token(token) do
|
||||
Revoke.revoke(token)
|
||||
end
|
||||
|
||||
defp create_access_token({:error, error}, _), do: {:error, error}
|
||||
|
||||
defp create_access_token({:ok, token}, %{app: app, user: user} = token_params) do
|
||||
Token.create(app, user, add_refresh_token(token_params, token.refresh_token))
|
||||
end
|
||||
|
||||
defp add_refresh_token(params, token) do
|
||||
case Config.get([:oauth2, :issue_new_refresh_token], false) do
|
||||
true -> Map.put(params, :refresh_token, token)
|
||||
false -> params
|
||||
end
|
||||
end
|
||||
end
|
||||
26
lib/pleroma/web/o_auth/token/strategy/revoke.ex
Normal file
26
lib/pleroma/web/o_auth/token/strategy/revoke.ex
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.Token.Strategy.Revoke do
|
||||
@moduledoc """
|
||||
Functions for dealing with revocation.
|
||||
"""
|
||||
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.Web.OAuth.App
|
||||
alias Pleroma.Web.OAuth.Token
|
||||
|
||||
@doc "Finds and revokes access token for app and by token"
|
||||
@spec revoke(App.t(), map()) :: {:ok, Token.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def revoke(%App{} = app, %{"token" => token} = _attrs) do
|
||||
with {:ok, token} <- Token.get_by_token(app, token),
|
||||
do: revoke(token)
|
||||
end
|
||||
|
||||
@doc "Revokes access token"
|
||||
@spec revoke(Token.t()) :: {:ok, Token.t()} | {:error, Ecto.Changeset.t()}
|
||||
def revoke(%Token{} = token) do
|
||||
Repo.delete(token)
|
||||
end
|
||||
end
|
||||
72
lib/pleroma/web/o_auth/token/utils.ex
Normal file
72
lib/pleroma/web/o_auth/token/utils.ex
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.OAuth.Token.Utils do
|
||||
@moduledoc """
|
||||
Auxiliary functions for dealing with tokens.
|
||||
"""
|
||||
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.Web.OAuth.App
|
||||
|
||||
@doc "Fetch app by client credentials from request"
|
||||
@spec fetch_app(Plug.Conn.t()) :: {:ok, App.t()} | {:error, :not_found}
|
||||
def fetch_app(conn) do
|
||||
res =
|
||||
conn
|
||||
|> fetch_client_credentials()
|
||||
|> fetch_client
|
||||
|
||||
case res do
|
||||
%App{} = app -> {:ok, app}
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_client({id, secret}) when is_binary(id) and is_binary(secret) do
|
||||
Repo.get_by(App, client_id: id, client_secret: secret)
|
||||
end
|
||||
|
||||
defp fetch_client({_id, _secret}), do: nil
|
||||
|
||||
defp fetch_client_credentials(conn) do
|
||||
# Per RFC 6749, HTTP Basic is preferred to body params
|
||||
with ["Basic " <> encoded] <- Plug.Conn.get_req_header(conn, "authorization"),
|
||||
{:ok, decoded} <- Base.decode64(encoded),
|
||||
[id, secret] <-
|
||||
Enum.map(
|
||||
String.split(decoded, ":"),
|
||||
fn s -> URI.decode_www_form(s) end
|
||||
) do
|
||||
{id, secret}
|
||||
else
|
||||
_ -> {conn.params["client_id"], conn.params["client_secret"]}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "convert token inserted_at to unix timestamp"
|
||||
def format_created_at(%{inserted_at: inserted_at} = _token) do
|
||||
inserted_at
|
||||
|> DateTime.from_naive!("Etc/UTC")
|
||||
|> DateTime.to_unix()
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec generate_token(keyword()) :: binary()
|
||||
def generate_token(opts \\ []) do
|
||||
opts
|
||||
|> Keyword.get(:size, 32)
|
||||
|> :crypto.strong_rand_bytes()
|
||||
|> Base.url_encode64(padding: false)
|
||||
end
|
||||
|
||||
# XXX - for whatever reason our token arrives urlencoded, but Plug.Conn should be
|
||||
# decoding it. Investigate sometime.
|
||||
def fix_padding(token) do
|
||||
token
|
||||
|> URI.decode()
|
||||
|> Base.url_decode64!(padding: false)
|
||||
|> Base.url_encode64(padding: false)
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue