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,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