From 5af502c81cf738fba2beeb83c30b4741b854415c Mon Sep 17 00:00:00 2001 From: Eric Wong Date: Sat, 26 Jun 2010 07:46:21 +0000 Subject: [PATCH] make this a modular sinatra application This should make the RubyGem work more reliably. --- bin/local-openid | 303 +--------------------------------------------------- lib/local_openid.rb | 301 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+), 300 deletions(-) rewrite bin/local-openid (100%) create mode 100644 lib/local_openid.rb diff --git a/bin/local-openid b/bin/local-openid dissimilarity index 100% index 45c4ede..7fc6b33 100755 --- a/bin/local-openid +++ b/bin/local-openid @@ -1,300 +1,3 @@ -#!/home/ew/bin/ruby -# A personal OpenID identity provider, authentication is done by editing -# a YAML file on the server where this application runs -# (~/.local-openid/config.yml by default) instead of via HTTP/HTTPS -# form authentication in the browser. - -require 'tempfile' -require 'time' -require 'yaml' - -require 'sinatra' -require 'openid' -require 'openid/extensions/sreg' -require 'openid/extensions/pape' -require 'openid/store/filesystem' -set :static, false -set :sessions, true -set :environment, :production -set :logging, false # load Rack::CommonLogger in config.ru instead - -BEGIN { - $local_openid ||= - File.expand_path(ENV['LOCAL_OPENID_DIR'] || '~/.local-openid') - Dir.mkdir($local_openid) unless File.directory?($local_openid) -} - -# all the sinatra endpoints: -get('/xrds') { big_lock { render_xrds(true) } } -get('/') { big_lock { get_or_post } } -post('/') { big_lock { get_or_post } } - -private - -# yes, I use gsub for templating because I find it easier than erb :P -PROMPT = %q! -OpenID login: %s -

reload this page when approved: %s

