Skip to content

Commit

Permalink
Add basic authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
imdrasil committed Jul 5, 2018
1 parent 486678a commit cf07f51
Show file tree
Hide file tree
Showing 21 changed files with 321 additions and 76 deletions.
65 changes: 65 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Authentication

To add authentication to your user model just use `Jennifer::Model::Authentication` module's `with_authentication` macro:

```crystal
require "jennifer/model/authentication"
class User < Jennifer::Model::Base
include Jennifer::Model::Authentication
with_authentication
mapping(
id: Primary32,
email: {type: String, default: ""},
password_digest: {type: String, default: ""},
password: Password,
password_confirmation: { type: String?, virtual: true }
)
end
```

`Password` in the `password` field definition is actually `Jennifer::Model::Authentication::Password` constant which includes definition for virtual password attribute. It looks like:

```crystal
Password = {
type: String?,
virtual: true,
setter: false
}
```

Mapping automatically resolves it to it's definition. At the moment only top level non generic definition could be used, e.g. `password: { type: Password }` and `password: Password?` are not supported.

For authentication `Crypto::Bcrypt::Password` is used. This mechanism requires you to have a `password_digest`, `password`, `password_confirmation` attributes defined in your mapping. This attribute can be customized - `with_authentication` macro accepts next arguments:

- `password` - represents string based raw password attribute name;
- `password_digest` - represents string based encrypted password.

> NOTE: `password_confirmation` attribute name is generated based on the `password` value + `_confirmation`.
The following validations are added automatically:

- password must be present on creation;
- password length should be less than or equal to 51 characters;
- confirmation of password (using a password_confirmation attribute).

