forget-passwords 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +27 -0
- data/.rspec +3 -0
- data/.travis.yml +5 -0
- data/Gemfile +6 -0
- data/LICENSE +202 -0
- data/README.md +603 -0
- data/Rakefile +6 -0
- data/behaviour.org +112 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/content/basic-401.xhtml +15 -0
- data/content/basic-404.xhtml +10 -0
- data/content/basic-409.xhtml +14 -0
- data/content/basic-500.xhtml +10 -0
- data/content/cookie-expired.xhtml +15 -0
- data/content/email-409.xhtml +15 -0
- data/content/email-sent.xhtml +11 -0
- data/content/email.xhtml +10 -0
- data/content/logged-out-all.xhtml +10 -0
- data/content/logged-out.xhtml +10 -0
- data/content/nonce-expired.xhtml +15 -0
- data/content/not-on-list.xhtml +15 -0
- data/content/post-405.xhtml +10 -0
- data/content/uri-409.xhtml +10 -0
- data/etc/text-only.xsl +105 -0
- data/exe/forgetpw +7 -0
- data/forget-passwords.gemspec +67 -0
- data/lib/forget-passwords/cli.rb +514 -0
- data/lib/forget-passwords/fastcgi.rb +28 -0
- data/lib/forget-passwords/state.rb +535 -0
- data/lib/forget-passwords/template.rb +269 -0
- data/lib/forget-passwords/types.rb +118 -0
- data/lib/forget-passwords/version.rb +3 -0
- data/lib/forget-passwords.rb +635 -0
- metadata +312 -0
@@ -0,0 +1,635 @@
|
|
1
|
+
require 'forget-passwords/version'
|
2
|
+
require 'forget-passwords/types'
|
3
|
+
require 'forget-passwords/state'
|
4
|
+
require 'forget-passwords/template'
|
5
|
+
require 'uuid-ncname'
|
6
|
+
|
7
|
+
require 'dry-types'
|
8
|
+
require 'dry-schema'
|
9
|
+
|
10
|
+
require 'rack'
|
11
|
+
require 'rack/request'
|
12
|
+
require 'rack/response'
|
13
|
+
|
14
|
+
require 'mail'
|
15
|
+
|
16
|
+
module ForgetPasswords
|
17
|
+
|
18
|
+
# An error response (with status, headers, etc) that can be raised
|
19
|
+
# and caught.
|
20
|
+
class ErrorResponse < RuntimeError
|
21
|
+
|
22
|
+
attr_reader :response
|
23
|
+
|
24
|
+
# Create a new error response.
|
25
|
+
#
|
26
|
+
# @param body [#to_s, #each] the response body
|
27
|
+
# @param status [Integer] the HTTP status code
|
28
|
+
# @param headers [Hash] the header set
|
29
|
+
#
|
30
|
+
def initialize body = nil, status = 500, headers = {}
|
31
|
+
if body.is_a? Rack::Response
|
32
|
+
@response = body
|
33
|
+
else
|
34
|
+
@response = Rack::Response.new body, status, headers
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Returns the error message (which is the response body).
|
39
|
+
#
|
40
|
+
# @return [String] the error message (response body)
|
41
|
+
#
|
42
|
+
def message
|
43
|
+
@response.body
|
44
|
+
end
|
45
|
+
|
46
|
+
# Sets a new error message (response body). Does not change
|
47
|
+
# anything else, like headers or status or anything.
|
48
|
+
#
|
49
|
+
# @param msg [#to_s] the new error message
|
50
|
+
#
|
51
|
+
def message= msg
|
52
|
+
@response.body = msg.to_s
|
53
|
+
end
|
54
|
+
|
55
|
+
# Generate a new exception with a Rack::Response as a message.
|
56
|
+
# Otherwise creates a new error response.
|
57
|
+
#
|
58
|
+
# @param message [Rack::Response, #to_s] the response object or string
|
59
|
+
#
|
60
|
+
# @return [ForgetPasswords::ErrorResponse] a new error response
|
61
|
+
#
|
62
|
+
def self.exception message
|
63
|
+
# XXX TODO auto generate (x)html from text message?
|
64
|
+
case message
|
65
|
+
when Rack::Response then self.new message
|
66
|
+
else
|
67
|
+
self.new message.to_s, 500, { 'Content-Type' => 'text/plain' }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Returns itself if the message is nil. Otherwise it runs the
|
72
|
+
# class method with the message as its argument.
|
73
|
+
#
|
74
|
+
# @param message [nil, Rack::Response, #to_s] optional response
|
75
|
+
# object or string
|
76
|
+
#
|
77
|
+
# @return [ForgetPasswords::ErrorResponse] a new error response
|
78
|
+
#
|
79
|
+
def exception message = nil
|
80
|
+
return self if message.nil?
|
81
|
+
self.class.exception message
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
class App
|
86
|
+
require 'time'
|
87
|
+
require 'uri'
|
88
|
+
|
89
|
+
require 'uuidtools'
|
90
|
+
require 'uuid-ncname'
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
TEN_MINUTES = ISO8601::Duration.new('PT10M').freeze
|
95
|
+
TWO_WEEKS = ISO8601::Duration.new('P2W').freeze
|
96
|
+
|
97
|
+
XPATHNS = { html: 'http://www.w3.org/1999/xhtml' }.freeze
|
98
|
+
|
99
|
+
# we want all these constants to be public so they show up in the docs
|
100
|
+
DEFAULT_KEYS = { query: 'knock', cookie: 'forgetpw',
|
101
|
+
email: 'email', logout: 'logout' }.freeze
|
102
|
+
DEFAULT_VARS = { user: 'FCGI_USER', redirect: 'FCGI_REDIRECT'}.freeze
|
103
|
+
DEFAULT_PATH = (Pathname(__FILE__) + '../../content').expand_path.freeze
|
104
|
+
|
105
|
+
SH = ForgetPasswords::Types::SymbolHash
|
106
|
+
ST = ForgetPasswords::Types::String
|
107
|
+
AT = ForgetPasswords::Types::ASCIIToken
|
108
|
+
|
109
|
+
Keys = SH.schema({
|
110
|
+
query: 'knock',
|
111
|
+
cookie: 'forgetpw',
|
112
|
+
email: 'email',
|
113
|
+
logout: 'all',
|
114
|
+
forward: 'forward',
|
115
|
+
}.transform_values { |x| AT.default x.freeze }).hash_default
|
116
|
+
|
117
|
+
Vars = SH.schema({
|
118
|
+
user: 'FCGI_USER',
|
119
|
+
redirect: 'FCGI_REDIRECT',
|
120
|
+
type: 'FCGI_CONTENT_TYPE',
|
121
|
+
}.transform_values { |x| AT.default x.freeze }).hash_default
|
122
|
+
|
123
|
+
Targets = SH.schema({
|
124
|
+
login: '/email-link',
|
125
|
+
logout: '/logout',
|
126
|
+
logout_one: '/logged-out',
|
127
|
+
logout_all: '/logged-out-all',
|
128
|
+
}.transform_values { |x| ST.default x.freeze }).hash_default
|
129
|
+
|
130
|
+
# mapping override with specific values
|
131
|
+
Mapping = SH.schema({
|
132
|
+
default_401: 'basic-401.xhtml',
|
133
|
+
default_404: 'basic-404.xhtml',
|
134
|
+
default_409: 'basic-409.xhtml',
|
135
|
+
default_500: 'basic-500.xhtml',
|
136
|
+
knock_bad: 'basic-409.xhtml',
|
137
|
+
knock_not_found: 'basic-409.xhtml',
|
138
|
+
knock_expired: 'nonce-expired.xhtml',
|
139
|
+
cookie_bad: 'basic-409.xhtml',
|
140
|
+
cookie_not_found: 'basic-409.xhtml',
|
141
|
+
cookie_expired: 'cookie-expired.xhtml',
|
142
|
+
no_user: 'not-on-list.xhtml',
|
143
|
+
forward_bad: 'uri-409.xhtml',
|
144
|
+
email: 'email.xhtml',
|
145
|
+
email_bad: 'email-409.xhtml',
|
146
|
+
email_not_listed: 'not-on-list.xhtml',
|
147
|
+
email_failed: 'basic-500.xhtml',
|
148
|
+
email_sent: 'email-sent.xhtml',
|
149
|
+
post_only: 'post-405.xhtml',
|
150
|
+
}.transform_values { |x| ST.default x.freeze }).hash_default
|
151
|
+
|
152
|
+
# this is the closest thing to "inheritance"
|
153
|
+
RawTemplates = ForgetPasswords::Template::Mapper::RawParams.schema(
|
154
|
+
mapping: Mapping
|
155
|
+
).hash_default
|
156
|
+
|
157
|
+
# which means we have to duplicate the constructor and its default
|
158
|
+
Templates = ForgetPasswords::Types.Constructor(ForgetPasswords::Template::Mapper) do |x|
|
159
|
+
if x.is_a? ForgetPasswords::Template::Mapper
|
160
|
+
x
|
161
|
+
else
|
162
|
+
raw = RawTemplates.(x)
|
163
|
+
path = raw.delete :path
|
164
|
+
ForgetPasswords::Template::Mapper.new path, **raw
|
165
|
+
end
|
166
|
+
end.default do
|
167
|
+
raw = RawTemplates.({})
|
168
|
+
path = raw.delete :path
|
169
|
+
ForgetPasswords::Template::Mapper.new path, **raw
|
170
|
+
end
|
171
|
+
|
172
|
+
EMail = SH.schema(
|
173
|
+
from: Dry::Types['string'],
|
174
|
+
method: ForgetPasswords::Types::Coercible::Symbol.default(:sendmail),
|
175
|
+
options?: ForgetPasswords::Types::Hash.map(
|
176
|
+
ForgetPasswords::Types::NormSym, ForgetPasswords::Types::Atomic)
|
177
|
+
).hash_default
|
178
|
+
|
179
|
+
# the composed configuration hash
|
180
|
+
Config = SH.schema(
|
181
|
+
state: ForgetPasswords::State::Type,
|
182
|
+
keys: Keys,
|
183
|
+
vars: Vars,
|
184
|
+
targets: Targets,
|
185
|
+
templates: Templates,
|
186
|
+
email: EMail,
|
187
|
+
).hash_default
|
188
|
+
|
189
|
+
# Return a token suitable for being either a nonce or a cookie.
|
190
|
+
# Returns a compact UUID.
|
191
|
+
#
|
192
|
+
# @return [String] a compact UUID.
|
193
|
+
#
|
194
|
+
def make_token
|
195
|
+
UUID::NCName.to_ncname UUIDTools::UUID.random_create
|
196
|
+
end
|
197
|
+
|
198
|
+
# Return a copy of the given URI with the nonce token in the
|
199
|
+
# `knock` parameter.
|
200
|
+
#
|
201
|
+
# @param uri [URI] the desired base URI
|
202
|
+
# @param token [#to_s] the token
|
203
|
+
#
|
204
|
+
# @return [URI] the new URI
|
205
|
+
#
|
206
|
+
def make_login_link uri, token
|
207
|
+
key = @keys[:query].to_s
|
208
|
+
# strip off any old key(s) that might be present
|
209
|
+
query = URI.decode_www_form(uri.query || '').reject do |pair|
|
210
|
+
pair.first == key
|
211
|
+
end
|
212
|
+
|
213
|
+
# append the new one
|
214
|
+
query << [key, token]
|
215
|
+
|
216
|
+
# add to uri
|
217
|
+
uri = uri.dup
|
218
|
+
uri.query = URI.encode_www_form query
|
219
|
+
|
220
|
+
uri
|
221
|
+
end
|
222
|
+
|
223
|
+
# Return an absolute request-URI from the Rack::Request.
|
224
|
+
#
|
225
|
+
# @param req [Rack::Request] the request object
|
226
|
+
#
|
227
|
+
# @return [URI] the full URI.
|
228
|
+
#
|
229
|
+
def req_uri req
|
230
|
+
URI(req.base_url) + req.env['REQUEST_URI']
|
231
|
+
end
|
232
|
+
|
233
|
+
# Return a copy of the given URI minus zero or more query parameters.
|
234
|
+
#
|
235
|
+
# @param uri [URI] the URI
|
236
|
+
# @param *key [Array<#to_s>] the query key(s) to remove
|
237
|
+
#
|
238
|
+
# @return [URI] the new URI
|
239
|
+
#
|
240
|
+
def uri_minus_query uri, *key
|
241
|
+
return uri unless uri.query
|
242
|
+
uri = uri.dup
|
243
|
+
key = key.map(&:to_s)
|
244
|
+
|
245
|
+
query = URI.decode_www_form(uri.query || '').reject do |pair|
|
246
|
+
key.include? pair.first
|
247
|
+
end
|
248
|
+
uri.query = query.empty? ? nil : URI.encode_www_form(query)
|
249
|
+
uri
|
250
|
+
end
|
251
|
+
|
252
|
+
# Test whether the token is well-formed.
|
253
|
+
#
|
254
|
+
# @return [true, false] the well-formedness of the token
|
255
|
+
#
|
256
|
+
def token_ok? token
|
257
|
+
!!UUID::NCName.valid?(token)
|
258
|
+
end
|
259
|
+
|
260
|
+
# Return a Time object correctly delta'd by an {ISO8601::Duration}.
|
261
|
+
#
|
262
|
+
# @param duration [ISO8601::Duration] the duration
|
263
|
+
# @param from [nil, Time] anchor time, if other than `Time.now`
|
264
|
+
#
|
265
|
+
# @return [Time] the new time
|
266
|
+
#
|
267
|
+
def time_delta duration, from = Time.now
|
268
|
+
from.to_time.gmtime +
|
269
|
+
duration.to_seconds(ISO8601::DateTime.new from.iso8601)
|
270
|
+
end
|
271
|
+
|
272
|
+
# Expire a token.
|
273
|
+
#
|
274
|
+
def expire token
|
275
|
+
@state.token.expire token
|
276
|
+
end
|
277
|
+
|
278
|
+
# Extract an e-mail address from a string, or otherwise return nil.
|
279
|
+
#
|
280
|
+
# @param string [#to_s] presumably a string
|
281
|
+
#
|
282
|
+
# @return [Mail::Address, nil] maybe an e-mail address.
|
283
|
+
#
|
284
|
+
def email_in string
|
285
|
+
return nil unless string.to_s.include? ?@
|
286
|
+
begin
|
287
|
+
Mail::Address.new string.to_s.strip.downcase
|
288
|
+
rescue Mail::Field::IncompleteParseError
|
289
|
+
nil
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
# Send the e-mail containing the link to log in.
|
294
|
+
#
|
295
|
+
# @param req [Rack::Request] the HTTP request object
|
296
|
+
# @param address [#to_s] the principal's e-mail address
|
297
|
+
#
|
298
|
+
# @return [Mail::Message] the message sent to the address.
|
299
|
+
#
|
300
|
+
def send_link req, email, uri
|
301
|
+
# set up the variables
|
302
|
+
uri ||= req_uri req
|
303
|
+
token = @state.new_token email, oneoff: true
|
304
|
+
vars = {
|
305
|
+
URL: uri.to_s,
|
306
|
+
PRETTY_URL: uri.to_s.sub(/^https?:\/\/(.*?)\/*$/i, "\\1"),
|
307
|
+
KNOCK_URL: make_login_link(uri, token),
|
308
|
+
DOMAIN: URI(req.base_url).host,
|
309
|
+
EMAIL: email.to_s,
|
310
|
+
}
|
311
|
+
|
312
|
+
# grab the template since we'll use it
|
313
|
+
template = @templates[:email]
|
314
|
+
|
315
|
+
# process the templates
|
316
|
+
doc = template.process vars: vars
|
317
|
+
sub = doc.xpath('normalize-space((//title|//html:title)[1])', XPATHNS)
|
318
|
+
|
319
|
+
html = template.serialize doc, { 'Accept' => 'text/html' }
|
320
|
+
text = template.serialize doc, { 'Accept' => 'text/plain' }
|
321
|
+
|
322
|
+
# fuuuuuu the block operates as instance_exec
|
323
|
+
em = @email
|
324
|
+
Mail.new do
|
325
|
+
from em[:from]
|
326
|
+
to email
|
327
|
+
subject sub
|
328
|
+
html_part { content_type 'text/html'; body html }
|
329
|
+
text_part { body text }
|
330
|
+
delivery_method em[:method], **(em[:options] || {})
|
331
|
+
end.deliver
|
332
|
+
end
|
333
|
+
|
334
|
+
def raise_error status, key, req, vars: {}
|
335
|
+
uri = req_uri req
|
336
|
+
resp = Rack::Response.new
|
337
|
+
resp.status = status
|
338
|
+
@templates[key].populate resp, req, vars, base: uri
|
339
|
+
resp.set_header "Variable-#{@vars[:type]}", resp.content_type
|
340
|
+
raise ForgetPasswords::ErrorResponse, resp
|
341
|
+
end
|
342
|
+
|
343
|
+
# @!group Actual Handlers
|
344
|
+
|
345
|
+
def default_401 req
|
346
|
+
uri = req_uri req
|
347
|
+
resp = Rack::Response.new
|
348
|
+
resp.status = 401
|
349
|
+
@templates[:default_401].populate resp, req, {
|
350
|
+
FORWARD: req_uri(req).to_s, LOGIN: @targets[:login] }, base: uri
|
351
|
+
resp.set_header "Variable-#{@vars[:type]}", resp.content_type
|
352
|
+
resp
|
353
|
+
end
|
354
|
+
|
355
|
+
def handle_knock req, token
|
356
|
+
uri = req_uri req
|
357
|
+
target = uri_minus_query uri, @keys[:query]
|
358
|
+
resp = Rack::Response.new
|
359
|
+
|
360
|
+
raise_error(409, :knock_bad, req) unless token_ok? token
|
361
|
+
|
362
|
+
raise_error(401, :knock_expired, req,
|
363
|
+
vars: { LOGIN: @targets[:login], FORWARD: target.to_s }) unless
|
364
|
+
@state.token.valid? token
|
365
|
+
|
366
|
+
raise_error(403, :knock_not_found, req) unless
|
367
|
+
user = @state.user_for(token)
|
368
|
+
|
369
|
+
# stamp the knock token so we know not to use it again
|
370
|
+
@state.stamp_token token, req.ip
|
371
|
+
|
372
|
+
# remove existing cookie
|
373
|
+
if (token = req.cookies[@keys[:cookie]])
|
374
|
+
@state.token.expire token
|
375
|
+
resp.delete_cookie @keys[:cookie], { value: token }
|
376
|
+
end
|
377
|
+
|
378
|
+
# we never use the knock token again so we can overwrite it with
|
379
|
+
# a new cookie
|
380
|
+
token = @state.new_token user, cookie: true
|
381
|
+
|
382
|
+
# set the user and redirect location as variables
|
383
|
+
resp.set_header "Variable-#{@vars[:user]}", user.to_s
|
384
|
+
resp.set_header "Variable-#{@vars[:redirect]}", target.to_s if
|
385
|
+
target != uri # (note this should always be true)
|
386
|
+
resp.set_cookie @keys[:cookie], {
|
387
|
+
value: token, secure: req.ssl?, httponly: true, domain: uri.host,
|
388
|
+
expires: time_delta(@state.expiry[:cookie]),
|
389
|
+
}
|
390
|
+
|
391
|
+
# response has to be 200 or the auth handler won't pick it up
|
392
|
+
# (response is already 200 by default)
|
393
|
+
|
394
|
+
# content-length has to be present but empty or it will crap out
|
395
|
+
resp.set_header 'Content-Length', ''
|
396
|
+
|
397
|
+
resp
|
398
|
+
end
|
399
|
+
|
400
|
+
def handle_cookie req, token = nil
|
401
|
+
token ||= req.cookies[@keys[:cookie]]
|
402
|
+
|
403
|
+
resp = Rack::Response.new
|
404
|
+
|
405
|
+
vars = { LOGIN: @targets[:login], FORWARD: req_uri(req).to_s }
|
406
|
+
|
407
|
+
# check if token is well-formed
|
408
|
+
raise_error(409, :cookie_bad, req, vars: vars) unless token_ok? token
|
409
|
+
|
410
|
+
# check if the cookie is still valid
|
411
|
+
raise_error(401, :cookie_expired, req, vars: vars) unless
|
412
|
+
@state.token.valid? token, cookie: true
|
413
|
+
|
414
|
+
# check if there is an actual user associated with the cookie
|
415
|
+
raise_error(403, :no_user, req, vars: vars) unless
|
416
|
+
user = @state.user_for(token, record: true, cookie: true)
|
417
|
+
|
418
|
+
raise_error(403, :email_not_listed, req, vars: vars) unless
|
419
|
+
@state.acl.listed? req_uri(req), user.email
|
420
|
+
|
421
|
+
now = Time.now
|
422
|
+
@state.freshen_token token, from: now
|
423
|
+
|
424
|
+
# stamp the token
|
425
|
+
@state.stamp_token token, req.ip, seen: now
|
426
|
+
|
427
|
+
# update the cookie expiration
|
428
|
+
resp.set_cookie @keys[:cookie], {
|
429
|
+
value: token, secure: req.ssl?, httponly: true,
|
430
|
+
expires: time_delta(@state.expiry[:cookie], now),
|
431
|
+
}
|
432
|
+
|
433
|
+
# just set the variable
|
434
|
+
resp.set_header "Variable-#{@vars[:user]}", user.principal.to_s
|
435
|
+
|
436
|
+
# content-length has to be present but empty or it will crap out
|
437
|
+
resp.set_header 'Content-Length', ''
|
438
|
+
|
439
|
+
resp
|
440
|
+
end
|
441
|
+
|
442
|
+
def handle_login req
|
443
|
+
uri = req_uri req
|
444
|
+
resp = Rack::Response.new
|
445
|
+
|
446
|
+
# check that the forwarding URI is well-formed and has the same
|
447
|
+
# scheme/authority
|
448
|
+
forward = uri + req.POST[@keys[:forward]] rescue nil
|
449
|
+
raise_error(409, :forward_bad, req) unless forward and
|
450
|
+
(forward = forward.normalize).host == uri.host
|
451
|
+
|
452
|
+
vars = { LOGIN: @targets[:login].to_s, FORWARD: forward.to_s }
|
453
|
+
|
454
|
+
# obtain the email address from the form
|
455
|
+
raise_error(409, :email_bad, req, vars: vars) unless
|
456
|
+
address = email_in(req.POST[@keys[:email]])
|
457
|
+
|
458
|
+
# XXX TODO wrap this business in a transaction like an adult?
|
459
|
+
|
460
|
+
# check the email against the list
|
461
|
+
raise_error(401, :email_not_listed, req, vars: vars) unless
|
462
|
+
@state.acl.listed? uri, address
|
463
|
+
|
464
|
+
# XXX TODO consider rate-limiting so as not to bombard the
|
465
|
+
# target with emails; return either 429 (too many requests) or
|
466
|
+
# perhaps the new code 425 (too early)
|
467
|
+
|
468
|
+
# find or create the user based on the email (this should never
|
469
|
+
# fail, except internally)
|
470
|
+
@state.new_user address
|
471
|
+
|
472
|
+
# send the email
|
473
|
+
begin
|
474
|
+
send_link req, address, forward
|
475
|
+
rescue StandardError => e
|
476
|
+
# XXX generic logger???
|
477
|
+
warn e.full_message
|
478
|
+
warn caller
|
479
|
+
|
480
|
+
# anyway,,,
|
481
|
+
raise_error(500, :email_failed, req)
|
482
|
+
end
|
483
|
+
|
484
|
+
# return 200 now because this is now a content handler
|
485
|
+
resp.status = 200
|
486
|
+
@templates[:email_sent].populate resp, req, {
|
487
|
+
FORWARD: forward.to_s, FROM: @email[:from].to_s, EMAIL: address.to_s },
|
488
|
+
base: uri
|
489
|
+
end
|
490
|
+
|
491
|
+
def handle_logout req, all = nil
|
492
|
+
all = req.GET[@keys[:logout]] if all.nil?
|
493
|
+
all = /^\s*(1|true|on|yes)\s*$/i.match? all.to_s
|
494
|
+
|
495
|
+
resp = Rack::Response.new
|
496
|
+
|
497
|
+
# this does the actual "logging out"
|
498
|
+
if token = req.cookies[@keys[:cookie]]
|
499
|
+
if all and id = @state.token.id_for(token)
|
500
|
+
# nuke all the cookies for the id
|
501
|
+
@state.expire_tokens_for id
|
502
|
+
else
|
503
|
+
# invalidate the token associated with the cookie
|
504
|
+
@state.token.expire token
|
505
|
+
end
|
506
|
+
# clear the cookie
|
507
|
+
resp.delete_cookie @keys[:cookie], { value: token }
|
508
|
+
end
|
509
|
+
|
510
|
+
# otherwise this thing will pretend like you're logging out even
|
511
|
+
# if you were never logged in
|
512
|
+
|
513
|
+
# we do when we actually process the token in the query string
|
514
|
+
resp.status = 303
|
515
|
+
resp.write 'Redirecting...'
|
516
|
+
resp.location = (req_uri(req) +
|
517
|
+
@targets[all ? :logout_one : :logout_all]).to_s
|
518
|
+
|
519
|
+
resp
|
520
|
+
end
|
521
|
+
|
522
|
+
# def handle_post req
|
523
|
+
# return Rack::Response[403, { 'Content-Type' => 'text/plain' }, 'wat lol']
|
524
|
+
|
525
|
+
# if logout = req.POST[@keys[:logout]]
|
526
|
+
# handle_logout req, logout
|
527
|
+
# elsif email = req.POST[@keys[:email]]
|
528
|
+
# handle_login req, email
|
529
|
+
# elsif token = req.cookies[@keys[:cookie]]
|
530
|
+
# # next check for a cookie
|
531
|
+
# handle_cookie req, token
|
532
|
+
# else
|
533
|
+
# default_401 req
|
534
|
+
# end
|
535
|
+
# end
|
536
|
+
|
537
|
+
def handle_auth req
|
538
|
+
if knock = req.GET[@keys[:query]]
|
539
|
+
# check for a knock first; this overrides everything
|
540
|
+
handle_knock req, knock
|
541
|
+
# elsif req.post?
|
542
|
+
# # next check for a login/logout attempt
|
543
|
+
# handle_post req
|
544
|
+
elsif token = req.cookies[@keys[:cookie]]
|
545
|
+
# next check for a cookie
|
546
|
+
handle_cookie req, token
|
547
|
+
else
|
548
|
+
default_401 req
|
549
|
+
end
|
550
|
+
end
|
551
|
+
|
552
|
+
def handle_content req
|
553
|
+
uri = req_uri req
|
554
|
+
if proc = @dispatch[uri.path]
|
555
|
+
return instance_exec req, &proc
|
556
|
+
else
|
557
|
+
# return 404 lol
|
558
|
+
raise_error(404, :default_404, req)
|
559
|
+
end
|
560
|
+
end
|
561
|
+
|
562
|
+
# @!endgroup
|
563
|
+
|
564
|
+
DISPATCH = {
|
565
|
+
login: -> req {
|
566
|
+
raise_error(405, :post_only, req) unless req.post?
|
567
|
+
|
568
|
+
handle_login req
|
569
|
+
},
|
570
|
+
logout: -> req {
|
571
|
+
raise_error(405, :post_only, req) unless req.post?
|
572
|
+
|
573
|
+
handle_logout req
|
574
|
+
},
|
575
|
+
}
|
576
|
+
|
577
|
+
public
|
578
|
+
|
579
|
+
def initialize state, keys: {}, vars: {}, targets: {},
|
580
|
+
templates: {}, email: {}, debug: false
|
581
|
+
|
582
|
+
@debug = debug
|
583
|
+
|
584
|
+
# process config
|
585
|
+
config = Config.({ state: state, keys: keys, vars: vars,
|
586
|
+
targets: targets, templates: templates, email: email }).to_h
|
587
|
+
|
588
|
+
# then assign members
|
589
|
+
config.each { |key, value| instance_variable_set "@#{key.to_s}", value }
|
590
|
+
|
591
|
+
# warn @email.inspect
|
592
|
+
|
593
|
+
# create a dispatch table for content requests
|
594
|
+
# XXX this will have to be expanded for multiple hosts
|
595
|
+
@dispatch = @targets.reduce({}) do |a, pair|
|
596
|
+
if proc = DISPATCH[pair.first]
|
597
|
+
a[pair.last] ||= proc
|
598
|
+
end
|
599
|
+
a
|
600
|
+
end.compact.to_h
|
601
|
+
|
602
|
+
end
|
603
|
+
|
604
|
+
def call env
|
605
|
+
# do surgery to request sceme
|
606
|
+
if env['REQUEST_SCHEME']
|
607
|
+
env['HTTPS'] = 'on' if env['REQUEST_SCHEME'].downcase == 'https'
|
608
|
+
end
|
609
|
+
req = Rack::Request.new env
|
610
|
+
resp = Rack::Response.new
|
611
|
+
|
612
|
+
# keep this around for when we split this into app and middleware
|
613
|
+
|
614
|
+
# unless env['FCGI_ROLE'] == 'AUTHORIZER'
|
615
|
+
# resp.status = 500
|
616
|
+
# resp.body << "ForgetPasswords::App only works as a FastCGI authorizer!"
|
617
|
+
# return resp.finish
|
618
|
+
# end
|
619
|
+
|
620
|
+
warn env.inspect if @debug
|
621
|
+
|
622
|
+
begin
|
623
|
+
resp = if env['FCGI_ROLE'] == 'AUTHORIZER'
|
624
|
+
handle_auth req
|
625
|
+
else
|
626
|
+
handle_content req
|
627
|
+
end
|
628
|
+
rescue ForgetPasswords::ErrorResponse => e
|
629
|
+
resp = e.response
|
630
|
+
end
|
631
|
+
|
632
|
+
return resp.finish
|
633
|
+
end
|
634
|
+
end
|
635
|
+
end
|