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.
@@ -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