Skip to content
This repository has been archived by the owner on Sep 7, 2021. It is now read-only.

Commit

Permalink
initial import of middleware (v0.1.0)
Browse files Browse the repository at this point in the history
  • Loading branch information
mislav committed Jan 3, 2010
0 parents commit 13ff555
Show file tree
Hide file tree
Showing 4 changed files with 354 additions and 0 deletions.
79 changes: 79 additions & 0 deletions README.markdown
@@ -0,0 +1,79 @@
Drop-in login functionality for your webapp
===========================================

Drop this Rack middleware in your web application to enable user logins through Twitter.


How to use
----------

First, [register a new Twitter application][register] (if you haven't already). Check
the <i>"Yes, use Twitter for login"</i> option. You can put anything as <i>"Callback
URL"</i> since the real callback URL is provided dynamically, anyway. Note down your
OAuth consumer key and secret.

Next, install this library:

[sudo] gem install twitter-login

You have to require 'twitter/login' in your app. If you're using Bundler:

## Gemfile
clear_sources
source 'http://gemcutter.org'
gem 'twitter-login', :require_as => 'twitter/login'

Now configure your app to use the middleware. This might be different across web
frameworks.

## Sinatra
enable :sessions
use Twitter::Login, :key => 'CONSUMER KEY', :secret => 'SECRET'
helpers Twitter::Login::Helpers

## Rails
# environment.rb:
config.middleware.use Twitter::Login, :key => 'CONSUMER KEY', :secret => 'SECRET'

# application_controller.rb
include Twitter::Login::Helpers

Fill in the `:key`, `:secret` placeholders with real values. You're done.


What it does
------------

This middleware handles GET requests to "/login" resource of your app. Make a login
link that points to "/login" and you're all set to receive logins from Twitter.

The user will first be redirected to Twitter to approve your application. After that he
or she is redirected back to "/login" with an OAuth verifier GET parameter. The
middleware then identifies the authenticating user, saves this info to session and
redirects to the root of your website.


Configuration
-------------

Available options for `Twitter::Login` middleware are:

* `:key` -- OAuth consumer key *(required)*
* `:secret` -- OAuth secret *(required)*
* `:login_path` -- where user goes to login (default: "/login")
* `:return_to` -- where user goes after login (default: "/")


Helpers
-------

The `Twitter::Login::Helpers` module (for Sinatra, Rails) adds these methods to your app:

* `twitter_user` (Hashie::Mash) -- Info about authenticated user. Check this object to
know whether there is a currently logged-in user. Access user data like `twitter_user.screen_name`
* `twitter_logout` -- Erases info about Twitter login from session, effectively logging-out the Twitter user
* `twitter_consumer` (Twitter::Base) -- An OAuth consumer client from ["twitter" gem][gem].
With it you can query anything on behalf of authenticated user, e.g. `twitter_consumer.friends_timeline`

[register]: http://twitter.com/apps/new
[gem]: http://rdoc.info/projects/jnunemaker/twitter
140 changes: 140 additions & 0 deletions lib/twitter/login.rb
@@ -0,0 +1,140 @@
require 'twitter'
require 'rack/request'
require 'hashie/mash'

class Twitter::Login
attr_reader :options

DEFAULTS = {
:login_path => '/login', :return_to => '/',
:site => 'http://twitter.com', :authorize_path => '/oauth/authenticate'
}

def initialize(app, options)
@app = app
@options = DEFAULTS.merge options
end

def call(env)
request = Request.new(env)

if request.get? and request.path == options[:login_path]
# detect if Twitter redirected back here
if request[:oauth_verifier]
handle_twitter_authorization(request) do
@app.call(env)
end
elsif request[:denied]
# user refused to log in with Twitter, so give up
redirect_to_return_path(request)
else
# user clicked to login; send them to Twitter
redirect_to_twitter(request)
end
else
@app.call(env)
end
end

module Helpers
def twitter_consumer
token = OAuth::AccessToken.new(oauth_consumer, *session[:access_token])
Twitter::Base.new token
end

def oauth_consumer
OAuth::Consumer.new(*session[:oauth_consumer])
end

def twitter_user
if session[:twitter_user]
Hashie::Mash[session[:twitter_user]]
end
end

def twitter_logout
[:oauth_consumer, :access_token, :twitter_user].each do |key|
session[key] = nil # work around a Rails 2.3.5 bug
session.delete key
end
end
end

class Request < Rack::Request
# for storing :request_token, :access_token
def session
env['rack.session'] ||= {}
end

# SUCKS: must duplicate logic from the `url` method
def url_for(path)
url = scheme + '://' + host

if scheme == 'https' && port != 443 ||
scheme == 'http' && port != 80
url << ":#{port}"
end

url << path
end
end

protected

def redirect_to_twitter(request)
# create a request token and store its parameter in session
token = oauth_consumer.get_request_token(:oauth_callback => request.url)
request.session[:request_token] = [token.token, token.secret]
# redirect to Twitter authorization page
redirect token.authorize_url
end

def handle_twitter_authorization(request)
access_token = get_access_token(request)

# get and store authenticated user's info from Twitter
twitter = Twitter::Base.new access_token
request.session[:twitter_user] = twitter.verify_credentials.to_hash

# pass the request down to the main app
response = yield

# check if the app implemented anything at :login_path
if response[0].to_i == 404
# if not, redirect to :return_to path
redirect_to_return_path(request)
else
# use the response from the app without modification
response
end
end

private

def get_access_token(request)
# replace the request token in session with access token
request_token = ::OAuth::RequestToken.new(oauth_consumer, *request.session[:request_token])
access_token = request_token.get_access_token(:oauth_verifier => request[:oauth_verifier])

# store access token and OAuth consumer parameters in session
request.session.delete(:request_token)
request.session[:access_token] = [access_token.token, access_token.secret]
consumer = access_token.consumer
request.session[:oauth_consumer] = [consumer.key, consumer.secret, consumer.options]

return access_token
end

def redirect_to_return_path(request)
redirect request.url_for(options[:return_to])
end

def redirect(url)
["302", {'Location' => url, 'Content-type' => 'text/plain'}, []]
end

def oauth_consumer
::OAuth::Consumer.new options[:key], options[:secret],
:site => options[:site], :authorize_path => options[:authorize_path]
end
end
114 changes: 114 additions & 0 deletions spec/login_spec.rb
@@ -0,0 +1,114 @@
require 'twitter/login'
require 'rack/mock'
require 'rack/utils'
require 'rack/session/cookie'
require 'rack/builder'

require 'fakeweb'
FakeWeb.allow_net_connect = false

describe Twitter::Login do
before(:all) do
@app ||= begin
main_app = lambda { |env|
request = Rack::Request.new(env)
if request.path == '/'
['200 OK', {'Content-type' => 'text/plain'}, ["Hello world"]]
else
['404 Not Found', {'Content-type' => 'text/plain'}, ["Nothing here"]]
end
}

builder = Rack::Builder.new
builder.use Rack::Session::Cookie
builder.use described_class, :key => 'abc', :secret => '123'
builder.run main_app
builder.to_app
end
end

before(:each) do
@request = Rack::MockRequest.new(@app)
end

it "should login with Twitter" do
consumer = mock_oauth_consumer('OAuth Consumer')
token = mock('Request Token', :authorize_url => 'http://disney.com/oauth', :token => 'abc', :secret => '123')
consumer.should_receive(:get_request_token).with(:oauth_callback => 'http://example.org/login').and_return(token)
# request.session[:request_token] = token
# redirect token.authorize_url

get('/login', :lint => true)
response.status.should == 302
response['Location'].should == 'http://disney.com/oauth'
response.body.should be_empty
session[:request_token].should == ['abc', '123']
end

it "should authorize with Twitter" do
consumer = mock_oauth_consumer('OAuth Consumer', :key => 'con', :secret => 'sumer', :options => {:one=>'two'})
request_token = mock('Request Token')
OAuth::RequestToken.should_receive(:new).with(consumer, 'abc', '123').and_return(request_token)
access_token = mock('Access Token', :token => 'access1', :secret => '42', :consumer => consumer)
request_token.should_receive(:get_access_token).with(:oauth_verifier => 'abc').and_return(access_token)

twitter = mock('Twitter Base')
Twitter::Base.should_receive(:new).with(access_token).and_return(twitter)
user_credentials = Hashie::Mash.new :screen_name => 'faker',
:name => 'Fake Jr.', :profile_image_url => 'http://disney.com/mickey.png',
:followers_count => '13', :friends_count => '6', :statuses_count => '52'
twitter.should_receive(:verify_credentials).and_return(user_credentials)

session_data = {:request_token => ['abc', '123']}
get('/login?oauth_verifier=abc', build_session(session_data).update(:lint => true))
response.status.should == 302
response['Location'].should == 'http://example.org/'
session[:request_token].should be_nil
session[:access_token].should == ['access1', '42']
session[:oauth_consumer].should == ['con', 'sumer', {:one => 'two'}]

current_user = session[:twitter_user]
current_user['screen_name'].should == 'faker'
end

protected

[:get, :post, :put, :delete, :head].each do |method|
class_eval("def #{method}(*args) @response = @request.#{method}(*args) end")
end

def response
@response
end

def session
@session ||= begin
escaped = response['Set-Cookie'].match(/\=(.+?);/)[1]
cookie_load Rack::Utils.unescape(escaped)
end
end

private

def build_session(data)
encoded = cookie_dump(data)
{ 'HTTP_COOKIE' => Rack::Utils.build_query('rack.session' => encoded) }
end

def cookie_load(encoded)
decoded = encoded.unpack('m*').first
Marshal.load(decoded)
end

def cookie_dump(obj)
[Marshal.dump(obj)].pack('m*')
end

def mock_oauth_consumer(*args)
consumer = mock(*args)
OAuth::Consumer.should_receive(:new).and_return(consumer)
# .with(instance_of(String), instance_of(String),
# :site => 'http://twitter.com', :authorize_path => '/oauth/authenticate')
consumer
end
end
21 changes: 21 additions & 0 deletions twitter-login.gemspec
@@ -0,0 +1,21 @@
Gem::Specification.new do |gem|
gem.name = 'twitter-login'
gem.version = '0.1.0'
gem.date = Date.today

gem.add_dependency 'twitter', '~> 0.8.0'
gem.add_development_dependency 'rspec', '~> 1.2.9'
gem.add_development_dependency 'fakeweb', '~> 1.2.8'

gem.summary = "Rack middleware to provide login functionality through Twitter"
gem.description = "Rack middleware for Sinatra, Rails, and other web frameworks that provides user login functionality through Twitter."

gem.authors = ['Mislav Marohnić']
gem.email = 'mislav.marohnic@gmail.com'
gem.homepage = 'http://github.com/mislav/twitter-login'

gem.rubyforge_project = nil
gem.has_rdoc = false

gem.files = Dir['Rakefile', '{bin,lib,rails,test,spec}/**/*', 'README*', 'LICENSE*'] & `git ls-files -z`.split("\0")
end

0 comments on commit 13ff555

Please sign in to comment.