Skip to content

Instantly share code, notes, and snippets.

@jimsynz
Created March 19, 2014 01:42
Show Gist options
  • Save jimsynz/9633932 to your computer and use it in GitHub Desktop.
Save jimsynz/9633932 to your computer and use it in GitHub Desktop.
Ember API Token
# app/controllers/api_controller.rb
class ApiController < ApplicationController
skip_before_action :verify_authenticity_token
respond_to :json
rescue_from UserAuthenticationService::NotAuthorized, with: :not_authorized
rescue_from ActiveRecord::RecordNotFound, with: :not_found
before_filter :api_session_token_authenticate!
private
def signed_in?
!!current_api_session_token.user
end
def current_user
current_api_session_token.user
end
def api_session_token_authenticate!
return not_authorized unless authorization_header && current_api_session_token.valid?
end
def ensure_signed_in!
return not_authorized unless current_user
end
def current_api_session_token(token=authorization_header)
@current_api_session_token ||= ApiSessionToken.new(token)
end
def authorization_header
request.headers['HTTP_AUTHORIZATION']
end
def not_authorized message = "Not Authorized"
error message, 401
end
def not_found message = "Not Found"
error message, 404
end
def not_acceptable message = "Not acceptable"
error message, 406
end
def bad_request message = "Bad request"
error message, 400
end
def error message, status
render json: { error: message }, status: status
end
end
# app/assets/javascripts/models/api_session_token.coffee
App.ApiSessionToken = Em.Object.extend
token: null
ttl: 0
userId: null
status: 0
user: (->
App.User.find(@get('userId')) if @get('hasUser')
).property('userId')
isAlive: (-> @get('ttl') > 0).property('ttl')
hasToken: (->
token = @get('token')
if token && (typeof(token) == 'string') && (token.length > 0)
true
else
false
).property('token')
hasUser: Em.computed.notEmpty('userId')
isValid: (->
@get('hasToken') && @get('isAlive') && !@get('isInError')
).property('hasToken', 'isAlive')
isInError: (->
@get('status') > 399 && @get('status') != 401
).property('status')
needsRefresh: (->
ttl = @get('ttl')
if @get('isInError')
ttl < 1
else
ttl < 11
).property('ttl', 'isInError')
init: ->
@scheduleTtlDecrement()
decrementTtl: ->
@decrementProperty('ttl') if @get('isAlive')
@scheduleTtlDecrement()
scheduleTtlDecrement: ->
Em.run.later(this, @decrementTtl, 1000)
autoRefresh: (->
if @get('needsRefresh') && !@get('isRefreshing')
@refresh()
).observes('needsRefresh', 'isRefreshing')
storeTokenInLocalStorage: (-> localStorage['token'] = @get('token')).observes('token')
authenticate: (username,password)->
App.ApiSessionToken.acquire(@, {username: username, password: password})
authenticateWithApiSecret: (username, apiSecret)->
apiKey = (new jsSHA("#{username}:#{apiSecret}:#{@get('token')}", 'TEXT')).getHash('SHA-256', 'HEX')
App.ApiSessionToken.acquire(@, {username: username, api_key: apiKey})
refresh: ->
App.ApiSessionToken.acquire(@)
abandon: ->
localStorage.removeItem('token')
localStorage.removeItem('api_username')
localStorage.removeItem('api_secret')
token = @
Em.run ->
new Em.RSVP.Promise (resolve,reject)->
$.ajax
dataType: 'json'
data: { token: token.get('token') }
url: "/sessions"
type: 'DELETE'
success: ->
resolve(token.refresh())
error: (xhr,status,error)->
reject(error)
App.ApiSessionToken.reopenClass
acquire: (token, credentials={})->
Em.run ->
token ||= App.ApiSessionToken.create(token: localStorage['token'])
token.set('isRefreshing', true)
credentials.token = if token.get('hasToken')
token.get('token')
else
localStorage['token']
new Em.RSVP.Promise (resolve,reject)->
$.ajax
dataType: 'json'
data: credentials
url: "/sessions"
type: 'POST'
success: (data,status,xhr)->
Em.run ->
token.setProperties
token: data.api_session_token.token
ttl: data.api_session_token.ttl
userId: data.api_session_token.user_id
status: xhr.status
message: xhr.statusText
isRefreshing: false
resolve(token)
error: (xhr,status,error)->
Em.run ->
token.setProperties
status: xhr.status
message: xhr.statusText
ttl: 10
isRefreshing: false
reject(token)
# app/models/api_session_token.rb
require 'json_serializing_model'
class ApiSessionToken
extend ActiveModel::Naming
include ActiveModel::Serialization
include JsonSerializingModel
TTL = 20.minutes
UnknownToken = Class.new(RuntimeError)
def initialize(existing_token=nil, redis=ApiSessionToken.redis_connection)
@redis = redis
@token = existing_token if valid_existing_token? existing_token
unless expired?
self.last_seen = Time.now
self.user = user if user
end
end
def token
@token ||= MicroToken.generate 128
end
def ttl
return 0 if @deleted
return TTL unless last_seen
elapsed = Time.now - last_seen
remaining = (TTL - elapsed).floor
remaining > 0 ? remaining : 0
end
def last_seen
@last_seen ||= retrieve_last_seen
end
def last_seen=(as_at)
set_with_expire(last_seen_key, as_at.iso8601)
@last_seen = as_at
end
def user
return if expired?
@user ||= retrieve_user
end
def user=(user)
set_with_expire(user_id_key, user.id)
@user = user
end
def expired?
ttl < 1
end
def valid?
!expired?
end
def deleted?
@deleted
end
def delete!
@redis.del(last_seen_key, user_id_key)
@deleted = true
end
private
def valid_existing_token? t
t && retrieve_last_seen(t)
end
def set_with_expire(key,val)
@redis[key] = val
@redis.expire(key, TTL)
end
def retrieve_last_seen(t=token)
ls = @redis[last_seen_key(t)]
ls && Time.parse(ls)
end
def retrieve_user
user_id = @redis[user_id_key]
User.find(user_id) if user_id
end
def last_seen_key(t=token)
"session_token/#{t}/last_seen"
end
def user_id_key
"session_token/#{token}/user_id"
end
def self.redis_connection
@redis ||= begin
opts = {}
opts[:driver] = :hiredis
Redis.new opts
end
end
end
# app/serializers/api_session_token_serializer.rb
class ApiSessionTokenSerializer < ApplicationSerializer
attributes :token, :ttl
has_one :user
end
# app/assets/javascripts/routes/application_route.coffee
App.ApplicationRoute = Em.Route.extend
model: ->
if localStorage.api_username? && localStorage.api_secret?
App.ApiSessionToken.acquire().then (token)->
if token.get('hasUser')
token
else
token.authenticateWithApiSecret(localStorage.api_username, localStorage.api_secret)
else
App.ApiSessionToken.acquire()
afterModel: (model)->
App.set('sessionToken', model)
# lib/json_serializing_model.rb
module JsonSerializingModel
def active_model_serializer
"#{self.class.to_s}Serializer".constantize
end
def serialize_to_json(serializer=active_model_serializer)
serializer.new(self).as_json.to_json
end
end
# app/controllers/sessions_controller.rb
class Api::SessionsController < ApiController
skip_before_filter :api_session_token_authenticate!, only: [:create]
def create
token = current_api_session_token(params[:token] || authorization_header)
if params[:username]
@user = User.confirmed.find_by_username(params[:username])
token.user = @user if provided_valid_password? || provided_valid_api_key?
end
respond_with token
end
def show
respond_with current_api_session_token
end
def destroy
current_api_session_token.delete!
render nothing: true, status: 204
end
private
def provided_valid_password?
params[:password] && UserAuthenticationService.authenticate_with_password!(@user, params[:password])
end
def provided_valid_api_key?
params[:api_key] && UserAuthenticationService.authenticate_with_api_key!(@user, params[:api_key], current_api_session_token.token)
end
def api_session_token_url(token)
api_v1_sessions_path(token)
end
end
# app/services/user_authentication_service.rb
module UserAuthenticationService
NotAuthorized = Class.new(Exception)
module_function
def authenticate_with_password(user, attempt)
user && BCrypt::Password.new(user.password) == attempt
end
def authenticate_with_password!(*args)
authenticate_with_password(*args) or raise NotAuthorized
end
def authenticate_with_api_key(user, key, current_token)
user && key && current_token && OpenSSL::Digest::SHA256.new("#{user.username}:#{user.api_secret}:#{current_token}") == key
end
def authenticate_with_api_key!(*args)
authenticate_with_api_key(*args) or raise NotAuthorized
end
end
# app/assets/javascripts/controllers/users_sign_in_controller.coffee
# Most of this could be moved to a route.
App.UsersSignInController = Em.Controller.extend
needs: ['application']
sessionTokenBinding: 'controllers.application.model'
init: ->
@_super()
@send('reset')
username: null
password: null
errors: null
rememberMe: false
hasUsername: Em.computed.bool('username')
hasPassword: Em.computed.bool('password')
signInButtonEnabled: Em.computed.and('hasUsername', 'hasPassword')
signInButtonDisabled: Em.computed.not('signInButtonEnabled')
actions:
reset: ->
@setProperties(username: null, password: null, errors: null, rememberMe: false)
attemptSignIn: ->
onSuccess = (token)=>
token.get('user').then (user)=>
user.get('session').refresh(user).then =>
@send('authenticationDidSucceed', user)
onFailure = (token)=>
@send('authenticationFailed')
username = @get('username')
password = @get('password')
@get('sessionToken').authenticate(username, password).then(onSuccess,onFailure)
authenticationDidSucceed: (user)->
@send('storeApiSecret', user) if @get('rememberMe')
@send('reset')
@transitionToRoute('user.index', user)
authenticationFailed: ->
@set('errors', Em.Object.create(password: ['Authentication failed']))
storeApiSecret: (user)->
if user.get('hasApiSecret')
localStorage['api_username'] = user.get('username')
localStorage['api_secret'] = user.get('apiSecret')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment