Session-based OAuth auth fixes (token expiration check), refactoring, tweaks.

This commit is contained in:
Ivan Tashkinov 2020-11-21 19:47:25 +03:00
parent 73e66fd31f
commit ccc2cf0e87
11 changed files with 164 additions and 196 deletions

View file

@ -5,13 +5,21 @@
defmodule Pleroma.Helpers.AuthHelper do defmodule Pleroma.Helpers.AuthHelper do
alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.OAuthScopesPlug
import Plug.Conn
@doc """ @doc """
Skips OAuth permissions (scopes) checks, assigns nil `:token`. Skips OAuth permissions (scopes) checks, assigns nil `:token`.
Intended to be used with explicit authentication and only when OAuth token cannot be determined. Intended to be used with explicit authentication and only when OAuth token cannot be determined.
""" """
def skip_oauth(conn) do def skip_oauth(conn) do
conn conn
|> Plug.Conn.assign(:token, nil) |> assign(:token, nil)
|> OAuthScopesPlug.skip_plug() |> OAuthScopesPlug.skip_plug()
end end
def drop_auth_info(conn) do
conn
|> assign(:user, nil)
|> assign(:token, nil)
end
end end

View file

@ -363,7 +363,15 @@ defp handle_token_exchange_error(%Plug.Conn{} = conn, _error) do
def token_revoke(%Plug.Conn{} = conn, %{"token" => _token} = params) do def token_revoke(%Plug.Conn{} = conn, %{"token" => _token} = params) do
with {:ok, app} <- Token.Utils.fetch_app(conn), with {:ok, app} <- Token.Utils.fetch_app(conn),
{:ok, _token} <- RevokeToken.revoke(app, params) do {:ok, %Token{} = oauth_token} <- RevokeToken.revoke(app, params) do
conn =
with session_token = get_session(conn, :oauth_token),
%Token{token: ^session_token} <- oauth_token do
delete_session(conn, :oauth_token)
else
_ -> conn
end
json(conn, %{}) json(conn, %{})
else else
_error -> _error ->

View file

@ -27,6 +27,14 @@ defmodule Pleroma.Web.OAuth.Token do
timestamps() timestamps()
end end
@doc "Gets token by unique access token"
@spec get_by_token(String.t()) :: {:ok, t()} | {:error, :not_found}
def get_by_token(token) do
token
|> Query.get_by_token()
|> Repo.find_resource()
end
@doc "Gets token for app by access token" @doc "Gets token for app by access token"
@spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} @spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found}
def get_by_token(%App{id: app_id} = _app, token) do def get_by_token(%App{id: app_id} = _app, token) do

View file

@ -3,6 +3,8 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.OAuthPlug do defmodule Pleroma.Web.Plugs.OAuthPlug do
@moduledoc "Performs OAuth authentication by token from params / headers / cookies."
import Plug.Conn import Plug.Conn
import Ecto.Query import Ecto.Query
@ -17,45 +19,26 @@ def init(options), do: options
def call(%{assigns: %{user: %User{}}} = conn, _), do: conn def call(%{assigns: %{user: %User{}}} = conn, _), do: conn
def call(%{params: %{"access_token" => access_token}} = conn, _) do
with {:ok, user, token_record} <- fetch_user_and_token(access_token) do
conn
|> assign(:token, token_record)
|> assign(:user, user)
else
_ ->
# token found, but maybe only with app
with {:ok, app, token_record} <- fetch_app_and_token(access_token) do
conn
|> assign(:token, token_record)
|> assign(:app, app)
else
_ -> conn
end
end
end
def call(conn, _) do def call(conn, _) do
case fetch_token_str(conn) do with {:ok, token_str} <- fetch_token_str(conn) do
{:ok, token} -> with {:ok, user, user_token} <- fetch_user_and_token(token_str),
with {:ok, user, token_record} <- fetch_user_and_token(token) do false <- Token.is_expired?(user_token) do
conn
|> assign(:token, token_record)
|> assign(:user, user)
else
_ ->
# token found, but maybe only with app
with {:ok, app, token_record} <- fetch_app_and_token(token) do
conn
|> assign(:token, token_record)
|> assign(:app, app)
else
_ -> conn
end
end
_ ->
conn conn
|> assign(:token, user_token)
|> assign(:user, user)
else
_ ->
with {:ok, app, app_token} <- fetch_app_and_token(token_str),
false <- Token.is_expired?(app_token) do
conn
|> assign(:token, app_token)
|> assign(:app, app)
else
_ -> conn
end
end
else
_ -> conn
end end
end end
@ -70,7 +53,6 @@ defp fetch_user_and_token(token) do
preload: [user: user] preload: [user: user]
) )
# credo:disable-for-next-line Credo.Check.Readability.MaxLineLength
with %Token{user: user} = token_record <- Repo.one(query) do with %Token{user: user} = token_record <- Repo.one(query) do
{:ok, user, token_record} {:ok, user, token_record}
end end
@ -86,29 +68,23 @@ defp fetch_app_and_token(token) do
end end
end end
# Gets token from session by :oauth_token key # Gets token string from conn (in params / headers / session)
# #
@spec fetch_token_from_session(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()} @spec fetch_token_str(Plug.Conn.t() | list(String.t())) :: :no_token_found | {:ok, String.t()}
defp fetch_token_from_session(conn) do defp fetch_token_str(%Plug.Conn{params: %{"access_token" => access_token}} = _conn) do
case get_session(conn, :oauth_token) do {:ok, access_token}
nil -> :no_token_found
token -> {:ok, token}
end
end end
# Gets token from headers
#
@spec fetch_token_str(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()}
defp fetch_token_str(%Plug.Conn{} = conn) do defp fetch_token_str(%Plug.Conn{} = conn) do
headers = get_req_header(conn, "authorization") headers = get_req_header(conn, "authorization")
with :no_token_found <- fetch_token_str(headers), with {:ok, token} <- fetch_token_str(headers) do
do: fetch_token_from_session(conn) {:ok, token}
else
_ -> fetch_token_from_session(conn)
end
end end
@spec fetch_token_str(Keyword.t()) :: :no_token_found | {:ok, String.t()}
defp fetch_token_str([]), do: :no_token_found
defp fetch_token_str([token | tail]) do defp fetch_token_str([token | tail]) do
trimmed_token = String.trim(token) trimmed_token = String.trim(token)
@ -117,4 +93,14 @@ defp fetch_token_str([token | tail]) do
_ -> fetch_token_str(tail) _ -> fetch_token_str(tail)
end end
end end
defp fetch_token_str([]), do: :no_token_found
@spec fetch_token_from_session(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()}
defp fetch_token_from_session(conn) do
case get_session(conn, :oauth_token) do
nil -> :no_token_found
token -> {:ok, token}
end
end
end end

View file

@ -1,31 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.SessionAuthenticationPlug do
@moduledoc """
Authenticates user by session-stored `:user_id` and request-contained username.
Username can be provided via HTTP Basic Auth (the password is not checked and can be anything).
"""
import Plug.Conn
alias Pleroma.Helpers.AuthHelper
def init(options) do
options
end
def call(%{assigns: %{user: %Pleroma.User{}}} = conn, _), do: conn
def call(conn, _) do
with saved_user_id <- get_session(conn, :user_id),
%{auth_user: %{id: ^saved_user_id}} <- conn.assigns do
conn
|> assign(:user, conn.assigns.auth_user)
|> AuthHelper.skip_oauth()
else
_ -> conn
end
end
end

View file

@ -4,14 +4,15 @@
defmodule Pleroma.Web.Plugs.SetUserSessionIdPlug do defmodule Pleroma.Web.Plugs.SetUserSessionIdPlug do
import Plug.Conn import Plug.Conn
alias Pleroma.User
alias Pleroma.Web.OAuth.Token
def init(opts) do def init(opts) do
opts opts
end end
def call(%{assigns: %{user: %User{id: id}}} = conn, _) do def call(%{assigns: %{token: %Token{} = oauth_token}} = conn, _) do
put_session(conn, :user_id, id) put_session(conn, :oauth_token, oauth_token.token)
end end
def call(conn, _), do: conn def call(conn, _), do: conn

View file

@ -3,7 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.UserEnabledPlug do defmodule Pleroma.Web.Plugs.UserEnabledPlug do
import Plug.Conn alias Pleroma.Helpers.AuthHelper
alias Pleroma.User alias Pleroma.User
def init(options) do def init(options) do
@ -12,8 +12,11 @@ def init(options) do
def call(%{assigns: %{user: %User{} = user}} = conn, _) do def call(%{assigns: %{user: %User{} = user}} = conn, _) do
case User.account_status(user) do case User.account_status(user) do
:active -> conn :active ->
_ -> assign(conn, :user, nil) conn
_ ->
AuthHelper.drop_auth_info(conn)
end end
end end

View file

@ -34,6 +34,7 @@ defmodule Pleroma.Web.Router do
plug(:fetch_session) plug(:fetch_session)
plug(Pleroma.Web.Plugs.OAuthPlug) plug(Pleroma.Web.Plugs.OAuthPlug)
plug(Pleroma.Web.Plugs.UserEnabledPlug) plug(Pleroma.Web.Plugs.UserEnabledPlug)
plug(Pleroma.Web.Plugs.EnsureUserKeyPlug)
end end
pipeline :expect_authentication do pipeline :expect_authentication do
@ -48,7 +49,6 @@ defmodule Pleroma.Web.Router do
plug(Pleroma.Web.Plugs.OAuthPlug) plug(Pleroma.Web.Plugs.OAuthPlug)
plug(Pleroma.Web.Plugs.BasicAuthDecoderPlug) plug(Pleroma.Web.Plugs.BasicAuthDecoderPlug)
plug(Pleroma.Web.Plugs.UserFetcherPlug) plug(Pleroma.Web.Plugs.UserFetcherPlug)
plug(Pleroma.Web.Plugs.SessionAuthenticationPlug)
plug(Pleroma.Web.Plugs.AuthenticationPlug) plug(Pleroma.Web.Plugs.AuthenticationPlug)
end end
@ -319,18 +319,24 @@ defmodule Pleroma.Web.Router do
scope "/oauth", Pleroma.Web.OAuth do scope "/oauth", Pleroma.Web.OAuth do
scope [] do scope [] do
pipe_through(:oauth) pipe_through(:oauth)
get("/authorize", OAuthController, :authorize) get("/authorize", OAuthController, :authorize)
post("/authorize", OAuthController, :create_authorization)
end end
post("/authorize", OAuthController, :create_authorization)
post("/token", OAuthController, :token_exchange) post("/token", OAuthController, :token_exchange)
post("/revoke", OAuthController, :token_revoke)
get("/registration_details", OAuthController, :registration_details) get("/registration_details", OAuthController, :registration_details)
post("/mfa/challenge", MFAController, :challenge) post("/mfa/challenge", MFAController, :challenge)
post("/mfa/verify", MFAController, :verify, as: :mfa_verify) post("/mfa/verify", MFAController, :verify, as: :mfa_verify)
get("/mfa", MFAController, :show) get("/mfa", MFAController, :show)
scope [] do
pipe_through(:fetch_session)
post("/revoke", OAuthController, :token_revoke)
end
scope [] do scope [] do
pipe_through(:browser) pipe_through(:browser)

View file

@ -5,43 +5,48 @@
defmodule Pleroma.Web.Plugs.OAuthPlugTest do defmodule Pleroma.Web.Plugs.OAuthPlugTest do
use Pleroma.Web.ConnCase, async: true use Pleroma.Web.ConnCase, async: true
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.OAuth.Token.Strategy.Revoke
alias Pleroma.Web.Plugs.OAuthPlug alias Pleroma.Web.Plugs.OAuthPlug
import Pleroma.Factory alias Plug.Session
@session_opts [ import Pleroma.Factory
store: :cookie,
key: "_test",
signing_salt: "cooldude"
]
setup %{conn: conn} do setup %{conn: conn} do
user = insert(:user) user = insert(:user)
{:ok, %{token: token}} = Pleroma.Web.OAuth.Token.create(insert(:oauth_app), user) {:ok, oauth_token} = Token.create(insert(:oauth_app), user)
%{user: user, token: token, conn: conn} %{user: user, token: oauth_token, conn: conn}
end end
test "with valid token(uppercase), it assigns the user", %{conn: conn} = opts do test "it does nothing if a user is assigned", %{conn: conn} do
conn = assign(conn, :user, %Pleroma.User{})
ret_conn = OAuthPlug.call(conn, %{})
assert ret_conn == conn
end
test "with valid token (uppercase) in auth header, it assigns the user", %{conn: conn} = opts do
conn = conn =
conn conn
|> put_req_header("authorization", "BEARER #{opts[:token]}") |> put_req_header("authorization", "BEARER #{opts[:token].token}")
|> OAuthPlug.call(%{}) |> OAuthPlug.call(%{})
assert conn.assigns[:user] == opts[:user] assert conn.assigns[:user] == opts[:user]
end end
test "with valid token(downcase), it assigns the user", %{conn: conn} = opts do test "with valid token (downcase) in auth header, it assigns the user", %{conn: conn} = opts do
conn = conn =
conn conn
|> put_req_header("authorization", "bearer #{opts[:token]}") |> put_req_header("authorization", "bearer #{opts[:token].token}")
|> OAuthPlug.call(%{}) |> OAuthPlug.call(%{})
assert conn.assigns[:user] == opts[:user] assert conn.assigns[:user] == opts[:user]
end end
test "with valid token(downcase) in url parameters, it assigns the user", opts do test "with valid token (downcase) in url parameters, it assigns the user", opts do
conn = conn =
:get :get
|> build_conn("/?access_token=#{opts[:token]}") |> build_conn("/?access_token=#{opts[:token].token}")
|> put_req_header("content-type", "application/json") |> put_req_header("content-type", "application/json")
|> fetch_query_params() |> fetch_query_params()
|> OAuthPlug.call(%{}) |> OAuthPlug.call(%{})
@ -49,16 +54,16 @@ test "with valid token(downcase) in url parameters, it assigns the user", opts d
assert conn.assigns[:user] == opts[:user] assert conn.assigns[:user] == opts[:user]
end end
test "with valid token(downcase) in body parameters, it assigns the user", opts do test "with valid token (downcase) in body parameters, it assigns the user", opts do
conn = conn =
:post :post
|> build_conn("/api/v1/statuses", access_token: opts[:token], status: "test") |> build_conn("/api/v1/statuses", access_token: opts[:token].token, status: "test")
|> OAuthPlug.call(%{}) |> OAuthPlug.call(%{})
assert conn.assigns[:user] == opts[:user] assert conn.assigns[:user] == opts[:user]
end end
test "with invalid token, it not assigns the user", %{conn: conn} do test "with invalid token, it does not assign the user", %{conn: conn} do
conn = conn =
conn conn
|> put_req_header("authorization", "bearer TTTTT") |> put_req_header("authorization", "bearer TTTTT")
@ -67,14 +72,56 @@ test "with invalid token, it not assigns the user", %{conn: conn} do
refute conn.assigns[:user] refute conn.assigns[:user]
end end
test "when token is missed but token in session, it assigns the user", %{conn: conn} = opts do describe "with :oauth_token in session, " do
conn = setup %{token: oauth_token, conn: conn} do
conn session_opts = [
|> Plug.Session.call(Plug.Session.init(@session_opts)) store: :cookie,
|> fetch_session() key: "_test",
|> put_session(:oauth_token, opts[:token]) signing_salt: "cooldude"
|> OAuthPlug.call(%{}) ]
assert conn.assigns[:user] == opts[:user] conn =
conn
|> Session.call(Session.init(session_opts))
|> fetch_session()
|> put_session(:oauth_token, oauth_token.token)
%{conn: conn}
end
test "if session-stored token matches a valid OAuth token, assigns :user and :token", %{
conn: conn,
user: user,
token: oauth_token
} do
conn = OAuthPlug.call(conn, %{})
assert conn.assigns.user && conn.assigns.user.id == user.id
assert conn.assigns.token && conn.assigns.token.id == oauth_token.id
end
test "if session-stored token matches an expired OAuth token, does nothing", %{
conn: conn,
token: oauth_token
} do
expired_valid_until = NaiveDateTime.add(NaiveDateTime.utc_now(), -3600 * 24, :second)
oauth_token
|> Ecto.Changeset.change(valid_until: expired_valid_until)
|> Pleroma.Repo.update()
ret_conn = OAuthPlug.call(conn, %{})
assert ret_conn == conn
end
test "if session-stored token matches a revoked OAuth token, does nothing", %{
conn: conn,
token: oauth_token
} do
Revoke.revoke(oauth_token)
ret_conn = OAuthPlug.call(conn, %{})
assert ret_conn == conn
end
end end
end end

