use Rack::Request#base_url for server_root
[local-openid.git] / lib / local_openid.rb
blob0edbf37a04d643e9d37c789f552e8d0b300ffa9b
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_identity_xrds(true) } }
27   get('/provider/xrds') { big_lock { render_provider_xrds(true) } }
28   get('/provider') { big_lock { get_or_post_provider } }
29   post('/provider') { big_lock { get_or_post_provider } }
30   get('/') { big_lock { render_identity_xrds } }
31   post('/') { big_lock { render_identity_xrds } }
33   private
35   # yes, I use gsub for templating because I find it easier than erb :P
36   PROMPT = %q!<html>
37   <head><title>OpenID login: %s</title></head>
38   <body><h1>reload this page when approved: %s</h1></body>
39   </html>!
41   PROVIDER_XRDS_HTML = %q!<html><head>
42   <meta http-equiv="X-XRDS-Location" content="%sprovider/xrds" />
43   <title>OpenID server endpoint</title>
44   </head><body>OpenID server endpoint</body></html>!
46   IDENTITY_XRDS_HTML = %q!<html><head>
47   <link rel="openid.server" href="%sprovider" />
48   <link rel="openid2.provider" href="%sprovider" />
49   <link rel="openid2.local_id" href="%s" />
50   <link rel="openid.delegate"  href="%s" />
51   <meta http-equiv="X-XRDS-Location" content="%sxrds" />
52   <title>OpenID identity</title>
53   </head><body>OpenID identity</body></html>!
55   PROVIDER_XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
56   <xrds:XRDS
57     xmlns:xrds="xri://$xrds"
58     xmlns:openid="http://openid.net/xmlns/1.0"
59     xmlns="xri://$xrd*($v*2.0)">
60   <XRD version="2.0">
61     <Service priority="0">
62       %types
63       <URI>%sprovider</URI>
64     </Service>
65   </XRD>
66   </xrds:XRDS>!
68   IDENTITY_XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
69   <xrds:XRDS
70     xmlns:xrds="xri://$xrds"
71     xmlns:openid="http://openid.net/xmlns/1.0"
72     xmlns="xri://$xrd*($v*2.0)">
73   <XRD version="2.0">
74     <Service priority="0">
75       %types
76       <URI>%sprovider</URI>
77       <LocalID>%s</LocalID>
78       <openid:Delegate>%s</openid:Delegate>
79     </Service>
80   </XRD>
81   </xrds:XRDS>!
83   CONFIG_HEADER = %!
84   This file may be changed by #{__FILE__} or your favorite $EDITOR
85   comments will be deleted when modified by #{__FILE__}.  See the
86   comments end of this file for help on the format.
87   !.lstrip!
89   CONFIG_TRAILER = %!
90   Configuration file description.
92   * allowed_ips     An array of strings representing IPs that may
93                     authenticate through local-openid.  Only put
94                     IP addresses that you trust in here.
96   Each OpenID consumer trust root will have its own hash keyed by
97   the trust root URL.  Keys in this hash are:
99     - expires       The time at which this login will expire.
100                     This is generally the only entry you need to edit
101                     to approve a site.  You may also delete this line
102                     and rename the "expires1m" to this.
103     - expires1m     The time 1 minute from when this entry was updated.
104                     This is provided as a convenience for replacing
105                     the default "expires" entry.  This key may be safely
106                     removed by a user editing it.
107     - updated       Time this entry was updated, strictly informational.
108     - session_id    Unique identifier in your session cookie to prevent
109                     other users from hijacking your session.  You may
110                     delete this if you have changed browsers or computers.
111     - assoc_handle  See the OpenID specs, may be empty.  Do not edit this.
113   SReg keys supported by the Ruby OpenID implementation should be
114   supported, they include (but are not limited to):
115   ! << OpenID::SReg::DATA_FIELDS.map do |key, value|
116     "   - #{key}: #{value}"
117   end.join("\n") << %!
118   SReg keys may be global at the top-level or private to each trust root.
119   Per-trust root SReg entries override the global settings.
120   !
122   include OpenID::Server
124   # this is the heart of our provider logic, adapted from the
125   # Ruby OpenID gem Rails example
126   def get_or_post_provider
127     oidreq = begin
128       server.decode_request(params)
129     rescue ProtocolError => err
130       halt(500, err.to_s)
131     end
133     oidreq or return render_provider_xrds
135     oidresp = case oidreq
136     when CheckIDRequest
137       if oidreq.id_select && oidreq.immediate
138         oidreq.answer(false)
139       elsif is_authorized?(oidreq)
140         resp = oidreq.answer(true, nil, server_root)
141         add_sreg(oidreq, resp)
142         add_pape(oidreq, resp)
143         resp
144       elsif oidreq.immediate
145         oidreq.answer(false, server_root + "provider")
146       else
147         session[:id] ||= "#{Time.now.to_i}.#$$.#{rand}"
148         session[:ip] = request.ip
149         merge_config(oidreq)
150         write_config
152         # here we allow our user to open $EDITOR and edit the appropriate
153         # 'expires' field in config.yml corresponding to oidreq.trust_root
154         return PROMPT.gsub(/%s/, oidreq.trust_root)
155       end
156     else
157       server.handle_request(oidreq)
158     end
160     finalize_response(oidresp)
161   end
163   def server_root
164     "#{request.base_url}/"
165   end
167   def server
168     @server ||= Server.new(
169         OpenID::Store::Filesystem.new("#@@dir/store"),
170         server_root + "provider")
171   end
173   # support the simple registration extension if possible,
174   # allow per-site overrides of various data points
175   def add_sreg(oidreq, oidresp)
176     sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return
177     per_site = config[oidreq.trust_root] || {}
179     sreg_data = {}
180     sregreq.all_requested_fields.each do |field|
181       sreg_data[field] = per_site[field] || config[field]
182     end
184     sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data)
185     oidresp.add_extension(sregresp)
186   end
188   def add_pape(oidreq, oidresp)
189     papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return
190     paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies,
191                                           papereq.max_auth_age)
192     # since this implementation requires shell/filesystem access to the
193     # OpenID server to authenticate, we can say we're at the highest
194     # auth level possible...
195     paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL)
196     paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
197     paperesp.nist_auth_level = 4
198     oidresp.add_extension(paperesp)
199   end
201   def err(msg)
202     env['rack.errors'].write("#{msg}\n")
203     false
204   end
206   def finalize_response(oidresp)
207     server.signatory.sign(oidresp) if oidresp.needs_signing
208     web_response = server.encode_response(oidresp)
210     case web_response.code
211     when HTTP_OK
212       web_response.body
213     when HTTP_REDIRECT
214       location = web_response.headers['location']
215       err("redirecting to: #{location} ...")
216       redirect(location)
217     else
218       halt(500, web_response.body)
219     end
220   end
222   # the heart of our custom authentication logic
223   def is_authorized?(oidreq)
224     (config['allowed_ips'] ||= []).include?(request.ip) or
225       return err("Not allowed: #{request.ip}\n" \
226                  "You need to put this IP in the 'allowed_ips' array "\
227                  "in:\n #@@dir/config.yml")
229     request.ip == session[:ip] or
230       return err("session IP mismatch: " \
231                  "#{request.ip.inspect} != #{session[:ip].inspect}")
233     trust_root = oidreq.trust_root
234     per_site = config[trust_root] or
235       return err("trust_root unknown: #{trust_root}")
237     session_id = session[:id] or return err("no session ID")
239     assoc_handle = per_site['assoc_handle'] # this may be nil
240     expires = per_site['expires'] or
241       return err("no expires (trust_root=#{trust_root})")
243     assoc_handle == oidreq.assoc_handle or
244       return err("assoc_handle mismatch: " \
245                  "#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \
246                  " (trust_root=#{trust_root})")
248     per_site['session_id'] == session_id or
249       return err("session ID mismatch: " \
250                  "#{per_site['session_id'].inspect} != #{session_id.inspect}" \
251                  " (trust_root=#{trust_root})")
253     expires > Time.now or
254       return err("Expired: #{expires.inspect} (trust_root=#{trust_root})")
256     true
257   end
259   def config
260     @config ||= begin
261       YAML.load(File.read("#@@dir/config.yml"))
262     rescue Errno::ENOENT
263       {}
264     end
265   end
267   def merge_config(oidreq)
268     per_site = config[oidreq.trust_root] ||= {}
269     per_site.merge!({
270         'assoc_handle' => oidreq.assoc_handle,
271         'expires' => Time.at(0).utc,
272         'updated' => Time.now.utc,
273         'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR
274         'session_id' => session[:id],
275       })
276   end
278   def write_config
279     path = "#@@dir/config.yml"
280     tmp = Tempfile.new('config.yml', File.dirname(path))
281     tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# "))
282     tmp.syswrite(config.to_yaml)
283     tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# "))
284     tmp.fsync
285     File.rename(tmp.path, path)
286     tmp.close!
287   end
289   # this output is designed to be parsed by OpenID consumers
290   def render_provider_xrds(force = false)
291     if force || request.accept.include?('application/xrds+xml')
293       # this seems to work...
294       types = [ OpenID::OPENID_IDP_2_0_TYPE ]
296       headers['Content-Type'] = 'application/xrds+xml'
297       types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
298       PROVIDER_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
299     else # render a browser-friendly page with an XRDS pointer
300       headers['X-XRDS-Location'] = "#{server_root}provider/xrds"
301       PROVIDER_XRDS_HTML.gsub(/%s/, server_root)
302     end
303   end
305   def render_identity_xrds(force = false)
306     if force || request.accept.include?('application/xrds+xml')
308       # this seems to work...
309       types = [ OpenID::OPENID_2_0_TYPE,
310                 OpenID::OPENID_1_0_TYPE,
311                 OpenID::SREG_URI ]
313       headers['Content-Type'] = 'application/xrds+xml'
314       types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
315       IDENTITY_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
316     else # render a browser-friendly page with an XRDS pointer
317       headers['X-XRDS-Location'] = "#{server_root}xrds"
318       IDENTITY_XRDS_HTML.gsub(/%s/, server_root)
319     end
320   end
322   # if a single-user OpenID provider like us is being hit by multiple
323   # clients at once, then something is seriously wrong.  Can't use
324   # Mutexes here since somebody could be running this as a CGI script
325   def big_lock(&block)
326     lock = "#@@dir/lock"
327     File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp|
328       begin
329         yield
330       ensure
331         File.unlink(lock)
332       end
333     end
334     rescue Errno::EEXIST
335       err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil
336   end
338 #:startdoc: