diff options
| author | azul <azul@riseup.net> | 2014-04-17 10:12:05 +0200 | 
|---|---|---|
| committer | azul <azul@riseup.net> | 2014-04-17 10:12:05 +0200 | 
| commit | 3513ad74f950b113af1ba1e3d06bc6a55c48fde5 (patch) | |
| tree | db49ebd4428053d5c8d720275b77594a531a1ad1 /app/models | |
| parent | cb6442c344d6bdaf52c3878b2de2fcf4d85f2648 (diff) | |
| parent | 3d3688647fab7049e5b531c45b85c1e46a1d528f (diff) | |
Merge pull request #146 from azul/refactor/engines
Refactor/engines
Diffstat (limited to 'app/models')
| -rw-r--r-- | app/models/account.rb | 68 | ||||
| -rw-r--r-- | app/models/client_certificate.rb | 113 | ||||
| -rw-r--r-- | app/models/email.rb | 26 | ||||
| -rw-r--r-- | app/models/identity.rb | 136 | ||||
| -rw-r--r-- | app/models/local_email.rb | 68 | ||||
| -rw-r--r-- | app/models/login_format_validation.rb | 21 | ||||
| -rw-r--r-- | app/models/message.rb | 29 | ||||
| -rw-r--r-- | app/models/pgp_key.rb | 48 | ||||
| -rw-r--r-- | app/models/service_level.rb | 19 | ||||
| -rw-r--r-- | app/models/session.rb | 32 | ||||
| -rw-r--r-- | app/models/token.rb | 69 | ||||
| -rw-r--r-- | app/models/unauthenticated_user.rb | 6 | ||||
| -rw-r--r-- | app/models/user.rb | 179 | 
13 files changed, 814 insertions, 0 deletions
diff --git a/app/models/account.rb b/app/models/account.rb new file mode 100644 index 0000000..cf998e4 --- /dev/null +++ b/app/models/account.rb @@ -0,0 +1,68 @@ +# +# The Account model takes care of the livecycle of a user. +# It composes a User record and it's identity records. +# It also allows for other engines to hook into the livecycle by +# monkeypatching the create, update and destroy methods. +# There's an ActiveSupport load_hook at the end of this file to +# make this more easy. +# +class Account + +  attr_reader :user + +  def initialize(user = nil) +    @user = user +  end + +  # Returns the user record so it can be used in views. +  def self.create(attrs) +    @user = User.create(attrs).tap do |user| +      Identity.create_for user +    end +  end + +  def update(attrs) +    if attrs[:password_verifier].present? +      update_login(attrs[:login]) +      @user.update_attributes attrs.slice(:password_verifier, :password_salt) +    end +    # TODO: move into identity controller +    key = update_pgp_key(attrs[:public_key]) +    @user.errors.set :public_key, key.errors.full_messages +    @user.save && save_identities +    @user.refresh_identity +  end + +  def destroy +    return unless @user +    Identity.disable_all_for(@user) +    @user.destroy +  end + +  protected + +  def update_login(login) +    return unless login.present? +    @old_identity = Identity.for(@user) +    @user.login = login +    @new_identity = Identity.for(@user) # based on the new login +    @old_identity.destination = @user.email_address # alias old -> new +  end + +  def update_pgp_key(key) +    PgpKey.new(key).tap do |key| +      if key.present? && key.valid? +        @new_identity ||= Identity.for(@user) +        @new_identity.set_key(:pgp, key) +      end +    end +  end + +  def save_identities +    @new_identity.try(:save) && @old_identity.try(:save) +  end + +  # You can hook into the account lifecycle from different engines using +  #   ActiveSupport.on_load(:account) do ... +  ActiveSupport.run_load_hooks(:account, self) +end diff --git a/app/models/client_certificate.rb b/app/models/client_certificate.rb new file mode 100644 index 0000000..76b07a2 --- /dev/null +++ b/app/models/client_certificate.rb @@ -0,0 +1,113 @@ +# +# Model for certificates +# +# This file must be loaded after Config has been loaded. +# +require 'base64' +require 'digest/md5' +require 'openssl' +require 'certificate_authority' +require 'date' + +class ClientCertificate + +  attr_accessor :key                          # the client private RSA key +  attr_accessor :cert                         # the client x509 certificate, signed by the CA + +  # +  # generate the private key and client certificate +  # +  def initialize(options = {}) +    cert = CertificateAuthority::Certificate.new + +    # set subject +    cert.subject.common_name = common_name(options[:prefix]) + +    # set expiration +    cert.not_before = yesterday +    cert.not_after = months_from_yesterday(APP_CONFIG[:client_cert_lifespan]) + +    # generate key +    cert.serial_number.number = cert_serial_number +    cert.key_material.generate_key(APP_CONFIG[:client_cert_bit_size]) + +    # sign +    cert.parent = ClientCertificate.root_ca +    cert.sign! client_signing_profile + +    self.key = cert.key_material.private_key +    self.cert = cert +  end + +  def to_s +    self.key.to_pem + self.cert.to_pem +  end + +  private + +  def self.root_ca +    @root_ca ||= begin +                   crt = File.read(APP_CONFIG[:client_ca_cert]) +                   key = File.read(APP_CONFIG[:client_ca_key]) +                   openssl_cert = OpenSSL::X509::Certificate.new(crt) +                   cert = CertificateAuthority::Certificate.from_openssl(openssl_cert) +                   cert.key_material.private_key = OpenSSL::PKey::RSA.new(key, APP_CONFIG[:ca_key_password]) +                   cert +                 end +  end + +  # +  # For cert serial numbers, we need a non-colliding number less than 160 bits. +  # md5 will do nicely, since there is no need for a secure hash, just a short one. +  # (md5 is 128 bits) +  # +  def cert_serial_number +    Digest::MD5.hexdigest("#{rand(10**10)} -- #{Time.now}").to_i(16) +  end + +  def common_name(prefix = nil) +    [prefix, random_common_name].join +  end + +  # +  # for the random common name, we need a text string that will be unique across all certs. +  # ruby 1.8 doesn't have a built-in uuid generator, or we would use SecureRandom.uuid +  # +  def random_common_name +    cert_serial_number.to_s(36) +  end + +  def client_signing_profile +    { +      "digest" => APP_CONFIG[:client_cert_hash], +      "extensions" => { +        "keyUsage" => { +          "usage" => ["digitalSignature"] +        }, +        "extendedKeyUsage" => { +          "usage" => ["clientAuth"] +        } +      } +    } +  end + +  ## +  ## TIME HELPERS +  ## +  ## note: we use 'yesterday' instead of 'today', because times are in UTC, and some people on the planet +  ## are behind UTC. +  ## + +  def yesterday +    t = Time.now - 24*60*60 +    Time.utc t.year, t.month, t.day +  end + +  def months_from_yesterday(num) +    t = yesterday +    date = Date.new t.year, t.month, t.day +    date = date >> num # >> is months in the future operator +    Time.utc date.year, date.month, date.day +  end + +end diff --git a/app/models/email.rb b/app/models/email.rb new file mode 100644 index 0000000..a9a503f --- /dev/null +++ b/app/models/email.rb @@ -0,0 +1,26 @@ +class Email < String +  include ActiveModel::Validations + +  validates :email, +    :format => { +      :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/, #local part of email is case-sensitive, so allow uppercase letter. +      :message => "needs to be a valid email address" +    } + +  def to_partial_path +    "emails/email" +  end + +  def to_param +    to_s +  end + +  def email +    self +  end + +  def handle +    self.split('@').first +  end + +end diff --git a/app/models/identity.rb b/app/models/identity.rb new file mode 100644 index 0000000..9b97b51 --- /dev/null +++ b/app/models/identity.rb @@ -0,0 +1,136 @@ +class Identity < CouchRest::Model::Base +  include LoginFormatValidation + +  use_database :identities + +  belongs_to :user + +  property :address, LocalEmail +  property :destination, Email +  property :keys, HashWithIndifferentAccess + +  validate :unique_forward +  validate :alias_available +  validate :address_local_email +  validate :destination_email + +  design do +    view :by_user_id +    view :by_address_and_destination +    view :by_address +    view :pgp_key_by_email, +      map: <<-EOJS +      function(doc) { +        if (doc.type != 'Identity') { +          return; +        } +        if (typeof doc.keys === "object") { +          emit(doc.address, doc.keys["pgp"]); +        } +      } +    EOJS +    view :disabled, +      map: <<-EOJS +      function(doc) { +        if (doc.type != 'Identity') { +          return; +        } +        if (typeof doc.user_id === "undefined") { +          emit(doc._id, 1); +        } +      } +    EOJS + +  end + +  def self.for(user, attributes = {}) +    find_for(user, attributes) || build_for(user, attributes) +  end + +  def self.find_for(user, attributes = {}) +    attributes.reverse_merge! attributes_from_user(user) +    find_by_address_and_destination [attributes[:address], attributes[:destination]] +  end + +  def self.build_for(user, attributes = {}) +    attributes.reverse_merge! attributes_from_user(user) +    Identity.new(attributes) +  end + +  def self.create_for(user, attributes = {}) +    identity = build_for(user, attributes) +    identity.save +    identity +  end + +  def self.disable_all_for(user) +    Identity.by_user_id.key(user.id).each do |identity| +      identity.disable +      identity.save +    end +  end + +  def self.destroy_all_disabled +    Identity.disabled.each do |identity| +      identity.destroy +    end +  end + +  def self.attributes_from_user(user) +    { user_id: user.id, +      address: user.email_address, +      destination: user.email_address +    } +  end + +  def enabled? +    self.destination && self.user_id +  end + +  def disable +    self.destination = nil +    self.user_id = nil +  end + +  def keys +    read_attribute('keys') || HashWithIndifferentAccess.new +  end + +  def set_key(type, key) +    return if keys[type] == key.to_s +    write_attribute('keys', keys.merge(type => key.to_s)) +  end + +  # for LoginFormatValidation +  def login +    self.address.handle +  end + +  protected + +  def unique_forward +    same = Identity.find_by_address_and_destination([address, destination]) +    if same && same != self +      errors.add :base, "This alias already exists" +    end +  end + +  def alias_available +    same = Identity.find_by_address(address) +    if same && same.user != self.user +      errors.add :base, "This email has already been taken" +    end +  end + +  def address_local_email +    return if address.valid? #this ensures it is LocalEmail +    self.errors.add(:address, address.errors.messages[:email].first) #assumes only one error +  end + +  def destination_email +    return if destination.nil?   # this identity is disabled +    return if destination.valid? # this ensures it is Email +    self.errors.add(:destination, destination.errors.messages[:email].first) #assumes only one error #TODO +  end + +end diff --git a/app/models/local_email.rb b/app/models/local_email.rb new file mode 100644 index 0000000..2b4c65e --- /dev/null +++ b/app/models/local_email.rb @@ -0,0 +1,68 @@ +class LocalEmail < Email + +  BLACKLIST_FROM_RFC2142 = [ +    'postmaster', 'hostmaster', 'domainadmin', 'webmaster', 'www', +    'abuse', 'noc', 'security', 'usenet', 'news', 'uucp', +    'ftp', 'sales', 'marketing', 'support', 'info' +  ] + +  def self.domain +    APP_CONFIG[:domain] +  end + +  validates :email, +    :format => { +      :with => /@#{domain}\Z/i, +      :message => "needs to end in @#{domain}" +    } + +  validate :handle_allowed + +  def initialize(s) +    super +    append_domain_if_needed +  end + +  def to_key +    [handle] +  end + +  def domain +    LocalEmail.domain +  end + +  protected + +  def append_domain_if_needed +    unless self.index('@') +      self << '@' + domain +    end +  end + +  def handle_allowed +    errors.add(:handle, "is reserved.") if handle_reserved? +  end + +  def handle_reserved? +    # *ARRAY in a case statement tests if ARRAY includes the handle. +    case handle +    when *APP_CONFIG[:handle_blacklist] +      true +    when *APP_CONFIG[:handle_whitelist] +      false +    when *BLACKLIST_FROM_RFC2142 +      true +    else +      handle_in_passwd? +    end +  end + +  def handle_in_passwd? +    begin +      !!Etc.getpwnam(handle) +    rescue ArgumentError +      # handle was not found +      return false +    end +  end +end diff --git a/app/models/login_format_validation.rb b/app/models/login_format_validation.rb new file mode 100644 index 0000000..c1fcf70 --- /dev/null +++ b/app/models/login_format_validation.rb @@ -0,0 +1,21 @@ +module LoginFormatValidation +  extend ActiveSupport::Concern + +  #TODO: Probably will replace this. Playing with using it for aliases too, but won't want it connected to login field. + +  included do +    # Have multiple regular expression validations so we can get specific error messages: +    validates :login, +      :format => { :with => /\A.{2,}\z/, +        :message => "Must have at least two characters"} +    validates :login, +      :format => { :with => /\A[a-z\d_\.-]+\z/, +        :message => "Only lowercase letters, digits, . - and _ allowed."} +    validates :login, +      :format => { :with => /\A[a-z].*\z/, +        :message => "Must begin with a lowercase letter"} +    validates :login, +      :format => { :with => /\A.*[a-z\d]\z/, +        :message => "Must end with a letter or digit"} +  end +end diff --git a/app/models/message.rb b/app/models/message.rb new file mode 100644 index 0000000..424f094 --- /dev/null +++ b/app/models/message.rb @@ -0,0 +1,29 @@ +class Message < CouchRest::Model::Base + +  use_database :messages + +  property :text, String +  property :user_ids_to_show, [String] +  property :user_ids_have_shown, [String] # is this necessary to store? + +  timestamps! + +  design do +    own_path = Pathname.new(File.dirname(__FILE__)) +    load_views(own_path.join('..', 'designs', 'message')) +  end + +  def mark_as_read_by(user) +    user_ids_to_show.delete(user.id) +    # is it necessary to keep track of what users have already seen it? +    user_ids_have_shown << user.id unless read_by?(user) +  end + +  def read_by?(user) +    user_ids_have_shown.include?(user.id) +  end + +  def unread_by?(user) +    user_ids_to_show.include?(user.id) +  end +end diff --git a/app/models/pgp_key.rb b/app/models/pgp_key.rb new file mode 100644 index 0000000..66f8660 --- /dev/null +++ b/app/models/pgp_key.rb @@ -0,0 +1,48 @@ +class PgpKey +  include ActiveModel::Validations + +  KEYBLOCK_IDENTIFIERS = [ +    '-----BEGIN PGP PUBLIC KEY BLOCK-----', +    '-----END PGP PUBLIC KEY BLOCK-----', +  ] + +  # mostly for testing. +  attr_accessor :keyblock + +  validate :validate_keyblock_format + +  def initialize(keyblock = nil) +    @keyblock = keyblock +  end + +  def to_s +    @keyblock +  end + +  def present? +    @keyblock.present? +  end + +  # allow comparison with plain keyblock strings. +  def ==(other) +    self.equal?(other) or +    # relax the comparison on line ends. +    self.to_s.tr_s("\n\r", '') == other.tr_s("\r\n", '') +  end + +  protected + +  def validate_keyblock_format +    if keyblock_identifier_missing? +      errors.add :public_key_block, +        "does not look like an armored pgp public key block" +    end +  end + +  def keyblock_identifier_missing? +    KEYBLOCK_IDENTIFIERS.find do |identify| +      !@keyblock.include?(identify) +    end +  end + +end diff --git a/app/models/service_level.rb b/app/models/service_level.rb new file mode 100644 index 0000000..299aaf1 --- /dev/null +++ b/app/models/service_level.rb @@ -0,0 +1,19 @@ +class ServiceLevel + +  def initialize(attributes = {}) +    @id = attributes[:id] || APP_CONFIG[:default_service_level] +  end + +  def self.authenticated_select_options +    APP_CONFIG[:service_levels].map { |id,config_hash| [config_hash[:description], id] if config_hash[:name] != 'anonymous'}.compact +  end + +  def id +    @id +  end + +  def config_hash +    APP_CONFIG[:service_levels][@id] +  end + +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 0000000..0d7e10e --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,32 @@ +class Session < SRP::Session +  include ActiveModel::Validations +  include LoginFormatValidation + +  attr_accessor :login + +  validates :login, :presence => true + +  def initialize(user = nil, aa = nil) +    super(user, aa) if user +  end + +  def persisted? +    false +  end + +  def new_record? +    true +  end + +  def to_model +    self +  end + +  def to_key +    [object_id] +  end + +  def to_param +    nil +  end +end diff --git a/app/models/token.rb b/app/models/token.rb new file mode 100644 index 0000000..4856c31 --- /dev/null +++ b/app/models/token.rb @@ -0,0 +1,69 @@ +class Token < CouchRest::Model::Base + +  use_database :tokens + +  belongs_to :user + +  # timestamps! does not create setters and only sets updated_at +  # if the object has changed and been saved. Instead of triggering +  # that we rather use our own property we have control over: +  property :last_seen_at, Time, accessible: false + +  validates :user_id, presence: true + +  design do +    view :by_last_seen_at +  end + +  def self.expires_after +    APP_CONFIG[:auth] && APP_CONFIG[:auth][:token_expires_after] +  end + +  def self.expired +    return [] unless expires_after +    by_last_seen_at.endkey(expires_after.minutes.ago) +  end + +  def self.destroy_all_expired +    self.expired.each do |token| +      token.destroy +    end +  end + +  def authenticate +    if expired? +      destroy +      return nil +    else +      touch +      return user +    end +  end + +  # Tokens can be cleaned up in different ways. +  # So let's make sure we don't crash if they disappeared +  def destroy_with_rescue +    destroy_without_rescue +  rescue RestClient::ResourceNotFound +  end +  alias_method_chain :destroy, :rescue + +  def touch +    self.last_seen_at = Time.now +    save +  end + +  def expired? +    Token.expires_after and +    last_seen_at < Token.expires_after.minutes.ago +  end + +  def initialize(*args) +    super +    if new_record? +      self.id = SecureRandom.urlsafe_base64(32).gsub(/^_*/, '') +      self.last_seen_at = Time.now +    end +  end +end + diff --git a/app/models/unauthenticated_user.rb b/app/models/unauthenticated_user.rb new file mode 100644 index 0000000..0fc17d2 --- /dev/null +++ b/app/models/unauthenticated_user.rb @@ -0,0 +1,6 @@ +# The nil object for the user class +class UnauthenticatedUser < Object + +  # will probably want something here to return service level as  APP_CONFIG[:service_levels][0] but not sure how will be accessing. + +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..c297ac8 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,179 @@ +class User < CouchRest::Model::Base +  include LoginFormatValidation + +  use_database :users + +  property :login, String, :accessible => true +  property :password_verifier, String, :accessible => true +  property :password_salt, String, :accessible => true + +  property :enabled, TrueClass, :default => true + +  # these will be null by default but we shouldn't ever pull them directly, but only via the methods that will return the full ServiceLevel +  property :desired_service_level_code, Integer, :accessible => true +  property :effective_service_level_code, Integer, :accessible => true + +  property :one_month_warning_sent, TrueClass + +  before_save :update_effective_service_level + +  validates :login, :password_salt, :password_verifier, +    :presence => true + +  validates :login, +    :uniqueness => true, +    :if => :serverside? + +  validate :login_is_unique_alias + +  validates :password_salt, :password_verifier, +    :format => { :with => /\A[\dA-Fa-f]+\z/, :message => "Only hex numbers allowed" } + +  validates :password, :presence => true, +    :confirmation => true, +    :format => { :with => /.{8}.*/, :message => "needs to be at least 8 characters long" } + +  timestamps! + +  design do +    own_path = Pathname.new(File.dirname(__FILE__)) +    load_views(own_path.join('..', 'designs', 'user')) +    view :by_login +    view :by_created_at +  end # end of design + +  def to_json(options={}) +    { +      :login => login, +      :ok => valid? +    }.to_json(options) +  end + +  def salt +    password_salt.hex +  end + +  def verifier +    password_verifier.hex +  end + +  def username +    login +  end + +  def email_address +    LocalEmail.new(login) +  end + +  # Since we are storing admins by login, we cannot allow admins to change their login. +  def is_admin? +    APP_CONFIG['admins'].include? self.login +  end + +  def most_recent_tickets(count=3) +    Ticket.for_user(self).limit(count).all #defaults to having most recent updated first +  end + +  def messages(unseen = true) +    #TODO for now this only shows unseen messages. Will we ever want seen ones? Is it necessary to store? + +    # we don't want to emit all the userids associated with a message, so only emit id and text. +    Message.by_user_ids_to_show.key(self.id).map { |message| [message.id, message.text] } + +  end + +  # DEPRECATED +  # +  # Please set the key on the identity directly +  # WARNING: This will not be serialized with the user record! +  # It is only a workaround for the key form. +  def public_key=(value) +    identity.set_key(:pgp, value) +  end + +  # DEPRECATED +  # +  # Please access identity.keys[:pgp] directly +  def public_key +    identity.keys[:pgp] +  end + +  def account +    Account.new(self) +  end + +  def identity +    @identity ||= Identity.for(self) +  end + +  def refresh_identity +    @identity = Identity.for(self) +  end + +  def desired_service_level +    code = self.desired_service_level_code || APP_CONFIG[:default_service_level] +    ServiceLevel.new({id: code}) +  end + +  def effective_service_level +    code = self.effective_service_level_code || self.desired_service_level.id +    ServiceLevel.new({id: code}) +  end + + +  def self.send_one_month_warnings + +    # To determine warnings to send, need to get all users where one_month_warning_sent is not set, and where it was created greater than or equal to 1 month ago. +    # TODO: might want to further limit to enabled accounts, and, based on provider's service level configuration, for particular service levels. +    users_to_warn = User.by_created_at_and_one_month_warning_not_sent.endkey(Time.now-1.month) + +    users_to_warn.each do |user| +      # instead of loop could use something like: +      # message.user_ids_to_show = users_to_warn.map(&:id) +      # but would still need to loop through users to store one_month_warning_sent + +      if !@message +        # create a message for today's date +        # only want to create once, and only if it will be used. +        @message = Message.new(:text => I18n.t(:payment_one_month_warning, :date_in_one_month => (Time.now+1.month).strftime("%Y-%d-%m"))) +      end + +      @message.user_ids_to_show << user.id +      user.one_month_warning_sent = true +      user.save +    end +    @message.save if @message + +  end + +  protected + +  ## +  #  Validation Functions +  ## + +  def login_is_unique_alias +    alias_identity = Identity.find_by_address(self.email_address) +    return if alias_identity.blank? +    if alias_identity.user != self +      errors.add(:login, "has already been taken") +    end +  end + +  def password +    password_verifier +  end + +  # used as a condition for validations that are server side only +  def serverside? +    true +  end + +  def update_effective_service_level +    # TODO: Is this always the case? Might there be a situation where the admin has set the effective service level and we don't want it changed to match the desired one? +    if self.desired_service_level_code_changed? +      self.effective_service_level_code = self.desired_service_level_code +    end +  end + +end  | 
