Session-based OAuth auth fixes (token expiration check), refactoring, tweaks.
[akkoma] / lib / pleroma / web / o_auth / o_auth_controller.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.OAuth.OAuthController do
6 use Pleroma.Web, :controller
7
8 alias Pleroma.Helpers.UriHelper
9 alias Pleroma.Maps
10 alias Pleroma.MFA
11 alias Pleroma.Registration
12 alias Pleroma.Repo
13 alias Pleroma.User
14 alias Pleroma.Web.Auth.Authenticator
15 alias Pleroma.Web.ControllerHelper
16 alias Pleroma.Web.OAuth.App
17 alias Pleroma.Web.OAuth.Authorization
18 alias Pleroma.Web.OAuth.MFAController
19 alias Pleroma.Web.OAuth.MFAView
20 alias Pleroma.Web.OAuth.OAuthView
21 alias Pleroma.Web.OAuth.Scopes
22 alias Pleroma.Web.OAuth.Token
23 alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken
24 alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken
25 alias Pleroma.Web.Plugs.RateLimiter
26
27 require Logger
28
29 if Pleroma.Config.oauth_consumer_enabled?(), do: plug(Ueberauth)
30
31 plug(:fetch_session)
32 plug(:fetch_flash)
33
34 plug(:skip_plug, [
35 Pleroma.Web.Plugs.OAuthScopesPlug,
36 Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug
37 ])
38
39 plug(RateLimiter, [name: :authentication] when action == :create_authorization)
40
41 action_fallback(Pleroma.Web.OAuth.FallbackController)
42
43 @oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob"
44
45 # Note: this definition is only called from error-handling methods with `conn.params` as 2nd arg
46 def authorize(%Plug.Conn{} = conn, %{"authorization" => _} = params) do
47 {auth_attrs, params} = Map.pop(params, "authorization")
48 authorize(conn, Map.merge(params, auth_attrs))
49 end
50
51 def authorize(%Plug.Conn{assigns: %{token: %Token{}}} = conn, %{"force_login" => _} = params) do
52 if ControllerHelper.truthy_param?(params["force_login"]) do
53 do_authorize(conn, params)
54 else
55 handle_existing_authorization(conn, params)
56 end
57 end
58
59 # Note: the token is set in oauth_plug, but the token and client do not always go together.
60 # For example, MastodonFE's token is set if user requests with another client,
61 # after user already authorized to MastodonFE.
62 # So we have to check client and token.
63 def authorize(
64 %Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
65 %{"client_id" => client_id} = params
66 ) do
67 with %Token{} = t <- Repo.get_by(Token, token: token.token) |> Repo.preload(:app),
68 ^client_id <- t.app.client_id do
69 handle_existing_authorization(conn, params)
70 else
71 _ -> do_authorize(conn, params)
72 end
73 end
74
75 def authorize(%Plug.Conn{} = conn, params), do: do_authorize(conn, params)
76
77 defp do_authorize(%Plug.Conn{} = conn, params) do
78 app = Repo.get_by(App, client_id: params["client_id"])
79 available_scopes = (app && app.scopes) || []
80 scopes = Scopes.fetch_scopes(params, available_scopes)
81
82 scopes =
83 if scopes == [] do
84 available_scopes
85 else
86 scopes
87 end
88
89 # Note: `params` might differ from `conn.params`; use `@params` not `@conn.params` in template
90 render(conn, Authenticator.auth_template(), %{
91 response_type: params["response_type"],
92 client_id: params["client_id"],
93 available_scopes: available_scopes,
94 scopes: scopes,
95 redirect_uri: params["redirect_uri"],
96 state: params["state"],
97 params: params
98 })
99 end
100
101 defp handle_existing_authorization(
102 %Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
103 %{"redirect_uri" => @oob_token_redirect_uri}
104 ) do
105 render(conn, "oob_token_exists.html", %{token: token})
106 end
107
108 defp handle_existing_authorization(
109 %Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
110 %{} = params
111 ) do
112 app = Repo.preload(token, :app).app
113
114 redirect_uri =
115 if is_binary(params["redirect_uri"]) do
116 params["redirect_uri"]
117 else
118 default_redirect_uri(app)
119 end
120
121 if redirect_uri in String.split(app.redirect_uris) do
122 redirect_uri = redirect_uri(conn, redirect_uri)
123 url_params = %{access_token: token.token}
124 url_params = Maps.put_if_present(url_params, :state, params["state"])
125 url = UriHelper.modify_uri_params(redirect_uri, url_params)
126 redirect(conn, external: url)
127 else
128 conn
129 |> put_flash(:error, dgettext("errors", "Unlisted redirect_uri."))
130 |> redirect(external: redirect_uri(conn, redirect_uri))
131 end
132 end
133
134 def create_authorization(
135 %Plug.Conn{} = conn,
136 %{"authorization" => _} = params,
137 opts \\ []
138 ) do
139 with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]),
140 {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do
141 after_create_authorization(conn, auth, params)
142 else
143 error ->
144 handle_create_authorization_error(conn, error, params)
145 end
146 end
147
148 def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{
149 "authorization" => %{"redirect_uri" => @oob_token_redirect_uri}
150 }) do
151 # Enforcing the view to reuse the template when calling from other controllers
152 conn
153 |> put_view(OAuthView)
154 |> render("oob_authorization_created.html", %{auth: auth})
155 end
156
157 def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{
158 "authorization" => %{"redirect_uri" => redirect_uri} = auth_attrs
159 }) do
160 app = Repo.preload(auth, :app).app
161
162 # An extra safety measure before we redirect (also done in `do_create_authorization/2`)
163 if redirect_uri in String.split(app.redirect_uris) do
164 redirect_uri = redirect_uri(conn, redirect_uri)
165 url_params = %{code: auth.token}
166 url_params = Maps.put_if_present(url_params, :state, auth_attrs["state"])
167 url = UriHelper.modify_uri_params(redirect_uri, url_params)
168 redirect(conn, external: url)
169 else
170 conn
171 |> put_flash(:error, dgettext("errors", "Unlisted redirect_uri."))
172 |> redirect(external: redirect_uri(conn, redirect_uri))
173 end
174 end
175
176 defp handle_create_authorization_error(
177 %Plug.Conn{} = conn,
178 {:error, scopes_issue},
179 %{"authorization" => _} = params
180 )
181 when scopes_issue in [:unsupported_scopes, :missing_scopes] do
182 # Per https://github.com/tootsuite/mastodon/blob/
183 # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39
184 conn
185 |> put_flash(:error, dgettext("errors", "This action is outside the authorized scopes"))
186 |> put_status(:unauthorized)
187 |> authorize(params)
188 end
189
190 defp handle_create_authorization_error(
191 %Plug.Conn{} = conn,
192 {:account_status, :confirmation_pending},
193 %{"authorization" => _} = params
194 ) do
195 conn
196 |> put_flash(:error, dgettext("errors", "Your login is missing a confirmed e-mail address"))
197 |> put_status(:forbidden)
198 |> authorize(params)
199 end
200
201 defp handle_create_authorization_error(
202 %Plug.Conn{} = conn,
203 {:mfa_required, user, auth, _},
204 params
205 ) do
206 {:ok, token} = MFA.Token.create(user, auth)
207
208 data = %{
209 "mfa_token" => token.token,
210 "redirect_uri" => params["authorization"]["redirect_uri"],
211 "state" => params["authorization"]["state"]
212 }
213
214 MFAController.show(conn, data)
215 end
216
217 defp handle_create_authorization_error(
218 %Plug.Conn{} = conn,
219 {:account_status, :password_reset_pending},
220 %{"authorization" => _} = params
221 ) do
222 conn
223 |> put_flash(:error, dgettext("errors", "Password reset is required"))
224 |> put_status(:forbidden)
225 |> authorize(params)
226 end
227
228 defp handle_create_authorization_error(
229 %Plug.Conn{} = conn,
230 {:account_status, :deactivated},
231 %{"authorization" => _} = params
232 ) do
233 conn
234 |> put_flash(:error, dgettext("errors", "Your account is currently disabled"))
235 |> put_status(:forbidden)
236 |> authorize(params)
237 end
238
239 defp handle_create_authorization_error(%Plug.Conn{} = conn, error, %{"authorization" => _}) do
240 Authenticator.handle_error(conn, error)
241 end
242
243 @doc "Renew access_token with refresh_token"
244 def token_exchange(
245 %Plug.Conn{} = conn,
246 %{"grant_type" => "refresh_token", "refresh_token" => token} = _params
247 ) do
248 with {:ok, app} <- Token.Utils.fetch_app(conn),
249 {:ok, %{user: user} = token} <- Token.get_by_refresh_token(app, token),
250 {:ok, token} <- RefreshToken.grant(token) do
251 json(conn, OAuthView.render("token.json", %{user: user, token: token}))
252 else
253 _error -> render_invalid_credentials_error(conn)
254 end
255 end
256
257 def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"} = params) do
258 with {:ok, app} <- Token.Utils.fetch_app(conn),
259 fixed_token = Token.Utils.fix_padding(params["code"]),
260 {:ok, auth} <- Authorization.get_by_token(app, fixed_token),
261 %User{} = user <- User.get_cached_by_id(auth.user_id),
262 {:ok, token} <- Token.exchange_token(app, auth) do
263 json(conn, OAuthView.render("token.json", %{user: user, token: token}))
264 else
265 error ->
266 handle_token_exchange_error(conn, error)
267 end
268 end
269
270 def token_exchange(
271 %Plug.Conn{} = conn,
272 %{"grant_type" => "password"} = params
273 ) do
274 with {:ok, %User{} = user} <- Authenticator.get_user(conn),
275 {:ok, app} <- Token.Utils.fetch_app(conn),
276 requested_scopes <- Scopes.fetch_scopes(params, app.scopes),
277 {:ok, token} <- login(user, app, requested_scopes) do
278 json(conn, OAuthView.render("token.json", %{user: user, token: token}))
279 else
280 error ->
281 handle_token_exchange_error(conn, error)
282 end
283 end
284
285 def token_exchange(
286 %Plug.Conn{} = conn,
287 %{"grant_type" => "password", "name" => name, "password" => _password} = params
288 ) do
289 params =
290 params
291 |> Map.delete("name")
292 |> Map.put("username", name)
293
294 token_exchange(conn, params)
295 end
296
297 def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"} = _params) do
298 with {:ok, app} <- Token.Utils.fetch_app(conn),
299 {:ok, auth} <- Authorization.create_authorization(app, %User{}),
300 {:ok, token} <- Token.exchange_token(app, auth) do
301 json(conn, OAuthView.render("token.json", %{token: token}))
302 else
303 _error ->
304 handle_token_exchange_error(conn, :invalid_credentails)
305 end
306 end
307
308 # Bad request
309 def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
310
311 defp handle_token_exchange_error(%Plug.Conn{} = conn, {:mfa_required, user, auth, _}) do
312 conn
313 |> put_status(:forbidden)
314 |> json(build_and_response_mfa_token(user, auth))
315 end
316
317 defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :deactivated}) do
318 render_error(
319 conn,
320 :forbidden,
321 "Your account is currently disabled",
322 %{},
323 "account_is_disabled"
324 )
325 end
326
327 defp handle_token_exchange_error(
328 %Plug.Conn{} = conn,
329 {:account_status, :password_reset_pending}
330 ) do
331 render_error(
332 conn,
333 :forbidden,
334 "Password reset is required",
335 %{},
336 "password_reset_required"
337 )
338 end
339
340 defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :confirmation_pending}) do
341 render_error(
342 conn,
343 :forbidden,
344 "Your login is missing a confirmed e-mail address",
345 %{},
346 "missing_confirmed_email"
347 )
348 end
349
350 defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :approval_pending}) do
351 render_error(
352 conn,
353 :forbidden,
354 "Your account is awaiting approval.",
355 %{},
356 "awaiting_approval"
357 )
358 end
359
360 defp handle_token_exchange_error(%Plug.Conn{} = conn, _error) do
361 render_invalid_credentials_error(conn)
362 end
363
364 def token_revoke(%Plug.Conn{} = conn, %{"token" => _token} = params) do
365 with {:ok, app} <- Token.Utils.fetch_app(conn),
366 {:ok, %Token{} = oauth_token} <- RevokeToken.revoke(app, params) do
367 conn =
368 with session_token = get_session(conn, :oauth_token),
369 %Token{token: ^session_token} <- oauth_token do
370 delete_session(conn, :oauth_token)
371 else
372 _ -> conn
373 end
374
375 json(conn, %{})
376 else
377 _error ->
378 # RFC 7009: invalid tokens [in the request] do not cause an error response
379 json(conn, %{})
380 end
381 end
382
383 def token_revoke(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
384
385 # Response for bad request
386 defp bad_request(%Plug.Conn{} = conn, _) do
387 render_error(conn, :internal_server_error, "Bad request")
388 end
389
390 @doc "Prepares OAuth request to provider for Ueberauth"
391 def prepare_request(%Plug.Conn{} = conn, %{
392 "provider" => provider,
393 "authorization" => auth_attrs
394 }) do
395 scope =
396 auth_attrs
397 |> Scopes.fetch_scopes([])
398 |> Scopes.to_string()
399
400 state =
401 auth_attrs
402 |> Map.delete("scopes")
403 |> Map.put("scope", scope)
404 |> Jason.encode!()
405
406 params =
407 auth_attrs
408 |> Map.drop(~w(scope scopes client_id redirect_uri))
409 |> Map.put("state", state)
410
411 # Handing the request to Ueberauth
412 redirect(conn, to: o_auth_path(conn, :request, provider, params))
413 end
414
415 def request(%Plug.Conn{} = conn, params) do
416 message =
417 if params["provider"] do
418 dgettext("errors", "Unsupported OAuth provider: %{provider}.",
419 provider: params["provider"]
420 )
421 else
422 dgettext("errors", "Bad OAuth request.")
423 end
424
425 conn
426 |> put_flash(:error, message)
427 |> redirect(to: "/")
428 end
429
430 def callback(%Plug.Conn{assigns: %{ueberauth_failure: failure}} = conn, params) do
431 params = callback_params(params)
432 messages = for e <- Map.get(failure, :errors, []), do: e.message
433 message = Enum.join(messages, "; ")
434
435 conn
436 |> put_flash(
437 :error,
438 dgettext("errors", "Failed to authenticate: %{message}.", message: message)
439 )
440 |> redirect(external: redirect_uri(conn, params["redirect_uri"]))
441 end
442
443 def callback(%Plug.Conn{} = conn, params) do
444 params = callback_params(params)
445
446 with {:ok, registration} <- Authenticator.get_registration(conn) do
447 auth_attrs = Map.take(params, ~w(client_id redirect_uri scope scopes state))
448
449 case Repo.get_assoc(registration, :user) do
450 {:ok, user} ->
451 create_authorization(conn, %{"authorization" => auth_attrs}, user: user)
452
453 _ ->
454 registration_params =
455 Map.merge(auth_attrs, %{
456 "nickname" => Registration.nickname(registration),
457 "email" => Registration.email(registration)
458 })
459
460 conn
461 |> put_session_registration_id(registration.id)
462 |> registration_details(%{"authorization" => registration_params})
463 end
464 else
465 error ->
466 Logger.debug(inspect(["OAUTH_ERROR", error, conn.assigns]))
467
468 conn
469 |> put_flash(:error, dgettext("errors", "Failed to set up user account."))
470 |> redirect(external: redirect_uri(conn, params["redirect_uri"]))
471 end
472 end
473
474 defp callback_params(%{"state" => state} = params) do
475 Map.merge(params, Jason.decode!(state))
476 end
477
478 def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs}) do
479 render(conn, "register.html", %{
480 client_id: auth_attrs["client_id"],
481 redirect_uri: auth_attrs["redirect_uri"],
482 state: auth_attrs["state"],
483 scopes: Scopes.fetch_scopes(auth_attrs, []),
484 nickname: auth_attrs["nickname"],
485 email: auth_attrs["email"]
486 })
487 end
488
489 def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do
490 with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
491 %Registration{} = registration <- Repo.get(Registration, registration_id),
492 {_, {:ok, auth, _user}} <-
493 {:create_authorization, do_create_authorization(conn, params)},
494 %User{} = user <- Repo.preload(auth, :user).user,
495 {:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do
496 conn
497 |> put_session_registration_id(nil)
498 |> after_create_authorization(auth, params)
499 else
500 {:create_authorization, error} ->
501 {:register, handle_create_authorization_error(conn, error, params)}
502
503 _ ->
504 {:register, :generic_error}
505 end
506 end
507
508 def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "register"} = params) do
509 with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
510 %Registration{} = registration <- Repo.get(Registration, registration_id),
511 {:ok, user} <- Authenticator.create_from_registration(conn, registration) do
512 conn
513 |> put_session_registration_id(nil)
514 |> create_authorization(
515 params,
516 user: user
517 )
518 else
519 {:error, changeset} ->
520 message =
521 Enum.map(changeset.errors, fn {field, {error, _}} ->
522 "#{field} #{error}"
523 end)
524 |> Enum.join("; ")
525
526 message =
527 String.replace(
528 message,
529 "ap_id has already been taken",
530 "nickname has already been taken"
531 )
532
533 conn
534 |> put_status(:forbidden)
535 |> put_flash(:error, "Error: #{message}.")
536 |> registration_details(params)
537
538 _ ->
539 {:register, :generic_error}
540 end
541 end
542
543 defp do_create_authorization(conn, auth_attrs, user \\ nil)
544
545 defp do_create_authorization(
546 %Plug.Conn{} = conn,
547 %{
548 "authorization" =>
549 %{
550 "client_id" => client_id,
551 "redirect_uri" => redirect_uri
552 } = auth_attrs
553 },
554 user
555 ) do
556 with {_, {:ok, %User{} = user}} <-
557 {:get_user, (user && {:ok, user}) || Authenticator.get_user(conn)},
558 %App{} = app <- Repo.get_by(App, client_id: client_id),
559 true <- redirect_uri in String.split(app.redirect_uris),
560 requested_scopes <- Scopes.fetch_scopes(auth_attrs, app.scopes),
561 {:ok, auth} <- do_create_authorization(user, app, requested_scopes) do
562 {:ok, auth, user}
563 end
564 end
565
566 defp do_create_authorization(%User{} = user, %App{} = app, requested_scopes)
567 when is_list(requested_scopes) do
568 with {:account_status, :active} <- {:account_status, User.account_status(user)},
569 {:ok, scopes} <- validate_scopes(app, requested_scopes),
570 {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
571 {:ok, auth}
572 end
573 end
574
575 # Note: intended to be a private function but opened for AccountController that logs in on signup
576 @doc "If checks pass, creates authorization and token for given user, app and requested scopes."
577 def login(%User{} = user, %App{} = app, requested_scopes) when is_list(requested_scopes) do
578 with {:ok, auth} <- do_create_authorization(user, app, requested_scopes),
579 {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)},
580 {:ok, token} <- Token.exchange_token(app, auth) do
581 {:ok, token}
582 end
583 end
584
585 # Special case: Local MastodonFE
586 defp redirect_uri(%Plug.Conn{} = conn, "."), do: auth_url(conn, :login)
587
588 defp redirect_uri(%Plug.Conn{}, redirect_uri), do: redirect_uri
589
590 defp get_session_registration_id(%Plug.Conn{} = conn), do: get_session(conn, :registration_id)
591
592 defp put_session_registration_id(%Plug.Conn{} = conn, registration_id),
593 do: put_session(conn, :registration_id, registration_id)
594
595 defp build_and_response_mfa_token(user, auth) do
596 with {:ok, token} <- MFA.Token.create(user, auth) do
597 MFAView.render("mfa_response.json", %{token: token, user: user})
598 end
599 end
600
601 @spec validate_scopes(App.t(), map() | list()) ::
602 {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
603 defp validate_scopes(%App{} = app, params) when is_map(params) do
604 requested_scopes = Scopes.fetch_scopes(params, app.scopes)
605 validate_scopes(app, requested_scopes)
606 end
607
608 defp validate_scopes(%App{} = app, requested_scopes) when is_list(requested_scopes) do
609 Scopes.validate(requested_scopes, app.scopes)
610 end
611
612 def default_redirect_uri(%App{} = app) do
613 app.redirect_uris
614 |> String.split()
615 |> Enum.at(0)
616 end
617
618 defp render_invalid_credentials_error(conn) do
619 render_error(conn, :bad_request, "Invalid credentials")
620 end
621 end