Macaw.Auth.Plug (macaw_auth v0.4.1)

Copy Markdown View Source

The request side of Macaw.Auth, for a Phoenix router.

import Macaw.Auth.Plug

pipeline :browser do
  plug :fetch_session
  plug :fetch_flash
  plug Macaw.Plug.ClientIp, trusted_proxies: [...]
  plug :fetch_current_user
end

pipeline :signed_in do
  plug :require_authenticated
end

fetch_current_user/2 assigns :current_user, :current_session and :pending_step (all nil when signed out). require_authenticated/2 sends a signed-out visitor to the sign-in page (remembering where a GET was going) and a session that owes a step to that step's page; only the step's page and sign-out are reachable meanwhile.

The second factor's page calls verify_second_factor/2 with the submitted code; enrollment's page, after the backend confirms it, complete_step(conn, :second_factor_enroll).

Run Macaw.Plug.ClientIp before these, so a session records, and the throttle counts, the client's address, not a proxy's. Run fetch_flash before require_role/2 and authorize/2, or their refusal goes home without saying why.

Remembering a browser. log_in(conn, user, remember: true) (a "remember me" box) also sets a signed cookie holding the session's token (remember_cookie/0, config :macaw_auth, remember_cookie:), which fetch_current_user/2 reads when the session has none, after the browser closed. It lives for the backend's idle limit, set again each time the backend records the session's use, is http_only and same_site: "Lax", and is secure exactly when the request is https; no setting turns that off. It dies with its session: a revoked or expired session's token no longer resolves, and log_out/1 deletes it. Behind a TLS-terminating proxy, make the request's scheme the client's (Plug.RewriteOn with :x_forwarded_proto), or the cookie goes out without secure. The session cookie is the host's (its endpoint's Plug.Session options); set secure: true there for an https site, as this cookie does by itself.

LiveView sockets. log_in/3 puts the session's live_socket_id (Macaw.Auth.live_socket_id/1) in the session, so Phoenix names the session's LiveView sockets by it. With config :macaw_auth, endpoint: MyAppWeb.Endpoint, every revocation (Macaw.Auth.revoke/2, log_out/1) broadcasts "disconnect" on it and Phoenix drops those sockets; without :endpoint the hooks' own guard (Macaw.Auth.Live) takes the pages away.

Redirects are Phoenix.Controller.redirect/2's, behind this module's own check that the target is a path on this site (site_path?/1). A remembered path came from a request, and Phoenix raises on one that isn't safe, so anything else goes home instead: it neither leaves the site nor fails the request.

Summary

Functions

Checks credentials as Macaw.Auth.authenticate/2 does, passing the client's address (conn.remote_ip, the client's after Macaw.Plug.ClientIp) as :remote_ip, for the backend's throttle.

Asks the app's authorizer (Macaw.Auth.authorize/3) whether the current user may do action, and refuses as require_role/2 does: home, with an error flash when the pipeline ran fetch_flash. A signed-out visitor is sent to sign in, as require_role/2 sends one, remembering where a GET was going. With no authorizer configured, every request is refused.

Records a step the current session completed and sends it on: to the next step, else where it was going. A session that no longer exists (revoked meanwhile) is logged out.

Sends the browser to the session's next step, or where it was going.

Assigns the signed-in user, session and pending step, or nils.

Starts a session for a user who passed Macaw.Auth.authenticate/2: the old session is renewed (no fixation), the token stored with the session's live_socket_id (Macaw.Auth.live_socket_id/1), and the browser sent to the step the session owes, else where it was going, else home.

Revokes the session (telling its open pages, and with :endpoint disconnecting its LiveView sockets), renews the session, deletes the remember cookie, and goes to sign in.

Sends a signed-in visitor away from the sign-in page.

The remember cookie's name: config :macaw_auth, remember_cookie:, default "_macaw_auth_remember".

Sends a signed-out visitor to sign in, and a session owing a step to that step's page.

Sends a user with none of roles home, with an error flash when the pipeline ran fetch_flash (without it, home with no message). A signed-out visitor is sent to sign in instead, remembering where a GET was going, as require_authenticated/2 does. What a role allows is the app's to decide; this only checks membership.

Whether path is a path on this site that Phoenix.Controller.redirect/2 accepts: one leading slash, then none of a backslash, a tab or a line break.

The session key holding the token, for hosts that need it (LiveView's session).

Checks a second-factor code for the current session, passing the client's address for the backend's throttle. On success the :second_factor step is complete and the browser goes on, to the next step or where it was going: {:ok, conn} (redirected, halted). On a refusal the conn stays for the app's page to render the reason: {:error, reason, conn} (:invalid_code, :throttled, or the session's own refusal), and with no current session {:error, :invalid, conn}.

