# 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. #:stopdoc: 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_identity_xrds(true) } } get('/provider/xrds') { big_lock { render_provider_xrds(true) } } get('/provider') { big_lock { get_or_post_provider } } post('/provider') { big_lock { get_or_post_provider } } get('/') { big_lock { render_identity_xrds } } post('/') { big_lock { render_identity_xrds } } 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

! PROVIDER_XRDS_HTML = %q! OpenID server endpoint OpenID server endpoint! IDENTITY_XRDS_HTML = %q! OpenID identity OpenID identity! PROVIDER_XRDS_XML = %q! %types %sprovider ! IDENTITY_XRDS_XML = %q! %types %sprovider %s %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 have 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_provider oidreq = begin server.decode_request(params) rescue ProtocolError => err halt(500, err.to_s) end oidreq or return render_provider_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 + "provider") 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 + "provider") 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_provider_xrds(force = false) if force || request.accept.include?('application/xrds+xml') # this seems to work... types = [ OpenID::OPENID_IDP_2_0_TYPE ] headers['Content-Type'] = 'application/xrds+xml' types = types.map { |uri| "#{uri}" }.join("\n") PROVIDER_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}provider/xrds" PROVIDER_XRDS_HTML.gsub(/%s/, server_root) end end def render_identity_xrds(force = false) if force || request.accept.include?('application/xrds+xml') # this seems to work... types = [ OpenID::OPENID_2_0_TYPE, OpenID::OPENID_1_0_TYPE, OpenID::SREG_URI ] headers['Content-Type'] = 'application/xrds+xml' types = types.map { |uri| "#{uri}" }.join("\n") IDENTITY_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" IDENTITY_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 #:startdoc: