This repository has been archived by the owner on Sep 7, 2021. It is now read-only.
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
initial import of middleware (v0.1.0)
- Loading branch information
0 parents
commit 13ff555
Showing
4 changed files
with
354 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |