fixup packaging
[local-openid.git] / lib / local_openid.rb
blob3d87d5f6c45454a3be48f57c064a7e02bcebc20d
1 # A personal OpenID identity provider, authentication is done by editing
2 # a YAML file on the server where this application runs
3 # (~/.local-openid/config.yml by default) instead of via HTTP/HTTPS
4 # form authentication in the browser.
5 #:stopdoc:
6 require 'tempfile'
7 require 'time'
8 require 'yaml'
10 require 'sinatra/base'
11 require 'openid'
12 require 'openid/extensions/sreg'
13 require 'openid/extensions/pape'
14 require 'openid/store/filesystem'
16 class LocalOpenID < Sinatra::Base
17   set :static, false
18   set :sessions, true
19   set :environment, :production
20   set :logging, false # load Rack::CommonLogger in config.ru instead
22   @@dir ||= File.expand_path(ENV['LOCAL_OPENID_DIR'] || '~/.local-openid')
23   Dir.mkdir(@@dir) unless File.directory?(@@dir)
25   # all the sinatra endpoints:
26   get('/xrds') { big_lock { render_xrds(true) } }
27   get('/') { big_lock { get_or_post } }
28   post('/') { big_lock { get_or_post } }
30   private
32   # yes, I use gsub for templating because I find it easier than erb :P
33   PROMPT = %q!<html>
34   <head><title>OpenID login: %s</title></head>
35   <body><h1>reload this page when approved: %s</h1></body>
36   </html>!
38   XRDS_HTML = %q!<html><head>
39   <link rel="openid.server" href="%s" />
40   <link rel="openid2.provider" href="%s" />
41   <meta http-equiv="X-XRDS-Location" content="%sxrds" />
42   <title>OpenID server endpoint</title>
43   </head><body>OpenID server endpoint</body></html>!
45   XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
46   <xrds:XRDS
47     xmlns:xrds="xri://$xrds"
48     xmlns="xri://$xrd*($v*2.0)">
49   <XRD>
50     <Service priority="0">
51       %types
52       <URI>%s</URI>
53     </Service>
54   </XRD>
55   </xrds:XRDS>!
57   CONFIG_HEADER = %!
58   This file may be changed by #{__FILE__} or your favorite $EDITOR
59   comments will be deleted when modified by #{__FILE__}.  See the
60   comments end of this file for help on the format.
61   !.lstrip!
63   CONFIG_TRAILER = %!
64   Configuration file description.
66   * allowed_ips     An array of strings representing IPs that may
67                     authenticate through local-openid.  Only put
68                     IP addresses that you trust in here.
70   Each OpenID consumer trust root will have its own hash keyed by
71   the trust root URL.  Keys in this hash are:
73     - expires       The time at which this login will expire.
74                     This is generally the only entry you need to edit
75                     to approve a site.  You may also delete this line
76                     and rename the "expires1m" to this.
77     - expires1m     The time 1 minute from when this entry was updated.
78                     This is provided as a convenience for replacing
79                     the default "expires" entry.  This key may be safely
80                     removed by a user editing it.
81     - updated       Time this entry was updated, strictly informational.
82     - session_id    Unique identifier in your session cookie to prevent
83                     other users from hijacking your session.  You may
84                     delete this if you've changed browsers or computers.
85     - assoc_handle  See the OpenID specs, may be empty.  Do not edit this.
87   SReg keys supported by the Ruby OpenID implementation should be
88   supported, they include (but are not limited to):
89   ! << OpenID::SReg::DATA_FIELDS.map do |key, value|
90     "   - #{key}: #{value}"
91   end.join("\n") << %!
92   SReg keys may be global at the top-level or private to each trust root.
93   Per-trust root SReg entries override the global settings.
94   !
96   include OpenID::Server
98   # this is the heart of our provider logic, adapted from the
99   # Ruby OpenID gem Rails example
100   def get_or_post
101     oidreq = begin
102       server.decode_request(params)
103     rescue ProtocolError => err
104       halt(500, err.to_s)
105     end
107     oidreq or return render_xrds
109     oidresp = case oidreq
110     when CheckIDRequest
111       if oidreq.id_select && oidreq.immediate
112         oidreq.answer(false)
113       elsif is_authorized?(oidreq)
114         resp = oidreq.answer(true, nil, server_root)
115         add_sreg(oidreq, resp)
116         add_pape(oidreq, resp)
117         resp
118       elsif oidreq.immediate
119         oidreq.answer(false, server_root)
120       else
121         session[:id] ||= "#{Time.now.to_i}.#$$.#{rand}"
122         session[:ip] = request.ip
123         merge_config(oidreq)
124         write_config
126         # here we allow our user to open $EDITOR and edit the appropriate
127         # 'expires' field in config.yml corresponding to oidreq.trust_root
128         return PROMPT.gsub(/%s/, oidreq.trust_root)
129       end
130     else
131       server.handle_request(oidreq)
132     end
134     finalize_response(oidresp)
135   end
137   # we're the provider for exactly one identity.  However, we do rely on
138   # being proxied and being hit with an appropriate HTTP Host: header.
139   # Don't expect OpenID consumers to handle port != 80.
140   def server_root
141     "http://#{request.host}/"
142   end
144   def server
145     @server ||= Server.new(
146         OpenID::Store::Filesystem.new("#@@dir/store"),
147         server_root)
148   end
150   # support the simple registration extension if possible,
151   # allow per-site overrides of various data points
152   def add_sreg(oidreq, oidresp)
153     sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return
154     per_site = config[oidreq.trust_root] || {}
156     sreg_data = {}
157     sregreq.all_requested_fields.each do |field|
158       sreg_data[field] = per_site[field] || config[field]
159     end
161     sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data)
162     oidresp.add_extension(sregresp)
163   end
165   def add_pape(oidreq, oidresp)
166     papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return
167     paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies,
168                                           papereq.max_auth_age)
169     # since this implementation requires shell/filesystem access to the
170     # OpenID server to authenticate, we can say we're at the highest
171     # auth level possible...
172     paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL)
173     paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
174     paperesp.nist_auth_level = 4
175     oidresp.add_extension(paperesp)
176   end
178   def err(msg)
179     env['rack.errors'].write("#{msg}\n")
180     false
181   end
183   def finalize_response(oidresp)
184     server.signatory.sign(oidresp) if oidresp.needs_signing
185     web_response = server.encode_response(oidresp)
187     case web_response.code
188     when HTTP_OK
189       web_response.body
190     when HTTP_REDIRECT
191       location = web_response.headers['location']
192       err("redirecting to: #{location} ...")
193       redirect(location)
194     else
195       halt(500, web_response.body)
196     end
197   end
199   # the heart of our custom authentication logic
200   def is_authorized?(oidreq)
201     (config['allowed_ips'] ||= []).include?(request.ip) or
202       return err("Not allowed: #{request.ip}\n" \
203                  "You need to put this IP in the 'allowed_ips' array "\
204                  "in:\n #@@dir/config.yml")
206     request.ip == session[:ip] or
207       return err("session IP mismatch: " \
208                  "#{request.ip.inspect} != #{session[:ip].inspect}")
210     trust_root = oidreq.trust_root
211     per_site = config[trust_root] or
212       return err("trust_root unknown: #{trust_root}")
214     session_id = session[:id] or return err("no session ID")
216     assoc_handle = per_site['assoc_handle'] # this may be nil
217     expires = per_site['expires'] or
218       return err("no expires (trust_root=#{trust_root})")
220     assoc_handle == oidreq.assoc_handle or
221       return err("assoc_handle mismatch: " \
222                  "#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \
223                  " (trust_root=#{trust_root})")
225     per_site['session_id'] == session_id or
226       return err("session ID mismatch: " \
227                  "#{per_site['session_id'].inspect} != #{session_id.inspect}" \
228                  " (trust_root=#{trust_root})")
230     expires > Time.now or
231       return err("Expired: #{expires.inspect} (trust_root=#{trust_root})")
233     true
234   end
236   def config
237     @config ||= begin
238       YAML.load(File.read("#@@dir/config.yml"))
239     rescue Errno::ENOENT
240       {}
241     end
242   end
244   def merge_config(oidreq)
245     per_site = config[oidreq.trust_root] ||= {}
246     per_site.merge!({
247         'assoc_handle' => oidreq.assoc_handle,
248         'expires' => Time.at(0).utc,
249         'updated' => Time.now.utc,
250         'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR
251         'session_id' => session[:id],
252       })
253   end
255   def write_config
256     path = "#@@dir/config.yml"
257     tmp = Tempfile.new('config.yml', File.dirname(path))
258     tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# "))
259     tmp.syswrite(config.to_yaml)
260     tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# "))
261     tmp.fsync
262     File.rename(tmp.path, path)
263     tmp.close!
264   end
266   # this output is designed to be parsed by OpenID consumers
267   def render_xrds(force = false)
268     if force || request.accept.include?('application/xrds+xml')
270       # this seems to work...
271       types = request.accept.include?('application/xrds+xml') ?
272          [ OpenID::OPENID_2_0_TYPE,
273            OpenID::OPENID_1_0_TYPE,
274            OpenID::SREG_URI ] :
275          [ OpenID::OPENID_IDP_2_0_TYPE ]
277       headers['Content-Type'] = 'application/xrds+xml'
278       types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
279       XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
280     else # render a browser-friendly page with an XRDS pointer
281       headers['X-XRDS-Location'] = "#{server_root}xrds"
282       XRDS_HTML.gsub(/%s/, server_root)
283     end
284   end
286   # if a single-user OpenID provider like us is being hit by multiple
287   # clients at once, then something is seriously wrong.  Can't use
288   # Mutexes here since somebody could be running this as a CGI script
289   def big_lock(&block)
290     lock = "#@@dir/lock"
291     File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp|
292       begin
293         yield
294       ensure
295         File.unlink(lock)
296       end
297     end
298     rescue Errno::EEXIST
299       err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil
300   end
302 #:startdoc: