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 |