-! - -XRDS_HTML = %q! - - - -OpenID server endpoint -OpenID server endpoint! - -XRDS_XML = %q! - - - - %types - %s - - -! - -CONFIG_HEADER = %! -This file may be changed by #{__FILE__} or your favorite $EDITOR -comments will be deleted when modified by #{__FILE__}. See the -comments end of this file for help on the format. -!.lstrip! - -CONFIG_TRAILER = %! -Configuration file description. - -* allowed_ips An array of strings representing IPs that may - authenticate through local-openid. Only put - IP addresses that you trust in here. - -Each OpenID consumer trust root will have its own hash keyed by -the trust root URL. Keys in this hash are: - - - expires The time at which this login will expire. - This is generally the only entry you need to edit - to approve a site. You may also delete this line - and rename the "expires1m" to this. - - expires1m The time 1 minute from when this entry was updated. - This is provided as a convenience for replacing - the default "expires" entry. This key may be safely - removed by a user editing it. - - updated Time this entry was updated, strictly informational. - - session_id Unique identifier in your session cookie to prevent - other users from hijacking your session. You may - delete this if you've changed browsers or computers. - - assoc_handle See the OpenID specs, may be empty. Do not edit this. - -SReg keys supported by the Ruby OpenID implementation should be -supported, they include (but are not limited to): -! << OpenID::SReg::DATA_FIELDS.map do |key, value| - " - #{key}: #{value}" -end.join("\n") << %! -SReg keys may be global at the top-level or private to each trust root. -Per-trust root SReg entries override the global settings. -! - -include OpenID::Server - -# this is the heart of our provider logic, adapted from the -# Ruby OpenID gem Rails example -def get_or_post - oidreq = begin - server.decode_request(params) - rescue ProtocolError => err - halt(500, err.to_s) - end - - oidreq or return render_xrds - - oidresp = case oidreq - when CheckIDRequest - if oidreq.id_select && oidreq.immediate - oidreq.answer(false) - elsif is_authorized?(oidreq) - resp = oidreq.answer(true, nil, server_root) - add_sreg(oidreq, resp) - add_pape(oidreq, resp) - resp - elsif oidreq.immediate - oidreq.answer(false, server_root) - else - session[:id] ||= "#{Time.now.to_i}.#$$.#{rand}" - session[:ip] = request.ip - merge_config(oidreq) - write_config - - # here we allow our user to open $EDITOR and edit the appropriate - # 'expires' field in config.yml corresponding to oidreq.trust_root - return PROMPT.gsub(/%s/, oidreq.trust_root) - end - else - server.handle_request(oidreq) - end - - finalize_response(oidresp) -end - -# we're the provider for exactly one identity. However, we do rely on -# being proxied and being hit with an appropriate HTTP Host: header. -# Don't expect OpenID consumers to handle port != 80. -def server_root - "http://#{request.host}/" -end - -def server - @server ||= Server.new( - OpenID::Store::Filesystem.new("#$local_openid/store"), - server_root) -end - -# support the simple registration extension if possible, -# allow per-site overrides of various data points -def add_sreg(oidreq, oidresp) - sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return - per_site = config[oidreq.trust_root] || {} - - sreg_data = {} - sregreq.all_requested_fields.each do |field| - sreg_data[field] = per_site[field] || config[field] - end - - sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data) - oidresp.add_extension(sregresp) -end - -def add_pape(oidreq, oidresp) - papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return - paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies, - papereq.max_auth_age) - # since this implementation requires shell/filesystem access to the - # OpenID server to authenticate, we can say we're at the highest - # auth level possible... - paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL) - paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') - paperesp.nist_auth_level = 4 - oidresp.add_extension(paperesp) -end - -def err(msg) - env['rack.errors'].write("#{msg}\n") - false -end - -def finalize_response(oidresp) - server.signatory.sign(oidresp) if oidresp.needs_signing - web_response = server.encode_response(oidresp) - - case web_response.code - when HTTP_OK - web_response.body - when HTTP_REDIRECT - location = web_response.headers['location'] - err("redirecting to: #{location} ...") - redirect(location) - else - halt(500, web_response.body) - end -end - -# the heart of our custom authentication logic -def is_authorized?(oidreq) - (config['allowed_ips'] ||= []).include?(request.ip) or - return err("Not allowed: #{request.ip}\n" \ - "You need to put this IP in the 'allowed_ips' array "\ - "in:\n #$local_openid/config.yml") - - request.ip == session[:ip] or - return err("session IP mismatch: " \ - "#{request.ip.inspect} != #{session[:ip].inspect}") - - trust_root = oidreq.trust_root - per_site = config[trust_root] or - return err("trust_root unknown: #{trust_root}") - - session_id = session[:id] or return err("no session ID") - - assoc_handle = per_site['assoc_handle'] # this may be nil - expires = per_site['expires'] or - return err("no expires (trust_root=#{trust_root})") - - assoc_handle == oidreq.assoc_handle or - return err("assoc_handle mismatch: " \ - "#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \ - " (trust_root=#{trust_root})") - - per_site['session_id'] == session_id or - return err("session ID mismatch: " \ - "#{per_site['session_id'].inspect} != #{session_id.inspect}" \ - " (trust_root=#{trust_root})") - - expires > Time.now or - return err("Expired: #{expires.inspect} (trust_root=#{trust_root})") - - true -end - -def config - @config ||= begin - YAML.load(File.read("#$local_openid/config.yml")) - rescue Errno::ENOENT - {} - end -end - -def merge_config(oidreq) - per_site = config[oidreq.trust_root] ||= {} - per_site.merge!({ - 'assoc_handle' => oidreq.assoc_handle, - 'expires' => Time.at(0).utc, - 'updated' => Time.now.utc, - 'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR - 'session_id' => session[:id], - }) -end - -def write_config - path = "#$local_openid/config.yml" - tmp = Tempfile.new('config.yml', File.dirname(path)) - tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# ")) - tmp.syswrite(config.to_yaml) - tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# ")) - tmp.fsync - File.rename(tmp.path, path) - tmp.close! -end - -# this output is designed to be parsed by OpenID consumers -def render_xrds(force = false) - if force || request.accept.include?('application/xrds+xml') - - # this seems to work... - types = request.accept.include?('application/xrds+xml') ? - [ OpenID::OPENID_2_0_TYPE, OpenID::OPENID_1_0_TYPE, OpenID::SREG_URI ] : - [ OpenID::OPENID_IDP_2_0_TYPE ] - - headers['Content-Type'] = 'application/xrds+xml' - types = types.map { |uri| "#{uri}" }.join("\n") - XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types) - else # render a browser-friendly page with an XRDS pointer - headers['X-XRDS-Location'] = "#{server_root}xrds" - XRDS_HTML.gsub(/%s/, server_root) - end -end - -# if a single-user OpenID provider like us is being hit by multiple -# clients at once, then something is seriously wrong. Can't use -# Mutexes here since somebody could be running this as a CGI script -def big_lock(&block) - lock = "#$local_openid/lock" - File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp| - begin - yield - ensure - File.unlink(lock) - end - end - rescue Errno::EEXIST - err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil -end +#!/usr/bin/env ruby +require 'local_openid' +LocalOpenID.run! diff --git a/lib/local_openid.rb b/lib/local_openid.rb new file mode 100644 index 0000000..34ff753 --- /dev/null +++ b/lib/local_openid.rb @@ -0,0 +1,301 @@ +# A personal OpenID identity provider, authentication is done by editing +# a YAML file on the server where this application runs +# (~/.local-openid/config.yml by default) instead of via HTTP/HTTPS +# form authentication in the browser. + +require 'tempfile' +require 'time' +require 'yaml' + +require 'sinatra/base' +require 'openid' +require 'openid/extensions/sreg' +require 'openid/extensions/pape' +require 'openid/store/filesystem' + +class LocalOpenID < Sinatra::Base + set :static, false + set :sessions, true + set :environment, :production + set :logging, false # load Rack::CommonLogger in config.ru instead + + @@dir ||= File.expand_path(ENV['LOCAL_OPENID_DIR'] || '~/.local-openid') + Dir.mkdir(@@dir) unless File.directory?(@@dir) + + # all the sinatra endpoints: + get('/xrds') { big_lock { render_xrds(true) } } + get('/') { big_lock { get_or_post } } + post('/') { big_lock { get_or_post } } + + private + + # yes, I use gsub for templating because I find it easier than erb :P + PROMPT = %q! + OpenID login: %s +