If password confirmation validation is not needed, simply leave out the value for password_confirmation (i.e. don't provide a form field for it). When this attribute has a nil value, the validation will not be triggered.

```crystal
user = User.build(name: "david")
user.password = ""
user.password_confirmation = "nomatch"
user.save # => false, password required
user.password = "mUc3m00RsqyRe"
user.save # => false, confirmation doesn't match
user.password_confirmation = 'mUc3m00RsqyRe'
user.save # => true
user.authenticate("notright") # => false
user.authenticate("mUc3m00RsqyRe") # => user
User.find_by(name: "david").try(&.authenticate("notright")) # nil
User.find_by(name: "david").try(&.authenticate("mUc3m00RsqyRe")) # => user
```
18 changes: 1 addition & 17 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,25 @@
# Jennifer Documentation

* [Latest API Documentation](https://imdrasil.github.io/jennifer.cr/latest/)

* [Other API versions](https://imdrasil.github.io/jennifer.cr/versions)

## Content

* [Configuration](./configuration.md)

* [Migration](./migration.md)

* [Model. Mapping](./model_mapping.md)

* [Model. STI](./model_sti.md)

* [View](./view.md)

* [Callbacks](./callbacks.md)

* [Validation](./validation.md)

* [Timestamps](./timestamps.md)
* [Authentication](./authentication.md)
* [Relations](./relations.md)

* [CRUD](./crud.md)

* [Jennifer::Record](./record.md)

* [Query DSL](./query_dsl.md)

* [Eager loading](./eager_loading.md)

* [Pagination and Ordering](./pagination_and_ordering.md)

* [Model. Scopes](./model_scopes.md)

* [Aggregation](./aggregation.md)

* [Transaction and Lock](./transaction_and_lock.md)
27 changes: 27 additions & 0 deletions examples/migrations/20180312114638349_create_users.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class CreateUsers < Jennifer::Migration::Base
def up
create_table(:users) do |t|
t.string :email, {:null => false}
t.string :password_digest, {:null => false}
t.string :name
end

change_table(:contacts) do |t|
t.add_column :user_id, :integer
end
refresh_male_contacts_view
end

def down
drop_table :users
change_table(:contacts) do |t|
t.drop_column :user_id
end
refresh_male_contacts_view
end

private def refresh_male_contacts_view
drop_view(:male_contacts)
create_view(:male_contacts, Jennifer::Query["contacts"].where { sql("gender = 'male'") })
end
end
3 changes: 2 additions & 1 deletion generate-docs.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
f1="./src/jennifer.cr "
f2="./src/jennifer/adapter/mysql.cr " # for mysql
f3="./src/jennifer/adapter/postgres.cr " # for postgres
f4="./src/jennifer/model/authentication.cr "

echo $f1$f2$f3 | xargs crystal doc
echo $f1$f2$f3$f4 | xargs crystal doc -odoc
4 changes: 2 additions & 2 deletions spec/adapter/result_parsers_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ require "../spec_helper"
describe Jennifer::Adapter::ResultParsers do
adapter = Jennifer::Adapter.default_adapter
contact_fields = db_specific(
postgres: -> { %w(id name age tags ballance gender created_at updated_at description) },
mysql: -> { %w(id name age ballance gender created_at updated_at description) }
postgres: -> { %w(id name age tags ballance gender created_at updated_at description user_id) },
mysql: -> { %w(id name age ballance gender created_at updated_at description user_id) }
)

describe "#result_to_hash" do
Expand Down
21 changes: 20 additions & 1 deletion spec/factories.cr
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@ class QueryFactory < Factory::Base
end
end

class UserFactory < Factory::Jennifer::Base
attr :name, "User"
sequence(:email) { |i| "email#{i}@example.com" }

trait :with_valid_password do
assign :password, "password"
assign :password_confirmation, "password"
end

trait :with_invalid_password_confirmation do
assign :password, "password"
assign :password_confirmation, "passwordd"
end

trait :with_password_digest do
attr :password_digest, Crypto::Bcrypt::Password.create("password").to_s, String
end
end

class ContactFactory < Factory::Jennifer::Base
postgres_only do
argument_type (Array(Int32) | Int32 | PG::Numeric | String?)
Expand Down Expand Up @@ -114,5 +133,5 @@ class MaleContactFactory < Factory::Base
attr :name, "Raphael"
attr :age, 21
attr :gender, "male"
attr :created_at, -> { Time.utc_now }
attr :created_at, ->{ Time.utc_now }
end
1 change: 0 additions & 1 deletion spec/model/all.cr

This file was deleted.

70 changes: 70 additions & 0 deletions spec/model/authentication_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
require "../spec_helper"

describe Jennifer::Model::Authentication do
describe "%with_authentication" do
context "with default field names" do
default_user = Factory.build_user

describe "validations" do
it do
user = Factory.build_user
user.password = "1" * 52
user.should validate(:password).with("is too long (maximum is 51 characters)")
end

it { Factory.build_user([:with_invalid_password_confirmation]).should validate(:password).with("doesn't match Password") }
it { Factory.build_user.should validate(:password).with("can't be blank") }
it { Factory.build_user([:with_valid_password]).should be_valid }

it do
user = Factory.build_user
user.password_digest = Crypto::Bcrypt::Password.create("password").to_s
user.should be_valid
end
it do
Factory.create_user([:with_password_digest])
user = User.all.last!
user.should be_valid
end
end

describe "::password_digest_cost" do
it { User.password_digest_cost.should eq(Crypto::Bcrypt::DEFAULT_COST) }
end

describe "#password=" do
it do
user = Factory.build_user
user.password = nil
user.password_digest.should eq("")
end

it do
user = Factory.build_user
user.password = ""
user.password_digest.should eq("")
end

it do
user = Factory.build_user
user.password = "1" * 53
user.password_digest.should eq("")
end

it do
user = Factory.build_user
user.password = "password"
user.password_digest.empty?.should_not be_true
end
end

describe "#authenticate" do
it { Factory.build_user([:with_password_digest]).authenticate("gibberish").should be_nil }
it do
user = Factory.build_user([:with_password_digest])
user.authenticate("password").should eq(user)
end
end
end
end
end
14 changes: 5 additions & 9 deletions spec/model/mapping_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -259,15 +259,11 @@ describe Jennifer::Model::Mapping do

describe "::field_count" do
it "returns correct number of model fields" do
postgres_only do
proper_count = 9
Contact.field_count.should eq(proper_count)
end

mysql_only do
proper_count = 8
Contact.field_count.should eq(proper_count)
end
proper_count = db_specific(
mysql: -> { 9 },
postgres: -> { 10 }
)
Contact.field_count.should eq(proper_count)
end
end

Expand Down
2 changes: 1 addition & 1 deletion spec/model/relation_definition_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe Jennifer::Model::RelationDefinition do

describe "%has_many" do
it "adds relation name to RELATIONS constant" do
Contact::RELATIONS.size.should eq(6)
Contact::RELATIONS.size.should eq(7)
Contact::RELATIONS.has_key?("addresses").should be_true
end

Expand Down
26 changes: 13 additions & 13 deletions spec/model/validation_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe Jennifer::Model::Validation do
end
end

describe "%validates_inclucions" do
describe "%validates_inclusions" do
it "pass valid" do
a = Factory.build_contact(age: 75)
a.should be_valid
Expand Down Expand Up @@ -166,7 +166,7 @@ describe Jennifer::Model::Validation do
a = Factory.build_address(street: "Saint Moon walk")
a.should validate(:street).with("is invalid")
end

context "allows blank" do
pending "doesn't add error message" do
end
Expand Down Expand Up @@ -264,16 +264,16 @@ describe Jennifer::Model::Validation do
end

describe "%validates_uniqueness" do
it "pass valid" do
p = Factory.build_country(name: "123asd")
p.should be_valid
end
it { Factory.build_country(name: "123asd").should be_valid }
it { Factory.create_country(name: "123asd").should be_valid }

it "doesn't pass invalid" do
it do
Factory.create_country(name: "123asd")
p = Factory.build_country(name: "123asd")
p.should validate(:name).with("has already been taken")
end

pending "allows blank" {}
end

describe "%validates_presence" do
Expand Down Expand Up @@ -455,19 +455,19 @@ describe Jennifer::Model::Validation do

it "pass validation" do
c = ConfirmationContact.build({
:name => "name",
:case_insensitive_name => "cin",
:name_confirmation => "name",
:name => "name",
:case_insensitive_name => "cin",
:name_confirmation => "name",
:case_insensitive_name_confirmation => "CIN"
})
c.should be_valid
end

it "adds error message if doesn't satisfies validation" do
c = ConfirmationContact.build({
:name => "name",
:case_insensitive_name => "cin",
:name_confirmation => "Name",
:name => "name",
:case_insensitive_name => "cin",
:name_confirmation => "Name",
:case_insensitive_name_confirmation => "NIC"
})
c.should validate(:name).with("doesn't match Name")
Expand Down
Loading

0 comments on commit cf07f51

Please sign in to comment.