forked from AkkomaGang/akkoma
Merge branch 'issue_800' into 'develop'
[#800] added ability renew access_token by refresh_token See merge request pleroma/pleroma!1045
This commit is contained in:
commit
6b79be4a06
15 changed files with 561 additions and 86 deletions
|
@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
- Mastodon API: [Reports](https://docs.joinmastodon.org/api/rest/reports/)
|
- Mastodon API: [Reports](https://docs.joinmastodon.org/api/rest/reports/)
|
||||||
- ActivityPub C2S: OAuth endpoints
|
- ActivityPub C2S: OAuth endpoints
|
||||||
- Metadata RelMe provider
|
- Metadata RelMe provider
|
||||||
|
- OAuth: added support for refresh tokens
|
||||||
- Emoji packs and emoji pack manager
|
- Emoji packs and emoji pack manager
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
@ -473,6 +473,10 @@
|
||||||
total_user_limit: 300,
|
total_user_limit: 300,
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
config :pleroma, :oauth2,
|
||||||
|
token_expires_in: 600,
|
||||||
|
issue_new_refresh_token: true
|
||||||
|
|
||||||
# Import environment specific config. This must remain at the bottom
|
# Import environment specific config. This must remain at the bottom
|
||||||
# of this file so it overrides the configuration defined above.
|
# of this file so it overrides the configuration defined above.
|
||||||
import_config "#{Mix.env()}.exs"
|
import_config "#{Mix.env()}.exs"
|
||||||
|
|
|
@ -80,3 +80,10 @@ Additional parameters can be added to the JSON body/Form data:
|
||||||
- `hide_favorites` - if true, user's favorites timeline will be hidden
|
- `hide_favorites` - if true, user's favorites timeline will be hidden
|
||||||
- `show_role` - if true, user's role (e.g admin, moderator) will be exposed to anyone in the API
|
- `show_role` - if true, user's role (e.g admin, moderator) will be exposed to anyone in the API
|
||||||
- `default_scope` - the scope returned under `privacy` key in Source subentity
|
- `default_scope` - the scope returned under `privacy` key in Source subentity
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
*Pleroma supports refreshing tokens.
|
||||||
|
|
||||||
|
`POST /oauth/token`
|
||||||
|
Post here request with grant_type=refresh_token to obtain new access token. Returns an access token.
|
||||||
|
|
|
@ -474,7 +474,7 @@ Authentication / authorization settings.
|
||||||
* `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_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.
|
* `oauth_consumer_strategies`: the list of enabled OAuth consumer strategies; by default it's set by OAUTH_CONSUMER_STRATEGIES environment variable.
|
||||||
|
|
||||||
# OAuth consumer mode
|
## OAuth consumer mode
|
||||||
|
|
||||||
OAuth consumer mode allows sign in / sign up via external OAuth providers (e.g. Twitter, Facebook, Google, Microsoft, etc.).
|
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).
|
Implementation is based on Ueberauth; see the list of [available strategies](https://github.com/ueberauth/ueberauth/wiki/List-of-Strategies).
|
||||||
|
@ -527,6 +527,13 @@ config :ueberauth, Ueberauth,
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## OAuth 2.0 provider - :oauth2
|
||||||
|
|
||||||
|
Configure OAuth 2 provider capabilities:
|
||||||
|
|
||||||
|
* `token_expires_in` - The lifetime in seconds of the access token.
|
||||||
|
* `issue_new_refresh_token` - Keeps old refresh token or generate new refresh token when to obtain an access token.
|
||||||
|
|
||||||
## :emoji
|
## :emoji
|
||||||
* `shortcode_globs`: Location of custom emoji files. `*` can be used as a wildcard. Example `["/emoji/custom/**/*.png"]`
|
* `shortcode_globs`: Location of custom emoji files. `*` can be used as a wildcard. Example `["/emoji/custom/**/*.png"]`
|
||||||
* `groups`: Emojis are ordered in groups (tags). This is an array of key-value pairs where the key is the groupname and the value the location or array of locations. `*` can be used as a wildcard. Example `[Custom: ["/emoji/*.png", "/emoji/custom/*.png"]]`
|
* `groups`: Emojis are ordered in groups (tags). This is an array of key-value pairs where the key is the groupname and the value the location or array of locations. `*` can be used as a wildcard. Example `[Custom: ["/emoji/*.png", "/emoji/custom/*.png"]]`
|
||||||
|
|
|
@ -19,4 +19,32 @@ defmodule Instrumenter do
|
||||||
def init(_, opts) do
|
def init(_, opts) do
|
||||||
{:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))}
|
{:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc "find resource based on prepared query"
|
||||||
|
@spec find_resource(Ecto.Query.t()) :: {:ok, struct()} | {:error, :not_found}
|
||||||
|
def find_resource(%Ecto.Query{} = query) do
|
||||||
|
case __MODULE__.one(query) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
resource -> {:ok, resource}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_resource(_query), do: {:error, :not_found}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets association from cache or loads if need
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Repo.get_assoc(token, :user)
|
||||||
|
%User{}
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec get_assoc(struct(), atom()) :: {:ok, struct()} | {:error, :not_found}
|
||||||
|
def get_assoc(resource, association) do
|
||||||
|
case __MODULE__.preload(resource, association) do
|
||||||
|
%{^association => assoc} when not is_nil(assoc) -> {:ok, assoc}
|
||||||
|
_ -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.App do
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
schema "apps" do
|
schema "apps" do
|
||||||
field(:client_name, :string)
|
field(:client_name, :string)
|
||||||
field(:redirect_uris, :string)
|
field(:redirect_uris, :string)
|
||||||
|
|
|
@ -13,6 +13,7 @@ defmodule Pleroma.Web.OAuth.Authorization do
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
schema "oauth_authorizations" do
|
schema "oauth_authorizations" do
|
||||||
field(:token, :string)
|
field(:token, :string)
|
||||||
field(:scopes, {:array, :string}, default: [])
|
field(:scopes, {:array, :string}, default: [])
|
||||||
|
@ -63,4 +64,11 @@ def delete_user_authorizations(%User{id: user_id}) do
|
||||||
)
|
)
|
||||||
|> Repo.delete_all()
|
|> Repo.delete_all()
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -13,11 +13,15 @@ defmodule Pleroma.Web.OAuth.OAuthController do
|
||||||
alias Pleroma.Web.OAuth.App
|
alias Pleroma.Web.OAuth.App
|
||||||
alias Pleroma.Web.OAuth.Authorization
|
alias Pleroma.Web.OAuth.Authorization
|
||||||
alias Pleroma.Web.OAuth.Token
|
alias Pleroma.Web.OAuth.Token
|
||||||
|
alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken
|
||||||
|
alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken
|
||||||
|
|
||||||
import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2]
|
import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2]
|
||||||
|
|
||||||
if Pleroma.Config.oauth_consumer_enabled?(), do: plug(Ueberauth)
|
if Pleroma.Config.oauth_consumer_enabled?(), do: plug(Ueberauth)
|
||||||
|
|
||||||
|
@expires_in Pleroma.Config.get([:oauth2, :token_expires_in], 600)
|
||||||
|
|
||||||
plug(:fetch_session)
|
plug(:fetch_session)
|
||||||
plug(:fetch_flash)
|
plug(:fetch_flash)
|
||||||
|
|
||||||
|
@ -138,25 +142,33 @@ defp handle_create_authorization_error(conn, error, %{"authorization" => _}) do
|
||||||
Authenticator.handle_error(conn, error)
|
Authenticator.handle_error(conn, error)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc "Renew access_token with refresh_token"
|
||||||
|
def token_exchange(
|
||||||
|
conn,
|
||||||
|
%{"grant_type" => "refresh_token", "refresh_token" => token} = params
|
||||||
|
) do
|
||||||
|
with %App{} = app <- get_app_from_request(conn, params),
|
||||||
|
{:ok, %{user: user} = token} <- Token.get_by_refresh_token(app, token),
|
||||||
|
{:ok, token} <- RefreshToken.grant(token) do
|
||||||
|
response_attrs = %{created_at: Token.Utils.format_created_at(token)}
|
||||||
|
|
||||||
|
json(conn, response_token(user, token, response_attrs))
|
||||||
|
else
|
||||||
|
_error ->
|
||||||
|
put_status(conn, 400)
|
||||||
|
|> json(%{error: "Invalid credentials"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do
|
def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do
|
||||||
with %App{} = app <- get_app_from_request(conn, params),
|
with %App{} = app <- get_app_from_request(conn, params),
|
||||||
fixed_token = fix_padding(params["code"]),
|
fixed_token = Token.Utils.fix_padding(params["code"]),
|
||||||
%Authorization{} = auth <-
|
{:ok, auth} <- Authorization.get_by_token(app, fixed_token),
|
||||||
Repo.get_by(Authorization, token: fixed_token, app_id: app.id),
|
|
||||||
%User{} = user <- User.get_cached_by_id(auth.user_id),
|
%User{} = user <- User.get_cached_by_id(auth.user_id),
|
||||||
{:ok, token} <- Token.exchange_token(app, auth),
|
{:ok, token} <- Token.exchange_token(app, auth) do
|
||||||
{:ok, inserted_at} <- DateTime.from_naive(token.inserted_at, "Etc/UTC") do
|
response_attrs = %{created_at: Token.Utils.format_created_at(token)}
|
||||||
response = %{
|
|
||||||
token_type: "Bearer",
|
|
||||||
access_token: token.token,
|
|
||||||
refresh_token: token.refresh_token,
|
|
||||||
created_at: DateTime.to_unix(inserted_at),
|
|
||||||
expires_in: 60 * 10,
|
|
||||||
scope: Enum.join(token.scopes, " "),
|
|
||||||
me: user.ap_id
|
|
||||||
}
|
|
||||||
|
|
||||||
json(conn, response)
|
json(conn, response_token(user, token, response_attrs))
|
||||||
else
|
else
|
||||||
_error ->
|
_error ->
|
||||||
put_status(conn, 400)
|
put_status(conn, 400)
|
||||||
|
@ -177,16 +189,7 @@ def token_exchange(
|
||||||
true <- Enum.any?(scopes),
|
true <- Enum.any?(scopes),
|
||||||
{:ok, auth} <- Authorization.create_authorization(app, user, scopes),
|
{:ok, auth} <- Authorization.create_authorization(app, user, scopes),
|
||||||
{:ok, token} <- Token.exchange_token(app, auth) do
|
{:ok, token} <- Token.exchange_token(app, auth) do
|
||||||
response = %{
|
json(conn, response_token(user, token))
|
||||||
token_type: "Bearer",
|
|
||||||
access_token: token.token,
|
|
||||||
refresh_token: token.refresh_token,
|
|
||||||
expires_in: 60 * 10,
|
|
||||||
scope: Enum.join(token.scopes, " "),
|
|
||||||
me: user.ap_id
|
|
||||||
}
|
|
||||||
|
|
||||||
json(conn, response)
|
|
||||||
else
|
else
|
||||||
{:auth_active, false} ->
|
{:auth_active, false} ->
|
||||||
# Per https://github.com/tootsuite/mastodon/blob/
|
# Per https://github.com/tootsuite/mastodon/blob/
|
||||||
|
@ -218,10 +221,12 @@ def token_exchange(
|
||||||
token_exchange(conn, params)
|
token_exchange(conn, params)
|
||||||
end
|
end
|
||||||
|
|
||||||
def token_revoke(conn, %{"token" => token} = params) do
|
# Bad request
|
||||||
|
def token_exchange(conn, params), do: bad_request(conn, params)
|
||||||
|
|
||||||
|
def token_revoke(conn, %{"token" => _token} = params) do
|
||||||
with %App{} = app <- get_app_from_request(conn, params),
|
with %App{} = app <- get_app_from_request(conn, params),
|
||||||
%Token{} = token <- Repo.get_by(Token, token: token, app_id: app.id),
|
{:ok, _token} <- RevokeToken.revoke(app, params) do
|
||||||
{:ok, %Token{}} <- Repo.delete(token) do
|
|
||||||
json(conn, %{})
|
json(conn, %{})
|
||||||
else
|
else
|
||||||
_error ->
|
_error ->
|
||||||
|
@ -230,6 +235,15 @@ def token_revoke(conn, %{"token" => token} = params) do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def token_revoke(conn, params), do: bad_request(conn, params)
|
||||||
|
|
||||||
|
# Response for bad request
|
||||||
|
defp bad_request(conn, _) do
|
||||||
|
conn
|
||||||
|
|> put_status(500)
|
||||||
|
|> json(%{error: "Bad request"})
|
||||||
|
end
|
||||||
|
|
||||||
@doc "Prepares OAuth request to provider for Ueberauth"
|
@doc "Prepares OAuth request to provider for Ueberauth"
|
||||||
def prepare_request(conn, %{"provider" => provider, "authorization" => auth_attrs}) do
|
def prepare_request(conn, %{"provider" => provider, "authorization" => auth_attrs}) do
|
||||||
scope =
|
scope =
|
||||||
|
@ -278,16 +292,13 @@ def callback(conn, params) do
|
||||||
params = callback_params(params)
|
params = callback_params(params)
|
||||||
|
|
||||||
with {:ok, registration} <- Authenticator.get_registration(conn) do
|
with {:ok, registration} <- Authenticator.get_registration(conn) do
|
||||||
user = Repo.preload(registration, :user).user
|
|
||||||
auth_attrs = Map.take(params, ~w(client_id redirect_uri scope scopes state))
|
auth_attrs = Map.take(params, ~w(client_id redirect_uri scope scopes state))
|
||||||
|
|
||||||
if user do
|
case Repo.get_assoc(registration, :user) do
|
||||||
create_authorization(
|
{:ok, user} ->
|
||||||
conn,
|
create_authorization(conn, %{"authorization" => auth_attrs}, user: user)
|
||||||
%{"authorization" => auth_attrs},
|
|
||||||
user: user
|
_ ->
|
||||||
)
|
|
||||||
else
|
|
||||||
registration_params =
|
registration_params =
|
||||||
Map.merge(auth_attrs, %{
|
Map.merge(auth_attrs, %{
|
||||||
"nickname" => Registration.nickname(registration),
|
"nickname" => Registration.nickname(registration),
|
||||||
|
@ -399,37 +410,31 @@ defp do_create_authorization(
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# XXX - for whatever reason our token arrives urlencoded, but Plug.Conn should be
|
defp get_app_from_request(conn, params) do
|
||||||
# decoding it. Investigate sometime.
|
conn
|
||||||
defp fix_padding(token) do
|
|> fetch_client_credentials(params)
|
||||||
token
|
|> fetch_client
|
||||||
|> URI.decode()
|
|
||||||
|> Base.url_decode64!(padding: false)
|
|
||||||
|> Base.url_encode64(padding: false)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_app_from_request(conn, params) do
|
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, params) do
|
||||||
# Per RFC 6749, HTTP Basic is preferred to body params
|
# Per RFC 6749, HTTP Basic is preferred to body params
|
||||||
{client_id, client_secret} =
|
|
||||||
with ["Basic " <> encoded] <- get_req_header(conn, "authorization"),
|
with ["Basic " <> encoded] <- get_req_header(conn, "authorization"),
|
||||||
{:ok, decoded} <- Base.decode64(encoded),
|
{:ok, decoded} <- Base.decode64(encoded),
|
||||||
[id, secret] <-
|
[id, secret] <-
|
||||||
String.split(decoded, ":")
|
Enum.map(
|
||||||
|> Enum.map(fn s -> URI.decode_www_form(s) end) do
|
String.split(decoded, ":"),
|
||||||
|
fn s -> URI.decode_www_form(s) end
|
||||||
|
) do
|
||||||
{id, secret}
|
{id, secret}
|
||||||
else
|
else
|
||||||
_ -> {params["client_id"], params["client_secret"]}
|
_ -> {params["client_id"], params["client_secret"]}
|
||||||
end
|
end
|
||||||
|
|
||||||
if client_id && client_secret do
|
|
||||||
Repo.get_by(
|
|
||||||
App,
|
|
||||||
client_id: client_id,
|
|
||||||
client_secret: client_secret
|
|
||||||
)
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Special case: Local MastodonFE
|
# Special case: Local MastodonFE
|
||||||
|
@ -441,4 +446,16 @@ defp get_session_registration_id(conn), do: get_session(conn, :registration_id)
|
||||||
|
|
||||||
defp put_session_registration_id(conn, registration_id),
|
defp put_session_registration_id(conn, registration_id),
|
||||||
do: put_session(conn, :registration_id, registration_id)
|
do: put_session(conn, :registration_id, registration_id)
|
||||||
|
|
||||||
|
defp response_token(%User{} = user, token, opts \\ %{}) do
|
||||||
|
%{
|
||||||
|
token_type: "Bearer",
|
||||||
|
access_token: token.token,
|
||||||
|
refresh_token: token.refresh_token,
|
||||||
|
expires_in: @expires_in,
|
||||||
|
scope: Enum.join(token.scopes, " "),
|
||||||
|
me: user.ap_id
|
||||||
|
}
|
||||||
|
|> Map.merge(opts)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.Token do
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
alias Pleroma.Repo
|
alias Pleroma.Repo
|
||||||
alias Pleroma.User
|
alias Pleroma.User
|
||||||
|
@ -13,6 +14,9 @@ defmodule Pleroma.Web.OAuth.Token do
|
||||||
alias Pleroma.Web.OAuth.Authorization
|
alias Pleroma.Web.OAuth.Authorization
|
||||||
alias Pleroma.Web.OAuth.Token
|
alias Pleroma.Web.OAuth.Token
|
||||||
|
|
||||||
|
@expires_in Pleroma.Config.get([:oauth2, :token_expires_in], 600)
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
schema "oauth_tokens" do
|
schema "oauth_tokens" do
|
||||||
field(:token, :string)
|
field(:token, :string)
|
||||||
field(:refresh_token, :string)
|
field(:refresh_token, :string)
|
||||||
|
@ -24,28 +28,67 @@ defmodule Pleroma.Web.OAuth.Token do
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
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
|
||||||
|
from(t in __MODULE__, where: t.app_id == ^app_id and t.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
|
||||||
|
from(t in __MODULE__,
|
||||||
|
where: t.app_id == ^app_id and t.refresh_token == ^token,
|
||||||
|
preload: [:user]
|
||||||
|
)
|
||||||
|
|> Repo.find_resource()
|
||||||
|
end
|
||||||
|
|
||||||
def exchange_token(app, auth) do
|
def exchange_token(app, auth) do
|
||||||
with {:ok, auth} <- Authorization.use_token(auth),
|
with {:ok, auth} <- Authorization.use_token(auth),
|
||||||
true <- auth.app_id == app.id do
|
true <- auth.app_id == app.id do
|
||||||
create_token(app, User.get_cached_by_id(auth.user_id), auth.scopes)
|
create_token(
|
||||||
|
app,
|
||||||
|
User.get_cached_by_id(auth.user_id),
|
||||||
|
%{scopes: auth.scopes}
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_token(%App{} = app, %User{} = user, scopes \\ nil) do
|
defp put_token(changeset) do
|
||||||
scopes = scopes || app.scopes
|
changeset
|
||||||
token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
|
|> change(%{token: Token.Utils.generate_token()})
|
||||||
refresh_token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
|
|> validate_required([:token])
|
||||||
|
|> unique_constraint(:token)
|
||||||
|
end
|
||||||
|
|
||||||
token = %Token{
|
defp put_refresh_token(changeset, attrs) do
|
||||||
token: token,
|
refresh_token = Map.get(attrs, :refresh_token, Token.Utils.generate_token())
|
||||||
refresh_token: refresh_token,
|
|
||||||
scopes: scopes,
|
|
||||||
user_id: user.id,
|
|
||||||
app_id: app.id,
|
|
||||||
valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
Repo.insert(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
|
||||||
|
|
||||||
|
def create_token(%App{} = app, %User{} = user, attrs \\ %{}) do
|
||||||
|
%__MODULE__{user_id: user.id, app_id: app.id}
|
||||||
|
|> cast(%{scopes: attrs[:scopes] || app.scopes}, [:scopes])
|
||||||
|
|> validate_required([:scopes, :user_id, :app_id])
|
||||||
|
|> put_valid_until(attrs)
|
||||||
|
|> put_token
|
||||||
|
|> put_refresh_token(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_user_tokens(%User{id: user_id}) do
|
def delete_user_tokens(%User{id: user_id}) do
|
||||||
|
@ -73,4 +116,10 @@ def get_user_tokens(%User{id: user_id}) do
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Repo.preload(:app)
|
|> Repo.preload(:app)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def is_expired?(%__MODULE__{valid_until: valid_until}) do
|
||||||
|
NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_expired?(_), do: false
|
||||||
end
|
end
|
||||||
|
|
54
lib/pleroma/web/oauth/token/strategy/refresh_token.ex
Normal file
54
lib/pleroma/web/oauth/token/strategy/refresh_token.ex
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
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_token(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
|
22
lib/pleroma/web/oauth/token/strategy/revoke.ex
Normal file
22
lib/pleroma/web/oauth/token/strategy/revoke.ex
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
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
|
30
lib/pleroma/web/oauth/token/utils.ex
Normal file
30
lib/pleroma/web/oauth/token/utils.ex
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
defmodule Pleroma.Web.OAuth.Token.Utils do
|
||||||
|
@moduledoc """
|
||||||
|
Auxiliary functions for dealing with tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@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
|
|
@ -0,0 +1,7 @@
|
||||||
|
defmodule Pleroma.Repo.Migrations.AddRefreshTokenIndexToToken do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create(unique_index(:oauth_tokens, [:refresh_token]))
|
||||||
|
end
|
||||||
|
end
|
44
test/repo_test.exs
Normal file
44
test/repo_test.exs
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
defmodule Pleroma.RepoTest do
|
||||||
|
use Pleroma.DataCase
|
||||||
|
import Pleroma.Factory
|
||||||
|
|
||||||
|
describe "find_resource/1" do
|
||||||
|
test "returns user" do
|
||||||
|
user = insert(:user)
|
||||||
|
query = from(t in Pleroma.User, where: t.id == ^user.id)
|
||||||
|
assert Repo.find_resource(query) == {:ok, user}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns not_found" do
|
||||||
|
query = from(t in Pleroma.User, where: t.id == ^"9gBuXNpD2NyDmmxxdw")
|
||||||
|
assert Repo.find_resource(query) == {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_assoc/2" do
|
||||||
|
test "get assoc from preloaded data" do
|
||||||
|
user = %Pleroma.User{name: "Agent Smith"}
|
||||||
|
token = %Pleroma.Web.OAuth.Token{insert(:oauth_token) | user: user}
|
||||||
|
assert Repo.get_assoc(token, :user) == {:ok, user}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "get one-to-one assoc from repo" do
|
||||||
|
user = insert(:user, name: "Jimi Hendrix")
|
||||||
|
token = refresh_record(insert(:oauth_token, user: user))
|
||||||
|
|
||||||
|
assert Repo.get_assoc(token, :user) == {:ok, user}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "get one-to-many assoc from repo" do
|
||||||
|
user = insert(:user)
|
||||||
|
notification = refresh_record(insert(:notification, user: user))
|
||||||
|
|
||||||
|
assert Repo.get_assoc(user, :notifications) == {:ok, [notification]}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "return error if has not assoc " do
|
||||||
|
token = insert(:oauth_token, user: nil)
|
||||||
|
assert Repo.get_assoc(token, :user) == {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -12,6 +12,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
|
||||||
alias Pleroma.Web.OAuth.Authorization
|
alias Pleroma.Web.OAuth.Authorization
|
||||||
alias Pleroma.Web.OAuth.Token
|
alias Pleroma.Web.OAuth.Token
|
||||||
|
|
||||||
|
@oauth_config_path [:oauth2, :issue_new_refresh_token]
|
||||||
@session_opts [
|
@session_opts [
|
||||||
store: :cookie,
|
store: :cookie,
|
||||||
key: "_test",
|
key: "_test",
|
||||||
|
@ -714,4 +715,199 @@ test "rejects an invalid authorization code" do
|
||||||
refute Map.has_key?(resp, "access_token")
|
refute Map.has_key?(resp, "access_token")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "POST /oauth/token - refresh token" do
|
||||||
|
setup do
|
||||||
|
oauth_token_config = Pleroma.Config.get(@oauth_config_path)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Pleroma.Config.get(@oauth_config_path, oauth_token_config)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "issues a new access token with keep fresh token" do
|
||||||
|
Pleroma.Config.put(@oauth_config_path, true)
|
||||||
|
user = insert(:user)
|
||||||
|
app = insert(:oauth_app, scopes: ["read", "write"])
|
||||||
|
|
||||||
|
{:ok, auth} = Authorization.create_authorization(app, user, ["write"])
|
||||||
|
{:ok, token} = Token.exchange_token(app, auth)
|
||||||
|
|
||||||
|
response =
|
||||||
|
build_conn()
|
||||||
|
|> post("/oauth/token", %{
|
||||||
|
"grant_type" => "refresh_token",
|
||||||
|
"refresh_token" => token.refresh_token,
|
||||||
|
"client_id" => app.client_id,
|
||||||
|
"client_secret" => app.client_secret
|
||||||
|
})
|
||||||
|
|> json_response(200)
|
||||||
|
|
||||||
|
ap_id = user.ap_id
|
||||||
|
|
||||||
|
assert match?(
|
||||||
|
%{
|
||||||
|
"scope" => "write",
|
||||||
|
"token_type" => "Bearer",
|
||||||
|
"expires_in" => 600,
|
||||||
|
"access_token" => _,
|
||||||
|
"refresh_token" => _,
|
||||||
|
"me" => ^ap_id
|
||||||
|
},
|
||||||
|
response
|
||||||
|
)
|
||||||
|
|
||||||
|
refute Repo.get_by(Token, token: token.token)
|
||||||
|
new_token = Repo.get_by(Token, token: response["access_token"])
|
||||||
|
assert new_token.refresh_token == token.refresh_token
|
||||||
|
assert new_token.scopes == auth.scopes
|
||||||
|
assert new_token.user_id == user.id
|
||||||
|
assert new_token.app_id == app.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "issues a new access token with new fresh token" do
|
||||||
|
Pleroma.Config.put(@oauth_config_path, false)
|
||||||
|
user = insert(:user)
|
||||||
|
app = insert(:oauth_app, scopes: ["read", "write"])
|
||||||
|
|
||||||
|
{:ok, auth} = Authorization.create_authorization(app, user, ["write"])
|
||||||
|
{:ok, token} = Token.exchange_token(app, auth)
|
||||||
|
|
||||||
|
response =
|
||||||
|
build_conn()
|
||||||
|
|> post("/oauth/token", %{
|
||||||
|
"grant_type" => "refresh_token",
|
||||||
|
"refresh_token" => token.refresh_token,
|
||||||
|
"client_id" => app.client_id,
|
||||||
|
"client_secret" => app.client_secret
|
||||||
|
})
|
||||||
|
|> json_response(200)
|
||||||
|
|
||||||
|
ap_id = user.ap_id
|
||||||
|
|
||||||
|
assert match?(
|
||||||
|
%{
|
||||||
|
"scope" => "write",
|
||||||
|
"token_type" => "Bearer",
|
||||||
|
"expires_in" => 600,
|
||||||
|
"access_token" => _,
|
||||||
|
"refresh_token" => _,
|
||||||
|
"me" => ^ap_id
|
||||||
|
},
|
||||||
|
response
|
||||||
|
)
|
||||||
|
|
||||||
|
refute Repo.get_by(Token, token: token.token)
|
||||||
|
new_token = Repo.get_by(Token, token: response["access_token"])
|
||||||
|
refute new_token.refresh_token == token.refresh_token
|
||||||
|
assert new_token.scopes == auth.scopes
|
||||||
|
assert new_token.user_id == user.id
|
||||||
|
assert new_token.app_id == app.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 400 if we try use access token" do
|
||||||
|
user = insert(:user)
|
||||||
|
app = insert(:oauth_app, scopes: ["read", "write"])
|
||||||
|
|
||||||
|
{:ok, auth} = Authorization.create_authorization(app, user, ["write"])
|
||||||
|
{:ok, token} = Token.exchange_token(app, auth)
|
||||||
|
|
||||||
|
response =
|
||||||
|
build_conn()
|
||||||
|
|> post("/oauth/token", %{
|
||||||
|
"grant_type" => "refresh_token",
|
||||||
|
"refresh_token" => token.token,
|
||||||
|
"client_id" => app.client_id,
|
||||||
|
"client_secret" => app.client_secret
|
||||||
|
})
|
||||||
|
|> json_response(400)
|
||||||
|
|
||||||
|
assert %{"error" => "Invalid credentials"} == response
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 400 if refresh_token invalid" do
|
||||||
|
app = insert(:oauth_app, scopes: ["read", "write"])
|
||||||
|
|
||||||
|
response =
|
||||||
|
build_conn()
|
||||||
|
|> post("/oauth/token", %{
|
||||||
|
"grant_type" => "refresh_token",
|
||||||
|
"refresh_token" => "token.refresh_token",
|
||||||
|
"client_id" => app.client_id,
|
||||||
|
"client_secret" => app.client_secret
|
||||||
|
})
|
||||||
|
|> json_response(400)
|
||||||
|
|
||||||
|
assert %{"error" => "Invalid credentials"} == response
|
||||||
|
end
|
||||||
|
|
||||||
|
test "issues a new token if token expired" do
|
||||||
|
user = insert(:user)
|
||||||
|
app = insert(:oauth_app, scopes: ["read", "write"])
|
||||||
|
|
||||||
|
{:ok, auth} = Authorization.create_authorization(app, user, ["write"])
|
||||||
|
{:ok, token} = Token.exchange_token(app, auth)
|
||||||
|
|
||||||
|
change =
|
||||||
|
Ecto.Changeset.change(
|
||||||
|
token,
|
||||||
|
%{valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), -86_400 * 30)}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, access_token} = Repo.update(change)
|
||||||
|
|
||||||
|
response =
|
||||||
|
build_conn()
|
||||||
|
|> post("/oauth/token", %{
|
||||||
|
"grant_type" => "refresh_token",
|
||||||
|
"refresh_token" => access_token.refresh_token,
|
||||||
|
"client_id" => app.client_id,
|
||||||
|
"client_secret" => app.client_secret
|
||||||
|
})
|
||||||
|
|> json_response(200)
|
||||||
|
|
||||||
|
ap_id = user.ap_id
|
||||||
|
|
||||||
|
assert match?(
|
||||||
|
%{
|
||||||
|
"scope" => "write",
|
||||||
|
"token_type" => "Bearer",
|
||||||
|
"expires_in" => 600,
|
||||||
|
"access_token" => _,
|
||||||
|
"refresh_token" => _,
|
||||||
|
"me" => ^ap_id
|
||||||
|
},
|
||||||
|
response
|
||||||
|
)
|
||||||
|
|
||||||
|
refute Repo.get_by(Token, token: token.token)
|
||||||
|
token = Repo.get_by(Token, token: response["access_token"])
|
||||||
|
assert token
|
||||||
|
assert token.scopes == auth.scopes
|
||||||
|
assert token.user_id == user.id
|
||||||
|
assert token.app_id == app.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST /oauth/token - bad request" do
|
||||||
|
test "returns 500" do
|
||||||
|
response =
|
||||||
|
build_conn()
|
||||||
|
|> post("/oauth/token", %{})
|
||||||
|
|> json_response(500)
|
||||||
|
|
||||||
|
assert %{"error" => "Bad request"} == response
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST /oauth/revoke - bad request" do
|
||||||
|
test "returns 500" do
|
||||||
|
response =
|
||||||
|
build_conn()
|
||||||
|
|> post("/oauth/revoke", %{})
|
||||||
|
|> json_response(500)
|
||||||
|
|
||||||
|
assert %{"error" => "Bad request"} == response
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue