[#923] OAuth: prototype of sign in / sign up with Twitter.

This commit is contained in:
Ivan Tashkinov 2019-03-15 17:08:03 +03:00
parent 63ab61ed3f
commit aacbf0f570
10 changed files with 209 additions and 37 deletions

View file

@ -369,11 +369,15 @@
rel: false rel: false
] ]
config :pleroma, :auth, oauth_consumer_enabled: false
config :ueberauth, config :ueberauth,
Ueberauth, Ueberauth,
base_path: "/oauth", base_path: "/oauth",
providers: [ providers: [
twitter: {Ueberauth.Strategy.Twitter, []} twitter:
{Ueberauth.Strategy.Twitter,
[callback_params: ~w[client_id redirect_uri scope scopes]]}
] ]
config :ueberauth, Ueberauth.Strategy.Twitter.OAuth, config :ueberauth, Ueberauth.Strategy.Twitter.OAuth,

View file

@ -40,6 +40,8 @@ defmodule Pleroma.User do
field(:email, :string) field(:email, :string)
field(:name, :string) field(:name, :string)
field(:nickname, :string) field(:nickname, :string)
field(:auth_provider, :string)
field(:auth_provider_uid, :string)
field(:password_hash, :string) field(:password_hash, :string)
field(:password, :string, virtual: true) field(:password, :string, virtual: true)
field(:password_confirmation, :string, virtual: true) field(:password_confirmation, :string, virtual: true)
@ -206,6 +208,36 @@ def reset_password(user, data) do
update_and_set_cache(password_update_changeset(user, data)) update_and_set_cache(password_update_changeset(user, data))
end end
# TODO: FIXME (WIP):
def oauth_register_changeset(struct, params \\ %{}) do
info_change = User.Info.confirmation_changeset(%User.Info{}, :confirmed)
changeset =
struct
|> cast(params, [:email, :nickname, :name, :bio, :auth_provider, :auth_provider_uid])
|> validate_required([:auth_provider, :auth_provider_uid])
|> unique_constraint(:email)
|> unique_constraint(:nickname)
|> validate_exclusion(:nickname, Pleroma.Config.get([Pleroma.User, :restricted_nicknames]))
|> validate_format(:email, @email_regex)
|> validate_length(:bio, max: 1000)
|> put_change(:info, info_change)
if changeset.valid? do
nickname = changeset.changes[:nickname]
ap_id = (nickname && User.ap_id(%User{nickname: nickname})) || nil
followers = User.ap_followers(%User{nickname: ap_id})
changeset
|> put_change(:ap_id, ap_id)
|> unique_constraint(:ap_id)
|> put_change(:following, [followers])
|> put_change(:follower_address, followers)
else
changeset
end
end
def register_changeset(struct, params \\ %{}, opts \\ []) do def register_changeset(struct, params \\ %{}, opts \\ []) do
confirmation_status = confirmation_status =
if opts[:confirmed] || !Pleroma.Config.get([:instance, :account_activation_required]) do if opts[:confirmed] || !Pleroma.Config.get([:instance, :account_activation_required]) do
@ -504,13 +536,19 @@ def get_by_nickname(nickname) do
end end
end end
def get_by_email(email), do: Repo.get_by(User, email: email)
def get_by_nickname_or_email(nickname_or_email) do def get_by_nickname_or_email(nickname_or_email) do
case user = Repo.get_by(User, nickname: nickname_or_email) do get_by_nickname(nickname_or_email) || get_by_email(nickname_or_email)
%User{} -> user
nil -> Repo.get_by(User, email: nickname_or_email)
end
end end
def get_by_auth_provider_uid(auth_provider, auth_provider_uid),
do:
Repo.get_by(User,
auth_provider: to_string(auth_provider),
auth_provider_uid: to_string(auth_provider_uid)
)
def get_cached_user_info(user) do def get_cached_user_info(user) do
key = "user_info:#{user.id}" key = "user_info:#{user.id}"
Cachex.fetch!(:user_cache, key, fn _ -> user_info(user) end) Cachex.fetch!(:user_cache, key, fn _ -> user_info(user) end)

View file

@ -12,8 +12,13 @@ def implementation do
) )
end end
@callback get_user(Plug.Conn.t()) :: {:ok, User.t()} | {:error, any()} @callback get_user(Plug.Conn.t(), Map.t()) :: {:ok, User.t()} | {:error, any()}
def get_user(plug), do: implementation().get_user(plug) def get_user(plug, params), do: implementation().get_user(plug, params)
@callback get_or_create_user_by_oauth(Plug.Conn.t(), Map.t()) ::
{:ok, User.t()} | {:error, any()}
def get_or_create_user_by_oauth(plug, params),
do: implementation().get_or_create_user_by_oauth(plug, params)
@callback handle_error(Plug.Conn.t(), any()) :: any() @callback handle_error(Plug.Conn.t(), any()) :: any()
def handle_error(plug, error), do: implementation().handle_error(plug, error) def handle_error(plug, error), do: implementation().handle_error(plug, error)

View file

@ -8,9 +8,9 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do
@behaviour Pleroma.Web.Auth.Authenticator @behaviour Pleroma.Web.Auth.Authenticator
def get_user(%Plug.Conn{} = conn) do def get_user(%Plug.Conn{} = _conn, %{
%{"authorization" => %{"name" => name, "password" => password}} = conn.params "authorization" => %{"name" => name, "password" => password}
}) do
with {_, %User{} = user} <- {:user, User.get_by_nickname_or_email(name)}, with {_, %User{} = user} <- {:user, User.get_by_nickname_or_email(name)},
{_, true} <- {:checkpw, Pbkdf2.checkpw(password, user.password_hash)} do {_, true} <- {:checkpw, Pbkdf2.checkpw(password, user.password_hash)} do
{:ok, user} {:ok, user}
@ -20,6 +20,56 @@ def get_user(%Plug.Conn{} = conn) do
end end
end end
def get_user(%Plug.Conn{} = _conn, _params), do: {:error, :missing_credentials}
def get_or_create_user_by_oauth(
%Plug.Conn{assigns: %{ueberauth_auth: %{provider: provider, uid: uid} = auth}},
_params
) do
user = User.get_by_auth_provider_uid(provider, uid)
if user do
{:ok, user}
else
info = auth.info
email = info.email
nickname = info.nickname
# TODO: FIXME: connect to existing (non-oauth) account (need a UI flow for that) / generate a random nickname?
email =
if email && User.get_by_email(email) do
nil
else
email
end
nickname =
if nickname && User.get_by_nickname(nickname) do
nil
else
nickname
end
new_user =
User.oauth_register_changeset(
%User{},
%{
auth_provider: to_string(provider),
auth_provider_uid: to_string(uid),
name: info.name,
bio: info.description,
email: email,
nickname: nickname
}
)
Pleroma.Repo.insert(new_user)
end
end
def get_or_create_user_by_oauth(%Plug.Conn{} = _conn, _params),
do: {:error, :missing_credentials}
def handle_error(%Plug.Conn{} = _conn, error) do def handle_error(%Plug.Conn{} = _conn, error) do
error error
end end

View file

@ -57,10 +57,17 @@ defmodule Pleroma.Web.Endpoint do
do: "__Host-pleroma_key", do: "__Host-pleroma_key",
else: "pleroma_key" else: "pleroma_key"
same_site =
if Pleroma.Config.get([:auth, :oauth_consumer_enabled]) do
# Note: "SameSite=Strict" prevents sign in with external OAuth provider (no cookies during callback request)
"SameSite=Lax"
else
"SameSite=Strict"
end
# The session will be stored in the cookie and signed, # The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with. # this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it. # Set :encryption_salt if you would also like to encrypt it.
# Note: "SameSite=Strict" would cause issues with Twitter OAuth
plug( plug(
Plug.Session, Plug.Session,
store: :cookie, store: :cookie,
@ -68,7 +75,7 @@ defmodule Pleroma.Web.Endpoint do
signing_salt: {Pleroma.Config, :get, [[__MODULE__, :signing_salt], "CqaoopA2"]}, signing_salt: {Pleroma.Config, :get, [[__MODULE__, :signing_salt], "CqaoopA2"]},
http_only: true, http_only: true,
secure: secure_cookies, secure: secure_cookies,
extra: "SameSite=Lax" extra: same_site
) )
plug(Pleroma.Web.Router) plug(Pleroma.Web.Router)

View file