View file

@ -1,65 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.SessionAuthenticationPlugTest do
use Pleroma.Web.ConnCase, async: true
alias Pleroma.User
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Plugs.PlugHelper
alias Pleroma.Web.Plugs.SessionAuthenticationPlug
setup %{conn: conn} do
session_opts = [
store: :cookie,
key: "_test",
signing_salt: "cooldude"
]
conn =
conn
|> Plug.Session.call(Plug.Session.init(session_opts))
|> fetch_session()
|> assign(:auth_user, %User{id: 1})
%{conn: conn}
end
test "it does nothing if a user is assigned", %{conn: conn} do
conn = assign(conn, :user, %User{})
ret_conn = SessionAuthenticationPlug.call(conn, %{})
assert ret_conn == conn
end
# Scenario: requester has the cookie and knows the username (not necessarily knows the password)
test "if the auth_user has the same id as the user_id in the session, it assigns the user", %{
conn: conn
} do
conn =
conn
|> put_session(:user_id, conn.assigns.auth_user.id)
|> SessionAuthenticationPlug.call(%{})
assert conn.assigns.user == conn.assigns.auth_user
assert conn.assigns.token == nil
assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
end
# Scenario: requester has the cookie but doesn't know the username
test "if the auth_user has a different id as the user_id in the session, it does nothing", %{
conn: conn
} do
conn = put_session(conn, :user_id, -1)
ret_conn = SessionAuthenticationPlug.call(conn, %{})
assert ret_conn == conn
end
test "if the session does not contain user_id, it does nothing", %{
conn: conn
} do
assert conn == SessionAuthenticationPlug.call(conn, %{})
end
end

View file

@ -5,7 +5,6 @@
defmodule Pleroma.Web.Plugs.SetUserSessionIdPlugTest do defmodule Pleroma.Web.Plugs.SetUserSessionIdPlugTest do
use Pleroma.Web.ConnCase, async: true use Pleroma.Web.ConnCase, async: true
alias Pleroma.User
alias Pleroma.Web.Plugs.SetUserSessionIdPlug alias Pleroma.Web.Plugs.SetUserSessionIdPlug
setup %{conn: conn} do setup %{conn: conn} do
@ -18,28 +17,26 @@ defmodule Pleroma.Web.Plugs.SetUserSessionIdPlugTest do
conn = conn =
conn conn
|> Plug.Session.call(Plug.Session.init(session_opts)) |> Plug.Session.call(Plug.Session.init(session_opts))
|> fetch_session |> fetch_session()
%{conn: conn} %{conn: conn}
end end
test "doesn't do anything if the user isn't set", %{conn: conn} do test "doesn't do anything if the user isn't set", %{conn: conn} do
ret_conn = ret_conn = SetUserSessionIdPlug.call(conn, %{})
conn
|> SetUserSessionIdPlug.call(%{})
assert ret_conn == conn assert ret_conn == conn
end end
test "sets the user_id in the session to the user id of the user assign", %{conn: conn} do test "sets :oauth_token in session to :token assign", %{conn: conn} do
Code.ensure_compiled(Pleroma.User) %{user: user, token: oauth_token} = oauth_access(["read"])
conn = ret_conn =
conn conn
|> assign(:user, %User{id: 1}) |> assign(:user, user)
|> assign(:token, oauth_token)
|> SetUserSessionIdPlug.call(%{}) |> SetUserSessionIdPlug.call(%{})
id = get_session(conn, :user_id) assert get_session(ret_conn, :oauth_token) == oauth_token.token
assert id == 1
end end
end end