1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
| | # 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!<html>
<head><title>OpenID login: %s</title></head>
<body><h1>reload this page when approved: %s</h1></body>
</html>!
PROVIDER_XRDS_HTML = %q!<html><head>
<meta http-equiv="X-XRDS-Location" content="%sprovider/xrds" />
<title>OpenID server endpoint</title>
</head><body>OpenID server endpoint</body></html>!
IDENTITY_XRDS_HTML = %q!<html><head>
<link rel="openid.server" href="%sprovider" />
<link rel="openid2.provider" href="%sprovider" />
<link rel="openid2.local_id" href="%s" />
<link rel="openid.delegate" href="%s" />
<meta http-equiv="X-XRDS-Location" content="%sxrds" />
<title>OpenID identity</title>
</head><body>OpenID identity</body></html>!
PROVIDER_XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
<xrds:XRDS
xmlns:xrds="xri://$xrds"
xmlns:openid="http://openid.net/xmlns/1.0"
xmlns="xri://$xrd*($v*2.0)">
<XRD version="2.0">
<Service priority="0">
%types
<URI>%sprovider</URI>
</Service>
</XRD>
</xrds:XRDS>!
IDENTITY_XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
<xrds:XRDS
xmlns:xrds="xri://$xrds"
xmlns:openid="http://openid.net/xmlns/1.0"
xmlns="xri://$xrd*($v*2.0)">
<XRD version="2.0">
<Service priority="0">
%types
<URI>%sprovider</URI>
<LocalID>%s</LocalID>
<openid:Delegate>%s</openid:Delegate>
</Service>
</XRD>
</xrds:XRDS>!
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
def server_root
"#{request.base_url}/"
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| "<Type>#{uri}</Type>" }.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| "<Type>#{uri}</Type>" }.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:
|