@ -15,20 +15,57 @@ defmodule Pleroma.Web.OAuth.OAuthController do
import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2] import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2]
plug(Ueberauth) if Pleroma.Config.get([:auth, :oauth_consumer_enabled]), do: plug(Ueberauth)
plug(:fetch_session) plug(:fetch_session)
plug(:fetch_flash) plug(:fetch_flash)
action_fallback(Pleroma.Web.OAuth.FallbackController) action_fallback(Pleroma.Web.OAuth.FallbackController)
def callback(%{assigns: %{ueberauth_failure: _failure}} = conn, _params) do def request(conn, params) do
message =
if params["provider"] do
"Unsupported OAuth provider: #{params["provider"]}."
else
"Bad OAuth request."
end
conn conn
|> put_flash(:error, "Failed to authenticate.") |> put_flash(:error, message)
|> redirect(to: "/") |> redirect(to: "/")
end end
def callback(%{assigns: %{ueberauth_auth: _auth}} = _conn, _params) do def callback(%{assigns: %{ueberauth_failure: failure}} = conn, %{"redirect_uri" => redirect_uri}) do
raise "Authenticated successfully. Sign up via OAuth is not yet implemented." messages = for e <- Map.get(failure, :errors, []), do: e.message
message = Enum.join(messages, "; ")
conn
|> put_flash(:error, "Failed to authenticate: #{message}.")
|> redirect(external: redirect_uri(conn, redirect_uri))
end
def callback(
conn,
%{"client_id" => client_id, "redirect_uri" => redirect_uri} = params
) do
with {:ok, user} <- Authenticator.get_or_create_user_by_oauth(conn, params) do
do_create_authorization(
conn,
%{
"authorization" => %{
"client_id" => client_id,
"redirect_uri" => redirect_uri,
"scope" => oauth_scopes(params, nil)
}
},
user
)
else
_ ->
conn
|> put_flash(:error, "Failed to set up user account.")
|> redirect(external: redirect_uri(conn, redirect_uri))
end
end end
def authorize(conn, params) do def authorize(conn, params) do
@ -47,14 +84,21 @@ def authorize(conn, params) do
}) })
end end
def create_authorization(conn, %{ def create_authorization(conn, params), do: do_create_authorization(conn, params, nil)
defp do_create_authorization(
conn,
%{
"authorization" => "authorization" =>
%{ %{
"client_id" => client_id, "client_id" => client_id,
"redirect_uri" => redirect_uri "redirect_uri" => redirect_uri
} = auth_params } = auth_params
}) do } = params,
with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn)}, user
) do
with {_, {:ok, %User{} = user}} <-
{:get_user, (user && {:ok, user}) || Authenticator.get_user(conn, params)},
%App{} = app <- Repo.get_by(App, client_id: client_id), %App{} = app <- Repo.get_by(App, client_id: client_id),
true <- redirect_uri in String.split(app.redirect_uris), true <- redirect_uri in String.split(app.redirect_uris),
scopes <- oauth_scopes(auth_params, []), scopes <- oauth_scopes(auth_params, []),
@ -63,13 +107,7 @@ def create_authorization(conn, %{
{:missing_scopes, false} <- {:missing_scopes, scopes == []}, {:missing_scopes, false} <- {:missing_scopes, scopes == []},
{:auth_active, true} <- {:auth_active, User.auth_active?(user)}, {:auth_active, true} <- {:auth_active, User.auth_active?(user)},
{:ok, auth} <- Authorization.create_authorization(app, user, scopes) do {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
redirect_uri = redirect_uri = redirect_uri(conn, redirect_uri)
if redirect_uri == "." do
# Special case: Local MastodonFE
mastodon_api_url(conn, :login)
else
redirect_uri
end
cond do cond do
redirect_uri == "urn:ietf:wg:oauth:2.0:oob" -> redirect_uri == "urn:ietf:wg:oauth:2.0:oob" ->
@ -225,4 +263,9 @@ defp get_app_from_request(conn, params) do
nil nil
end end
end end
# Special case: Local MastodonFE
defp redirect_uri(conn, "."), do: mastodon_api_url(conn, :login)
defp redirect_uri(_conn, redirect_uri), do: redirect_uri
end end

View file

@ -5,5 +5,4 @@
defmodule Pleroma.Web.OAuth.OAuthView do defmodule Pleroma.Web.OAuth.OAuthView do
use Pleroma.Web, :view use Pleroma.Web, :view
import Phoenix.HTML.Form import Phoenix.HTML.Form
import Phoenix.HTML.Link
end end

View file

@ -0,0 +1,14 @@
<h2>External OAuth Authorization</h2>
<%= form_for @conn, o_auth_path(@conn, :request, :twitter), [method: "get"], fn f -> %>
<div class="scopes-input">
<%= label f, :scope, "Permissions" %>
<div class="scopes">
<%= text_input f, :scope, value: Enum.join(@available_scopes, " ") %>
</div>
</div>
<%= hidden_input f, :client_id, value: @client_id %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :state, value: @state%>
<%= submit "Sign in with Twitter" %>
<% end %>

View file

@ -36,7 +36,7 @@
<%= submit "Authorize" %> <%= submit "Authorize" %>
<% end %> <% end %>
<br> <%= if Pleroma.Config.get([:auth, :oauth_consumer_enabled]) do %>
<%= link to: "/oauth/twitter", class: "alert alert-info" do %> <br>
Sign in with Twitter <%= render @view_module, "consumer.html", assigns %>
<% end %> <% end %>

View file

@ -0,0 +1,12 @@
defmodule Pleroma.Repo.Migrations.AddAuthProviderAndAuthProviderUidToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :auth_provider, :string
add :auth_provider_uid, :string
end
create unique_index(:users, [:auth_provider, :auth_provider_uid])
end
end