Class: ForgetPasswords::State

Inherits:
Object
  • Object
show all
Defined in:
lib/forget-passwords/state.rb

Constant Summary collapse

TEN_MINUTES =
ISO8601::Duration.new('PT10M').freeze
TWO_WEEKS =
ISO8601::Duration.new('P2W').freeze
Expiry =
ForgetPasswords::Types::SymbolHash.schema(
query:  ForgetPasswords::Types::Duration.default(TEN_MINUTES),
cookie: ForgetPasswords::Types::Duration.default(TWO_WEEKS)).hash_default
RawParams =
ForgetPasswords::Types::SymbolHash.schema(
dsn:       ForgetPasswords::Types::String,
user?:     ForgetPasswords::Types::String,
password?: ForgetPasswords::Types::String,
expiry:    Expiry).hash_default
Type =
ForgetPasswords::Types.Constructor(self) do |x|
  # this will w
  if x.is_a? self
    x
  else
    raw = RawParams.(x)
    self.new raw[:dsn], **raw.slice(:user, :password)
  end
end

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(dsn, create: true, user: nil, password: nil, expiry: { query: TEN_MINUTES, cookie: TWO_WEEKS }, debug: false) ⇒ State

Returns a new instance of State.



319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/forget-passwords/state.rb', line 319

def initialize dsn, create: true, user: nil, password: nil,
    expiry: { query: TEN_MINUTES, cookie: TWO_WEEKS }, debug: false
  @db = Sequel.connect dsn

  # XXX more reliable way to get this info?
  if /postgres/i.match? @db.class.name
    # anyway whatever
    @db.extension :constant_sql_override
    @db.set_constant_sql S::CURRENT_TIMESTAMP,
      "TIMEZONE('UTC', CURRENT_TIMESTAMP)"
      # "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"
  end

  @expiry = Expiry.(expiry)
  # warn expiry.inspect

  if debug
    require 'logger'
    @db.loggers << Logger.new($stderr)
  end

  first_run if create
end

Instance Attribute Details

#aclObject (readonly)

Returns the value of attribute acl.



317
318
319
# File 'lib/forget-passwords/state.rb', line 317

def acl
  @acl
end

#dbObject (readonly)

Returns the value of attribute db.



317
318
319
# File 'lib/forget-passwords/state.rb', line 317

def db
  @db
end

#expiryObject (readonly)

Returns the value of attribute expiry.



317
318
319
# File 'lib/forget-passwords/state.rb', line 317

def expiry
  @expiry
end

#tokenObject (readonly)

Returns the value of attribute token.



317
318
319
# File 'lib/forget-passwords/state.rb', line 317

def token
  @token
end

#usageObject (readonly)

Returns the value of attribute usage.



317
318
319
# File 'lib/forget-passwords/state.rb', line 317

def usage
  @usage
end

#userObject (readonly)

Returns the value of attribute user.



317
318
319
# File 'lib/forget-passwords/state.rb', line 317

def user
  @user
end

Instance Method Details

#expire_tokens_for(principal, cookie: nil) ⇒ Object

Expire all cookies associated with a principal.

Parameters:

Returns:



465
466
467
468
469
# File 'lib/forget-passwords/state.rb', line 465

def expire_tokens_for principal, cookie: nil
  id = principal.is_a?(Integer) ? principal : id_for(principal)
  raise "No user with ID #{principal} found" unless id
  @token.for(id).expire_all cookie: cookie
end

#freshen_token(token, from: Time.now, cookie: true) ⇒ true, false

Freshen the expiry date of the token.

Parameters:

  • token (String)

    the token

  • from (Time, DateTime) (defaults to: Time.now)

    the reference time

  • cookie (true, false) (defaults to: true)

    whether the token is a cookie

Returns:

  • (true, false)

    whether any tokens were affected.



497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/forget-passwords/state.rb', line 497

def freshen_token token, from: Time.now, cookie: true
  uuid = UUID::NCName.valid?(token) ?
    UUID::NCName.from_ncname(token) : token
  exp = @expiry[cookie ? :cookie : :query]
  # this is dumb that this is how you have to do this
  delta = from.to_time.gmtime +
    exp.to_seconds(ISO8601::DateTime.new from.iso8601)
  # aaanyway...
  rows = @token.where(
    token: uuid).fresh(cookie: cookie).update(expires: delta)
  rows > 0
end

#id_for(principal, create: true, email: nil) ⇒ Object



400
401
402
403
# File 'lib/forget-passwords/state.rb', line 400

def id_for principal, create: true, email: nil
  user = record_for principal, create: create, email: email
  user.id if user
end

#initialize!Object



347
348
349
# File 'lib/forget-passwords/state.rb', line 347

def initialize!
  first_run force: true
end

#initialized?Boolean

Returns:

  • (Boolean)


343
344
345
# File 'lib/forget-passwords/state.rb', line 343

def initialized?
  CREATE_SEQ.select { |t| db.table_exists? t } == CREATE_SEQ
end

#new_token(principal, cookie: false, oneoff: false, expires: nil) ⇒ Object



409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# File 'lib/forget-passwords/state.rb', line 409

