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