Phoenix 1.8 includes a new feature called scopes. They enforce a simple but extensible authorization pattern by default.
After running mix phx.gen.auth
, basic authentication is generated for the app, much like in previous versions of Phoenix. But, in v1.8 an accounts/scope.ex
file is also created, the app's config.exs
is updated and all subsequent generators that create contexts will inject scopes into them to lock down authorization based upon the user in the current scope.
mix phx.gen.auth Accounts User users
Initial impressions auth changes
The default behavior for newly registered users is now to only request an email and to use "magic" email links for login. This has a couple of advantages—every user's email will be verified when they register, and some users prefer not to deal with passwords. Once users have signed up, they can add a password in their settings page and use it for logging in in the future. Many prefer this, myself included, now that password managers are so common, even at the OS level.
Scopes are opt-out
Scopes are opt-out! This means that after running the auth generator, the behavior of phx.gen.context
, phx.gen.html
, and phx.gen.live
is changed. Let's look at a simple example:
mix phx.gen.live Curriculum Level levels name:string difficulty:integer
This generator still generates a schema, a migration, a context file, and liveviews as it would have previously. However, the output is different, due to scopes. Let's start with the schema:
defmodule App.Curriculum.Level do
use Ecto.Schema
import Ecto.Changeset
schema "levels" do
field :name, :string
field :difficulty, :integer
field :user_id, :id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(level, attrs, user_scope) do
level
|> cast(attrs, [:name, :difficulty])
|> validate_required([:name, :difficulty])
|> put_change(:user_id, user_scope.user.id)
end
end
In addition to the name
and difficulty
we specified in the generator above, the schema now includes a user_id
as well. The changeset function requires a user with an ID to be passed in in its user_scope
parameter, as well. The migration also includes a user_id
, too:
def change do
create table(:levels) do
add :name, :string
add :difficulty, :integer
add :user_id, references(:users, type: :id, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
end
Changes in the context module
Scopes are now added as a parameter to every function in the newly-generated context module:
def subscribe_levels(%Scope{} = scope) do
key = scope.user.id
Phoenix.PubSub.subscribe(ZhuyinKing.PubSub, "user:#{key}:levels")
end
defp broadcast(%Scope{} = scope, message) do
key = scope.user.id
Phoenix.PubSub.broadcast(ZhuyinKing.PubSub, "user:#{key}:levels", message)
end
def list_levels(%Scope{} = scope) do
Repo.all(from level in Level, where: level.user_id == ^scope.user.id)
end
#...
This means that any calls to any of them that don't include a scope with a valid user will result in an error. Also, resources are restricted to the user who created them by default. I.e., a user can only list or get records they created.
The new Scope module
When you run mix phx.gen.auth
, it creates a user_auth.ex
module inside the accounts context it creates, as in previous versions. However, there's a new file, too: /accounts/scopes.ex
. It's very short:
defmodule AppWeb.Accounts.Scope do
alias AppWeb.Accounts.User
defstruct user: nil
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
end
The functions in user_auth
that previously would have put a current_user
into the assigns of a conn or socket, now call Scope.for(user)
and pass the result into the assigns under current_scope
. The structure of a conn or socket will now be this:
%{assigns: %{current_scope: %{user: some_user}}} # if a user is logged in
%{assigns: %{current_scope: nil}} # if no user is logged in
Final thoughts
Phoenix auth generators now include not only authentication but also a starting point for authorization. It's opt-out, which is a ideal in my opinion. Authorization is always highly dependent on the app one is working on, so it's good to have an escape hatch, but it's also good to put some basics in place by default. The more a framework can steer its users into a secure by default approach, where we remove restrictions as needed, as opposed to everything being open by default, with restrictions added where needed, the fewer security vulnerabilities we'll have!
In the next episode, I'll take a more hands-on look at opening up some of this generated code based on common real-world use cases.
No Comments