Skip to content

Commit ff118a8

Browse files
committed
Move auth system from Beacon core to LiveAdmin
Auth belongs in the admin interface, not the CMS core. Beacon core renders pages and manages content — it has no business knowing about login flows, session cookies, or admin user roles. Auth Context (lib/beacon/live_admin/auth.ex): - User CRUD, password management, session handling - Role assignment/checking (super_admin, site_admin, site_editor, site_viewer) - OIDC authentication (match email to pre-provisioned users) Schemas (lib/beacon/live_admin/auth/): - User: email, name, hashed_password (dev mode), avatar_url, login tracking - UserSession: token-based sessions - UserRole: role + site scoping Controllers: - OIDCController: authorize redirect + callback with openid_connect library - DevLoginController: email+password for development Plugs: - RequireAuth: session cookie authentication - RequireRole: role-based authorization with site scoping Migration V001 (lib/beacon/live_admin/migrations/v001.ex): - beacon_users, beacon_user_sessions, beacon_user_roles tables Mix Task: mix beacon_live_admin.create_admin Dependencies: bcrypt_elixir ~> 3.0, openid_connect ~> 1.0
1 parent 314b6e6 commit ff118a8

18 files changed

Lines changed: 907 additions & 19 deletions

lib/beacon/live_admin/auth.ex

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
defmodule Beacon.LiveAdmin.Auth do
2+
@moduledoc """
3+
Authentication and authorization context for Beacon CMS.
4+
5+
Provides user management, session handling, role-based authorization,
6+
and OIDC integration. For platform-level operations (users, roles,
7+
sessions), uses the repo from the first running Beacon site.
8+
"""
9+
10+
import Ecto.Query
11+
12+
alias Beacon.LiveAdmin.Auth.User
13+
alias Beacon.LiveAdmin.Auth.UserRole
14+
alias Beacon.LiveAdmin.Auth.UserSession
15+
16+
# ---------------------------------------------------------------------------
17+
# Repo Helper
18+
# ---------------------------------------------------------------------------
19+
20+
defp repo do
21+
site = Beacon.Registry.running_sites() |> List.first()
22+
Beacon.Config.fetch!(site).repo
23+
end
24+
25+
# ---------------------------------------------------------------------------
26+
# User CRUD
27+
# ---------------------------------------------------------------------------
28+
29+
@doc """
30+
Creates a new user.
31+
32+
## Examples
33+
34+
iex> Beacon.LiveAdmin.Auth.create_user(%{email: "user@example.com", name: "User"})
35+
{:ok, %Beacon.LiveAdmin.Auth.User{}}
36+
37+
"""
38+
def create_user(attrs) do
39+
%User{}
40+
|> User.changeset(attrs)
41+
|> repo().insert()
42+
end
43+
44+
@doc """
45+
Updates an existing user.
46+
"""
47+
def update_user(%User{} = user, attrs) do
48+
user
49+
|> User.changeset(attrs)
50+
|> repo().update()
51+
end
52+
53+
@doc """
54+
Deletes a user and all associated sessions and roles (via cascading delete).
55+
"""
56+
def delete_user(%User{} = user) do
57+
repo().delete(user)
58+
end
59+
60+
@doc """
61+
Lists users with optional pagination.
62+
63+
## Options
64+
65+
* `:page` - Page number (default: 1)
66+
* `:per_page` - Results per page (default: 20)
67+
68+
"""
69+
def list_users(opts \\ []) do
70+
page = Keyword.get(opts, :page, 1)
71+
per_page = Keyword.get(opts, :per_page, 20)
72+
73+
User
74+
|> order_by([u], asc: u.email)
75+
|> limit(^per_page)
76+
|> offset(^((page - 1) * per_page))
77+
|> repo().all()
78+
end
79+
80+
@doc """
81+
Gets a user by ID. Returns `nil` if not found.
82+
"""
83+
def get_user(id) do
84+
repo().get(User, id)
85+
end
86+
87+
@doc """
88+
Gets a user by email. Returns `nil` if not found.
89+
"""
90+
def get_user_by_email(email) when is_binary(email) do
91+
repo().get_by(User, email: email)
92+
end
93+
94+
# ---------------------------------------------------------------------------
95+
# Password (dev mode)
96+
# ---------------------------------------------------------------------------
97+
98+
@doc """
99+
Sets a password for the given user by hashing it with Bcrypt.
100+
"""
101+
def set_password(%User{} = user, password) when is_binary(password) do
102+
user
103+
|> User.password_changeset(%{password: password})
104+
|> repo().update()
105+
end
106+
107+
@doc """
108+
Verifies a plaintext password against the user's stored hash.
109+
110+
Returns `true` if the password matches, `false` otherwise.
111+
Always performs a dummy check when no hash is stored to prevent timing attacks.
112+
"""
113+
def verify_password(%User{hashed_password: hashed_password}, password)
114+
when is_binary(hashed_password) and is_binary(password) do
115+
Bcrypt.verify_pass(password, hashed_password)
116+
end
117+
118+
def verify_password(_user, _password) do
119+
Bcrypt.no_user_verify()
120+
false
121+
end
122+
123+
# ---------------------------------------------------------------------------
124+
# Sessions
125+
# ---------------------------------------------------------------------------
126+
127+
@doc """
128+
Creates a new session for the given user.
129+
130+
Returns the raw token binary that should be stored in the client cookie.
131+
"""
132+
def create_session(%User{} = user) do
133+
changeset = UserSession.changeset(%UserSession{}, %{user_id: user.id})
134+
135+
case repo().insert(changeset) do
136+
{:ok, session} -> {:ok, session.token}
137+
{:error, changeset} -> {:error, changeset}
138+
end
139+
end
140+
141+
@doc """
142+
Looks up a user by their session token.
143+
144+
Returns the user struct or `nil` if the session is invalid.
145+
"""
146+
def get_user_by_session_token(token) when is_binary(token) do
147+
session =
148+
UserSession
149+
|> where([s], s.token == ^token)
150+
|> join(:inner, [s], u in assoc(s, :user))
151+
|> select([s, u], u)
152+
|> repo().one()
153+
154+
session
155+
end
156+
157+
def get_user_by_session_token(_), do: nil
158+
159+
@doc """
160+
Deletes a session by token.
161+
"""
162+
def delete_session(token) when is_binary(token) do
163+
UserSession
164+
|> where([s], s.token == ^token)
165+
|> repo().delete_all()
166+
167+
:ok
168+
end
169+
170+
@doc """
171+
Deletes all sessions for the given user.
172+
"""
173+
def delete_user_sessions(%User{} = user) do
174+
UserSession
175+
|> where([s], s.user_id == ^user.id)
176+
|> repo().delete_all()
177+
178+
:ok
179+
end
180+
181+
# ---------------------------------------------------------------------------
182+
# Roles
183+
# ---------------------------------------------------------------------------
184+
185+
@doc """
186+
Assigns a role to a user, optionally scoped to a site.
187+
"""
188+
def assign_role(%User{} = user, role, site \\ nil) do
189+
%UserRole{}
190+
|> UserRole.changeset(%{user_id: user.id, role: to_string(role), site: site})
191+
|> repo().insert()
192+
end
193+
194+
@doc """
195+
Revokes a role from a user, optionally scoped to a site.
196+
"""
197+
def revoke_role(%User{} = user, role, site \\ nil) do
198+
query =
199+
UserRole
200+
|> where([r], r.user_id == ^user.id and r.role == ^to_string(role))
201+
202+
query =
203+
if is_nil(site) do
204+
where(query, [r], is_nil(r.site))
205+
else
206+
where(query, [r], r.site == ^site)
207+
end
208+
209+
repo().delete_all(query)
210+
:ok
211+
end
212+
213+
@doc """
214+
Lists all roles for the given user.
215+
"""
216+
def list_roles(%User{} = user) do
217+
UserRole
218+
|> where([r], r.user_id == ^user.id)
219+
|> repo().all()
220+
end
221+
222+
@doc """
223+
Returns `true` if the user has the specified role (optionally scoped to a site).
224+
"""
225+
def has_role?(%User{} = user, role, site \\ nil) do
226+
query =
227+
UserRole
228+
|> where([r], r.user_id == ^user.id and r.role == ^to_string(role))
229+
230+
query =
231+
if is_nil(site) do
232+
where(query, [r], is_nil(r.site))
233+
else
234+
where(query, [r], r.site == ^site)
235+
end
236+
237+
repo().exists?(query)
238+
end
239+
240+
@doc """
241+
Returns `true` if the user is a super admin.
242+
"""
243+
def is_super_admin?(%User{} = user) do
244+
has_role?(user, "super_admin")
245+
end
246+
247+
@doc """
248+
Returns `true` if the user can access the given site.
249+
250+
A user can access a site if they are a super_admin or hold any role for that site.
251+
"""
252+
def can_access_site?(%User{} = user, site) do
253+
is_super_admin?(user) ||
254+
UserRole
255+
|> where([r], r.user_id == ^user.id and r.site == ^site)
256+
|> repo().exists?()
257+
end
258+
259+
@doc """
260+
Checks authorization and raises `Beacon.LiveAdmin.Auth.UnauthorizedError` if the user
261+
is not permitted to perform the given action on the site.
262+
263+
Actions map to minimum required roles:
264+
265+
* `:manage` - requires `super_admin` or `site_admin`
266+
* `:edit` - requires `super_admin`, `site_admin`, or `site_editor`
267+
* `:view` - requires any role for the site or `super_admin`
268+
269+
"""
270+
def authorize!(%User{} = user, action, site) do
271+
authorized =
272+
case action do
273+
:manage ->
274+
is_super_admin?(user) || has_role?(user, "site_admin", site)
275+
276+
:edit ->
277+
is_super_admin?(user) ||
278+
has_role?(user, "site_admin", site) ||
279+
has_role?(user, "site_editor", site)
280+
281+
:view ->
282+
can_access_site?(user, site)
283+
284+
_ ->
285+
false
286+
end
287+
288+
unless authorized do
289+
raise Beacon.LiveAdmin.Auth.UnauthorizedError,
290+
message: "user #{user.email} is not authorized to #{action} on site #{inspect(site)}"
291+
end
292+
293+
:ok
294+
end
295+
296+
# ---------------------------------------------------------------------------
297+
# OIDC
298+
# ---------------------------------------------------------------------------
299+
300+
@doc """
301+
Authenticates a user via OIDC by email lookup.
302+
303+
Updates the last login timestamp and provider. Returns `{:error, :not_found}`
304+
if no user with the given email exists.
305+
"""
306+
def authenticate_oidc(email, provider \\ "oidc") when is_binary(email) do
307+
case get_user_by_email(email) do
308+
nil ->
309+
{:error, :not_found}
310+
311+
user ->
312+
user
313+
|> User.changeset(%{last_login_at: DateTime.utc_now(), last_login_provider: provider})
314+
|> repo().update()
315+
end
316+
end
317+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
defmodule Beacon.LiveAdmin.Auth.Config do
2+
@moduledoc """
3+
Runtime configuration helpers for Beacon authentication.
4+
5+
All values are read from `Application.get_env(:beacon, :auth)`.
6+
7+
## Example configuration
8+
9+
config :beacon, :auth,
10+
dev_mode: true,
11+
session_signing_salt: "my_salt",
12+
session_max_age: 86400 * 30,
13+
providers: [
14+
google: [
15+
discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration",
16+
client_id: "...",
17+
client_secret: "...",
18+
response_type: "code",
19+
scope: "openid email profile"
20+
]
21+
]
22+
23+
"""
24+
25+
@doc "Returns `true` when dev-mode authentication (password login) is enabled."
26+
def dev_mode? do
27+
auth_config()[:dev_mode] || false
28+
end
29+
30+
@doc "Returns the list of configured OIDC providers."
31+
def providers do
32+
auth_config()[:providers] || []
33+
end
34+
35+
@doc "Returns the signing salt used for session cookies."
36+
def session_signing_salt do
37+
auth_config()[:session_signing_salt] || "beacon_auth"
38+
end
39+
40+
@doc "Returns the maximum session age in seconds (default: 30 days)."
41+
def session_max_age do
42+
auth_config()[:session_max_age] || 86_400 * 30
43+
end
44+
45+
defp auth_config do
46+
Application.get_env(:beacon, :auth) || []
47+
end
48+
end

0 commit comments

Comments
 (0)