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,514 @@
1
+ require 'commander'
2
+ require 'yaml'
3
+ require 'pathname'
4
+ require 'iso8601'
5
+ require 'uri'
6
+ require 'dry-schema'
7
+ require 'deep_merge'
8
+
9
+ require 'forget-passwords'
10
+
11
+ module ForgetPasswords
12
+
13
+ class CLI
14
+ include Commander::Methods
15
+
16
+ private
17
+
18
+ ONE_YEAR = ISO8601::Duration.new('P1Y').freeze
19
+
20
+ CFG_FILE = Pathname('forgetpw.yml').freeze
21
+
22
+ DEFAULTS = {
23
+ host: 'localhost',
24
+ port: 10101,
25
+ state: { dsn: 'sqlite://forgetpw.sqlite' },
26
+ }.freeze
27
+
28
+ Config = ForgetPasswords::App::Config.schema(
29
+ host: ForgetPasswords::Types::Hostname.default('localhost'.freeze),
30
+ port: Dry::Types['integer'].default(10101),
31
+ pid?: ForgetPasswords::Types::ExtantPathname).hash_default
32
+
33
+ def normalize_hash h, strings: false, flatten: false, dup: false,
34
+ freeze: false
35
+ return h unless h.is_a? Hash
36
+ out = {}
37
+ h.each do |k, v|
38
+ ks = k.to_s
39
+ ks = ks.to_sym unless strings
40
+ v = if v.is_a?(Hash)
41
+ normalize_hash v, strings: strings, flatten: flatten,
42
+ dup: dup, freeze: freeze
43
+ elsif v.respond_to?(:to_a)
44
+ v.to_a.map do |x|
45
+ normalize_hash x, strings: strings, flatten: flatten,
46
+ dup: dup, freeze: freeze
47
+ end
48
+ elsif flatten
49
+ v.is_a?(Numeric) ? v : v.to_s
50
+ else
51
+ v
52
+ end
53
+ v = v.dup if dup
54
+ v = v.freeze if freeze
55
+ out[ks] = v
56
+ end
57
+ out
58
+ end
59
+
60
+ def read_config cfg = @cfgfile, clean: true, commit: true
61
+ unless cfg.is_a? Hash
62
+ raise 'need a config file' unless
63
+ cfg and (cfg.is_a?(Pathname) or cfg.respond_to?(:to_s))
64
+ cfg = Pathname(cfg).expand_path
65
+ if cfg.exist?
66
+ raise "Config file #{cfg} is not readable" unless cfg.readable?
67
+ cfg = YAML.load_file cfg
68
+ else
69
+ cfg = {}
70
+ end
71
+ end
72
+
73
+ cfg = normalize_hash cfg if clean
74
+
75
+ merge_config @config, cfg, commit: true if commit
76
+
77
+ cfg
78
+ end
79
+
80
+ def validate_config cfg = @config
81
+ Config.call cfg
82
+ end
83
+
84
+ OPTION_MAP = {
85
+ base_url: :base,
86
+ query_key: :query,
87
+ cookie_key: :cookie,
88
+ lifetime: :expiry,
89
+ listen: :host,
90
+ }.freeze
91
+
92
+ def cmdline_config options, mapping = {}
93
+ options ||= {}
94
+ # Commander::Command::Options is weird
95
+ begin
96
+ h = options.__hash__.dup
97
+ rescue NoMethodError
98
+ h = options
99
+ end
100
+
101
+ out = {}
102
+
103
+ h.keys.each do |k|
104
+ next unless v = mapping[k]
105
+ v = [v] unless v.is_a? Array
106
+ x = out
107
+ v.each_index do |i|
108
+ vi = v[i]
109
+ if i + 1 == v.length
110
+ x[vi] = h[k] # assign the value to the hash member
111
+ else
112
+ x = x[vi] ||= {} # append another hash
113
+ end
114
+ end
115
+ end
116
+
117
+ out
118
+ end
119
+
120
+ def merge_config *cfg, commit: false, validate: false
121
+ raise 'wah wah need a config' unless (out = cfg.shift)
122
+ # normalize does an implicit deep clone
123
+ out = normalize_hash(out, dup: true)
124
+
125
+ until cfg.empty?
126
+ #out.deep_merge normalize_hash(cfg.shift, dup: true)
127
+ out = normalize_hash(cfg.shift, dup: true).deep_merge out
128
+ end
129
+
130
+ if validate
131
+ test = Config.call out
132
+ if test.success?
133
+ out = test.to_h
134
+ else
135
+ raise RuntimeError.new test.errors.messages
136
+ end
137
+ end
138
+
139
+ @config = out if commit
140
+
141
+ out
142
+ end
143
+
144
+ def write_config cfg = @config, file: @cfgfile
145
+ out = normalize_hash cfg, strings: true, flatten: true
146
+ file.open(?w) { |fh| fh.write out.to_yaml }
147
+ end
148
+
149
+ def connect dsn = @dsn
150
+ @state = State.connect dsn
151
+ end
152
+
153
+ public
154
+
155
+ def run
156
+ program :name, File.basename($0)
157
+ program :version, ForgetPasswords::VERSION
158
+ program :description, 'Command line manager for ForgetPasswords'
159
+ program :int_message, 'mkay bye'
160
+
161
+ @cfgfile = CFG_FILE.expand_path
162
+ @config = DEFAULTS.dup
163
+
164
+ global_option '-b', '--base-uri URI',
165
+ 'A base URI for relative references' do |o|
166
+ @config[:base] = URI(o)
167
+ end
168
+
169
+ global_option '-c', '--config FILE',
170
+ 'The location of the configuration file' do |o|
171
+ @cfgfile = Pathname(o).expand_path
172
+ end
173
+
174
+ global_option '-d', '--dsn STRING',
175
+ 'Specify a data source name, overriding configuration' do |o|
176
+ @config[:state][:dsn] = o
177
+ end
178
+
179
+ global_option '-D', '--debug-sql',
180
+ 'Log SQL queries to standard error' do
181
+ @log_sql = true
182
+ end
183
+
184
+ command :init do |c|
185
+ c.syntax = "#{program :name} init [OPTIONS]"
186
+ c.summary = 'Initializes configuration file and state database.'
187
+ c.description = <<-DESC
188
+ This command initializes the configuration file (default `$PWD/forgetpw.yml`)
189
+ and state database (default `sqlite://forgetpw.sqlite`). Global parameters
190
+ (-b, -c, -d) will be used to record the default base URL, config file
191
+ location, and data source name, respectively.
192
+
193
+ Most configuration parameters have sane defaults, and the base URI is
194
+ optional. If an existing configuration file is found at the specified
195
+ location, you will be asked before overwriting it. Initialization will
196
+ likewise overwrite any existing database tables, so you'll also get a
197
+ chance to move those out of the way if you want to keep them.
198
+
199
+ If you are using a network-attached RDBMS (Postgres, MySQL, Oracle,
200
+ etc), you will almost certainly need to create the designated
201
+ database, user, and any applicable access rights before running this
202
+ command.
203
+ DESC
204
+
205
+ c.option '--query-key TOKEN', 'A URI query key; defaults to `knock`'
206
+ c.option '--cookie-key TOKEN', 'A cookie key; defaults to `forgetpw`'
207
+ c.option '--expiry DURATION',
208
+ 'Global default expiry, given as an ISO8601 duration (default P1Y)'
209
+ c.option '--url-expiry DURATION',
210
+ 'Set the default expiry duration for URLs only'
211
+ c.option '--cookie-expiry DURATION',
212
+ 'Set the default expiry duration for cookies only'
213
+ c.option '--user-var TOKEN',
214
+ 'Environment variable name for user (default `FCGI_USER`)'
215
+ c.option '--redirect-var TOKEN',
216
+ 'Environment variable name for redirect (default `FCGI_REDIRECT`)'
217
+ c.option '-l', '--listen HOST',
218
+ 'Specify listening address for FastCGI daemon (default localhost)'
219
+ c.option '-p', '--port NUMBER',
220
+ 'Specify TCP port for FastCGI daemon (default 10101)'
221
+ c.option '-P', '--pid FILE',
222
+ 'Create a PID file when FastCGI daemon is detached'
223
+
224
+ c.action do |_, opts|
225
+ # check the directory where we're going to drop the config file
226
+ dir = @cfgfile.dirname
227
+ unless dir.exist?
228
+ rel = dir.relative_path_from Pathname.pwd
229
+ if agree "Directory #{rel} doesn't exist. Try to create it?"
230
+ begin
231
+ dir.mkpath
232
+ rescue Errno::EACCES
233
+ say "Could not create #{rel}. :("
234
+ exit 1
235
+ end
236
+ end
237
+ end
238
+
239
+ # check for an existing config file
240
+ if @cfgfile.exist?
241
+ rel = @cfgfile.relative_path_from Pathname.pwd
242
+ # get confirmation if config file already exists
243
+ x = "Configuration file #{rel} already exists. Overwrite?"
244
+ unless agree x
245
+ say "Not overwriting #{rel}."
246
+ exit 1
247
+ end
248
+
249
+ # complain if not writable
250
+ unless @cfgfile.writable?
251
+ say "Not overwriting #{rel}, which is not writable."
252
+ exit 1
253
+ end
254
+ end
255
+
256
+ # wrap these calls
257
+ begin
258
+ cfg = cmdline_config opts
259
+ merge_config @config, cfg, commit: true
260
+ write_config
261
+ rescue SystemCallError => e
262
+ rel = @cfgfile.relative_path_from Pathname.pwd
263
+ say "Could not write #{rel}: #{e}"
264
+ exit 1
265
+ #rescue OptionParser::InvalidArgument => e
266
+ # say "One or more of the command-line options was invalid: #{e}"
267
+ # exit 1
268
+ end
269
+
270
+ do_db = true
271
+ begin
272
+ state = State.new @config[:dsn], create: false, debug: @log_sql
273
+
274
+ # check for existence of database
275
+ if state.initialized?
276
+ x = "Database #{@config[:dsn]} is already initialized. Overwrite?"
277
+ unless agree x
278
+ say "Not overwriting #{@config[:dsn]}."
279
+ do_db = false
280
+ end
281
+ end
282
+
283
+ # now create the tables
284
+ state.initialize! if do_db
285
+
286
+ rescue Sequel::DatabaseConnectionError => e
287
+ # complain if database doesn't exist or if i don't have access
288
+ say "Could not connect to #{@config[:dsn]}: #{e}"
289
+ do_db = false
290
+ end
291
+
292
+ # now tell the user what i did
293
+ say 'Created new configuration file' +
294
+ "#{do_db ? ' and state database' : ''}."
295
+ end
296
+ end
297
+
298
+ command :privilege do |c|
299
+ c.syntax = "#{program :name} privilege [OPTIONS] EMAIL|DOMAIN ..."
300
+ c.summary = "Adds or revokes privileges to an e-mail address or domain."
301
+ c.description = <<-DESC
302
+ This command will either add or revoke access privileges to one or
303
+ more e-mail address or e-mail domain, associated with a Web domain.
304
+ DESC
305
+
306
+ c.option '-d', '--domain DOMAIN',
307
+ 'Constrain to the given Web domain'
308
+ c.option '-l', '--list', 'List instead of acting'
309
+ c.option '-r', '--revoke', 'Revoke privileges instead of granting them'
310
+
311
+ c.action do |args, opts|
312
+ read_config
313
+ @config = Config.(@config)
314
+
315
+ db = @config[:state]
316
+ # warn db.inspect
317
+
318
+ domain = opts.domain ||= ''
319
+
320
+ if opts.list
321
+ else
322
+ method = opts.revoke ? :revoke : :permit
323
+ db.transaction do
324
+ args.each do |email|
325
+ db.acl.send method, domain, email
326
+ say "added #{email} to #{domain.empty? ? ?* : domain}"
327
+ end
328
+ end
329
+ end
330
+
331
+ end
332
+ end
333
+
334
+ command :mint do |c|
335
+ c.syntax = "#{program :name} [mint] [OPTIONS] USERID [URL]"
336
+ c.summary = 'Mints a new URL associated with the given user.'
337
+ c.description = <<-DESC
338
+ This command mints a new URL associated with the given user. If the
339
+ URL is omitted, the slug will be appended to the configured base
340
+ URL. If that is missing too, the command will just return the
341
+ generated token. If there is already an active token for this user, it
342
+ will be reused with the new URL, unless you pass -n or -x, which will
343
+ add a new
344
+
345
+ This command will create a new record for a given user ID if none is
346
+ present. You can include an optional email address (-e), or you can make
347
+ the user ID an email address, LDAP DN, Kerberos principal, or whatever
348
+ you want. The user ID, whatever it ends up as, is what will be showing
349
+ up verbatim in the `REMOTE_USER` field of downstream Web apps. Note
350
+ that this database only maps one kind of identifier to another, and is
351
+ not meant to be authoritative storage for user profiles.
352
+ DESC
353
+
354
+ c.option '-e', '--email EMAIL',
355
+ 'Set the email address for the (new) user'
356
+ c.option '-l', '--lifetime DURATION',
357
+ 'How long this URL will work, as an ISO8601 duration (default P1Y)'
358
+ c.option '-n', '--new',
359
+ 'Force minting a new token even if the current one is still fresh'
360
+ c.option '-x', '--expire',
361
+ 'Expire any active tokens in circulation (implies --new)'
362
+ c.option '-1', '--oneoff', 'The token will expire after the first ' +
363
+ 'time it is used (implies --new).'
364
+
365
+ c.action do |args, opts|
366
+ read_config
367
+ merge_config @config, cmdline_config(opts),
368
+ commit: true
369
+
370
+ user, url = *(args.map(&:strip))
371
+
372
+ raise Commander::Runner::CommandError.new 'No user supplied' unless
373
+ user and user != ''
374
+ if url and url != ''
375
+ begin
376
+ if @config[:base]
377
+ url = @config[:base].merge url
378
+ else
379
+ url = URI(url)
380
+ end
381
+
382
+ scheme = url.scheme.to_s.downcase
383
+
384
+ if scheme.start_with? 'http'
385
+ say 'Unencrypted HTTP will be insecure,' +
386
+ "but I assume you know what you're doing" if
387
+ url.scheme == 'http'
388
+ else
389
+ say 'Gonna be hard doing Web authentication ' +
390
+ "against a non-Web URL, but you're the boss!"
391
+ end
392
+ rescue URI::InvalidURIError => e
393
+ raise Commander::Runner::CommandError.new e
394
+ end
395
+ else
396
+ url = @config[:base] ? @config[:base].dup : nil
397
+ end
398
+
399
+ # handle implications of expire/oneoff options
400
+ opts.default :new => !!(opts.expire || opts.oneoff)
401
+
402
+ begin
403
+
404
+ # connect to the database
405
+ state = State.new @config[:dsn], debug: @log_sql,
406
+ query_expires: @config[:expiry][:url],
407
+ cookie_expires: @config[:expiry][:cookie]
408
+
409
+ id = state.id_for user, create: true, email: opts.email
410
+
411
+ # obtain the latest live token for this principal
412
+ token = state.token_for id unless opts.new
413
+
414
+ # eh i don't like this logic but it was the least-bad
415
+ # option i could think of at the time
416
+ if !token || opts.new
417
+ if opts.expire
418
+ say "Expiring all live tokens for #{user}."
419
+ # burn all existing query-string tokens
420
+ state.expire_tokens_for id, cookie: false
421
+
422
+ # XXX do we put in a command-line switch for burning
423
+ # the cookies too?
424
+ end
425
+ # create new token
426
+ token = state.new_token id, oneoff: opts.oneoff,
427
+ expires: @config[:expiry][:query]
428
+ end
429
+
430
+ if url
431
+ if (query = url.query)
432
+ query = URI::decode_www_form query
433
+ else
434
+ query = []
435
+ end
436
+
437
+ # now add the key
438
+ query << [@config[:query], token]
439
+
440
+ url.query = URI::encode_www_form query
441
+
442
+ say "Here's the link to give to #{user} (and only #{user}): " +
443
+ url.to_s
444
+ else
445
+ say "No URL given, so here's your token: #{token}"
446
+ end
447
+
448
+ exit 0
449
+
450
+ rescue Sequel::DatabaseConnectionError => e
451
+ say "Could not connect to #{@config[:dsn]}: #{e}"
452
+ exit 1
453
+ end
454
+ end
455
+ end
456
+
457
+ command :fcgi do |c|
458
+ c.syntax = "#{program :name} fcgi [OPTIONS]"
459
+ c.summary = 'Runs the ForgetPasswords FastCGI authenticator.'
460
+ c.description = <<-DESC
461
+ This command fires up the ForgetPasswords FastCGI authenticator service. By
462
+ default it runs in the foreground, listening on localhost, TCP port
463
+ 10101. All of these parameters of course can be changed, either in
464
+ configuration or on the command line. Of course this daemon is only
465
+ half the setup, the other half being done on the Web server, using
466
+ something like `mod_authnz_fcgi`.
467
+ DESC
468
+ c.option '-l', '--listen HOST',
469
+ 'Specify listening address (default localhost)'
470
+ c.option '-p', '--port NUMBER', 'Specify TCP port (default 10101)'
471
+ c.option '-z', '--detach', 'Detach and daemonize the process'
472
+ c.option '-P', '--pid FILE', 'Create a PID file when detached'
473
+
474
+ c.action do |args, opts|
475
+ require 'forget-passwords'
476
+ require 'rack'
477
+
478
+ # booooo
479
+ require 'forget-passwords/fastcgi'
480
+
481
+ read_config
482
+ merge_config @config,
483
+ cmdline_config(opts, { listen: :host, port: :port }), commit: true
484
+ @config = Config.(@config)
485
+
486
+ appcfg = @config.slice(
487
+ :keys, :vars, :targets, :templates, :email
488
+ ).merge({ debug: @log_sql })
489
+
490
+ say 'Running authenticator daemon on ' +
491
+ "fcgi://#{@config[:host]}:#{@config[:port]}/"
492
+
493
+ Rack::Server.start({
494
+ # app: ForgetPasswords::App.new(@config[:dsn], debug: @log_sql),
495
+ app: ForgetPasswords::App.new(@config[:state], **appcfg),
496
+ server: 'hacked-fcgi',
497
+ environment: 'none',
498
+ daemonize: opts.detach,
499
+ Host: @config[:host],
500
+ Port: @config[:port],
501
+ })
502
+ end
503
+ end
504
+
505
+ default_command :mint
506
+
507
+ run!
508
+ end
509
+
510
+ def self.run
511
+ new.run
512
+ end
513
+ end
514
+ end
@@ -0,0 +1,28 @@
1
+ # XXX THIS IS A HACK MFERS
2
+ require 'rack/handler'
3
+ require 'rack/handler/fastcgi'
4
+
5
+ module ForgetPasswords
6
+ # XXX this unfortunate chunk of code exists because of
7
+ # https://bz.apache.org/bugzilla/show_bug.cgi?id=65984
8
+ class FastCGI < Rack::Handler::FastCGI
9
+
10
+ def self.send_headers(out, status, headers)
11
+ out.print "Status: #{status}\r\n"
12
+ headers.each do |k, vs|
13
+ vs.split("\n").each { |v| out.print "#{k}: #{v}\r\n" }
14
+ end
15
+ out.print "\r\n"
16
+ # we remove out.flush from the headers
17
+ # out.flush
18
+ end
19
+
20
+ def self.send_body(out, body)
21
+ body.each { |part| out.print part }
22
+ # this one we keep and put it outside the loop
23
+ out.flush
24
+ end
25
+ end
26
+
27
+ Rack::Handler.register 'hacked-fcgi', FastCGI
28
+ end