forget-passwords 0.2.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -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