First crack
This commit is contained in:
70
app/models/application.rb
Normal file
70
app/models/application.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
class Application < ApplicationRecord
|
||||
has_many :application_groups, dependent: :destroy
|
||||
has_many :allowed_groups, through: :application_groups, source: :group
|
||||
has_many :oidc_authorization_codes, dependent: :destroy
|
||||
has_many :oidc_access_tokens, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
||||
validates :app_type, presence: true,
|
||||
inclusion: { in: %w[oidc trusted_header saml] }
|
||||
validates :client_id, uniqueness: { allow_nil: true }
|
||||
|
||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||
|
||||
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :oidc, -> { where(app_type: "oidc") }
|
||||
scope :trusted_header, -> { where(app_type: "trusted_header") }
|
||||
scope :saml, -> { where(app_type: "saml") }
|
||||
|
||||
# Type checks
|
||||
def oidc?
|
||||
app_type == "oidc"
|
||||
end
|
||||
|
||||
def trusted_header?
|
||||
app_type == "trusted_header"
|
||||
end
|
||||
|
||||
def saml?
|
||||
app_type == "saml"
|
||||
end
|
||||
|
||||
# Access control
|
||||
def user_allowed?(user)
|
||||
return false unless active?
|
||||
return false unless user.active?
|
||||
|
||||
# If no groups are specified, allow all active users
|
||||
return true if allowed_groups.empty?
|
||||
|
||||
# Otherwise, user must be in at least one of the allowed groups
|
||||
(user.groups & allowed_groups).any?
|
||||
end
|
||||
|
||||
# OIDC helpers
|
||||
def parsed_redirect_uris
|
||||
return [] unless redirect_uris.present?
|
||||
JSON.parse(redirect_uris)
|
||||
rescue JSON::ParserError
|
||||
redirect_uris.split("\n").map(&:strip).reject(&:blank?)
|
||||
end
|
||||
|
||||
def parsed_metadata
|
||||
return {} unless metadata.present?
|
||||
JSON.parse(metadata)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_client_credentials
|
||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||
self.client_secret ||= SecureRandom.urlsafe_base64(48)
|
||||
end
|
||||
end
|
||||
6
app/models/application_group.rb
Normal file
6
app/models/application_group.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class ApplicationGroup < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :group
|
||||
|
||||
validates :application_id, uniqueness: { scope: :group_id }
|
||||
end
|
||||
4
app/models/current.rb
Normal file
4
app/models/current.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :session
|
||||
delegate :user, to: :session, allow_nil: true
|
||||
end
|
||||
9
app/models/group.rb
Normal file
9
app/models/group.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class Group < ApplicationRecord
|
||||
has_many :user_groups, dependent: :destroy
|
||||
has_many :users, through: :user_groups
|
||||
has_many :application_groups, dependent: :destroy
|
||||
has_many :applications, through: :application_groups
|
||||
|
||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||
end
|
||||
34
app/models/oidc_access_token.rb
Normal file
34
app/models/oidc_access_token.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
class OidcAccessToken < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
validates :token, presence: true, uniqueness: true
|
||||
|
||||
scope :valid, -> { where("expires_at > ?", Time.current) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
|
||||
def active?
|
||||
!expired?
|
||||
end
|
||||
|
||||
def revoke!
|
||||
update!(expires_at: Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
self.token ||= SecureRandom.urlsafe_base64(48)
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
self.expires_at ||= 1.hour.from_now
|
||||
end
|
||||
end
|
||||
35
app/models/oidc_authorization_code.rb
Normal file
35
app/models/oidc_authorization_code.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class OidcAuthorizationCode < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
|
||||
before_validation :generate_code, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
validates :code, presence: true, uniqueness: true
|
||||
validates :redirect_uri, presence: true
|
||||
|
||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
|
||||
def valid?
|
||||
!used? && !expired?
|
||||
end
|
||||
|
||||
def consume!
|
||||
update!(used: true)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_code
|
||||
self.code ||= SecureRandom.urlsafe_base64(32)
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
self.expires_at ||= 10.minutes.from_now
|
||||
end
|
||||
end
|
||||
33
app/models/session.rb
Normal file
33
app/models/session.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
class Session < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
before_create :set_expiry
|
||||
before_save :update_activity
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where("expires_at > ?", Time.current) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
def active?
|
||||
!expired?
|
||||
end
|
||||
|
||||
def touch_activity!
|
||||
update_column(:last_activity_at, Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_expiry
|
||||
self.expires_at ||= remember_me ? 30.days.from_now : 24.hours.from_now
|
||||
self.last_activity_at ||= Time.current
|
||||
end
|
||||
|
||||
def update_activity
|
||||
self.last_activity_at = Time.current if expires_at_changed? || new_record?
|
||||
end
|
||||
end
|
||||
78
app/models/user.rb
Normal file
78
app/models/user.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
class User < ApplicationRecord
|
||||
has_secure_password
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :user_groups, dependent: :destroy
|
||||
has_many :groups, through: :user_groups
|
||||
|
||||
# Token generation for passwordless flows
|
||||
generates_token_for :invitation, expires_in: 7.days
|
||||
generates_token_for :password_reset, expires_in: 1.hour
|
||||
generates_token_for :magic_login, expires_in: 15.minutes
|
||||
|
||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||
|
||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :status, presence: true,
|
||||
inclusion: { in: %w[active disabled pending_invitation] }
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(status: "active") }
|
||||
scope :admins, -> { where(admin: true) }
|
||||
|
||||
# TOTP methods
|
||||
def totp_enabled?
|
||||
totp_secret.present?
|
||||
end
|
||||
|
||||
def enable_totp!
|
||||
require "rotp"
|
||||
self.totp_secret = ROTP::Base32.random
|
||||
self.backup_codes = generate_backup_codes
|
||||
save!
|
||||
end
|
||||
|
||||
def disable_totp!
|
||||
update!(totp_secret: nil, totp_required: false, backup_codes: nil)
|
||||
end
|
||||
|
||||
def totp_provisioning_uri(issuer: "Clinch")
|
||||
return nil unless totp_enabled?
|
||||
|
||||
require "rotp"
|
||||
totp = ROTP::TOTP.new(totp_secret, issuer: issuer)
|
||||
totp.provisioning_uri(email_address)
|
||||
end
|
||||
|
||||
def verify_totp(code)
|
||||
return false unless totp_enabled?
|
||||
|
||||
require "rotp"
|
||||
totp = ROTP::TOTP.new(totp_secret)
|
||||
totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||
end
|
||||
|
||||
def verify_backup_code(code)
|
||||
return false unless backup_codes.present?
|
||||
|
||||
codes = JSON.parse(backup_codes)
|
||||
if codes.include?(code)
|
||||
codes.delete(code)
|
||||
update(backup_codes: codes.to_json)
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def parsed_backup_codes
|
||||
return [] unless backup_codes.present?
|
||||
JSON.parse(backup_codes)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_backup_codes
|
||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
||||
end
|
||||
end
|
||||
6
app/models/user_group.rb
Normal file
6
app/models/user_group.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class UserGroup < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :group
|
||||
|
||||
validates :user_id, uniqueness: { scope: :group_id }
|
||||
end
|
||||
Reference in New Issue
Block a user