Background
I have a website built with Django and I would like to start writing parts of it in Phoenix.
Disclaimer
I am a beginner at Phoenix. I would like help & advice if I am doing something wrong.
Starting Point
In the last post we set up Phoenix inside our Docker container. We have nginx splitting traffic between Phoenix & Django. All requests go to Django except for the temporary /elixir
path which we direct to Phoenix for experimenting.
In this post, we're going to look at accessing the Django sessions information so that our Phoenix app can know if the user is logged in or not. We're going to have to:
- Check the session cookie
- Check the sessions table in the database
- Add some information to the request data to indicate if there is a current user session or not.
- Displaying some indication of the session on the page.
Checking the Session Cookie
The official documentation tells us that Django uses cookies for tracking the session IDs. If we log in to our Django app in Chrome, open up the inspector, go to the Application tab and then click on the localhost
entry under Cookies, then we can see a cookie called sessionid
which contains a string.
I'm not cookie expert but my understanding is that any available cookies for the domain are automatically sent along with each standard web request. So when we navigate to /elixir
the browser will be sending the same cookies over.
Phoenix is built on top of a layer called Plug. Looking at the documentation for Plug we can see that the cookies are available as an Elixir Map on the conn
data. This is the standard connection data that is fed to functions that are dealing with requests.
In order to access that cookie we're going to set up a new Phoenix controller called TimetableWeb.Session
. To do this we create a file called session.ex
in the directory lib/timetable_web/controllers/
in our Phoenix project. It will have the following contents:
defmodule TimetableWeb.Session do
import Plug.Conn
def init(_) do
end
def call(conn, _) do
case conn.cookies["sessionid"] do
nil ->
IO.puts "No sessionid found"
session_id ->
IO.puts "Session ID: #{session_id}"
end
# Return conn
conn
end
end
This is standard Plug format. For the moment, we don't need to do anything in the init
function but it still required. The call
function requires two arguments in order to receive the data from init
but we don't need it so we use a simple _
as the argument name.
As for the rest of the Plug, we look into the conn.cookies
Map for the sessionid
entry and pattern match on the result. If we get nil
then we know we don't have an active session at the moment. If we get something other than nil
then we know we must have the session ID. For the moment, we print an appropriate message and return the conn
at the end of the function. In Elixir the result of the last expression is returned from the function. There is no need for an explicit return
keyword in that situation.
Now we need to add our plug into the Phoenix request pipeline so that it can process incoming requests. To do this we edit our lib/timetable_web/router.ex
file with the following change:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
+ plug TimetableWeb.Session, repo: Timetable.Repo
end
pipeline :api do
plug :accepts, ["json"]
end
Here we are adding our plug to the end of the browser pipeline. We're not using the api
pipeline at the moment so we ignore that.
Now, if we go to our Django app, log in and visit /elixir
then we see:
[info] GET /elixir
Session ID: boj26dqs1v0okevigm2vnsu11gephx21
In our console output from the Phoenix app inside our Docker container.
If we now go to our Django app, log out and visit /elixir
, then we see:
[info] GET /elixir
No sessionid found
So we seem to be successfully getting the session cookie.
Checking the Sessions Database Table
Django doesn't store meaningful data in the session cookie. It is just an ID to access the data from the sessions table in the database. The documentation tells us that the session data is stored in a table called django_session
. If we run python manage.py dbshell
and in the Postgresql prompt run: \d django_session
then we get a description of the table:
Table "public.django_session"
Column | Type | Modifiers
-------------------+--------------------------+-----------
session_key | character varying(40) | not null
session_data | text | not null
expire_date | timestamp with time zone | not null
Indexes:
"django_session_pkey" PRIMARY KEY, btree (session_key)
"django_session_expire_date" btree (expire_date)
"django_session_session_key_like" btree (session_key varchar_pattern_ops)
We can see that there are 3 columns. The session_key
most likely maps to our session_id
. The session_data
most likely includes the useful information about the session. Finally, there is an expire_date
to indicate when the session should be considered 'stale' and no longer honoured. We can also see that the session_key
field is the primary key for this table.
We want to get to a position in which our Phoenix app can check this database table to retrieve the session information. Phoenix uses a layer called Ecto to model database tables and talk to databases. We are going to use a mix task to help generate Ecto schemas. The command we're going to run is:
mix phx.gen.schema Session sessions session_key:string session_data:string expire_date:date
This generates an Ecto schema that is almost correct for what we need. The problem is that it assumes that we're going to want to have an extra id
column to use as the primary key. This makes sense for a lot of tables but not our django_session
table where the primary key is the session_key
column. So we have to adjust the final schema to inform Ecto of our preferred primary key. Here is what it is going to look like:
defmodule Timetable.Session do
use Ecto.Schema
import Ecto.Changeset
alias Timetable.Session
@primary_key {:session_key, :string, autogenerate: false}
schema "django_session" do
field :session_data, :string
field :expire_date, :utc_datetime
end
@doc false
def changeset(%Session{} = session, attrs) do
session
|> cast(attrs, [:session_key, :session_data, :expire_date])
|> validate_required([:session_key, :session_data, :expire_date])
end
end
To check if this works, we can try it in the Elixir interactive shell: iex. If we run iex -S mix
from Phoenix project root directory, then we can do:
iex(1)> Timetable.Session |> Timetable.Repo.get_by(session_key: "boj26dqs1v0okevigm2vnsu11gephx21")
[debug] QUERY OK source="django_session" db=131.4ms
SELECT d0."session_key", d0."session_data", d0."expire_date" FROM "django_session" AS d0 WHERE (d0."session_key" = $1) ["boj26dqs1v0okevigm2vnsu11gephx21"]
%Timetable.Session{__meta__: #Ecto.Schema.Metadata<:loaded, "django_session">,
expire_date: #DateTime<2017-11-21 08:31:06.774119Z>,
session_data: "ODNjNWFkMDJh....cl9pZCI6IjEifQ==",
session_key: "boj26dqs1v0okevigm2vnsu11gephx21"
We can see that we received a record with the session data that we're interested in.
If we want to check the expire_date
as well we can do this using the Ecto query syntax:
iex(1)> import Ecto.Query, only: [from: 2]
Ecto.Query
iex(2)> now = DateTime.utc_now()
#DateTime<2017-11-12 10:31:16.153448Z>
iex(3)> (from s in Timetable.Session, where: s.session_key == "boj26dqs1v0okevigm2vnsu11gephx21" and s.expire_date >= ^now) |> Timetable.Repo.one
[debug] QUERY OK source="django_session" db=24.0ms
SELECT d0."session_key", d0."session_data", d0."expire_date" FROM "django_session" AS d0 WHERE ((d0."session_key" = 'boj26dqs1v0okevigm2vnsu11gephx21') AND (d0."expire_date" >= $1)) [{{2017, 11, 12}, {10, 31, 16, 153448}}]
%Timetable.Session{__meta__: #Ecto.Schema.Metadata<:loaded, "django_session">,
expire_date: #DateTime<2017-11-21 08:31:06.774119Z>,
session_data: "ODNjNWFkMDJh....cl9pZCI6IjEifQ==",
session_key: "boj26dqs1v0okevigm2vnsu11gephx21"}
And to check that it returns nothing when the session has expired we can do:
iex(1)> import Ecto.Query, only: [from: 2]
Ecto.Query
iex(2)> {:ok, later, 0} = DateTime.from_iso8601("2017-11-22T00:00:00Z")
{:ok, #DateTime<2017-11-22 00:00:00Z>, 0}
iex(3)> (from s in Timetable.Session, where: s.session_key == "boj26dqs1v0okevigm2vnsu11gephx21" and s.expire_date >= ^later) |> Timetable.Repo.one
[debug] QUERY OK source="django_session" db=2.9ms
SELECT d0."session_key", d0."session_data", d0."expire_date" FROM "django_session" AS d0 WHERE ((d0."session_key" = 'boj26dqs1v0okevigm2vnsu11gephx21') AND (d0."expire_date" >= $1)) [{{2017, 11, 22}, {0, 0, 0, 0}}]
nil
We get back nil
when providing a date after the session expire_date
.
Adding Session Information to the Request
Now that we have a way to extract information from the session table, we can update our Session plug to check for a valid session. We're going to update it to look like this:
defmodule TimetableWeb.Session do
import Plug.Conn
import Ecto.Query, only: [from: 2]
def init(opts) do
Keyword.fetch!(opts, :repo)
end
def call(conn, repo) do
now = DateTime.utc_now()
case conn.cookies["sessionid"] do
nil ->
assign(conn, :logged_in, false)
session_key ->
case repo.one(from s in Timetable.Session, where: s.session_key == ^session_key and s.expire_date >= ^now) do
nil ->
assign(conn, :logged_in, false)
_ ->
assign(conn, :logged_in, true)
end
end
end
end
We have done two main adjustments:
- The
init
function has been updated to look for and return the:repo
reference from theopts
. Thecall
function has been updated to receive therepo
as the second argument. - If the
call
function has found a session key in the cookie, we check the sessions table for that session. If the session is valid, then we return theconn
data with a newlogged_in
field set to true. In both of the other cases where no valid cookie or session is found, we return theconn
with alogged_in
field set to false.
This isn't very advanced yet. A normal system would want to access the session_data
from the session table as well but we'll leave that for the next post.
Displaying Session Information on the Page
For the final step, we want to display whether or not the user is logged in. We're going to keep it very basic for now and update the template for the Phoenix landing page which is lib/timetable_web/templates/page/index.html.eex
. We're going to make the follow change:
<div class="row marketing">
+
+ <div>
+ <%= if @logged_in do %>
+ Logged In!
+ <% else %>
+ Not Logged In
+ <% end %>
+ </div>
+
<div class="col-lg-6">
<h4>Resources</h4>
This uses EEx - Embedded Elixir - to display different content depending on the value of logged_in
. As a field on conn
, the logged_in
value is accessible in our templates.
Now when we log in to our Django app and navigate to our /elixir
page, we're going to see Logged In!
displayed to us on the page. And if we log out of our Django app and come back to this page we see Not Logged In
instead.
Conclusion
We have our Phoenix app reading the session information from the Django sessions table. We are able to update the web page in a basic manner to reflect the logged-in status of the current user.
In the next post, we'll extend this approach to also check the user table for the user name.
Top comments (3)
Heya Michael, thanks for the fascinating series of articles. We're in a slightly different place. We got some Django code, and also an already-implemented-and-deployed Phoenix app running on Cowboy. Did you ever consider trying to run Django on Cowboy instead of Phoenix on Nginx? Or is that just crazy talk?
Hey, thanks for the question. Honestly, I don't know enough about Cowboy and web servers in general to know if that is crazy.
When I started on this path I tried to figure out if I could have the Phoenix app in front of the Django app and any routes that the Phoenix app didn't match would 'fall-through' to the Django one. Then I could just implement Phoenix routes as I saw fit and not have to worry about the Nginx layer to switch between them. I asked on the Elixir subreddit and the responders didn't seem to suggest that as a way forward so I've ended up with Nginx at the moment. Maybe I can figure it out and switch at some point.
I currently run the Django app via Gunicorn. I'm not really sure what it would mean to run Django on Cowboy.
Sorry I'm not in a better position to advise. I ended up taking a break from the project but have recently picked it up so I'll be building a bit more experience with path I've taken as I go.
Thanks for the response. From some other web-searching, looks like Nginx is a common connector for both Phoenix and Django, but did not find anybody trying to run Django from Cowboy.