def new_token principal, cookie: false, oneoff: false, expires: nil
  id = principal.is_a?(Integer) ? principal : id_for(principal)
  raise "No user with ID #{principal} found" unless id

  # this should be a duration
  raise 'Expires should be an ISO8601::Duration' if
    expires && !expires.is_a?(ISO8601::Duration)
  expires ||= @expiry[cookie ? :cookie : :query]

  oneoff = false if cookie

  now = Time.now.gmtime
  # the iso8601 guy didn't make it so you could add a duration to
  # a DateTime, even though ISO8601::DateTime embeds a DateTime.
  # noOOoOOOo that would be too easy; instead you have to reparse it.
  expires = now + expires.to_seconds(ISO8601::DateTime.new now.iso8601)
  # anyway an integer to DateTime is a day, so we divide.

  uuid = UUIDTools::UUID.random_create

  @token.insert(user: id, token: uuid.to_s, slug: !cookie,
    oneoff: !!oneoff, expires: expires)

  UUID::NCName::to_ncname uuid, version: 1
end

#new_user(principal, email: nil) ⇒ Object



405
406
407
# File 'lib/forget-passwords/state.rb', line 405

def new_user principal, email: nil
  record_for principal, create: true, email: email
end

#record_for(principal, create: false, email: nil) ⇒ Object

XXX 2022-04-10 the email address is canonical now, lol



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'lib/forget-passwords/state.rb', line 357

def record_for principal, create: false, email: nil
  # so we can keep the same interface
  if principal
    # ensure this is a stripped string
    principal = principal.to_s.strip
    raise ArgumentError,
      'principal cannot be an empty string' if principal.empty?
  else
    raise ArgumentError,
      'email must be defined if principal is not' unless email
    # note we don't normalize case for the principal (may be dumb tbh)
    principal = email.to_s.strip
  end

  ds  = @user.select(:id).where(principal: principal)
  row = ds.first

  if create
    if email
      email = email.to_s.strip.downcase
      raise ArgumentError,
        "email must be a valid address, not #{email}" unless
        email.include? ?@
    elsif principal.include? ?@
      email = principal.dup
    else
      raise ArgumentError,
        'principal must be an email address if another not supplied'
    end

    if row
      row = @user[row.id]
      row.email = email
      row.save
    else
      row = { principal: principal, email: email  }
      row = @user.new.set(row).save
    end
  end

  row
end

#stamp_token(token, ip, seen: DateTime.now) ⇒ ForgetPasswords::State::Usage

Add a token to the usage log and associate it with an IP address.

Parameters:

  • token (String)

    the token

  • ip (String)

    the IP address that used

  • seen (Time, DateTime) (defaults to: DateTime.now)

    The timestamp (defaults to now).

Returns:

  • (ForgetPasswords::State::Usage)

    the token’s usage record



519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
# File 'lib/forget-passwords/state.rb', line 519

def stamp_token token, ip, seen: DateTime.now
  uuid  = UUID::NCName::from_ncname token, version: 1
  raise "Could not get UUID from token #{token}" unless uuid
  @db.transaction do
    # warn @usage.where(token: uuid, ip: ip).inspect
    rec = @usage.where(token: uuid, ip: ip).first
    # warn "#{uuid} #{ip}"
    if rec
      rec.update(seen: seen)
      rec # yo does update return the record? or
    else
      @usage.insert(token: uuid, ip: ip, seen: seen)
    end
  end
end

#token_for(principal, cookie: false, oneoff: false, expires: nil) ⇒ Object

from the author of sequel (2019-05-27):

15:11 < jeremyevans> dorian: DB.fromsame_table.as(:a).exclude(

DB.from{same_table.as(:b)}.where{(a[:order] < b[:order]) &
  {a[:key]=>b[:key]}}.select(1).exists)


442
443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/forget-passwords/state.rb', line 442

def token_for principal, cookie: false, oneoff: false, expires: nil
  id = principal.is_a?(Integer) ? principal : id_for(principal)
  raise "No user with ID #{principal} found" unless id

  # only query strings can be oneoffs
  cookie = !!cookie
  oneoff = false if cookie
  oneoff = !!oneoff

  # obtain the last (newest) "fresh" token for this user
  row = @token.fresh(cookie: cookie, oneoff: oneoff).for(id).by_date.first
  return UUID::NCName::to_ncname row.token, version: 1 if row
end

#transaction(&block) ⇒ Object



351
352
353
# File 'lib/forget-passwords/state.rb', line 351

def transaction &block
  @db.transaction(&block)
end

#user_for(token, record: false, id: false, cookie: false) ⇒ String?

Retrieve the user associated with a token, whether nonce or cookie.

Parameters:

  • token (String)

    the token

  • id (false, true) (defaults to: false)

    the user ID instead of the principal

  • cookie (false, true) (defaults to: false)

    whether the token is a cookie

Returns:

  • (String, nil)

    the user principal identifier or nil



479
480
481
482
483
484
485
486
487
# File 'lib/forget-passwords/state.rb', line 479

def user_for token, record: false, id: false, cookie: false
  uuid = UUID::NCName::from_ncname token, version: 1
  out  = @user.where(disabled: nil).join(:token, user: :id).select(
    :id, :principal, :email, :expires
  ).where(token: uuid, slug: !cookie).first

  # return the whole record if asked for it otherwise the id or principal
  record ? out : id ? out.id : out.principal if out
end