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.
- checksums.yaml +7 -0
- data/.gitignore +27 -0
- data/.rspec +3 -0
- data/.travis.yml +5 -0
- data/Gemfile +6 -0
- data/LICENSE +202 -0
- data/README.md +603 -0
- data/Rakefile +6 -0
- data/behaviour.org +112 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/content/basic-401.xhtml +15 -0
- data/content/basic-404.xhtml +10 -0
- data/content/basic-409.xhtml +14 -0
- data/content/basic-500.xhtml +10 -0
- data/content/cookie-expired.xhtml +15 -0
- data/content/email-409.xhtml +15 -0
- data/content/email-sent.xhtml +11 -0
- data/content/email.xhtml +10 -0
- data/content/logged-out-all.xhtml +10 -0
- data/content/logged-out.xhtml +10 -0
- data/content/nonce-expired.xhtml +15 -0
- data/content/not-on-list.xhtml +15 -0
- data/content/post-405.xhtml +10 -0
- data/content/uri-409.xhtml +10 -0
- data/etc/text-only.xsl +105 -0
- data/exe/forgetpw +7 -0
- data/forget-passwords.gemspec +67 -0
- data/lib/forget-passwords/cli.rb +514 -0
- data/lib/forget-passwords/fastcgi.rb +28 -0
- data/lib/forget-passwords/state.rb +535 -0
- data/lib/forget-passwords/template.rb +269 -0
- data/lib/forget-passwords/types.rb +118 -0
- data/lib/forget-passwords/version.rb +3 -0
- data/lib/forget-passwords.rb +635 -0
- metadata +312 -0
@@ -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
|