mholling-active_url 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Matthew Hollingworth
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.textile ADDED
@@ -0,0 +1,454 @@
1
+ h1. ActiveUrl
2
+
3
+ Like many Rails websites, my first production "Rails site":http://things.toswap.com.au needed user sign-ups. I wanted to have this work in a way that allowed a user to register only after confirming their email address.
4
+
5
+ The way to do this is with _secret URLs_.These are URLs that contain an encrypted string and are effectively impossible to guess. By sending a secret URL to an email address, if the URL is subsequently accessed, that's pretty much a guarantee that the email was received, since there's no other way that URL could have been obtained. (Check out the ??Rails Recipes?? book, which has a good chapter explaining secret URLs.)
6
+
7
+ h2. Introducing the ActiveUrl Gem
8
+
9
+ As a first attempt at contributing to the Rails community, I've extracted my site's secret URL functionality into a gem. Since it's used in a similar fashion to <code:ruy>ActiveRecord</code>, I've called it <code>ActiveUrl</code>.
10
+
11
+ How is my implementation distinctive? Basically, it's database-free. You don't need any new database tables or fields to use it, since all the relevant information is persisted in the URL itself. All you need to do to hide a page behind a secret URL is to nest its route beneath an ActiveUrl object that the library provides. Neat!
12
+
13
+ h2. Installation & Usage
14
+
15
+ First, install the gem:
16
+
17
+ <pre>
18
+ gem sources -a http://gems.github.com
19
+ sudo gem install mholling-active_url
20
+ </pre>
21
+
22
+ In your Rails app, make sure to specify the gem dependency in environment.rb:
23
+
24
+ <pre>
25
+ config.gem "mholling-active_url", :lib => "active_url", :source => "http://gems.github.com"
26
+ </pre>
27
+
28
+ Specify a secret passphrase for the library to perform its encryption. You can set this by adding an initializer (say active_url.rb) in your config/initializers directory. This will just set the secret passphrase for your app (you might not want to check this into your source control):
29
+
30
+ <pre>
31
+ ActiveUrl.config.secret = "my-app-encryption-secret"
32
+ </pre>
33
+
34
+ To generate secret URLs in your Rails application, simply inherit a model from <code>ActiveUrl::Base</code>, in the same way you would normally inherit from <code>ActiveRecord::Base</code>. These objects won't be stored in your database; instead they will be persisted as an encrypted ID and placed in an URL given only to that user (typically by email).
35
+
36
+ <pre>
37
+ class Secret < ActiveUrl::Base
38
+ ...
39
+ end
40
+ </pre>
41
+
42
+ The following class methods are available for your model:
43
+
44
+ * <code>attribute(*attribute_names)</code> [sets attributes on your model];
45
+ * <code>belongs_to(model_name)</code> [sets a "foreign key" attribute and an association method];
46
+ * <code>attr_accessible(*attribute_names)</code> [allows mass-assignment of attributes]
47
+ * validations: most of the ActiveRecord validations are available on the attributes you set;
48
+ * <code>after_save(callback_name)</code> [sets a callback to be run after the object is persisted];
49
+ * <code>find(id)</code> [finds an object from the specified ID, which will be extracted from an URL].
50
+
51
+ Save your object by using the <code>ActiveUrl::Base#save</code> method--this will run any validations and generate the encrypted ID if the validations pass. (You will usually use this method in your model's controller.)
52
+
53
+ In your controllers which deal with ActiveUrl models, you'll want to deal with the case of an invalid URL; usually just to render a 404. This is easily done using <code>rescue_from</code> in your application controller:
54
+
55
+ <pre>
56
+ rescue_from ActiveUrl::RecordNotFound do
57
+ render :file => "#{Rails.root}/public/404.html", :status => 404
58
+ end
59
+ </pre>
60
+
61
+ h2. Example: Confirming an Email Address
62
+
63
+ The typical use case for this example is the verification of an email address provided by a someone signing up to your website. You want to check that the address is valid by sending an email to that address; the user must follow a secret URL in the email to confirm they received the email.
64
+
65
+ h3. Registration Model
66
+
67
+ We don't want to create a User model until the email is confirmed, so instead we'll use a <code>ActiveUrl::Base</code> model. This is what will be created when a user registers:
68
+
69
+ <pre>
70
+ class Registration < ActiveUrl::Base
71
+ attribute :email, :accessible => true
72
+ validates_format_of :email, :with => /^[\w\.=-]+@[\w\.-]+\.[a-zA-Z]{2,4}$/ix
73
+ validate :email_not_taken
74
+
75
+ after_save :send_registration_email
76
+
77
+ protected
78
+
79
+ def email_not_taken
80
+ if User.find_by_email(email)
81
+ errors.add(:email, "is already in use")
82
+ end
83
+ end
84
+
85
+ def send_registration_email
86
+ Mailer.deliver_registration(self)
87
+ end
88
+ end
89
+ </pre>
90
+
91
+ Going through this step-by-step:
92
+
93
+ # First, we set our email attribute using <code>attribute :email</code>, which generates setter and getter methods for the attribute.
94
+ # Next, validate the email address so it at least looks right (<code>validates_format_of :email</code>).
95
+ # We also want to check that a user has not already signed up with that email address, so we add a custom validation (<code>email_not_taken</code>) which adds an error if a User with that email address is found.
96
+ # Finally, we set an <code>after_save</code> callback to actually send the registration email when the model is saved. In the mailer method, we pass in the object so that we know what email address to send to and what secret URL to use.
97
+
98
+ h3. Routes
99
+
100
+ Next, let's set up our routes to allow user creation only via an email confirmation. In routes.rb the relevant routes would be:
101
+
102
+ <pre>
103
+ map.resources :registrations, :only => [ :new, :create ] do |registration|
104
+ registration.resources :users, :only => [ :new, :create ]
105
+ end
106
+ </pre>
107
+
108
+ h3. Registrations Controller
109
+
110
+ To allow a user to register, create a registrations controller with just two REST actions, <code>new</code> and <code>create</code>. The controller is entirely generic, as it should be:
111
+
112
+ <pre>
113
+ class RegistrationsController < ApplicationController
114
+ def new
115
+ @registration = Registration.new
116
+ end
117
+
118
+ def create
119
+ @registration = Registration.new(params[:registration])
120
+ if @registration.save
121
+ flash[:notice] = "Please check your email to complete the registration."
122
+ redirect_to root_path # or wherever...
123
+ else
124
+ flash.now[:error] = "There were problems with that email address."
125
+ render :action => "new"
126
+ end
127
+ end
128
+ end
129
+ </pre>
130
+
131
+ When the <code>create</code> action succeeds, the registration object is saved and the registration email sent automatically by its <code>after_save</code> callback.
132
+
133
+ h3. Registration View
134
+
135
+ In the new.html.erb view, the registration form would look something like:
136
+
137
+ <pre>
138
+ <% form_for @registration do |form| %>
139
+ <div>
140
+ <%= form.label :email %>
141
+ <%= form.text_field :email %>
142
+ </div>
143
+ <div>
144
+ <%= form.submit "Register" %>
145
+ </div>
146
+ <% end %>
147
+ </pre>
148
+
149
+ h3. Mailer
150
+
151
+ Finally, we set the mailer to deliver a registration email to the supplied email address:
152
+
153
+ <pre>
154
+ class Mailer < ActionMailer::Base
155
+ def registration(registration)
156
+ subject "Registration successful"
157
+ recipients registration.email
158
+ from "admin@website.com"
159
+
160
+ body :registration => registration
161
+ end
162
+ end
163
+ </pre>
164
+
165
+ The registration object is passed through to the email template, where we use it to get the email address and also to generate the new user URL. Since the URL is secret, if it is subsequently accessed then we know that whoever is accessing it was able to read that email. Thus we have confirmed the email address as a real one, which is what we wanted.
166
+
167
+ The email template might look something like:
168
+
169
+ <pre>
170
+ Hi <%= @registration.email %>,
171
+
172
+ Thanks for registering! Please follow this link to complete your
173
+ registration process:
174
+
175
+ <%= new_registration_user_url(@registration, :host => "website.com") %>
176
+
177
+ Thanks!
178
+ website.com
179
+ </pre>
180
+
181
+ The secret URL generated in the email would look something like:
182
+
183
+ <pre>
184
+ http://website.com/registrations/yAfxbJIeUFKX9YiY6Pqv0UAwufcacnYabEYS7TxTgZY/users/new
185
+ </pre>
186
+
187
+ h3. User Model
188
+
189
+ In our <code>User</code> model, we want to make sure the email address cannot be mass-assigned, so be sure to use <code>attr_protected</code> (or even better, <code>attr_accessible</code>) to prevent this:
190
+
191
+ <pre>
192
+ class User < ActiveRecord::Base
193
+ ...
194
+ attr_protected :email
195
+ ...
196
+ end
197
+ </pre>
198
+
199
+ h3. Users Controller
200
+
201
+ Now let's turn our attention to the users controller. We access the <code>new</code> and <code>create</code> actions only via the nested routes, so that we can load our <code>Registration</code> object from the controller parameters. We'll use the <code>ActiveUrl::Base.find</code> method to retrieve the registration object, and then set the user's email address from it:
202
+
203
+ <pre>
204
+ class UsersController < ApplicationController
205
+ def new
206
+ @registration = Registration.find(params[:registration_id])
207
+ @user = User.new
208
+ @user.email = @registration.email
209
+ end
210
+
211
+ def create
212
+ @registration = Registration.find(params[:registration_id])
213
+ @user = User.new(params[:user])
214
+ @user.email = @registration.email
215
+ if @user.save
216
+ flash[:notice] = "Thanks for registering!"
217
+ redirect_to @user # or wherever...
218
+ else
219
+ flash.now[:error] = "There were problems with your information."
220
+ render :action => "new"
221
+ end
222
+ end
223
+ end
224
+ </pre>
225
+
226
+ h3. New User View
227
+
228
+ The exact contents of the user creation form will depend on our User model, among other things. Notably however,it will *not* include a field for the email address, since we've already obtained the email address from the registration object and we don't want the user to be able to subsequently change it. (It's probably advisable to include the email address in the form's text though, for the sake of clarity.)
229
+
230
+ The new user form might look something like this:
231
+
232
+ <pre>
233
+ <% form_for [ @registration, @user ] do |form| %>
234
+ <div>
235
+ Please enter new user details for <% @user.email %>.
236
+ </div>
237
+ <div>
238
+ <%= form.label :name %>
239
+ <%= form.text_field :name %>
240
+ </div>
241
+ <!-- ... other user fields here ... -->
242
+ <div>
243
+ <%= form.submit "OK" %>
244
+ </div>
245
+ <% end %>
246
+ </pre>
247
+
248
+ h2. Example: Resetting a Lost Password
249
+
250
+ Let's take a look at another application of the library - implementing a "reset password" function. Basically, we want to allow an user to change his/her password without logging in. We'll achieve this by sending the secret URL to the user when they submit a "forgot your password?" form.
251
+
252
+ Again, the basic idea is to hide the password-editing page behind the secret URL. The password-editing page will not be protected by the usual authentication requirements; instead, the knowledge of the secret URL is what authenticates the user.
253
+
254
+ h3. Model
255
+
256
+ Let's first take a look at an ActiveUrl model for the secret URL. We want to create an instance from an email address, which is what the user will still know once the password is forgotten. We could declare an email attribute as in the previous article, but the only thing our model really needs is a reference to a user, which we can derive from the email.
257
+
258
+ For this purpose, we'll use the <code>belongs_to</code> feature of ActiveUrl. This is a quick-and-dirty mirror of the corresponding ActiveRecord feature. (Its only purpose though is to relate a secret URL to an existing database record, so it's only got the bare minimum of functionality.) Let's use it:
259
+
260
+ <pre>
261
+ class Secret < ActiveUrl::Base
262
+ belongs_to :user
263
+ validates_presence_of :user
264
+
265
+ attr_reader :email
266
+ attr_accessible :email
267
+
268
+ def email=(email)
269
+ @email = email
270
+ self.user = User.find_by_email(email)
271
+ end
272
+
273
+ after_save :send_email
274
+
275
+ protected
276
+
277
+ def send_email
278
+ Mailer.deliver_secret(self)
279
+ end
280
+ end
281
+ </pre>
282
+
283
+ h4. Attributes
284
+
285
+ We've set the email as a _virtual attribute_, just as we might for a normal ActiveRecord object. In addition to setting an instance variable, the email setter method also sets the user. The <code>Secret#user=</code> method is generated by the <code>belongs_to</code> association. (<code>user_id=</code>, <code>user</code> and <code>user_id</code> methods are also generated.)
286
+
287
+ We can see what attributes are stored in the model, and what can be written by mass-assignment:
288
+
289
+ <pre>
290
+ Secret.attribute_names
291
+ # => #<Set: {:user_id}>
292
+
293
+ Secret.accessible_attributes
294
+ # => #<Set: {:email}>
295
+ </pre>
296
+
297
+ In other words, the only attribute stored in the model is the user id, but that id can only be set by setting the email.
298
+
299
+ <pre>
300
+ User.first
301
+ # => #<User id: 1, email: "name@example.com", ... >
302
+
303
+ secret = Secret.new(:user_id => 1)
304
+ secret.user_id
305
+ # => nil
306
+
307
+ secret = Secret.new(:email => "name@example.com")
308
+ secret.user_id
309
+ # => 1
310
+ </pre>
311
+
312
+ h4. Validations
313
+
314
+ A validation, <code>validates_presence_of :user</code>, ensures that an existing user is found for the given email address. The object won't save (and the email won't get sent) if there's no user with that email address.
315
+
316
+ (n.b. If you want to use the Rails error markup in your form, you might want to set an error on <code>email</code> instead.)
317
+
318
+ h4. Callbacks
319
+
320
+ Finally, note the <code>after_save</code> callback. It's a method which sends the secret URL to the user in an email, and it will get called when the controller successfully saves the object.
321
+
322
+ h3. Routes
323
+
324
+ Our routes are pretty simple. We only want to be able to create secrets, so we'll just have <code>new</code> and <code>create</code> routes. Nested under a secret, we want some routes for changing the user's password. This could be arranged in a few different ways, but let's put the password-changing actions in their own controller:
325
+
326
+ <pre>
327
+ map.resources :secrets, :only => [ :new, :create ] do |secret|
328
+ secret.resources :passwords, :only => [ :new, :create ]
329
+ end
330
+ </pre>
331
+
332
+ h3. Controller
333
+
334
+ As always, we strive for generic controllers, and we pretty much get one here:
335
+
336
+ <pre>
337
+ class SecretsController < ApplicationController
338
+ def new
339
+ @secret = Secret.new
340
+ end
341
+
342
+ def create
343
+ @secret = Secret.new(params[:secret])
344
+ if @secret.save
345
+ flash[:notice] = "Please check your email for a link to change your password."
346
+ redirect_to root_path # or wherever...
347
+ else
348
+ flash.now[:error] = "Unrecognised email address" # if you want to disclose this...
349
+ render :action => "new"
350
+ end
351
+ end
352
+ end
353
+ </pre>
354
+
355
+ Of course, there's also a <code>PasswordController</code>, which will contain the actions for changing the user's password. (The user to edit will be obtained from the secret, which in turn will be found from <code>params[:secret_id]</code>.) Its implementation will depend on the <code>User</code> model. Since these actions are hidden behind the secret URL, we'd want to skip the normal user authentication filters for the actions.
356
+
357
+ h3. View
358
+
359
+ How does the user actually request a password reset? By submitting his/her email address in a form. Link to this form on the login page:
360
+
361
+ <pre>
362
+ <%= link_to "I forgot my password", new_secret_path %>
363
+ </pre>
364
+
365
+ The form itself just asks for an email address:
366
+
367
+ <pre>
368
+ <% form_for @secret do |form| %>
369
+ <p>
370
+ OK, so you forgot your password.
371
+ No problems! Just enter your email address.
372
+ We'll send you a link to change your password.
373
+ </p>
374
+ <div>
375
+ <%= form.label :email %>
376
+ <%= form.text_field :email %>
377
+ </div>
378
+ <div>
379
+ <%= form.submit "OK" %>
380
+ </div>
381
+ <% end %>
382
+ </pre>
383
+
384
+ h3. Mailer
385
+
386
+ In our mailer we want to send an email containing the secret URL for the password edit action. The ActiveUrl object obtained from the URL contains all we need to know, so we just pass it through to the email template. We send the email to the secret's associated user:
387
+
388
+ <pre>
389
+ class Mailer < ActionMailer::Base
390
+ def secret(secret)
391
+ subject "Change password requested"
392
+ recipients secret.user.email
393
+ from "admin@website.com"
394
+
395
+ body :secret => secret
396
+ end
397
+ end
398
+ </pre>
399
+
400
+ The email template might look something like:
401
+
402
+ <pre>
403
+ Hi <%= @secret.user.first_name %>,
404
+
405
+ To change your password, please visit the following link:
406
+
407
+ <%= new_secret_password_url(@secret, :host => "website.com") %>
408
+
409
+ (If you did not request a password change, just ignore this email.)
410
+
411
+ Thanks!
412
+ website.com
413
+ </pre>
414
+
415
+ h3. Expiring the URL
416
+
417
+ There's a potential problem with the above implementation though. As it stands, the secret URL is static - the password reset URL for any given user will always be the same. This may or may not be a problem, depending on your security requirements.
418
+
419
+ It would be nice to have the URL expire once the password has been changed - in effect, to have a single-use URL. This is easily done. We add an attribute to the model containing the current password hash (or the cleartext password, if you store your user passwords in the clear - you shouldn't):
420
+
421
+ <pre>
422
+ attribute :password_hash
423
+
424
+ def email=(email)
425
+ @email = email
426
+ self.user = User.find_by_email(email)
427
+ self.password_hash = user.password_hash if user
428
+ end
429
+ </pre>
430
+
431
+ Then, simply validate the password hash to ensure it's the same as the user's:
432
+
433
+ <pre>
434
+ validate :password_hash_is_current, :if => :user
435
+
436
+ def password_hash_is_current
437
+ errors.add(:password_hash) unless user.password_hash == password_hash
438
+ end
439
+ </pre>
440
+
441
+ Since <code>ActiveUrl::Base.find</code> only finds valid objects, once the password has been changed, the secret URL won't validate and an <code>ActiveUrl::RecordNotFound</code> error will be raised. The controller will then drop through to a 404. Easy!
442
+
443
+ h2. Benefits of ActiveUrl
444
+
445
+ In other email confirmation schemes, whenever a registration process is initiated, a new user object is created, even before the email address is confirmed. This causes a couple of problems:
446
+
447
+ * The user model will need some form of state (to distinguish between confirmed and unconfirmed users).
448
+ * If a registration is initiated but not completed, the unconfirmed record will remain in the database, and will need to be manually removed at a later date.
449
+
450
+ The ActiveUrl gem overcomes both these problems by persisting all the relevant data to the URL itself, in encrypted form. No database table is needed.
451
+
452
+ One potential problem with this approach? The URL may become quite long if you store much data in the model. Keep the number of attributes and the length of their names to a minimum to avoid this. Typically, a single attribute or a <code>belongs_to</code> reference is all that's needed, and produces URLs of modest length.
453
+
454
+ Copyright (c) 2009 Matthew Hollingworth. See LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,55 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "active_url"
8
+ gem.summary = %Q{A Rails library for generating secret URLs.}
9
+ gem.description = <<-EOF
10
+ ActiveUrl enables the storing of a model in an encrypted URL. It facilitates implementation
11
+ of secret URLs for user (e.g. feed URLs) that can be accessed without logging in, and URLs
12
+ for confirming the email address of a new user.
13
+ EOF
14
+ gem.email = "mdholling@gmail.com"
15
+ gem.homepage = "http://github.com/mholling/active_url"
16
+ gem.authors = ["Matthew Hollingworth"]
17
+ gem.add_dependency 'activerecord'
18
+ gem.has_rdoc = false
19
+
20
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
21
+ end
22
+ rescue LoadError
23
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
24
+ end
25
+
26
+ require 'spec/rake/spectask'
27
+ Spec::Rake::SpecTask.new(:spec) do |spec|
28
+ spec.libs << 'lib' << 'spec'
29
+ spec.spec_files = FileList['spec/**/*_spec.rb']
30
+ end
31
+
32
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
33
+ spec.libs << 'lib' << 'spec'
34
+ spec.pattern = 'spec/**/*_spec.rb'
35
+ spec.rcov = true
36
+ end
37
+
38
+
39
+ task :default => :spec
40
+
41
+ require 'rake/rdoctask'
42
+ Rake::RDocTask.new do |rdoc|
43
+ if File.exist?('VERSION.yml')
44
+ config = YAML.load(File.read('VERSION.yml'))
45
+ version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
46
+ else
47
+ version = ""
48
+ end
49
+
50
+ rdoc.rdoc_dir = 'rdoc'
51
+ rdoc.title = "active_url #{version}"
52
+ rdoc.rdoc_files.include('README*')
53
+ rdoc.rdoc_files.include('lib/**/*.rb')
54
+ end
55
+
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :patch: 1
3
+ :major: 0
4
+ :minor: 1
@@ -0,0 +1,107 @@
1
+ module ActiveUrl
2
+ class RecordNotFound < ActiveUrlError
3
+ end
4
+
5
+ class Base
6
+ class_inheritable_reader :attribute_names
7
+ class_inheritable_reader :accessible_attributes
8
+
9
+ def self.attribute(*attribute_names)
10
+ options = attribute_names.extract_options!
11
+ attribute_names.map(&:to_sym).each { |attribute_name| add_attribute(attribute_name, options) }
12
+ # attribute_names.map(&:to_sym).each do |attribute_name|
13
+ # attr_accessor attribute_name
14
+ # self.attribute_names << attribute_name
15
+ # self.accessible_attributes << attribute_name if options[:accessible]
16
+ # end
17
+ end
18
+
19
+ def self.attr_accessible(*attribute_names)
20
+ self.accessible_attributes += attribute_names.map(&:to_sym)
21
+ end
22
+
23
+ attr_reader :id
24
+
25
+ def initialize(attributes = nil)
26
+ attributes ||= {}
27
+ self.attributes = attributes
28
+ end
29
+
30
+ def attributes=(attributes)
31
+ attributes.symbolize_keys.select do |key, value|
32
+ self.class.accessible_attributes.include? key
33
+ end.map do |key, value|
34
+ [ "#{key}=", value ]
35
+ end.each do |setter, value|
36
+ send setter, value if respond_to? setter
37
+ end
38
+ end
39
+
40
+ def attributes
41
+ attribute_names.inject({}) do |hash, name|
42
+ hash.merge(name => send(name))
43
+ end
44
+ end
45
+
46
+ def create
47
+ serialized = [ self.class.to_s, attributes ].to_yaml
48
+ @id = Crypto.encrypt(serialized)
49
+ end
50
+
51
+ def save
52
+ !create.blank?
53
+ end
54
+
55
+ def save!
56
+ save
57
+ end
58
+
59
+ def self.find(id)
60
+ raise RecordNotFound unless id.is_a?(String) && !id.blank?
61
+ serialized = begin
62
+ Crypto.decrypt(id)
63
+ rescue OpenSSL::CipherError
64
+ raise RecordNotFound
65
+ end
66
+ type, attributes = YAML.load(serialized)
67
+ raise RecordNotFound unless type == self.to_s && attributes.is_a?(Hash)
68
+ active_url = new
69
+ attributes.each { |key, value| active_url.send "#{key}=", value }
70
+ active_url.create
71
+ active_url
72
+ rescue RecordNotFound
73
+ raise RecordNotFound.new("Couldn't find #{self.name} with id=#{id}")
74
+ end
75
+
76
+ def to_param
77
+ @id.to_s
78
+ end
79
+
80
+ def new_record?
81
+ @id.nil?
82
+ end
83
+
84
+ def ==(other)
85
+ attributes == other.attributes && self.class == other.class
86
+ end
87
+
88
+ private
89
+
90
+ class_inheritable_writer :attribute_names
91
+ class_inheritable_writer :accessible_attributes
92
+ self.attribute_names = Set.new
93
+ self.accessible_attributes = Set.new
94
+
95
+ def self.add_attribute(attribute_name, options)
96
+ attr_accessor attribute_name
97
+ self.attribute_names << attribute_name
98
+ self.accessible_attributes << attribute_name if options[:accessible]
99
+ end
100
+ end
101
+
102
+ Base.class_eval do
103
+ extend BelongsTo
104
+ include Validations
105
+ include Callbacks
106
+ end
107
+ end
@@ -0,0 +1,32 @@
1
+ module ActiveUrl
2
+ module BelongsTo
3
+ def belongs_to(object_name)
4
+ begin
5
+ object_name.to_s.classify.constantize
6
+
7
+ attribute_name = "#{object_name}_id"
8
+ attribute attribute_name
9
+
10
+ define_method object_name do
11
+ begin
12
+ object_name.to_s.classify.constantize.find(send(attribute_name))
13
+ rescue ActiveRecord::RecordNotFound
14
+ nil
15
+ end
16
+ end
17
+
18
+ define_method "#{object_name}=" do |object|
19
+ if object.nil?
20
+ self.send "#{object_name}_id=", nil
21
+ elsif object.is_a?(object_name.to_s.classify.constantize)
22
+ self.send "#{object_name}_id=", object.id
23
+ else
24
+ raise TypeError.new("object is not of type #{object_name.to_s.classify}")
25
+ end
26
+ end
27
+ rescue NameError
28
+ raise ArgumentError.new("#{object_name.to_s.classify} is not an ActiveRecord class.")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,17 @@
1
+ module ActiveUrl
2
+ module Callbacks
3
+ def create_with_callbacks
4
+ result = create_without_callbacks
5
+ run_callbacks(:after_save) unless result.blank?
6
+ result
7
+ end
8
+
9
+ def self.included(base)
10
+ base.class_eval do
11
+ include ActiveSupport::Callbacks # Already included by ActiveRecord.
12
+ alias_method_chain :create, :callbacks
13
+ define_callbacks :after_save
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ require 'singleton'
2
+
3
+ module ActiveUrl
4
+ def self.config
5
+ Configuration.instance
6
+ end
7
+
8
+ class Configuration
9
+ include Singleton
10
+ attr_accessor :secret
11
+ end
12
+ end
@@ -0,0 +1,32 @@
1
+ require 'openssl'
2
+ require 'digest/sha2'
3
+ require 'base64'
4
+
5
+ module ActiveUrl
6
+ module Crypto
7
+ PADDING = { 2 => "==", 3 => "=" }
8
+
9
+ def self.encrypt(clear)
10
+ crypto = start(:encrypt)
11
+ cipher = crypto.update(clear)
12
+ cipher << crypto.final
13
+ Base64.encode64(cipher).gsub(/[\s=]+/, "").gsub("+", "-").gsub("/", "_")
14
+ end
15
+
16
+ def self.decrypt(b64)
17
+ cipher = Base64.decode64("#{b64.gsub("-", "+").gsub("_", "/")}#{PADDING[b64.length % 4]}")
18
+ crypto = start(:decrypt)
19
+ clear = crypto.update(cipher)
20
+ clear << crypto.final
21
+ end
22
+
23
+ private
24
+
25
+ def self.start(mode)
26
+ raise ::ArgumentError.new("Set a secret key using ActiveUrl.config.secret = 'your-secret'") if ActiveUrl.config.secret.blank?
27
+ crypto = OpenSSL::Cipher::Cipher.new('aes-256-ecb').send(mode)
28
+ crypto.key = Digest::SHA256.hexdigest(ActiveUrl.config.secret)
29
+ return crypto
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,4 @@
1
+ module ActiveUrl
2
+ class ActiveUrlError < StandardError
3
+ end
4
+ end
@@ -0,0 +1,57 @@
1
+ module ActiveUrl
2
+ class RecordInvalid < ActiveUrlError
3
+ attr_reader :record
4
+ def initialize(record)
5
+ @record = record
6
+ super("Validation failed: #{@record.errors.full_messages.join(", ")}")
7
+ end
8
+ end
9
+
10
+ module Validations
11
+ module ClassMethods
12
+ def self_and_descendants_from_active_record
13
+ [self]
14
+ end
15
+ alias_method :self_and_descendents_from_active_record, :self_and_descendants_from_active_record
16
+
17
+ def human_name()
18
+ end
19
+
20
+ def human_attribute_name(name, options = {})
21
+ name.to_s.humanize
22
+ end
23
+
24
+ def find_with_validation(id)
25
+ active_url = find_without_validation(id)
26
+ raise ActiveUrl::RecordNotFound unless active_url.valid?
27
+ active_url
28
+ end
29
+
30
+ private
31
+
32
+ def add_attribute_with_validation(attribute_name, options)
33
+ add_attribute_without_validation(attribute_name, options)
34
+ alias_method "#{attribute_name}_before_type_cast", attribute_name
35
+ end
36
+
37
+ end
38
+
39
+ def save_with_active_url_exception!
40
+ save_without_active_url_exception!
41
+ rescue ActiveRecord::RecordInvalid => e
42
+ raise ActiveUrl::RecordInvalid.new(e.record)
43
+ end
44
+
45
+ def self.included(base)
46
+ base.class_eval do
47
+ extend ClassMethods
48
+ include ActiveRecord::Validations
49
+ alias_method_chain :save!, :active_url_exception
50
+ class << self
51
+ alias_method_chain :find, :validation
52
+ alias_method_chain :add_attribute, :validation
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
data/lib/active_url.rb ADDED
@@ -0,0 +1,20 @@
1
+ require 'rubygems'
2
+ require 'active_record'
3
+ ActiveRecord::Base # hack to get ActiveRecord::Validations to load..?
4
+
5
+ require 'active_url/errors'
6
+ require 'active_url/configuration'
7
+ require 'active_url/crypto'
8
+ require 'active_url/belongs_to'
9
+ require 'active_url/validations'
10
+ require 'active_url/callbacks'
11
+ require 'active_url/base'
12
+
13
+ # module ActiveUrl
14
+ # autoload :Base, 'active_url/base'
15
+ # autoload :Configuration, 'active_url/configuration'
16
+ # autoload :Crypto, 'active_url/crypto'
17
+ # autoload :BelongsTo, 'active_url/belongs_to'
18
+ # autoload :Validations, 'active_url/validations'
19
+ # autoload :Callbacks, 'active_url/callbacks'
20
+ # end
@@ -0,0 +1,358 @@
1
+ require 'spec_helper'
2
+
3
+ describe ActiveUrl do
4
+ before(:all) do
5
+ ActiveUrl.config.secret = 'secret'
6
+ end
7
+
8
+ context "instance" do
9
+ before(:each) do
10
+ @url = ActiveUrl::Base.new
11
+ end
12
+
13
+ it "should have nil id" do
14
+ @url.id.should be_nil
15
+ end
16
+
17
+ it "should be a new_record" do
18
+ @url.should be_new_record
19
+ end
20
+
21
+ it "should be saveable" do
22
+ @url.save.should be_true
23
+ end
24
+
25
+ context "after saving" do
26
+ before(:each) do
27
+ @url.save
28
+ end
29
+
30
+ it "should have an id" do
31
+ @url.id.should_not be_blank
32
+ end
33
+
34
+ it "should not be a new record" do
35
+ @url.should_not be_new_record
36
+ end
37
+ end
38
+ end
39
+
40
+ context "derived" do
41
+ before(:all) do
42
+ class DerivedClass < ActiveUrl::Base
43
+ attribute :foo, :bar
44
+ attribute :baz, :accessible => true
45
+ attr_accessible :bar
46
+
47
+ attr_accessor :x, :y
48
+ attr_accessible :y
49
+ end
50
+ end
51
+
52
+ context "instance" do
53
+ it "should not mass-assign attributes by default" do
54
+ @url = DerivedClass.new(:foo => "foo")
55
+ @url.foo.should be_nil
56
+ end
57
+
58
+ it "should mass-assign attributes declared as attr_accessible" do
59
+ @url = DerivedClass.new(:bar => "bar")
60
+ @url.bar.should == "bar"
61
+ end
62
+
63
+ it "should mass-assigned attributes with :accessible specified on declaration" do
64
+ @url = DerivedClass.new(:baz => "baz")
65
+ @url.baz.should == "baz"
66
+ end
67
+
68
+ it "should not mass-assign virtual attributes by default" do
69
+ @url = DerivedClass.new(:x => "x")
70
+ @url.x.should be_nil
71
+ end
72
+
73
+ it "should mass-assign its accessible virtual attributes" do
74
+ @url = DerivedClass.new(:y => "y")
75
+ @url.y.should == "y"
76
+ end
77
+
78
+ it "should know its mass-assignable attribute names" do
79
+ @url = DerivedClass.new
80
+ [ :bar, :baz, :y ].each { |name| @url.accessible_attributes.should include(name) }
81
+ [ :foo, :x ].each { |name| @url.accessible_attributes.should_not include(name) }
82
+ end
83
+
84
+ it "should know its attribute names" do
85
+ @url = DerivedClass.new
86
+ [ :foo, :bar, :baz ].each { |name| @url.attribute_names.should include(name) }
87
+ [ :x, :y ].each { |name| @url.attribute_names.should_not include(name) }
88
+ end
89
+
90
+ context "equality" do
91
+ before(:all) do
92
+ class OtherClass < DerivedClass
93
+ end
94
+ end
95
+
96
+ it "should be based on class and attributes only" do
97
+ @url = DerivedClass.new(:bar => "bar", :baz => "baz")
98
+ @url2 = DerivedClass.new(:bar => "bar", :baz => "baz")
99
+ @url3 = DerivedClass.new(:bar => "BAR", :baz => "baz")
100
+ @url4 = OtherClass.new(:bar => "bar", :baz => "baz")
101
+ @url.should == @url2
102
+ @url.should_not == @url3
103
+ @url.should_not == @url4
104
+ end
105
+ end
106
+ end
107
+
108
+ context "class" do
109
+ it "should know its mass-assignable attribute names" do
110
+ [ :bar, :baz, :y ].each { |name| DerivedClass.accessible_attributes.should include(name) }
111
+ [ :foo, :x ].each { |name| DerivedClass.accessible_attributes.should_not include(name) }
112
+ end
113
+
114
+ it "should know its attribute names" do
115
+ [ :foo, :bar, :baz ].each { |name| DerivedClass.attribute_names.should include(name) }
116
+ [ :x, :y ].each { |name| DerivedClass.attribute_names.should_not include(name) }
117
+ end
118
+ end
119
+ end
120
+
121
+ context "instance with validations" do
122
+ before(:all) do
123
+ class Registration < ActiveUrl::Base
124
+ attribute :name, :email, :password, :age, :accessible => true
125
+ validates_presence_of :name
126
+ validates_format_of :email, :with => /^[\w\.=-]+@[\w\.-]+\.[a-zA-Z]{2,4}$/ix
127
+ validates_length_of :password, :minimum => 8
128
+ validates_numericality_of :age
129
+ after_save :send_registration_email
130
+
131
+ def send_registration_email
132
+ @sent = true
133
+ end
134
+ end
135
+ end
136
+
137
+ context "when invalid" do
138
+ before(:each) do
139
+ @registration = Registration.new(:email => "user @ example . com", :password => "short", :age => "ten")
140
+ end
141
+
142
+ it "should not validate" do
143
+ @registration.should_not be_valid
144
+ end
145
+
146
+ it "should not save" do
147
+ @registration.save.should_not be_true
148
+ @registration.id.should be_nil
149
+ end
150
+
151
+ it "should raise ActiveUrl::InvalidRecord when saved with bang" do
152
+ lambda { @registration.save! }.should raise_error(ActiveUrl::RecordInvalid)
153
+ end
154
+
155
+ context "and saved" do
156
+ before(:each) do
157
+ @registration.save
158
+ end
159
+
160
+ it "should have errors" do
161
+ @registration.errors.should_not be_empty
162
+ end
163
+
164
+ it "should validate presence of an attribute" do
165
+ @registration.errors[:name].should_not be_blank
166
+ end
167
+
168
+ it "should validate format of an attribute" do
169
+ @registration.errors[:email].should_not be_blank
170
+ end
171
+
172
+ it "should validate length of an attribute" do
173
+ @registration.errors[:password].should_not be_nil
174
+ end
175
+
176
+ it "should validate numericality of an attribute" do
177
+ @registration.errors[:age].should_not be_nil
178
+ end
179
+
180
+ it "should not execute any after_save callbacks" do
181
+ @registration.instance_variables.should_not include("@sent")
182
+ end
183
+ end
184
+ end
185
+
186
+ context "when valid" do
187
+ before(:each) do
188
+ @registration = Registration.new(:name => "John Doe", :email => "user@example.com", :password => "password", :age => "10")
189
+ end
190
+
191
+ it "should validate" do
192
+ @registration.should be_valid
193
+ end
194
+
195
+ context "and saved" do
196
+ before(:each) do
197
+ @registration.save
198
+ end
199
+
200
+ it "should have an id" do
201
+ @registration.id.should_not be_blank
202
+ end
203
+
204
+ it "should have a param equal to its id" do
205
+ @registration.id.should == @registration.to_param
206
+ end
207
+
208
+ it "should execute any after_save callbacks" do
209
+ @registration.instance_variables.should include("@sent")
210
+ end
211
+
212
+ context "and re-found by its class" do
213
+ before(:each) do
214
+ @found = Registration.find(@registration.id)
215
+ end
216
+
217
+ it "should exist" do
218
+ @found.should_not be_nil
219
+ end
220
+
221
+ it "should have the same id" do
222
+ @found.id.should == @registration.id
223
+ end
224
+
225
+ it "should have the same attributes" do
226
+ @found.attributes.should == @registration.attributes
227
+ end
228
+
229
+ it "should be valid" do
230
+ @found.should be_valid
231
+ end
232
+ end
233
+
234
+ context "and subsequently made invalid" do
235
+ before(:each) do
236
+ @registration.password = "short"
237
+ @registration.stub!(:valid?).and_return(true)
238
+ @registration.save
239
+ end
240
+
241
+ it "should not be found by its class" do
242
+ @registration.id.should_not be_blank
243
+ lambda { Registration.find(@registration.id) }.should raise_error(ActiveUrl::RecordNotFound)
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ it "should raise ActiveUrl::RecordNotFound if id does not exist" do
250
+ lambda { Registration.find("blah") }.should raise_error(ActiveUrl::RecordNotFound)
251
+ end
252
+ end
253
+
254
+ context "instance with belongs_to association" do
255
+ before(:all) do
256
+ # a simple pretend-ActiveRecord model for testing belongs_to without setting up a db:
257
+ class ::User < ActiveRecord::Base
258
+ def self.columns() @columns ||= []; end
259
+ def self.column(name, sql_type = nil, default = nil, null = true)
260
+ columns << ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null)
261
+ end
262
+ end
263
+
264
+ class Secret < ActiveUrl::Base
265
+ belongs_to :user
266
+ end
267
+ end
268
+
269
+ before(:each) do
270
+ @url = Secret.new
271
+ @user = User.new
272
+ @user.stub!(:id).and_return(1)
273
+ end
274
+
275
+ it "should raise ArgumentError if the association name is not an ActiveRecord class" do
276
+ lambda { Secret.belongs_to :foo }.should raise_error(ArgumentError)
277
+ end
278
+
279
+ it "should respond to association_id, association_id=, association & association=" do
280
+ @url.attribute_names.should include(:user_id)
281
+ @url.should respond_to(:user)
282
+ @url.should respond_to(:user=)
283
+ end
284
+
285
+ it "should have nil association if association or association_id not set" do
286
+ @url.user.should be_nil
287
+ end
288
+
289
+ it "should not allow mass assignment of association_id" do
290
+ @url = Secret.new(:user_id => @user.id)
291
+ @url.user_id.should be_nil
292
+ @url.user.should be_nil
293
+ end
294
+
295
+ it "should not allow mass assignment of association" do
296
+ @url = Secret.new(:user => @user)
297
+ @url.user_id.should be_nil
298
+ @url.user.should be_nil
299
+ end
300
+
301
+ it "should be able to have its association set to nil" do
302
+ @url.user_id = @user.id
303
+ @url.user = nil
304
+ @url.user_id.should be_nil
305
+ end
306
+
307
+ it "should raise ArgumentError if association is set to wrong type" do
308
+ lambda { @url.user = Object.new }.should raise_error(TypeError)
309
+ end
310
+
311
+ it "should find its association_id if association is set" do
312
+ @url.user = @user
313
+ @url.user_id.should == @user.id
314
+ end
315
+
316
+ it "should find its association if association_id is set" do
317
+ User.should_receive(:find).with(@user.id).and_return(@user)
318
+ @url.user_id = @user.id
319
+ @url.user.should == @user
320
+ end
321
+
322
+ it "should return nil association if association_id is unknown" do
323
+ User.should_receive(:find).and_raise(ActiveRecord::RecordNotFound)
324
+ @url.user_id = 10
325
+ @url.user.should be_nil
326
+ end
327
+
328
+ it "should know its association when found by id" do
329
+ User.should_receive(:find).with(@user.id).and_return(@user)
330
+ @url.user_id = @user.id
331
+ @url.save
332
+ @found = Secret.find(@url.id)
333
+ @found.user.should == @user
334
+ end
335
+
336
+ end
337
+ end
338
+
339
+ describe ActiveUrl::Crypto do
340
+ it "should raise ArgumentError if no secret is set" do
341
+ ActiveUrl.config.secret = nil
342
+ lambda { ActiveUrl::Crypto.encrypt("clear") }.should raise_error(ArgumentError)
343
+ ActiveUrl.config.secret = 'secret'
344
+ end
345
+
346
+ it "should decode what it encodes" do
347
+ ActiveUrl::Crypto.decrypt(ActiveUrl::Crypto.encrypt("clear")).should == "clear"
348
+ end
349
+
350
+ it "should always yield URL-safe output characters" do
351
+ url_safe = /^[\w\-]*$/
352
+ (1..20).each do |n|
353
+ clear = (0...8).inject("") { |string, n| string << rand(255).chr } # random string
354
+ cipher = ActiveUrl::Crypto.encrypt(clear)
355
+ cipher.should =~ url_safe
356
+ end
357
+ end
358
+ end
@@ -0,0 +1,9 @@
1
+ require 'spec'
2
+
3
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
4
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
5
+ require 'active_url'
6
+
7
+ Spec::Runner.configure do |config|
8
+
9
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mholling-active_url
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Matthew Hollingworth
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-05-21 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: activerecord
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ description: ActiveUrl enables the storing of a model in an encrypted URL. It facilitates implementation of secret URLs for user (e.g. feed URLs) that can be accessed without logging in, and URLs for confirming the email address of a new user.
26
+ email: mdholling@gmail.com
27
+ executables: []
28
+
29
+ extensions: []
30
+
31
+ extra_rdoc_files:
32
+ - LICENSE
33
+ - README.textile
34
+ files:
35
+ - LICENSE
36
+ - README.textile
37
+ - Rakefile
38
+ - VERSION.yml
39
+ - lib/active_url.rb
40
+ - lib/active_url/base.rb
41
+ - lib/active_url/belongs_to.rb
42
+ - lib/active_url/callbacks.rb
43
+ - lib/active_url/configuration.rb
44
+ - lib/active_url/crypto.rb
45
+ - lib/active_url/errors.rb
46
+ - lib/active_url/validations.rb
47
+ - spec/active_url_spec.rb
48
+ - spec/spec_helper.rb
49
+ has_rdoc: false
50
+ homepage: http://github.com/mholling/active_url
51
+ post_install_message:
52
+ rdoc_options:
53
+ - --charset=UTF-8
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: "0"
61
+ version:
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: "0"
67
+ version:
68
+ requirements: []
69
+
70
+ rubyforge_project:
71
+ rubygems_version: 1.2.0
72
+ signing_key:
73
+ specification_version: 2
74
+ summary: A Rails library for generating secret URLs.
75
+ test_files:
76
+ - spec/active_url_spec.rb
77
+ - spec/spec_helper.rb