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,535 @@
1
+ require_relative 'version'
2
+ require 'sequel'
3
+ require 'iso8601'
4
+ require 'uuidtools'
5
+ require 'uuid-ncname'
6
+ require 'uri'
7
+
8
+ require 'forget-passwords/types'
9
+
10
+ module ForgetPasswords
11
+ class State
12
+
13
+ TEN_MINUTES = ISO8601::Duration.new('PT10M').freeze
14
+ TWO_WEEKS = ISO8601::Duration.new('P2W').freeze
15
+
16
+ Expiry = ForgetPasswords::Types::SymbolHash.schema(
17
+ query: ForgetPasswords::Types::Duration.default(TEN_MINUTES),
18
+ cookie: ForgetPasswords::Types::Duration.default(TWO_WEEKS)).hash_default
19
+
20
+ RawParams = ForgetPasswords::Types::SymbolHash.schema(
21
+ dsn: ForgetPasswords::Types::String,
22
+ user?: ForgetPasswords::Types::String,
23
+ password?: ForgetPasswords::Types::String,
24
+ expiry: Expiry).hash_default
25
+
26
+ Type = ForgetPasswords::Types.Constructor(self) do |x|
27
+ # this will w
28
+ if x.is_a? self
29
+ x
30
+ else
31
+ raw = RawParams.(x)
32
+ self.new raw[:dsn], **raw.slice(:user, :password)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ S = Sequel
39
+
40
+ DISPATCH = {
41
+ # key is table name
42
+ user: {
43
+ # model class name if wildly different from table name
44
+ class: :User,
45
+
46
+ # these are the model bits
47
+ model: -> m {
48
+ m.one_to_many :token, key: :user, class: :Token
49
+ },
50
+
51
+ # this is the schema object
52
+ create: -> {
53
+ # need to write it this way if you want the pk to auto-increment
54
+ primary_key :id, type: Integer, primary_key_constraint_name: :pk_user
55
+ String :principal, null: false, text: true, unique: true
56
+ String :email, null: false, text: true, unique: true
57
+ DateTime :added, null: false, default: S::CURRENT_TIMESTAMP
58
+ DateTime :disabled, null: true
59
+ constraint :ck_principal, principal: S.function(:trim, :principal)
60
+ constraint(:ck_principal_ne) {
61
+ S.function(:length, S.function(:trim, :principal)) > 0 }
62
+ constraint :ck_email,
63
+ email: S.function(:trim, S.function(:lower, :email))
64
+ constraint(:ck_email_at) { S.like(:email, '%_@_%') }
65
+ },
66
+ },
67
+ token: {
68
+ class: :Token,
69
+ model: -> m {
70
+ m.one_to_many :usage, key: :token
71
+ m.many_to_one :user
72
+
73
+ # we can use exclude to invert this
74
+ def m.expired date: S::CURRENT_TIMESTAMP
75
+ where { expires < date }
76
+ end
77
+
78
+ def m.valid? token, cookie: false, oneoff: nil
79
+ uuid = UUID::NCName.valid?(token) ?
80
+ UUID::NCName.from_ncname(token) : token
81
+ !!where(token: uuid).fresh(cookie: cookie, oneoff: oneoff).first
82
+ end
83
+
84
+ m.dataset_module do
85
+ where(:expired) { expires < S::CURRENT_TIMESTAMP }
86
+ order :by_date, :added, :expires, :user
87
+
88
+ def for id
89
+ where(user: id)
90
+ end
91
+
92
+ def id_for token
93
+ uuid = UUID::NCName.valid?(token) ?
94
+ UUID::NCName.from_ncname(token) : token
95
+
96
+ rec = where(token: uuid).first
97
+ rec.user if rec
98
+ end
99
+
100
+ def fresh cookie: false, oneoff: nil
101
+ w = { slug: !cookie }
102
+ if oneoff.nil?
103
+ oneoff = !cookie
104
+ else
105
+ w[:oneoff] = !!oneoff
106
+ end
107
+ base = where(**w) { expires > S::CURRENT_TIMESTAMP }
108
+
109
+ base = base.left_join(
110
+ Usage.latest, [:token]).where(seen: nil) if !cookie and oneoff
111
+
112
+ base
113
+ end
114
+
115
+ def expire token
116
+ uuid = UUID::NCName.valid?(token) ?
117
+ UUID::NCName.from_ncname(token) : token
118
+
119
+ where(token: uuid).update(expires: S::CURRENT_TIMESTAMP)
120
+ end
121
+
122
+ def expire_all cookie: nil
123
+ base = where { expires > S::CURRENT_TIMESTAMP }
124
+ base = base.where(slug: !cookie) unless cookie.nil?
125
+
126
+ base.update(expires: S::CURRENT_TIMESTAMP)
127
+ end
128
+ end
129
+ },
130
+ create: -> {
131
+ String :token, null: false, fixed: true, size: 36
132
+ Integer :user, null: false
133
+ TrueClass :slug, null: false, default: false
134
+ TrueClass :oneoff, null: false, default: false
135
+ DateTime :added, null: false, default: S::CURRENT_TIMESTAMP
136
+ DateTime :expires, null: false, default: Time.at(2**31-1).to_datetime
137
+ primary_key [:token], name: :pk_token
138
+ unique [:token, :user], name: :uk_token
139
+ foreign_key [:user], :user, key: :id, name: :fk_token_user
140
+ constraint :ck_token,
141
+ :token => S.function(:trim, S.function(:lower, :token))
142
+ },
143
+ },
144
+ usage: {
145
+ class: :Usage,
146
+ model: -> m {
147
+ m.many_to_one :token
148
+
149
+ db = m.db
150
+
151
+ LATEST = db.from{usage.as(:ul)}.exclude(
152
+ db.from{usage.as(:ur)}.where{
153
+ (ul[:seen] < ur[:seen]) & {ul[:token] => ur[:token]}
154
+ }.select(1).exists)
155
+
156
+ # don't forget it's *def m.whatever*
157
+ def m.latest
158
+ LATEST
159
+ end
160
+
161
+ },
162
+ create: -> {
163
+ String :token, null: false, fixed: true, size: 36
164
+ String :ip, null: false, size: 40
165
+ DateTime :created, null: false, default: S::CURRENT_TIMESTAMP
166
+ DateTime :seen, null: false, default: S::CURRENT_TIMESTAMP
167
+ primary_key [:token, :ip], name: :pk_usage
168
+ foreign_key [:token], :token, name: :fk_usage_token
169
+ },
170
+ },
171
+ acl: {
172
+ class: :ACL,
173
+ model: -> m {
174
+
175
+ # XXX TAKE INTO ACCOUNT user.disabled
176
+
177
+ def m.listed? domain, email
178
+ # normalize the inputs
179
+ domain = (
180
+ domain.respond_to?(:host) ? domain.host : domain).strip.downcase
181
+ email = email.to_s.strip.downcase
182
+ _, mx = email.split ?@, 2
183
+ mparts = mx.split ?.
184
+
185
+ # we start from the most specific domain from the request-uri
186
+ dparts = domain.split ?.
187
+ (0..dparts.length).each do |i|
188
+ d = dparts[i..dparts.length].join ?.
189
+ # warn "trying #{email} on #{d}"
190
+
191
+ # then we try to get an exact match on the address
192
+ if x = where(domain: d, address: email).first
193
+ return x.ok
194
+ else
195
+ # then we try to get a match on the *address's* domain
196
+ # (note we leave one segment)
197
+ (0..mparts.length-1).each do |j|
198
+ md = mparts[j..mparts.length].join ?.
199
+ # warn "trying #{md} on #{d}"
200
+ if y = where(domain: d, address: md).first
201
+ return y.ok
202
+ end
203
+ end
204
+ end
205
+ end
206
+
207
+ false
208
+ end
209
+
210
+ def m.permit domain, email, force: false
211
+ # insert or update
212
+ domain = (
213
+ domain.respond_to?(:host) ? domain.host : domain
214
+ ).to_s.strip.downcase
215
+ email = email.to_s.strip.downcase
216
+ # warn "domain: #{domain}, email: #{email}"
217
+ rows = where(domain: domain, address: email).update ok: true
218
+ # warn rows.inspect
219
+ return true if rows > 0
220
+ insert domain: domain, address: email
221
+ true
222
+ end
223
+
224
+ def m.revoke domain, email, force: false
225
+ # update, noop if not present?
226
+ domain = (
227
+ domain.respond_to?(:host) ? domain.host : domain
228
+ ).to_s.strip.downcase
229
+ email = email.to_s.strip.downcase
230
+ rows = where(domain: domain, address: email).update ok: false
231
+ rows > 0 # if this is true then the record was updated
232
+ end
233
+
234
+ def m.forget domain, email
235
+ domain = (
236
+ domain.respond_to?(:host) ? domain.host : domain).strip.downcase
237
+ email = email.to_s.strip.downcase
238
+ rows = where(domain: domain, address: email).delete
239
+ rows > 0
240
+ end
241
+
242
+ },
243
+ create: -> {
244
+ String :domain, null: false, text: true, default: ''
245
+ String :address, null: false, text: true
246
+ TrueClass :ok, null: false, default: true
247
+ DateTime :seen, null: false, default: S::CURRENT_TIMESTAMP
248
+ primary_key [:domain, :address], name: :pk_acl
249
+ constraint :ck_domain,
250
+ domain: S.function(:trim, S.function(:lower, :domain))
251
+ constraint :ck_address,
252
+ address: S.function(:trim, S.function(:lower, :address))
253
+ constraint(:ck_address_ne) {
254
+ S.function(:length, S.function(:trim, :address)) > 0 }
255
+ },
256
+ },
257
+ }
258
+
259
+ # XXX this constant is arguably not necessary but ehh
260
+ CREATE_SEQ = DISPATCH.keys.freeze
261
+
262
+ def create_tables force: false
263
+
264
+ # lol sneaky
265
+ method = 'create_table' + (force ? ?! : ??)
266
+
267
+ # arggh sqlite has no drop table cascade and sequel doesn't
268
+ # compensate for it
269
+
270
+ cascade = force && db.adapter_scheme != :sqlite
271
+
272
+ CREATE_SEQ.each do |table|
273
+ # snag the proc
274
+ proc = DISPATCH[table][:create]
275
+ # sequel has no drop cascade for sqlite and this is on purpose
276
+ db.drop_table table, cascade: true if
277
+ cascade && db.table_exists?(table)
278
+ # m = db.method method
279
+ # m.call table, &proc
280
+ # warn table
281
+ db.send method, table, &proc
282
+ end
283
+ end
284
+
285
+ def first_run force: false
286
+ create_tables force: force
287
+
288
+ me = self.class
289
+
290
+ DISPATCH.each do |table, struct|
291
+ cname = struct[:class]
292
+ if me.const_defined? cname
293
+ cls = me.const_get cname
294
+ else
295
+ # create the class
296
+ cls = Class.new Sequel::Model(db[table])
297
+
298
+ # bind the class name
299
+ me.const_set cname, cls
300
+
301
+ # assemble the innards
302
+ struct[:model].call cls
303
+ end
304
+
305
+ # set @whatever; i haven't decided if i want to dump these yet
306
+ var = "@#{table.to_s}".to_sym
307
+ self.instance_variable_set(var, cls) unless
308
+ instance_variable_defined? var
309
+ end
310
+
311
+ end
312
+
313
+ ONE_YEAR = ISO8601::Duration.new('P1Y').freeze
314
+
315
+ public
316
+
317
+ attr_reader :db, :expiry, :user, :token, :usage, :acl
318
+
319
+ def initialize dsn, create: true, user: nil, password: nil,
320
+ expiry: { query: TEN_MINUTES, cookie: TWO_WEEKS }, debug: false
321
+ @db = Sequel.connect dsn
322
+
323
+ # XXX more reliable way to get this info?
324
+ if /postgres/i.match? @db.class.name
325
+ # anyway whatever
326
+ @db.extension :constant_sql_override
327
+ @db.set_constant_sql S::CURRENT_TIMESTAMP,
328
+ "TIMEZONE('UTC', CURRENT_TIMESTAMP)"
329
+ # "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"
330
+ end
331
+
332
+ @expiry = Expiry.(expiry)
333
+ # warn expiry.inspect
334
+
335
+ if debug
336
+ require 'logger'
337
+ @db.loggers << Logger.new($stderr)
338
+ end
339
+
340
+ first_run if create
341
+ end
342
+
343
+ def initialized?
344
+ CREATE_SEQ.select { |t| db.table_exists? t } == CREATE_SEQ
345
+ end
346
+
347
+ def initialize!
348
+ first_run force: true
349
+ end
350
+
351
+ def transaction &block
352
+ @db.transaction(&block)
353
+ end
354
+
355
+ # XXX 2022-04-10 the email address is canonical now, lol
356
+
357
+ def record_for principal, create: false, email: nil
358
+ # so we can keep the same interface
359
+ if principal
360
+ # ensure this is a stripped string
361
+ principal = principal.to_s.strip
362
+ raise ArgumentError,
363
+ 'principal cannot be an empty string' if principal.empty?
364
+ else
365
+ raise ArgumentError,
366
+ 'email must be defined if principal is not' unless email
367
+ # note we don't normalize case for the principal (may be dumb tbh)
368
+ principal = email.to_s.strip
369
+ end
370
+
371
+ ds = @user.select(:id).where(principal: principal)
372
+ row = ds.first
373
+
374
+ if create
375
+ if email
376
+ email = email.to_s.strip.downcase
377
+ raise ArgumentError,
378
+ "email must be a valid address, not #{email}" unless
379
+ email.include? ?@
380
+ elsif principal.include? ?@
381
+ email = principal.dup
382
+ else
383
+ raise ArgumentError,
384
+ 'principal must be an email address if another not supplied'
385
+ end
386
+
387
+ if row
388
+ row = @user[row.id]
389
+ row.email = email
390
+ row.save
391
+ else
392
+ row = { principal: principal, email: email }
393
+ row = @user.new.set(row).save
394
+ end
395
+ end
396
+
397
+ row
398
+ end
399
+
400
+ def id_for principal, create: true, email: nil
401
+ user = record_for principal, create: create, email: email
402
+ user.id if user
403
+ end
404
+
405
+ def new_user principal, email: nil
406
+ record_for principal, create: true, email: email
407
+ end
408
+
409
+ def new_token principal, cookie: false, oneoff: false, expires: nil
410
+ id = principal.is_a?(Integer) ? principal : id_for(principal)
411
+ raise "No user with ID #{principal} found" unless id
412
+
413
+ # this should be a duration
414
+ raise 'Expires should be an ISO8601::Duration' if
415
+ expires && !expires.is_a?(ISO8601::Duration)
416
+ expires ||= @expiry[cookie ? :cookie : :query]
417
+
418
+ oneoff = false if cookie
419
+
420
+ now = Time.now.gmtime
421
+ # the iso8601 guy didn't make it so you could add a duration to
422
+ # a DateTime, even though ISO8601::DateTime embeds a DateTime.
423
+ # noOOoOOOo that would be too easy; instead you have to reparse it.
424
+ expires = now + expires.to_seconds(ISO8601::DateTime.new now.iso8601)
425
+ # anyway an integer to DateTime is a day, so we divide.
426
+
427
+ uuid = UUIDTools::UUID.random_create
428
+
429
+ @token.insert(user: id, token: uuid.to_s, slug: !cookie,
430
+ oneoff: !!oneoff, expires: expires)
431
+
432
+ UUID::NCName::to_ncname uuid, version: 1
433
+ end
434
+
435
+ # from the author of sequel (2019-05-27):
436
+ #
437
+ # 15:11 < jeremyevans> dorian:
438
+ # DB.from{same_table.as(:a)}.exclude(
439
+ # DB.from{same_table.as(:b)}.where{(a[:order] < b[:order]) &
440
+ # {a[:key]=>b[:key]}}.select(1).exists)
441
+
442
+ def token_for principal, cookie: false, oneoff: false, expires: nil
443
+ id = principal.is_a?(Integer) ? principal : id_for(principal)
444
+ raise "No user with ID #{principal} found" unless id
445
+
446
+ # only query strings can be oneoffs
447
+ cookie = !!cookie
448
+ oneoff = false if cookie
449
+ oneoff = !!oneoff
450
+
451
+ # obtain the last (newest) "fresh" token for this user
452
+ row = @token.fresh(cookie: cookie, oneoff: oneoff).for(id).by_date.first
453
+ return UUID::NCName::to_ncname row.token, version: 1 if row
454
+ end
455
+
456
+
457
+
458
+ # Expire all cookies associated with a principal.
459
+ #
460
+ # @param
461
+ # @param
462
+ #
463
+ # @return
464
+ #
465
+ def expire_tokens_for principal, cookie: nil
466
+ id = principal.is_a?(Integer) ? principal : id_for(principal)
467
+ raise "No user with ID #{principal} found" unless id
468
+ @token.for(id).expire_all cookie: cookie
469
+ end
470
+
471
+ # Retrieve the user associated with a token, whether nonce or cookie.
472
+ #
473
+ # @param token [String] the token
474
+ # @param id [false, true] the user ID instead of the principal
475
+ # @param cookie [false, true] whether the token is a cookie
476
+ #
477
+ # @return [String, nil] the user principal identifier or nil
478
+ #
479
+ def user_for token, record: false, id: false, cookie: false
480
+ uuid = UUID::NCName::from_ncname token, version: 1
481
+ out = @user.where(disabled: nil).join(:token, user: :id).select(
482
+ :id, :principal, :email, :expires
483
+ ).where(token: uuid, slug: !cookie).first
484
+
485
+ # return the whole record if asked for it otherwise the id or principal
486
+ record ? out : id ? out.id : out.principal if out
487
+ end
488
+
489
+ # Freshen the expiry date of the token.
490
+ #
491
+ # @param token [String] the token
492
+ # @param from [Time, DateTime] the reference time
493
+ # @param cookie [true,false] whether the token is a cookie
494
+ #
495
+ # @return [true, false] whether any tokens were affected.
496
+ #
497
+ def freshen_token token, from: Time.now, cookie: true
498
+ uuid = UUID::NCName.valid?(token) ?
499
+ UUID::NCName.from_ncname(token) : token
500
+ exp = @expiry[cookie ? :cookie : :query]
501
+ # this is dumb that this is how you have to do this
502
+ delta = from.to_time.gmtime +
503
+ exp.to_seconds(ISO8601::DateTime.new from.iso8601)
504
+ # aaanyway...
505
+ rows = @token.where(
506
+ token: uuid).fresh(cookie: cookie).update(expires: delta)
507
+ rows > 0
508
+ end
509
+
510
+ # Add a token to the usage log and associate it with an
511
+ # IP address.
512
+ #
513
+ # @param token [String] the token
514
+ # @param ip [String] the IP address that used
515
+ # @param seen [Time,DateTime] The timestamp (defaults to now).
516
+ #
517
+ # @return [ForgetPasswords::State::Usage] the token's usage record
518
+ #
519
+ def stamp_token token, ip, seen: DateTime.now
520
+ uuid = UUID::NCName::from_ncname token, version: 1
521
+ raise "Could not get UUID from token #{token}" unless uuid
522
+ @db.transaction do
523
+ # warn @usage.where(token: uuid, ip: ip).inspect
524
+ rec = @usage.where(token: uuid, ip: ip).first
525
+ # warn "#{uuid} #{ip}"
526
+ if rec
527
+ rec.update(seen: seen)
528
+ rec # yo does update return the record? or
529
+ else
530
+ @usage.insert(token: uuid, ip: ip, seen: seen)
531
+ end
532
+ end
533
+ end
534
+ end
535
+ end