summaryrefslogtreecommitdiff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/account.rb68
-rw-r--r--app/models/client_certificate.rb113
-rw-r--r--app/models/email.rb26
-rw-r--r--app/models/identity.rb136
-rw-r--r--app/models/local_email.rb68
-rw-r--r--app/models/login_format_validation.rb21
-rw-r--r--app/models/message.rb29
-rw-r--r--app/models/pgp_key.rb48
-rw-r--r--app/models/service_level.rb19
-rw-r--r--app/models/session.rb32
-rw-r--r--app/models/token.rb69
-rw-r--r--app/models/unauthenticated_user.rb6
-rw-r--r--app/models/user.rb179
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