separate OpenID Provider identifier and user identifier to be distinct
[local-openid.git] / lib / local_openid.rb
blobab342a906174d0e365a6861961fd833f7f6ee80f
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   # we're the provider for exactly one identity.  However, we do rely on
164   # being proxied and being hit with an appropriate HTTP Host: header.
165   # Don't expect OpenID consumers to handle port != 80.
166   def server_root
167     "http://#{request.host}/"
168   end
170   def server
171     @server ||= Server.new(
172         OpenID::Store::Filesystem.new("#@@dir/store"),
173         server_root + "provider")
174   end
176   # support the simple registration extension if possible,
177   # allow per-site overrides of various data points
178   def add_sreg(oidreq, oidresp)
179     sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return
180     per_site = config[oidreq.trust_root] || {}
182     sreg_data = {}
183     sregreq.all_requested_fields.each do |field|
184       sreg_data[field] = per_site[field] || config[field]
185     end
187     sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data)
188     oidresp.add_extension(sregresp)
189   end
191   def add_pape(oidreq, oidresp)
192     papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return
193     paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies,
194                                           papereq.max_auth_age)
195     # since this implementation requires shell/filesystem access to the
196     # OpenID server to authenticate, we can say we're at the highest
197     # auth level possible...
198     paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL)
199     paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
200     paperesp.nist_auth_level = 4
201     oidresp.add_extension(paperesp)
202   end
204   def err(msg)
205     env['rack.errors'].write("#{msg}\n")
206     false
207   end
209   def finalize_response(oidresp)
210     server.signatory.sign(oidresp) if oidresp.needs_signing
211     web_response = server.encode_response(oidresp)
213     case web_response.code
214     when HTTP_OK
215       web_response.body
216     when HTTP_REDIRECT
217       location = web_response.headers['location']
218       err("redirecting to: #{location} ...")
219       redirect(location)
220     else
221       halt(500, web_response.body)
222     end
223   end
225   # the heart of our custom authentication logic
226   def is_authorized?(oidreq)
227     (config['allowed_ips'] ||= []).include?(request.ip) or
228       return err("Not allowed: #{request.ip}\n" \
229                  "You need to put this IP in the 'allowed_ips' array "\
230                  "in:\n #@@dir/config.yml")
232     request.ip == session[:ip] or
233       return err("session IP mismatch: " \
234                  "#{request.ip.inspect} != #{session[:ip].inspect}")
236     trust_root = oidreq.trust_root
237     per_site = config[trust_root] or
238       return err("trust_root unknown: #{trust_root}")
240     session_id = session[:id] or return err("no session ID")
242     assoc_handle = per_site['assoc_handle'] # this may be nil
243     expires = per_site['expires'] or
244       return err("no expires (trust_root=#{trust_root})")
246     assoc_handle == oidreq.assoc_handle or
247       return err("assoc_handle mismatch: " \
248                  "#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \
249                  " (trust_root=#{trust_root})")
251     per_site['session_id'] == session_id or
252       return err("session ID mismatch: " \
253                  "#{per_site['session_id'].inspect} != #{session_id.inspect}" \
254                  " (trust_root=#{trust_root})")
256     expires > Time.now or
257       return err("Expired: #{expires.inspect} (trust_root=#{trust_root})")
259     true
260   end
262   def config
263     @config ||= begin
264       YAML.load(File.read("#@@dir/config.yml"))
265     rescue Errno::ENOENT
266       {}
267     end
268   end
270   def merge_config(oidreq)
271     per_site = config[oidreq.trust_root] ||= {}
272     per_site.merge!({
273         'assoc_handle' => oidreq.assoc_handle,
274         'expires' => Time.at(0).utc,
275         'updated' => Time.now.utc,
276         'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR
277         'session_id' => session[:id],
278       })
279   end
281   def write_config
282     path = "#@@dir/config.yml"
283     tmp = Tempfile.new('config.yml', File.dirname(path))
284     tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# "))
285     tmp.syswrite(config.to_yaml)
286     tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# "))
287     tmp.fsync
288     File.rename(tmp.path, path)
289     tmp.close!
290   end
292   # this output is designed to be parsed by OpenID consumers
293   def render_provider_xrds(force = false)
294     if force || request.accept.include?('application/xrds+xml')
296       # this seems to work...
297       types = [ OpenID::OPENID_IDP_2_0_TYPE ]
299       headers['Content-Type'] = 'application/xrds+xml'
300       types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
301       PROVIDER_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
302     else # render a browser-friendly page with an XRDS pointer
303       headers['X-XRDS-Location'] = "#{server_root}provider/xrds"
304       PROVIDER_XRDS_HTML.gsub(/%s/, server_root)
305     end
306   end
308   def render_identity_xrds(force = false)
309     if force || request.accept.include?('application/xrds+xml')
311       # this seems to work...
312       types = [ OpenID::OPENID_2_0_TYPE,
313                 OpenID::OPENID_1_0_TYPE,
314                 OpenID::SREG_URI ]
316       headers['Content-Type'] = 'application/xrds+xml'
317       types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
318       IDENTITY_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
319     else # render a browser-friendly page with an XRDS pointer
320       headers['X-XRDS-Location'] = "#{server_root}xrds"
321       IDENTITY_XRDS_HTML.gsub(/%s/, server_root)
322     end
323   end
325   # if a single-user OpenID provider like us is being hit by multiple
326   # clients at once, then something is seriously wrong.  Can't use
327   # Mutexes here since somebody could be running this as a CGI script
328   def big_lock(&block)
329     lock = "#@@dir/lock"
330     File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp|
331       begin
332         yield
333       ensure
334         File.unlink(lock)
335       end
336     end
337     rescue Errno::EEXIST
338       err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil
339   end
341 #:startdoc: