From 463a61f51477e3a88a71187fd8d756f174583464 Mon Sep 17 00:00:00 2001 From: lukemelia Date: Sun, 4 Jun 2006 07:07:42 +0000 Subject: [PATCH] This changeset introduces some integrated web service type features that take advantage of the Rails 1.1 responds_to functionality. It also lays a foundation for future API enhancements. Basically, if you request the /projects, contexts/ or notes/ URLs with a client that specifies that it wants XML, Tracks will return XML. See DHH on the Accept header (http://www.loudthinking.com/arc/000572.html). But there's a wrinkle. The controller actions mapped to these URLs are protected by an authentication filter. In normal use, Tracks redirects an unauthenticated user to the login screen for session-based authentication. I've added a secondary authentication check that looks for a valid username and password coming from HTTP_BASIC authentication. To test out the new functionality, try this: curl -H 'Accept: application/xml' --basic --user YOUR_TRACKS_USERNAME:YOUR_TRACKS_PASSWORD http://localhost:3000/projects/ curl -H 'Accept: application/xml' --basic --user YOUR_TRACKS_USERNAME:YOUR_TRACKS_PASSWORD http://localhost:3000/contexts/ curl -H 'Accept: application/xml' --basic --user YOUR_TRACKS_USERNAME:YOUR_TRACKS_PASSWORD http://localhost:3000/notes/ HTTP_BASIC sends passwords in plain text, so the use of https is encouraged. I haven't tested this on a shared host yet, but Coda Hale, whose simple_http_auth inspired this solution and provided some copy and paste code for it (thanks, Coda!), has some notes about how to make it work in his plugin readme (http://svn.codahale.com/simple_http_auth/README). To wit, putting the following in .htaccess: RewriteRule ^(.*)$ dispatch.fcgi [E=X-HTTP_AUTHORIZATION:%{HTTP:Authorization},QSA,L] My thinking on this architecture is as follows: 1) Follow the spirit of responds_to and DRY to leverage existing controller code for API functionality 2) Get away from using the user token for API interactions. Let's keep it for feeds, so it's basically a "lite" form of security for read-only feeds. 3) Keep Tracks in shape to adopt the simply_restful plugin being developed alongside Rails Edge There's no real new functionality in this release that the existing API didn't provide (except for seeing your notes as XML, and somehow I don't think people are clamoring for that), but this work is an important step to being able to implement the types of API features people have been asking for. While I was at it, I did some refactoring to the login_controller for readability and style. Finally, I replaced the activity indicator graphic to work with the new navigation background color. git-svn-id: http://www.rousette.org.uk/svn/tracks-repos/trunk@251 a4c988fc-2ded-0310-b66e-134b36920a42 --- tracks/app/controllers/context_controller.rb | 6 +- tracks/app/controllers/login_controller.rb | 72 +++++++++---------- tracks/app/controllers/note_controller.rb | 6 +- tracks/app/controllers/project_controller.rb | 6 +- tracks/app/models/user.rb | 4 ++ tracks/lib/login_system.rb | 42 +++++++++-- tracks/public/images/spinner.gif | Bin 2306 -> 1553 bytes 7 files changed, 93 insertions(+), 43 deletions(-) diff --git a/tracks/app/controllers/context_controller.rb b/tracks/app/controllers/context_controller.rb index af9f0b1b..6e3b03f6 100644 --- a/tracks/app/controllers/context_controller.rb +++ b/tracks/app/controllers/context_controller.rb @@ -2,7 +2,7 @@ class ContextController < ApplicationController helper :todo - before_filter :login_required + prepend_before_filter :login_required layout "standard" def index @@ -16,6 +16,10 @@ class ContextController < ApplicationController def list self.init @page_title = "TRACKS::List Contexts" + respond_to do |wants| + wants.html + wants.xml { render :xml => @contexts.to_xml } + end end # Filter the projects to show just the one passed in the URL diff --git a/tracks/app/controllers/login_controller.rb b/tracks/app/controllers/login_controller.rb index add6d0ac..9c632fa6 100644 --- a/tracks/app/controllers/login_controller.rb +++ b/tracks/app/controllers/login_controller.rb @@ -11,12 +11,8 @@ class LoginController < ApplicationController session['user_id'] = @user.id # If checkbox on login page checked, we don't expire the session after 1 hour # of inactivity - session['noexpiry']= params['user_noexpiry'] - if session['noexpiry'] == "on" - msg = "will not expire." - else - msg = "will expire after 1 hour of inactivity." - end + session['noexpiry'] = params['user_noexpiry'] + msg = (should_expire_sessions?) ? "will not expire." : "will expire after 1 hour of inactivity." flash['notice'] = "Login successful: session #{msg}" redirect_back_or_default :controller => "todo", :action => "list" else @@ -27,32 +23,20 @@ class LoginController < ApplicationController end def signup - if User.find_all.empty? # signup the first user as admin + if User.find_all.empty? # the first user of the system @page_title = "Sign up as the admin user" - elsif session['user_id'] # we have someone logged in - get_admin_user - if session['user_id'] == @admin.id # logged in user is admin, so allow signup - @page_title = "Sign up a new user" - else - @page_title = "No signups" - @admin_email = @admin.preferences["admin_email"] - render :action => "nosignup" - return - end - else # no-one logged in, but we have some Users - get_admin_user - @page_title = "No signups" - @admin_email = @admin.preferences["admin_email"] - render :action => "nosignup" - return - end - - if session['new_user'] - @user = session['new_user'] - session['new_user'] = nil + @user = get_new_user else - @user = User.new - end + admin = User.find_admin + if current_user_is admin + @page_title = "Sign up a new user" + @user = get_new_user + else # all other situations (i.e. a non-admin is logged in, or no one is logged in, but we have some users) + @page_title = "No signups" + @admin_email = admin.preferences["admin_email"] + render :action => "nosignup" + end + end end def create @@ -74,7 +58,7 @@ class LoginController < ApplicationController end def delete - if params['id'] and ( params['id'] = @user.id or @user.is_admin ) + if params['id'] and ( params['id'] == @user.id or @user.is_admin ) @user = User.find(params['id']) # TODO: Maybe it would be better to mark deleted. That way user deletes can be reversed. @user.destroy @@ -93,12 +77,8 @@ class LoginController < ApplicationController # Gets called by periodically_call_remote to check whether # the session has timed out yet unless session == nil - return if @controller_name == 'feed' or session['noexpiry'] == "on" - # If the method is called by the feed controller - # (which we don't have under session control) - # or if we checked the box to keep logged in on login - # then the session is not going to get called if session + return unless should_expire_sessions? # Get expiry time (allow ten seconds window for the case where we have none) expiry_time = session['expiry_time'] || Time.now + 10 @time_left = expiry_time - Time.now @@ -111,4 +91,24 @@ class LoginController < ApplicationController end end + private + + def get_new_user + if session['new_user'] + user = session['new_user'] + session['new_user'] = nil + else + user = User.new + end + user + end + + def current_user_is(user) + session['user_id'] && session['user_id'] == user.id + end + + def should_expire_sessions? + session['noexpiry'] != "on" + end + end diff --git a/tracks/app/controllers/note_controller.rb b/tracks/app/controllers/note_controller.rb index 16be4baf..cfe20473 100644 --- a/tracks/app/controllers/note_controller.rb +++ b/tracks/app/controllers/note_controller.rb @@ -1,13 +1,17 @@ class NoteController < ApplicationController model :user - before_filter :login_required + prepend_before_filter :login_required layout "standard" def index @all_notes = @user.notes @page_title = "TRACKS::All notes" + respond_to do |wants| + wants.html + wants.xml { render :xml => @all_notes.to_xml } + end end def show diff --git a/tracks/app/controllers/project_controller.rb b/tracks/app/controllers/project_controller.rb index 3c972911..282f2422 100644 --- a/tracks/app/controllers/project_controller.rb +++ b/tracks/app/controllers/project_controller.rb @@ -3,7 +3,7 @@ class ProjectController < ApplicationController model :todo helper :todo - before_filter :login_required + prepend_before_filter :login_required layout "standard" @@ -18,6 +18,10 @@ class ProjectController < ApplicationController def list init @page_title = "TRACKS::List Projects" + respond_to do |wants| + wants.html + wants.xml { render :xml => @projects.to_xml } + end end # Filter the projects to show just the one passed in the URL diff --git a/tracks/app/models/user.rb b/tracks/app/models/user.rb index 7db65399..d5ab77b6 100644 --- a/tracks/app/models/user.rb +++ b/tracks/app/models/user.rb @@ -14,6 +14,10 @@ class User < ActiveRecord::Base def self.authenticate(login, pass) find_first(["login = ? AND password = ?", login, sha1(pass)]) end + + def self.find_admin + find_first([ "is_admin = ?", true ]) + end def change_password(pass,pass_confirm) self.password = pass diff --git a/tracks/lib/login_system.rb b/tracks/lib/login_system.rb index eb650973..1761f868 100644 --- a/tracks/lib/login_system.rb +++ b/tracks/lib/login_system.rb @@ -49,6 +49,12 @@ module LoginSystem if session['user_id'] and authorize?(User.find(session['user_id'])) return true end + + http_user, http_pass = get_basic_auth_data + if user = User.authenticate(http_user, http_pass) + session['user_id'] = user.id + return true + end # store current location so that we can # come back after the user logged in @@ -65,10 +71,10 @@ module LoginSystem # example use : # a popup window might just close itself for instance def access_denied - if request.xhr? - render :partial => 'login/redirect_to_login' - else - redirect_to :controller=>"login", :action =>"login" + respond_to do |wants| + wants.html { redirect_to :controller=>"login", :action =>"login" } + wants.js { render :partial => 'login/redirect_to_login' } + wants.xml { basic_auth_denied } end end @@ -87,5 +93,33 @@ module LoginSystem session['return-to'] = nil end end + + # HTTP Basic auth code adapted from Coda Hale's simple_http_auth plugin. Thanks, Coda! + def get_basic_auth_data + + auth_locations = ['REDIRECT_REDIRECT_X_HTTP_AUTHORIZATION', + 'REDIRECT_X_HTTP_AUTHORIZATION', + 'X-HTTP_AUTHORIZATION', 'HTTP_AUTHORIZATION'] + + authdata = nil + for location in auth_locations + if request.env.has_key?(location) + authdata = request.env[location].to_s.split + end + end + if authdata and authdata[0] == 'Basic' + user, pass = Base64.decode64(authdata[1]).split(':')[0..1] + else + user, pass = ['', ''] + end + return user, pass + end + + def basic_auth_denied + response.headers["Status"] = "Unauthorized" + response.headers["WWW-Authenticate"] = "Basic realm=\"'Tracks Login Required'\"" + render :text => "401 Unauthorized: You are not authorized to interact with Tracks.", :status => 401 + end + end \ No newline at end of file diff --git a/tracks/public/images/spinner.gif b/tracks/public/images/spinner.gif index 9a08feb74ad36e41313e65e163f282fb0cc47916..6da716ac3aab0b0001027a7f5b1b513dc347a374 100644 GIT binary patch literal 1553 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