Eric / NYC / Milky Way

Visual

Blog Index

Random 5

Stripe and Tap

Multi-tab Websockets

Ruby vs. Ruby

Cookies

Web Accounts

Emoji Bits

Honeypot Bots

Bad Content

JS Data Chutzpah

Sendgrid Ban

Clean URLs

Git

Concerns

Rails UJS

Extending Devise

ENV Variables

See All

Rethinking Web Accounts in Rails

October 2019

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

  1. Signing up for a website should be as easy as visiting a URL. No forms, no personal information, nothing.
  2. Passwords are optional.
  3. You can access your account instantly from anywhere, anytime. No logging in, no app, just visit a URL.
  4. 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 &mdashl 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.

Here’s some discussion on Hacker News