Oct 2019
Some chatter over at Hacker News
How would you explain a typical website experience to your cat?
- First, you go to a webpage that asks for a bunch of your personal information. And you give it to them.
- Then, every time you want to come back to this page to do something, you have to remember some of this information.
- And then long after you stop using this site, there’s a good chance your information will either be sold to someone else or hacked.
This is how things have been for 20 years!
Is there a better way? Who knows. But at the very least, here’s an attempt to rethink how we set up accounts and what happens to them over time.
The Setup
- Signing up for a website should be as easy as visiting a URL. No forms, no personal information, nothing.
- Passwords are optional.
- You can access your account instantly from anywhere, anytime. No logging in, no app, just visit a URL.
- Your account self-destructs eventually (or is adversely possessed). No more waiting around for an old account to get hacked.
Would I recommend this setup for your bank’s web app? Probably not. But my guess is there are other websites out there that could benefit from taking this lighter and more disposable approach.
The Details
Just visit a URL and I have a website? Sure thing. Our demo app URL will be sdnotes.com (short for standard definition notes).
- Have a yard sale next Sunday? Great, go to sdnotes.com/jimsyardsale
- Want a to-do list saved to the cloud? sdnotes.com/tim2do
- A shared wiki among friends? sdnotes.com/ourprivatesite
In all of these examples, your account is quietly created.
Start adding stuff to your page. At this point, no password has been added, but your site already works. Later on, if you want, you can add a password, but it isn’t required.
But won’t someone steal my site? Sure, that’s a possibility if you don’t add a password. The security model for this system is one part security-by-obscurity and one part embracing your site’s temporary nature.
Why don’t we just mandate a password? Well, we want our sites to be easy to update from anywhere, anytime, and possibly by multiple people (without having to share a password)
Since we’ve made creating a site so easy, what prevents people from hoarding websites? This is where self-destruction comes into play. If 30 days pass without a new post, your site is deleted from the record. Anyone can now claim it.
Use-it-or-lose-it applied to websites. Just because bits are so easy to preserve doesn’t mean we have to preserve them.
Implementation using Ruby on Rails
(Written using Rails v5.2.3)
Now we’ll shift gears to see how this could be accomplished using Ruby on Rails.
If you’ve suddenly lost interest, here’s the live site: sdnotes.com/yoursitename
First, let’s generate a Site
model. These will be our implicitly-created accounts — ie sdnotes.com/jimsyardsale.
rails g model Site name:string password_digest:string locked:boolean
class Site < ApplicationRecord
before_create :lock_init
VALID_NAME_REGEX = /\A[a-zA-Z\d\-]+\z/i # letters, numbers, dashes
validates :name, presence: true, uniqueness: true, length: { maximum: 50 }, format: { with: VALID_NAME_REGEX }
has_secure_password validations: false
private
def lock_init
self.locked = false
end
end
Note that the only required field is name
via presence: true
, uniqueness:true
and some REGEX to make sure we only allow letters, numbers, and dashes in our URL.
One of our initial goals was to not require a password. This allows us to update our site from wherever, whenever just by remembering our URL. To accomplish this, we add validations: false
to the has_secure_password
method — which is Rails’ built-in password authenticator. It takes in a password
in our form, and saves it as password_digest
in our database.
Our locked
attribute (from the above generate command) will just tell us whether a site has decided to add a password. We set this to default false
before creating a new site via the lock_init
private method, because we want our initial sites to live free and easy at first.
Now that we have our Site
model up and running, we can create new sites, but we’ll need to make this site creation as easy as going to a URL — remember we said no forms!
To do so, we’ll set up our first route:
get '/:id', to: 'sites#show', as: 'main'
So, when the browser makes a GET
request to sdnotes.com/yoursitename, yoursitename will become available to us via a params[:id]
in the show
action of the Sites
controller.
Now let’s create our Sites
controller, and a few actions we’ll need.
rails g controller Sites show add_password remove_password
class SitesController < ApplicationController
before_action :find_site, only: [:show]
def show
if @site.nil?
@site = Site.new(name: @slug)
if @site.save
flash.now[:notice] = "Your site is ready. Hi #{@slug} :)"
else
redirect_to main_path("faq"), alert: "Site not saved. Letter, numbers, and dashes accepted."
end
end
@feed = @site.posts.order("created_at DESC")
end
private
def find_site
@slug = params[:id].downcase
@site = Site.find_by(name: @slug)
end
end
As mentioned above, yoursitename is passed into our show
action as params[:id]
via the :find_site
before_action
. Great, let’s check whether this site exists yet. If it’s nil
, let’s create a new site, and all that’s required is name
, which we already have from the URL. That’s it, your site is already created! No password, email, pet’s name, phone number, etc.
What can I do on my site? Well, whatever you want your app to do. You’ll just need to create the corresponding models and associations. For the demo app, we have posts which belong to sites (site: references
).
rails g model Post body:text site:references
And we’ll just need to add has_many :posts, dependent: :destroy
to our Site
model. (Because of the site: references
command above, our Post
model already has the necessary belongs_to :site
.)
Password?
Now you’ve added some posts to your site. Maybe it’s a to-do list. Maybe it’s your wedding invitation. Maybe it’s your dream journal. Whatever it is, you’ve decided it needs a password. Your page will still be public on the web, you’ll just need a password to add posts.
To create our password, we’ll be updating our site’s record to include a password, and will have to provide a route.
patch '/:id', to: 'sites#add_password', as: 'site_pass'
Our form below matches the route created above — and since the add_password
action lives inside our SitesController
, we can give it access to @site
via :find_site
.
<%= form_for @site, url: site_pass_path(@site.name), method: :patch do |f| %>
<%= f.label :password %>
<%= f.password_field :password %>
<%= f.submit "Add password" %>
<% end %>
Now our SitesController
can update our record with a new password.
class SitesController < ApplicationController
before_action :find_site, only: [:show, :add_password, :remove_password]
before_action :unlocked?, only: [:add_password, :remove_password]
before_action :have_posts?, only: [:add_password]
def show
...
end
def add_password
if @site.update_attributes(pass_params)
lock_site
redirect_to site_pass_path(@site.name), notice: "Updated password"
else
redirect_to site_pass_path(@site.name), alert: "Update failed"
end
end
private
def pass_params
params.require(:site).permit(:password, :password_confirmation)
end
def find_site
@slug = params[:id].downcase
@site = Site.find_by(name: @slug)
if @site.present?
expired?
end
end
def lock_site
@site.update_attributes(locked: true)
end
def unlocked?
if private?
redirect_to main_path(@site.name), notice: 'Site is locked.'
end
end
def have_posts?
if @site.posts.count == 0
redirect_to main_path(@site.name), notice: 'Cannot add password to empty site.'
end
end
end
Note that before we add our password, we first need to make a couple checks — unlocked?
and have_posts?
. The unlocked?
method checks to make sure we have access to add a password — more on this later. The have_posts?
method only allows us to add a password if there are some posts. We don’t need anyone cybersquatting empty sites!
Once we’ve determined it’s okay to update a password, we update_attributes
our pass_params
method, which is just a way for Rails to protect what data a user can pass into a form. We also run the lock_site
method to make sure our site is now locked.
Alright, so our site is password protected! Now we have to enforce this password-protection for site visitors.
Sessions
Now that a user can password-protect write-access to a site, we’ll need to add a way for someone to log in.
So far, the only view
that we’ve been interested in is our show.html.erb
and that trend continues. (And ultimately that’s the only view we’ll need)
We have this line of code in the show.html.erb
:
<% if private? %>
... login form ...
<% end %>
The private?
method lives inside our SitesHelper
module (so we have access to it in all views — in our case, the show.html.erb
view).
module SitesHelper
def private?
@site.locked && session[@site.name.to_sym] != "session-unlocked"
end
end
If the site is locked, and the site session is not session-unlocked
, let’s render the login
form. In other words, if this site has a password, and you haven’t logged in, we’ll show you a login form.
It’s important to note that @site.locked
looks at the database for its value, whereas the session looks into the browser cookies.
Let’s take a step back — what is session[...]
? It’s a way for our app to store temporary data on the local browser, so we can remember when someone logs in. Luckily, Rails has some helpful session methods to help us out (which is different from the below SessionsController
).
rails g controller Sessions create
We’ve created a SessionsController
, but no Session
model. This is okay! As mentioned above, we’ll be storing sessions in browser cookies and not in our database.
When you login, we’ll be creating a session, and when close your browser, your session is automatically deleted by your browser. Here’s our new route:
post '/unlock', to: 'sessions#create', as: 'unlock'
And our corresponding login form:
<%= form_for(:session, url: unlock_path) do |f| %>
<%= f.label :password %>
<%= f.password_field :password %>
<%= f.hidden_field :site, value: @site.name %>
<%= f.submit "Unlock" %>
<% end %>
Great, so our form will POST
by default to the path specified in our route (unlock_path
), and send all the important login info to the create
action of our SessionsController
.
class SessionsController < ApplicationController
def create
site = Site.find_by(name: params[:session][:site])
if site && site.authenticate(params[:session][:password])
session[site.name.to_sym] = "session-unlocked"
redirect_to main_path(site.name), notice: 'Logged in. Free to add/delete posts'
else
flash.now[:danger] = 'Invalid password'
redirect_to main_path(site.name), notice: 'Incorrect password'
end
end
end
To create a session, we first find the site
using the hidden_field
from the form, and then we run the authenticate
method on our password
. The authenticate
method is available from has_secure_password
, and it returns true if you entered the right password.
Assuming we’ve entered the right password, we’re now going to set a session cookie. To do this, we use Rails’ session[...]
method. Session cookies take a key-value pair, so we set the key to a symbol representing the current site’s name(site.name.to_sym
), and then set the value to “session-unlocked”
.
It’s important that we set the key of the session cookie to be specific to the site name, as we could potentially be logged into multiple sites at once and not logged into others. Your browser can hold onto multiple session cookies. Great! So now we have a way for people to log in to individual sites. Your browser now holds information telling us whether you’ve logged in.
But we haven’t fully addressed enabling and disabling access to site functionality depending on login status. We hinted at it above — remember that before we allowed a user to add a password, we ran before_action :unlocked?
in our SitesController
.
# sites_controller.rb
def unlocked?
if private?
redirect_to main_path(@site.name), notice: 'Site is locked.'
end
end
And here’s our private?
method again:
module SitesHelper
def private?
@site.locked && session[@site.name.to_sym] != "session-unlocked"
end
end
Now we understand what’s happening here — if the site has a password (@site.locked
== true
), and the session cookie for the specific site (session[@site.name.to_sym]
) is not session-unlocked, we prevent you from updating a password (by redirecting you elsewhere).
Quick note regarding helper modules: to use our private?
method in our controllers (previously we were only using it in views), we have to include
the SitesHelper
module, either in a specific controller, or in ApplicationController
where it’ll be accessible in all controllers.
So where else should we restrict user access if a site is password protected? Our posts! Remember that only logged in users can add (and delete) posts. We’ll have to address this in two places, first in the view, and then later in the controller.
In our show.html.erb
view, we have:
<%= render 'posts/new_post' if postable? %>
And this postable?
method is added to the SitesHelper
module:
module SitesHelper
def postable?
!@site.locked || (@site.locked && session[@site.name.to_sym] == "session-unlocked")
end
def private?
@site.locked && session[@site.name.to_sym] != "session-unlocked"
end
end
Let’s take a look at our postable?
method. We want to render our new_post
form under certain circumstances:
- If a site does not have a password (
!@site.locked
), let’s show the new post form! - If a site does have a password (
@site.locked
) and the user has logged in to this specific site (session[@site.name.to_sym] == “session-unlocked”
), let’s show the new post form!
Right now, a site visitor should never see a way to add a post if they don’t have access. But this isn’t fool-proof just yet. Perhaps something goes wrong and the form is displayed, or maybe a sophisticated computer user injects form code. We need back-up in our PostsController
to make sure posts are not added by users without access.
class PostsController < ApplicationController
before_action :editable?, only: [:create]
def create
@site = Site.find_by(name: params[:post][:site])
@post = @site.posts.build(post_params)
if @post.save
redirect_to main_path(@post.site.name)
else
render 'sites/show'
end
end
private
def post_params
params.require(:post).permit(:body)
end
# make sure site is editable before creating post (either public or private + unlocked)
def editable?
@slug = params[:post][:site]
@site = Site.find_by(name: @slug)
if private?
redirect_to main_path(@site.name), notice: 'Site is locked.'
end
end
end
Before we create a new post, we run the private method editable?
. This method looks a lot like some of our previous methods — we check the same private?
method from earlier to make sure we have access to add a new post.
Recap
- We’ve been able to create accounts merely by visiting a URL.
- We can modify our site (add/delete posts) just by visiting the URL.
- And we can even add passwords later on if we desire.
The only part that’s missing is account self-destruction.
Self Destruction
Don’t you love when you get an email from some website you last logged into 5 years ago informing you that your personal information has been hacked? Well, nice to hear from you again!
As mentioned earlier, our accounts will self-destruct after 30 days of inactivity.
While you could query the database on a regular basis and delete expired accounts, we’ll just handle site deletion from our controller, and only delete sites when necessary.
To do so, we’ve added an additional check to our find_site
method in our SitesController
. If our site exists, we run another private method expired?
.
class SitesController < ApplicationController
...
private
def find_site
@slug = params[:id].downcase
@site = Site.find_by(name: @slug)
if @site.present?
expired?
end
end
def expired?
last_post = @site.posts.last
if last_post.present?
activity_limit = 31
@days_til_expire = activity_limit - (Time.zone.now - last_post.updated_at)/(3600*24)
if @days_til_expire < 0
@site.destroy
redirect_to main_path(@site.name), notice: 'Previous site here was deleted due to inactivity.'
end
end
end
end
Essentially, if a site has expired (30 days since a post), we want to delete the site right before someone tries to visit it. To recap — our initial GET
route sends us to the show
action in the SitesController
. Before we run the show
action, we check find_site
, and then expired?
. If expired?
calculates that 30 days have passed since the last post, we delete the site from our database immediately. Then, when we run the show
action, we don’t find a site record with the @slug
name, so we create a new record.
Homepage
If you’re still reading, maybe you’re wondering about our homepage? Well, we don’t have one! Instead, we’ll redirect users to their own personal page should they try to access the homepage. To do this, we add this route:
root 'sites#home'
Which takes us to the home
action of the SitesController
:
def home
@slug = ('a'..'z').to_a.shuffle[0,8].join
redirect_to main_path(@slug)
end
Here we use Ruby’s handy method chaining to randomly grab an 8 character string, and redirect you to that page, where we’ll begin the whole process from scratch.
Conclusion
So what was the point of this again?
- We have too many accounts/passwords online.
- Account creation flows are boring and/or unnecessary.
- Accounts aren’t secure (eventually).
Could there be different ways to handle all of this?
Yes! Our little thought experiment is alive in the wild — sdnotes.com , short for standard definition notes. Go make a site!
Here’s the Github repo.