Question: What's a good way to "lock" a Rails web application so a user must enter credentials to unlock it (but such that the user is still logged into the app and simply can't use the app without unlocking it)?
What I'm doing
I'm building a task management app that behaves like a kiosk to be used by multiple users. Users can log in to the app using their email address and password (full credentials), but once they're in, they can "swap" users by selecting their name from a list and entering a four-digit PIN (easy credentials).
Why have two types of credentials?
This is a multi-tenant app, and users use "full credentials" to simply get into the app and choose a tenant. Once they're in, they will frequently swap out as new employees come on shift and other employees leave because multiple employees will share a single work station.
Rather than forcing users to repeatedly enter their "full" credentials (by doing a full logout/login), I've created a name/PIN set of credentials they can use to easily swap. Once they're logged in with full credentials and have chosen their tenant, the app provides an autocomplete list of user's names, so it's very easy to select one's name from the list, enter the PIN and swap users.
Note that this "swapping" functionality is already built and working.
Why do I need to lock the app?
When a user is logged in to the app and a tenant, they may need to leave the app to go do other work. Without a lock screen, they only have two choices: fully log out so that the next user has to enter "full" credentials to use the app or just leave their account logged in for anyone to mess with.
I would like to allow them to click a button to "lock" the app when they walk away so that users can leave the app "logged in" without allowing other users to access others' accounts. Once they click "lock" all they can do is enter a name/PIN to unlock it, or click "sign out" to fully log out.
Here's what a typical use-case would look like:
Frank logs into the app using his email address and password.
He completes some tasks.
Jane wants to use the app, so she "swaps in" by choosing her name from a list and entering her four-digit PIN.
Now Jane is the logged-in user, meaning she can complete tasks, edit her profile, and other activities.
Jane completes some tasks and needs to go do something else in the store, so she clicks the "Lock" button on the screen.
The app is now locked and requires authentication to use (name and PIN) or the user can click "sign out" to fully go out of the app. The screen simply says something like "The app is locked. Enter your name and PIN to unlock it."
The options as I understand them
It seems like there are two ways to go here:
JavaScript of some kind
Some sort of controller logic using session variables or cookies
JavaScript makes me nervous because it's pretty hackable. I want to make sure the app is actually secure.
So I've been messing with ways of using session variables to do this. Here is what I've been trying:
User is authenticated to the app for a particular tenant
User clicks the "Lock" button
A "lock_ui" action is called in the Sessions controller
That action sets a few session variables (session[:locked], session[:locked_by], session[:locked_at])
The app then always redirects to Sessions#locked, which displays the "This is locked. Enter name and PIN to unlock" form unless it's unlocked or the user clicks "sign out".
Once a user enters a valid name/PIN, those session variables are deleted and the app functions normally.
Any suggestions on how I might do this so that it's secure?
Things I'm hung up on:
Should I be using some sort of application controller filter (before or around) to check to see if the app is locked? What does the application controller do if the app is locked? (Does it call some other action, render a the "locked" page directly, or something else?)
Or is this something that should be handled in the view layer? For example, I could update my application layout so it has an "if locked_ui render the 'locked' screen, else yield".
Some notes on how I'm currently set up:
This is a Rails 4.1.0 app using Ruby 1.9.3 hosted on heroku.
I'm using CanCan for authorization
I'm using multi-tenancy with scopes (and I'm not using sub-domains)
Each user has only one account that may have access to multiple tenants
I've seen JQuery BlockUI, but it seems more like a vanity thing than a functional security device
Here's what I ended up building.
To answer my own questions:
Should I be using some sort of application controller filter (before or around) to check to see if the app is locked? I am using a before_filter in my Application Controller to check whether the UI is locked. If it's locked, I redirect to the "locked" screen. I then added a skip_before_filter to the relevant actions in my Sessions Controller (essentially ignoring that before_filter when the user is navigating to the "locked" screen and related lock/unlock actions in the Sessions controller.
Or is this something that should be handled in the view layer? In the view layer, I mostly just needed to create the actual "locked" screen (where a user enters credentials to unlock the app), and I make sure to hide navigation elements while the UI is locked (so the user isn't confused as to why they click "Edit Profile" and don't leave the lock screen).
I'm using cookies to record the current state of the UI (locked or not). Here are some code snippets:
application_controller.rb
before_filter :confirm_unlocked_ui
private
def confirm_unlocked_ui
if signed_in? && locked_ui?
redirect_to locked_path
end
end
sessions_helper.rb
def locked_ui?
session[:locked] == '1'
end
def lock_ui
session[:locked] = '1'
session[:locked_by] = current_user.id
session[:locked_at] = Time.zone.now
end
def unlock_ui
session.delete(:locked)
session.delete(:locked_by)
session.delete(:locked_at)
end
sessions_controller.rb
def lock
session[:return_to] = params[:return_to] if params[:return_to]
lock_ui
redirect_to locked_path
end
def locked
#essentially just a view
end
def unlock
user = User.find_by_id(params[:session][:unlock_user_id])
if user && user.authenticate_with_pin(params[:session][:pin])
cookies[:auth_token] = user.auth_token
unlock_ui
redirect_back_or root_path
else
flash.now[:error] = "Invalid PIN."
render 'locked'
end
end
def destroy
sign_out
unlock_ui
redirect_to root_path
end
I really don't know how "good" this solution is, and one reason I'm posting it here is to see if others come up with better ideas or see any issues lurking with how I've done this.
Some more subtle things you might want to think about if you're trying this:
I'm using CanCan for authorization, and I've left that out here. If you have role-based authorization or something like that, be sure to check that this works for each role.
Notice that I allow users to 'sign out' if they don't want to 'unlock'. I do this using the 'destroy' action my sessions controller and I make sure to 'unlock_ui' before I redirect them after signing out. If I didn't do this, then the app would still be "locked" the next time they log in from that browser.
locked_path is an alias for Sessions#locked (basically just a view/form) that responds to a get request.
Sessions#Lock also responds to get requests.
Sessions#Unlock is what is called when the form is submitted from the "locked" view.
Related
I must build a Rails API, which responds to requests from an iPad app. I have, among other things, a Shop model, a User model and a Product model.
The iPad app works like that - once started, first the shop must log in with an ID and a password. This is done by the shop manager (probably in the morning, at the beginning of the working day or even just once, if they never log out). This happens on multiple iPads. Then, an iPad is given to a customer, who, within the “session” of the shop, logs in as a user. The point of this is, that a user can log in with the same credentials in different shops and depending on this, they can see different products in the iPad app.
So, within a Rails session I need to keep a current_user, but also a current_shop. The question is - how do I implement this?
I was thinking of the following - after the shop manager enters id and password, the API returns some token, which is persisted on the iPad. Then - when the user logs in, this token is sent along with their credentials, so that, at the moment of login I know in which shop the user is and know which products to return in the initial response after login. I also save the shop token in the user’s session.
I would first like to know if my general idea is correct. Also, I would like to know how would you implement it. I was thinking of using Devise for the user and hand-rolled authentication for the shop, but I must figure out how to integrate both.
Devise does allow you to use any model and multiple models in parallel. This allows you to use all the nifty helpers like current_user and authenticate_shop!.
What devise doesn't bring out of the box is an API authentication mechanism. That you can (and have to) implement yourself
Using tokens for each of the shop and user accounts seems straight forward. You can even use the same basic mechanism (maybe via HTTP-Header).
before_filter :authenticate_xxx_via_token
def authenticate_xxx_via_token
xxx_id = params[:xxx_id] || request.headers["X-XXX-ID"]
xxx_token = params[:auth_token] || request.headers["X-XXX-AUTH-TOKEN"]
xxx = xxx_id && Xxx.where(id:xxx_id).first
if xxx && Devise.secure_compare(xxx.authentication_token, xxx_token)
sign_in xxx, store: false
end
end
So do this once for each model and you can then protect your controllers/actions via authenticate_xxx! and use current_xxx.
Finally, don't forget to add SSL to your service or all of this is of little use.
I guess, you're integrating an API driven application, using devise will be of limited use to you. You could do it but I have a feeling it will cause more pain than it is worth.
Use Rails built'in has_secure_password - http://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html
The simplest possible flow without session tokens would go like this:
store manager's username and password into the iPad app (you can send an api call to the host to verify it's OK) and the app stores those values in its process memory (not on the disk!)
in subsequent API calls you resend the manager's username and password alongside with the customer's username and password and you verify both in before_action of your base controller
A bit more secure solution would be to use a session token which supports multiple ipads:
store manager enters his username and password into the ipad app and ipad app sends a store authentication call, you verify the credentials and return a SecureRandom.base64 digest and you set Rails.cache shop_owner/#{digest} key to value of shop_id for later retrieval, on the app you are free to put the digest on the disk
in every subsequent request the digest is sent alongside the customer credentials, in the before_action you check the cache for shop_owner/#{digest} and retrieve the store ID from there
I've been working on my first RoR/Shopify-application for the last couple of weeks and it is nearly finished. The only thing I'm having trouble with are the application charges. When a user sees my application in the store he clicks on "Get" and the authentication process is then initialized. The app is being installed and the user can Accept/Decline the charges. If he declines, he is redirected to my app listing in the store.
Now here is my problem. If the user declines the fees my application is still installed and if he clicks on my banner a second time I need to check whether he is paying for it or not. I do it via
if (ShopifyAPI::RecurringApplicationCharge.current)
some logic since the charge is valid
else
redirect_to billing_index_path(:shop_url => cur_shop_url)
My idea was to make the popup with the charging fees show up once again if the user hasn't paid yet. This doesn't work and I get an error saying that my site was not displayed because the X-Frame-Options are set to DENY. I've already tried to delete the options in my application_controller.rb and also in my application.rb using either
Response.headers.delete('X-Frame-Options')
and
config.action_dispatch.default_headers.delete('X-Frame-Options')
respectively. The first time the user installs the app everything seems to work, the embedding, the logic etc. but after he tries it a second time all hell breaks loose. How can I embed all the sites each time a user accesses my application? I read that Shopify's response has a DENY Header by default but I am sure that there must be a solution to this problem. I can't make a user uninstall the app every single time.
Thanks in advance
Shopify ignore 302's to their charge approval forms. The reason being, that your app is embedded in an iframe. The payment confirmation endpoint refuses to be embedded inside an iframe regardless of origin - they don't even whitelist their own site!!
This means you CANNOT simply do a form post and 302 on success to the subscription confirmation url, that would be too easy.
You MUST write some javascript that will take the confirmation url and perform window.top.location = confirm_url;.
You MUST break out of the iframe or shopify will also deny you redirect capabilities and you'll be back where you were.
Alternatively, you can open the link in a new window.
I had to convert my 'quick and simple' app to make an Ajax call to submit the subscription information to my server, then do a JS redirect using the resulting confirmation url sent back.
Have to say, Shopify's API is horrendously under-documented, and incredibly poorly implemented. At every turn their "API" puts up a roadblock.
Add these below code in config/application.rb file
config.action_dispatch.default_headers['P3P'] = 'CP="Not used"'
config.action_dispatch.default_headers.delete('X-Frame-Options')
config.active_record.raise_in_transactional_callbacks = true
Ok this is by far not the solution I was looking for but oh well. If I see that the charge is not valid I redirect the user to my billing_error_path. There I have a button with a link_to to my shopify-app listing. There he can click on "Get" once again and is then redirected back to my shop and can accept/decline the fees once again. Probably not the best solution but at least it does what I was looking for
In my web app after a user logs in a new session is created so until he closes the browser he stays logged in. The problem appears when admin wants to ban the user who's browser is still open. Even though the user is banned and cannot log in anymore, he still stays logged in until he closes the browser or manually logs out. This definitely should be fixed.
Is it possible to add a verifying method to every action of every controller? Of course I mean a smart way - not copy/paste 100 times.
add the following to your application controller:
before_filter :sign_out_banned_user
def sign_out_banned_user
if current_user.banned?
session[:current_user_id] = nil
redirect_to root_path, :notice => "You are banned"
return false
end
end
You must reset the session also i think.
I'm using Rail3 with Devise gem. It does a great job when you need to lock user from signing in.
But it works just for new login attempts.
If he is already logged in - it won't sign him out immediately.
Here's is the typical use case:
Given admin user
when detects suspicious activity of certain user he locks it with malicious_user.lock('locking-reason')
% can config/initializers/session_store.rb
AppFoo::Application.config.session_store :cookie_store, :key => '_foo_session'
Given HTTP's statelessness, you can't immediately log out a user because you will need to wait until they make another request to your server. You could get around this via a push service I suppose, but that would be overkill.
My solution would be to add that person to a blacklist and then check if they're on the blacklist whenever they try to access a section intended for logged-on users only. This will render them unable to log on until you decide whether or not their activity is suspicious.
Example:
User is suspected of intolerable activity
Admin wants to check this out, so they temporarily add the user to the blacklist.
User clicks on an area of the page they were currently on when added to the blacklist.
Code checks for loggin status and blacklisted users.
Since the user is blacklisted, they are informed that they need to sign in to access the content
Once the user tries to sign in again you can inform them that their account has been temporarily disabled (or you can do this in the previous step).
perhaps the easiest way would be to redirect the user to the logout action when you lock them so:
malicious_user.lock('locking-reason')
redirect_to '/logout' and return
I'm not familiar with Devise so this may not be the best solution or even possible but it's how I would approach the problem
Use a before_filter in the ApplicationController that will do the following
before_filter :kick_out_blocked_user
protected
def kick_out_blocked_user
unless current_user.try(:active?)
redirect_to destroy_user_session_path
end
end
I'm developing a web app in Ruby on Rails. I'm using authlogic to do authentication. My site has its own logins, but I'm hoping to also use Facebook. I tried out authlogic_facebook_connect (using Facebooker). After a lot of hiccups (the docs haven't really been fully kept up), I did get authlogic_facebook_connect to work and it's OK. The default "connect" behavior works perfectly when I'm faced with users who have never used by site before, but it results in a lot of duplicate logins for people that are using different email addresses for Facebook and for my site. Here's what I want:
When the user hits the Facebook "Connect" button (and after they go through the Facebook auth step of clicking 'Allow'), I want a box to pop up asking the user if they want to connect to a pre-existing account on my site or if they want to have an account automatically generated for them.
If they want it automatically generated for them, we're good and we proceed as normal, but if -- on the other hand -- they want to link their Facebook account to an account on my site, I actually want them to enter in their local credentials and find the correct account. In other words, I do not want my solution to automatically figure out which account looks like the right one, I want the user to do this.
Is there any gem / plugin / quick hack that will allow me to pull this off either using authlogic_facebook_connect or OAuth or something else?
--David
Someone else may be able to point you at the perfect gem for this, but I can tell you that I've worked on a similar problem and it wasn't much work to roll our own, based on the oauth2 gem.
Here's a sketch of the code/flow I use.
1) User clicks on 'Connect to Facebook' and this sends you to an action like this
def to_facebook
options = {
:redirect_uri => facebook_callback_url,
:scope => "email,publish_stream" # whatever you want to do
}
client = OAuth2::Client.new(FACEBOOK_API_KEY, FACEBOOK_API_SECRET, :site => FACEBOOK_API_SITE)
redirect_to client.web_server.authorize_url(options)
end
2) User goes over to facebook and gives you all the access you want, then facebook calls the callback you specified facebook_callback_url which should take you to an action like this:
def facebook_callback
client = OAuth2::Client.new(FACEBOOK_API_KEY, FACEBOOK_API_SECRET, :site => FACEBOOK_API_SITE)
access_token = client.web_server.get_access_token(params[:code], :redirect_uri => facebook_callback_url)
do_my_custom_user_association(access_token)
end
Then you can specify whatever you want in do_my_custom_user_association, you should have a current_user available from authlogic if someone is logged in, so you can redirect to a flow that lets the logged in user select if they want to merge into their current account or a different one. If there's no current user, you can send them to a create account flow, with some facebook data attached.
Note that this is just a sketch, there are error cases to handle (e.g. facebook_callback will be hit with the param error_reason if the get_acccess_token fails) and I'm not recommending you do all the oauth2 interaction right in your controller, but the basic idea is there.
See http://developers.facebook.com/docs/authentication/ if any of the oauth2 interactions don't make sense.