Functions

authenticate(conn, credentials, opts \\ [])

Checks credentials as Macaw.Auth.authenticate/2 does, passing the client's address (conn.remote_ip, the client's after Macaw.Plug.ClientIp) as :remote_ip, for the backend's throttle.

authorize(conn, action)

Asks the app's authorizer (Macaw.Auth.authorize/3) whether the current user may do action, and refuses as require_role/2 does: home, with an error flash when the pipeline ran fetch_flash. A signed-out visitor is sent to sign in, as require_role/2 sends one, remembering where a GET was going. With no authorizer configured, every request is refused.

plug :authorize, :manage
plug :authorize, {:edit, resource: &MyAppWeb.Posts.post/1}

The resource is what the :resource option's function (a remote capture, &Mod.fun/1, so the plug's options compile) gives of the conn, else nil. It is asked only of a signed-in request.

complete_step(conn, step)

Records a step the current session completed and sends it on: to the next step, else where it was going. A session that no longer exists (revoked meanwhile) is logged out.

continue(conn)

Sends the browser to the session's next step, or where it was going.

fetch_current_user(conn, opts)

Assigns the signed-in user, session and pending step, or nils.

The token is the session's; only when the session has none is it read from the remember cookie, and then, if it resolves, put back into the session (renewed, with the session's live_socket_id) and the cookie re-issued for another idle limit. A refused token is deleted from the session, and a refused remembered one from the cookie too.

When the backend records the session's use on this request (its touch, at most its interval), a remember cookie the browser sent for this session is set again too, for a fresh idle limit: a browser kept open past the idle limit keeps its cookie as long as its session lives. A browser that sent none never gets one here.

log_in(conn, user, opts \\ [])

Starts a session for a user who passed Macaw.Auth.authenticate/2: the old session is renewed (no fixation), the token stored with the session's live_socket_id (Macaw.Auth.live_socket_id/1), and the browser sent to the step the session owes, else where it was going, else home.

remember: true also sets the remember cookie (remember_cookie/0): signed, holding the token, for the backend's idle limit (Macaw.Auth.session_idle_seconds/1), http_only, same_site: "Lax", and secure exactly when the request is https. Without it, a remember cookie the browser sent is deleted. Other opts go to Macaw.Auth.create_session/2.

When the backend refuses the session ({:error, :invalid}: the user no longer exists), the sign-in fails: back to the sign-in page, with an error flash when the pipeline ran fetch_flash, and neither the session nor any cookie touched.

log_out(conn)

Revokes the session (telling its open pages, and with :endpoint disconnecting its LiveView sockets), renews the session, deletes the remember cookie, and goes to sign in.

redirect_if_signed_in(conn, opts)

Sends a signed-in visitor away from the sign-in page.

remember_cookie()

@spec remember_cookie() :: String.t()

The remember cookie's name: config :macaw_auth, remember_cookie:, default "_macaw_auth_remember".

require_authenticated(conn, opts)

Sends a signed-out visitor to sign in, and a session owing a step to that step's page.

require_role(conn, roles)

Sends a user with none of roles home, with an error flash when the pipeline ran fetch_flash (without it, home with no message). A signed-out visitor is sent to sign in instead, remembering where a GET was going, as require_authenticated/2 does. What a role allows is the app's to decide; this only checks membership.

site_path?(path)

Whether path is a path on this site that Phoenix.Controller.redirect/2 accepts: one leading slash, then none of a backslash, a tab or a line break.

token_key()

The session key holding the token, for hosts that need it (LiveView's session).

verify_second_factor(conn, code)

@spec verify_second_factor(Plug.Conn.t(), term()) ::
  {:ok, Plug.Conn.t()} | {:error, atom(), Plug.Conn.t()}

Checks a second-factor code for the current session, passing the client's address for the backend's throttle. On success the :second_factor step is complete and the browser goes on, to the next step or where it was going: {:ok, conn} (redirected, halted). On a refusal the conn stays for the app's page to render the reason: {:error, reason, conn} (:invalid_code, :throttled, or the session's own refusal), and with no current session {:error, :invalid, conn}.

def create(conn, %{"code" => code}) do
  case Macaw.Auth.Plug.verify_second_factor(conn, code) do
    {:ok, conn} -> conn
    {:error, reason, conn} -> render(conn, :new, error: reason)
  end
end