reload this page when approved: %s

+ ! + + XRDS_HTML = %q! + + + + OpenID server endpoint + OpenID server endpoint! + + XRDS_XML = %q! + + + + %types + %s + + + ! + + CONFIG_HEADER = %! + This file may be changed by #{__FILE__} or your favorite $EDITOR + comments will be deleted when modified by #{__FILE__}. See the + comments end of this file for help on the format. + !.lstrip! + + CONFIG_TRAILER = %! + Configuration file description. + + * allowed_ips An array of strings representing IPs that may + authenticate through local-openid. Only put + IP addresses that you trust in here. + + Each OpenID consumer trust root will have its own hash keyed by + the trust root URL. Keys in this hash are: + + - expires The time at which this login will expire. + This is generally the only entry you need to edit + to approve a site. You may also delete this line + and rename the "expires1m" to this. + - expires1m The time 1 minute from when this entry was updated. + This is provided as a convenience for replacing + the default "expires" entry. This key may be safely + removed by a user editing it. + - updated Time this entry was updated, strictly informational. + - session_id Unique identifier in your session cookie to prevent + other users from hijacking your session. You may + delete this if you've changed browsers or computers. + - assoc_handle See the OpenID specs, may be empty. Do not edit this. + + SReg keys supported by the Ruby OpenID implementation should be + supported, they include (but are not limited to): + ! << OpenID::SReg::DATA_FIELDS.map do |key, value| + " - #{key}: #{value}" + end.join("\n") << %! + SReg keys may be global at the top-level or private to each trust root. + Per-trust root SReg entries override the global settings. + ! + + include OpenID::Server + + # this is the heart of our provider logic, adapted from the + # Ruby OpenID gem Rails example + def get_or_post + oidreq = begin + server.decode_request(params) + rescue ProtocolError => err + halt(500, err.to_s) + end + + oidreq or return render_xrds + + oidresp = case oidreq + when CheckIDRequest + if oidreq.id_select && oidreq.immediate + oidreq.answer(false) + elsif is_authorized?(oidreq) + resp = oidreq.answer(true, nil, server_root) + add_sreg(oidreq, resp) + add_pape(oidreq, resp) + resp + elsif oidreq.immediate + oidreq.answer(false, server_root) + else + session[:id] ||= "#{Time.now.to_i}.#$$.#{rand}" + session[:ip] = request.ip + merge_config(oidreq) + write_config + + # here we allow our user to open $EDITOR and edit the appropriate + # 'expires' field in config.yml corresponding to oidreq.trust_root + return PROMPT.gsub(/%s/, oidreq.trust_root) + end + else + server.handle_request(oidreq) + end + + finalize_response(oidresp) + end + + # we're the provider for exactly one identity. However, we do rely on + # being proxied and being hit with an appropriate HTTP Host: header. + # Don't expect OpenID consumers to handle port != 80. + def server_root + "http://#{request.host}/" + end + + def server + @server ||= Server.new( + OpenID::Store::Filesystem.new("#@@dir/store"), + server_root) + end + + # support the simple registration extension if possible, + # allow per-site overrides of various data points + def add_sreg(oidreq, oidresp) + sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return + per_site = config[oidreq.trust_root] || {} + + sreg_data = {} + sregreq.all_requested_fields.each do |field| + sreg_data[field] = per_site[field] || config[field] + end + + sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data) + oidresp.add_extension(sregresp) + end + + def add_pape(oidreq, oidresp) + papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return + paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies, + papereq.max_auth_age) + # since this implementation requires shell/filesystem access to the + # OpenID server to authenticate, we can say we're at the highest + # auth level possible... + paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL) + paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + paperesp.nist_auth_level = 4 + oidresp.add_extension(paperesp) + end + + def err(msg) + env['rack.errors'].write("#{msg}\n") + false + end + + def finalize_response(oidresp) + server.signatory.sign(oidresp) if oidresp.needs_signing + web_response = server.encode_response(oidresp) + + case web_response.code + when HTTP_OK + web_response.body + when HTTP_REDIRECT + location = web_response.headers['location'] + err("redirecting to: #{location} ...") + redirect(location) + else + halt(500, web_response.body) + end + end + + # the heart of our custom authentication logic + def is_authorized?(oidreq) + (config['allowed_ips'] ||= []).include?(request.ip) or + return err("Not allowed: #{request.ip}\n" \ + "You need to put this IP in the 'allowed_ips' array "\ + "in:\n #@@dir/config.yml") + + request.ip == session[:ip] or + return err("session IP mismatch: " \ + "#{request.ip.inspect} != #{session[:ip].inspect}") + + trust_root = oidreq.trust_root + per_site = config[trust_root] or + return err("trust_root unknown: #{trust_root}") + + session_id = session[:id] or return err("no session ID") + + assoc_handle = per_site['assoc_handle'] # this may be nil + expires = per_site['expires'] or + return err("no expires (trust_root=#{trust_root})") + + assoc_handle == oidreq.assoc_handle or + return err("assoc_handle mismatch: " \ + "#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \ + " (trust_root=#{trust_root})") + + per_site['session_id'] == session_id or + return err("session ID mismatch: " \ + "#{per_site['session_id'].inspect} != #{session_id.inspect}" \ + " (trust_root=#{trust_root})") + + expires > Time.now or + return err("Expired: #{expires.inspect} (trust_root=#{trust_root})") + + true + end + + def config + @config ||= begin + YAML.load(File.read("#@@dir/config.yml")) + rescue Errno::ENOENT + {} + end + end + + def merge_config(oidreq) + per_site = config[oidreq.trust_root] ||= {} + per_site.merge!({ + 'assoc_handle' => oidreq.assoc_handle, + 'expires' => Time.at(0).utc, + 'updated' => Time.now.utc, + 'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR + 'session_id' => session[:id], + }) + end + + def write_config + path = "#@@dir/config.yml" + tmp = Tempfile.new('config.yml', File.dirname(path)) + tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# ")) + tmp.syswrite(config.to_yaml) + tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# ")) + tmp.fsync + File.rename(tmp.path, path) + tmp.close! + end + + # this output is designed to be parsed by OpenID consumers + def render_xrds(force = false) + if force || request.accept.include?('application/xrds+xml') + + # this seems to work... + types = request.accept.include?('application/xrds+xml') ? + [ OpenID::OPENID_2_0_TYPE, + OpenID::OPENID_1_0_TYPE, + OpenID::SREG_URI ] : + [ OpenID::OPENID_IDP_2_0_TYPE ] + + headers['Content-Type'] = 'application/xrds+xml' + types = types.map { |uri| "#{uri}" }.join("\n") + XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types) + else # render a browser-friendly page with an XRDS pointer + headers['X-XRDS-Location'] = "#{server_root}xrds" + XRDS_HTML.gsub(/%s/, server_root) + end + end + + # if a single-user OpenID provider like us is being hit by multiple + # clients at once, then something is seriously wrong. Can't use + # Mutexes here since somebody could be running this as a CGI script + def big_lock(&block) + lock = "#@@dir/lock" + File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp| + begin + yield + ensure + File.unlink(lock) + end + end + rescue Errno::EEXIST + err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil + end +end -- 2.11.4.GIT