reuse valid oauth tokens (#182)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Reviewed-on: #182
This commit is contained in:
parent
017b50550b
commit
618cf7ff7f
7 changed files with 202 additions and 5 deletions
|
@ -9,6 +9,7 @@ defmodule Pleroma.Helpers.AuthHelper do
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
|
|
||||||
@oauth_token_session_key :oauth_token
|
@oauth_token_session_key :oauth_token
|
||||||
|
@oauth_user_session_key :oauth_user
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Skips OAuth permissions (scopes) checks, assigns nil `:token`.
|
Skips OAuth permissions (scopes) checks, assigns nil `:token`.
|
||||||
|
@ -43,4 +44,16 @@ def put_session_token(%Conn{} = conn, token) when is_binary(token) do
|
||||||
def delete_session_token(%Conn{} = conn) do
|
def delete_session_token(%Conn{} = conn) do
|
||||||
delete_session(conn, @oauth_token_session_key)
|
delete_session(conn, @oauth_token_session_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def put_session_user(%Conn{} = conn, user) do
|
||||||
|
put_session(conn, @oauth_user_session_key, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_session_user(%Conn{} = conn) do
|
||||||
|
delete_session(conn, @oauth_user_session_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_session_user(%Conn{} = conn) do
|
||||||
|
get_session(conn, @oauth_user_session_key)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -27,7 +27,8 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do
|
||||||
def login(conn, %{"code" => auth_token} = params) do
|
def login(conn, %{"code" => auth_token} = params) do
|
||||||
with {:ok, app} <- local_mastofe_app(),
|
with {:ok, app} <- local_mastofe_app(),
|
||||||
{:ok, auth} <- Authorization.get_by_token(app, auth_token),
|
{:ok, auth} <- Authorization.get_by_token(app, auth_token),
|
||||||
{:ok, oauth_token} <- Token.exchange_token(app, auth) do
|
%User{} = user <- User.get_cached_by_id(auth.user_id),
|
||||||
|
{:ok, oauth_token} <- Token.get_or_exchange_token(auth, app, user) do
|
||||||
redirect_to =
|
redirect_to =
|
||||||
conn
|
conn
|
||||||
|> local_mastodon_post_login_path()
|
|> local_mastodon_post_login_path()
|
||||||
|
|
|
@ -94,4 +94,9 @@ 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)
|
from(t in __MODULE__, where: t.app_id == ^app_id and t.token == ^token)
|
||||||
|> Repo.find_resource()
|
|> Repo.find_resource()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_preeexisting_by_app_and_user(%App{id: app_id} = _app, %User{id: user_id} = _user) do
|
||||||
|
from(t in __MODULE__, where: t.app_id == ^app_id and t.user_id == ^user_id, limit: 1)
|
||||||
|
|> Repo.find_resource()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -59,18 +59,39 @@ def authorize(%Plug.Conn{assigns: %{token: %Token{}}} = conn, %{"force_login" =>
|
||||||
# after user already authorized to MastodonFE.
|
# after user already authorized to MastodonFE.
|
||||||
# So we have to check client and token.
|
# So we have to check client and token.
|
||||||
def authorize(
|
def authorize(
|
||||||
%Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
|
%Plug.Conn{assigns: %{token: %Token{} = token, user: %User{} = user}} = conn,
|
||||||
%{"client_id" => client_id} = params
|
%{"client_id" => client_id} = params
|
||||||
) do
|
) do
|
||||||
with %Token{} = t <- Repo.get_by(Token, token: token.token) |> Repo.preload(:app),
|
with %Token{} = t <- Repo.get_by(Token, token: token.token) |> Repo.preload(:app),
|
||||||
^client_id <- t.app.client_id do
|
^client_id <- t.app.client_id do
|
||||||
handle_existing_authorization(conn, params)
|
handle_existing_authorization(conn, params)
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
maybe_reuse_token(conn, params, user.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize(%Plug.Conn{} = conn, params) do
|
||||||
|
# if we have a user in the session, attempt to authenticate as them
|
||||||
|
# otherwise show the login form
|
||||||
|
maybe_reuse_token(conn, params, AuthHelper.get_session_user(conn))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_reuse_token(conn, params, user_id) when is_binary(user_id) do
|
||||||
|
with %User{} = user <- User.get_cached_by_id(user_id),
|
||||||
|
%App{} = app <- Repo.get_by(App, client_id: params["client_id"]),
|
||||||
|
{:ok, %Token{} = token} <- Token.get_preeexisting_by_app_and_user(app, user),
|
||||||
|
{:ok, %Authorization{} = auth} <-
|
||||||
|
Authorization.get_preeexisting_by_app_and_user(app, user) do
|
||||||
|
conn
|
||||||
|
|> assign(:token, token)
|
||||||
|
|> after_create_authorization(auth, %{"authorization" => params})
|
||||||
else
|
else
|
||||||
_ -> do_authorize(conn, params)
|
_ -> do_authorize(conn, params)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def authorize(%Plug.Conn{} = conn, params), do: do_authorize(conn, params)
|
defp maybe_reuse_token(conn, params, _user), do: do_authorize(conn, params)
|
||||||
|
|
||||||
defp do_authorize(%Plug.Conn{} = conn, params) do
|
defp do_authorize(%Plug.Conn{} = conn, params) do
|
||||||
app = Repo.get_by(App, client_id: params["client_id"])
|
app = Repo.get_by(App, client_id: params["client_id"])
|
||||||
|
@ -148,7 +169,9 @@ def create_authorization(%Plug.Conn{assigns: %{user: %User{} = user}} = conn, pa
|
||||||
def create_authorization(%Plug.Conn{} = conn, %{"authorization" => _} = params, opts) do
|
def create_authorization(%Plug.Conn{} = conn, %{"authorization" => _} = params, opts) do
|
||||||
with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]),
|
with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]),
|
||||||
{:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do
|
{:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do
|
||||||
after_create_authorization(conn, auth, params)
|
conn
|
||||||
|
|> AuthHelper.put_session_user(user.id)
|
||||||
|
|> after_create_authorization(auth, params)
|
||||||
else
|
else
|
||||||
error ->
|
error ->
|
||||||
handle_create_authorization_error(conn, error, params)
|
handle_create_authorization_error(conn, error, params)
|
||||||
|
@ -269,7 +292,7 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"}
|
||||||
fixed_token = Token.Utils.fix_padding(params["code"]),
|
fixed_token = Token.Utils.fix_padding(params["code"]),
|
||||||
{:ok, auth} <- Authorization.get_by_token(app, fixed_token),
|
{:ok, auth} <- Authorization.get_by_token(app, fixed_token),
|
||||||
%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) do
|
{:ok, token} <- Token.get_or_exchange_token(auth, app, user) do
|
||||||
after_token_exchange(conn, %{user: user, token: token})
|
after_token_exchange(conn, %{user: user, token: token})
|
||||||
else
|
else
|
||||||
error ->
|
error ->
|
||||||
|
@ -321,6 +344,7 @@ def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
|
||||||
def after_token_exchange(%Plug.Conn{} = conn, %{token: token} = view_params) do
|
def after_token_exchange(%Plug.Conn{} = conn, %{token: token} = view_params) do
|
||||||
conn
|
conn
|
||||||
|> AuthHelper.put_session_token(token.token)
|
|> AuthHelper.put_session_token(token.token)
|
||||||
|
|> AuthHelper.put_session_user(token.user_id)
|
||||||
|> json(OAuthView.render("token.json", view_params))
|
|> json(OAuthView.render("token.json", view_params))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -70,6 +70,16 @@ def exchange_token(app, auth) do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_preeexisting_by_app_and_user(app, user) do
|
||||||
|
Query.get_by_app(app.id)
|
||||||
|
|> Query.get_by_user(user.id)
|
||||||
|
|> Query.get_unexpired()
|
||||||
|
|> Query.preload([:user])
|
||||||
|
|> Query.sort_by_inserted_at()
|
||||||
|
|> Query.limit(1)
|
||||||
|
|> Repo.find_resource()
|
||||||
|
end
|
||||||
|
|
||||||
defp put_token(changeset) do
|
defp put_token(changeset) do
|
||||||
changeset
|
changeset
|
||||||
|> change(%{token: Token.Utils.generate_token()})
|
|> change(%{token: Token.Utils.generate_token()})
|
||||||
|
@ -86,6 +96,14 @@ defp put_refresh_token(changeset, attrs) do
|
||||||
|> unique_constraint(:refresh_token)
|
|> unique_constraint(:refresh_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_or_exchange_token(%Authorization{} = auth, %App{} = app, %User{} = user) do
|
||||||
|
if auth.used do
|
||||||
|
get_preeexisting_by_app_and_user(app, user)
|
||||||
|
else
|
||||||
|
exchange_token(app, auth)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp put_valid_until(changeset, attrs) do
|
defp put_valid_until(changeset, attrs) do
|
||||||
valid_until =
|
valid_until =
|
||||||
Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), lifespan()))
|
Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), lifespan()))
|
||||||
|
|
|
@ -38,6 +38,19 @@ def get_by_user(query \\ Token, user_id) do
|
||||||
from(q in query, where: q.user_id == ^user_id)
|
from(q in query, where: q.user_id == ^user_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_unexpired(query) do
|
||||||
|
now = NaiveDateTime.utc_now()
|
||||||
|
from(q in query, where: q.valid_until > ^now)
|
||||||
|
end
|
||||||
|
|
||||||
|
def limit(query, limit) do
|
||||||
|
from(q in query, limit: ^limit)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_by_inserted_at(query) do
|
||||||
|
from(q in query, order_by: [desc: :updated_at])
|
||||||
|
end
|
||||||
|
|
||||||
@spec preload(query, any) :: query
|
@spec preload(query, any) :: query
|
||||||
def preload(query \\ Token, assoc_preload \\ [])
|
def preload(query \\ Token, assoc_preload \\ [])
|
||||||
|
|
||||||
|
|
|
@ -494,6 +494,129 @@ test "renders authentication page if user is already authenticated but user requ
|
||||||
assert html_response(conn, 200) =~ ~s(type="submit")
|
assert html_response(conn, 200) =~ ~s(type="submit")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "allows access if the user has a prior authorization but is authenticated with another client",
|
||||||
|
%{
|
||||||
|
app: app,
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
user = insert(:user)
|
||||||
|
token = insert(:oauth_token, app: app, user: user)
|
||||||
|
|
||||||
|
other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
|
||||||
|
authorization = insert(:oauth_authorization, user: user, app: other_app)
|
||||||
|
_reusable_token = insert(:oauth_token, app: other_app, user: user)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> AuthHelper.put_session_token(token.token)
|
||||||
|
|> AuthHelper.put_session_user(user.id)
|
||||||
|
|> get(
|
||||||
|
"/oauth/authorize",
|
||||||
|
%{
|
||||||
|
"response_type" => "code",
|
||||||
|
"client_id" => other_app.client_id,
|
||||||
|
"redirect_uri" => OAuthController.default_redirect_uri(other_app),
|
||||||
|
"scope" => "read"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert URI.decode(redirected_to(conn)) ==
|
||||||
|
"https://other_redirect.url?code=#{authorization.token}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders login page if the user has an authorization but no token",
|
||||||
|
%{
|
||||||
|
app: app,
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
user = insert(:user)
|
||||||
|
token = insert(:oauth_token, app: app, user: user)
|
||||||
|
|
||||||
|
other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
|
||||||
|
_authorization = insert(:oauth_authorization, user: user, app: other_app)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> AuthHelper.put_session_token(token.token)
|
||||||
|
|> AuthHelper.put_session_user(user.id)
|
||||||
|
|> get(
|
||||||
|
"/oauth/authorize",
|
||||||
|
%{
|
||||||
|
"response_type" => "code",
|
||||||
|
"client_id" => other_app.client_id,
|
||||||
|
"redirect_uri" => OAuthController.default_redirect_uri(other_app),
|
||||||
|
"scope" => "read"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert html_response(conn, 200) =~ ~s(type="submit")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not reuse other people's tokens",
|
||||||
|
%{
|
||||||
|
app: app,
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
user = insert(:user)
|
||||||
|
other_user = insert(:user)
|
||||||
|
token = insert(:oauth_token, app: app, user: user)
|
||||||
|
|
||||||
|
other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
|
||||||
|
_authorization = insert(:oauth_authorization, user: other_user, app: other_app)
|
||||||
|
_reusable_token = insert(:oauth_token, app: other_app, user: other_user)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> AuthHelper.put_session_token(token.token)
|
||||||
|
|> AuthHelper.put_session_user(user.id)
|
||||||
|
|> get(
|
||||||
|
"/oauth/authorize",
|
||||||
|
%{
|
||||||
|
"response_type" => "code",
|
||||||
|
"client_id" => other_app.client_id,
|
||||||
|
"redirect_uri" => OAuthController.default_redirect_uri(other_app),
|
||||||
|
"scope" => "read"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert html_response(conn, 200) =~ ~s(type="submit")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not reuse expired tokens",
|
||||||
|
%{
|
||||||
|
app: app,
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
user = insert(:user)
|
||||||
|
token = insert(:oauth_token, app: app, user: user)
|
||||||
|
|
||||||
|
other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
|
||||||
|
_authorization = insert(:oauth_authorization, user: user, app: other_app)
|
||||||
|
|
||||||
|
_reusable_token =
|
||||||
|
insert(:oauth_token,
|
||||||
|
app: other_app,
|
||||||
|
user: user,
|
||||||
|
valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), -100)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> AuthHelper.put_session_token(token.token)
|
||||||
|
|> AuthHelper.put_session_user(user.id)
|
||||||
|
|> get(
|
||||||
|
"/oauth/authorize",
|
||||||
|
%{
|
||||||
|
"response_type" => "code",
|
||||||
|
"client_id" => other_app.client_id,
|
||||||
|
"redirect_uri" => OAuthController.default_redirect_uri(other_app),
|
||||||
|
"scope" => "read"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert html_response(conn, 200) =~ ~s(type="submit")
|
||||||
|
end
|
||||||
|
|
||||||
test "with existing authentication and non-OOB `redirect_uri`, redirects to app with `token` and `state` params",
|
test "with existing authentication and non-OOB `redirect_uri`, redirects to app with `token` and `state` params",
|
||||||
%{
|
%{
|
||||||
app: app,
|
app: app,
|
||||||
|
|
Loading…
Reference in a new issue