├── .gitignore
├── .php_cs
├── .travis.yml
├── LICENSE
├── README.md
├── composer.json
├── config
└── passwordless.php
├── lang
└── en
│ └── errors.php
├── migrations
└── 2016_11_21_121642_create_user_tokens_migration.php
├── phpunit.xml
├── src
├── Exceptions
│ └── InvalidTokenException.php
├── Models
│ └── Token.php
├── Providers
│ └── PasswordlessProvider.php
└── Traits
│ └── Passwordless.php
└── tests
├── PasswordlessTraitTest.php
└── TokenTest.php
/.gitignore:
--------------------------------------------------------------------------------
1 | vendor/
2 | composer.lock
3 |
--------------------------------------------------------------------------------
/.php_cs:
--------------------------------------------------------------------------------
1 | finder(DefaultFinder::create()->in(__DIR__))
75 | ->fixers($fixers)
76 | ->level(FixerInterface::NONE_LEVEL)
77 | ->setUsingCache(true);
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: php
2 |
3 | php:
4 | - 5.6
5 | - 7.0
6 | - 7.1
7 |
8 | sudo: true
9 |
10 | install:
11 | - travis_retry composer install --no-interaction --prefer-source
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 RAFIE Younes
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Laravel Passwordless
2 |
3 | Passwordless authentication for Laravel 5
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | ## Installation
15 |
16 | Add the package to your project using Composer:
17 |
18 | ```bash
19 | composer require whyounes/laravel-passwordless-auth
20 | ```
21 |
22 | Publish package assets:
23 |
24 | ```php
25 | php artisan vandor:publish
26 | ```
27 |
28 | Run the migration to create the tokens table:
29 |
30 | ```php
31 | php artisan migrate
32 | ```
33 |
34 | Add it to you providers list:
35 |
36 | ```php
37 | // config/app.php
38 |
39 | // ...
40 | 'providers' => [
41 | // ...
42 | Whyounes\Passwordless\Providers\PasswordlessProvider::class,
43 | };
44 | ```
45 |
46 | Add the `Passwordless` trait to your user model:
47 |
48 | ```php
49 | // app/User.php
50 |
51 | class User extends Authenticatable
52 | {
53 | use Whyounes\Passwordless\Traits\Passwordless;
54 |
55 | // ...
56 | }
57 | ```
58 |
59 | ## Configurations
60 |
61 | If you don't want to use the user email along with the token, you can change it by overriding the following method:
62 |
63 | ```php
64 | // app/User.php
65 |
66 | class User extends Authenticatable
67 | {
68 | use Whyounes\Passwordless\Traits\Passwordless;
69 |
70 | // ...
71 |
72 | protected function getIdentifierKey()
73 | {
74 | return 'email';
75 | }
76 | }
77 | ```
78 |
79 | You can change the expiration time inside the `config/passwordless.php` file:
80 |
81 | ```php
82 | // config/passwordless.php
83 |
84 | return [
85 | 'expire_in' => 15, // Minutes
86 | 'empty_tokens_after_login' => true // Empty user tokens after login
87 | ];
88 | ```
89 |
90 | You can set the `empty_tokens_after_login` config to false if you don't want to delete unused tokens from DB.
91 |
92 | ## Example
93 |
94 | Display the login form for user to type the email:
95 |
96 | ```php
97 | // routes/web.php
98 |
99 | Route::post('/login/direct', function() {
100 | return view('login.direct');
101 | });
102 | ```
103 |
104 | Catch the form submission:
105 |
106 | ```php
107 | // routes/web.php
108 |
109 | Route::post('/login/direct', function(Request $request) {
110 | // send link to user mail
111 | $user = App\User::where('email', $request->get('email'))->first();
112 | if (!$user) {
113 | return redirect()->back(404)->with('error', 'User not found');
114 | }
115 |
116 | // generate token and save it
117 | $token = $user->generateToken(true);
118 |
119 | // send email to user
120 | \Mail::send("mails.login", ['token' => $token], function($message) use($token) {
121 | $message->to($token->user->email);
122 | });
123 | });
124 | ```
125 |
126 | Catch the login link request:
127 |
128 | ```php
129 | // routes/web.php
130 |
131 | Route::get('/login/{token}', function(Request $request, $token) {
132 | $user = App\User::where('email', $request->get('email'))->first();
133 |
134 | if (!$user) {
135 | dd('User not found');
136 | }
137 |
138 | if($user->isValidToken($token))
139 | {
140 | // Login user
141 | Auth::login($user);
142 | } else {
143 | dd("Invalid token");
144 | }
145 | });
146 | ```
147 |
148 | Or, if you like working with exceptions:
149 |
150 | ```php
151 | // routes/web.php
152 |
153 | Route::get('/login/{token}', function(Request $request, $token) {
154 | try {
155 | $user = App\User::where('email', $request->get('email'))->firstOrFail();
156 | $user->validateToken($token);
157 |
158 | Auth::login($user);
159 | } catch(Illuminate\Database\Eloquent\ModelNotFoundException $ex) {
160 | dd('User not found');
161 | } catch(Whyounes\Passwordless\Exceptions\InvalidTokenException $ex) {
162 | dd("Invalid token");
163 | }
164 | });
165 | ```
166 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "whyounes/laravel-passwordless-auth",
3 | "type": "library",
4 | "description": "Laravel passwordless authentication for Laravel",
5 | "keywords": [
6 | "auth",
7 | "passwordless"
8 | ],
9 | "homepage": "https://github.com/whyounes/laravel-passwordless-auth",
10 | "license": "MIT",
11 | "authors": [
12 | {
13 | "name": "Rafie Younes",
14 | "email": "younes.rafie@gmail.com",
15 | "homepage": "http://younesrafie.com",
16 | "role": "Developer"
17 | }
18 | ],
19 | "require": {
20 | "php": "^5.5.9 || ^7.0",
21 | "illuminate/database": "5.1.* || 5.2.* || 5.3.*",
22 | "illuminate/config": "5.1.* || 5.2.* || 5.3.*",
23 | "illuminate/events": "5.1.* || 5.2.* || 5.3.*"
24 | },
25 | "require-dev": {
26 | "phpunit/phpunit": "^4.8 || ^5.0",
27 | "orchestra/testbench": "~3.0",
28 | "mockery/mockery": "~0.9.4"
29 | },
30 | "autoload": {
31 | "psr-4": {
32 | "Whyounes\\Passwordless\\": "src"
33 | },
34 | "classmap": [
35 | "tests"
36 | ]
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/config/passwordless.php:
--------------------------------------------------------------------------------
1 | 15, // Minutes
5 | 'empty_tokens_after_login' => false // Empty user tokens after login
6 | ];
7 |
--------------------------------------------------------------------------------
/lang/en/errors.php:
--------------------------------------------------------------------------------
1 | "Invalid token",
5 | ];
6 |
--------------------------------------------------------------------------------
/migrations/2016_11_21_121642_create_user_tokens_migration.php:
--------------------------------------------------------------------------------
1 | increments('id');
20 | $table->string('token');
21 | $table->integer('user_id')
22 | ->unsigned();
23 | $table->timestamp('created_at')
24 | ->nullable();
25 | }
26 | );
27 | }
28 |
29 |
30 | /**
31 | * Reverse the migrations.
32 | *
33 | * @return void
34 | */
35 | public function down()
36 | {
37 | Schema::dropIfExists('user_tokens');
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 | ./tests/
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/Exceptions/InvalidTokenException.php:
--------------------------------------------------------------------------------
1 | isExpired();
29 | }
30 |
31 | /**
32 | * Is token expired.
33 | *
34 | * @return bool
35 | */
36 | public function isExpired()
37 | {
38 | return $this->created_at
39 | ->diffInMinutes(Carbon::now()) > (int)config("passwordless.expire_in");
40 | }
41 |
42 | /**
43 | * Ignore the updated_at column.
44 | *
45 | * @param mixed $value Update date
46 | *
47 | * @return null
48 | */
49 | public function setUpdatedAt($value)
50 | {
51 | }
52 |
53 |
54 | /**
55 | * Token belongs to auth user
56 | *
57 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
58 | */
59 | public function user()
60 | {
61 | return $this->belongsTo(config("auth.providers.users.model"));
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/Providers/PasswordlessProvider.php:
--------------------------------------------------------------------------------
1 | loadMigrationsFrom(__DIR__.'/../../migrations');
18 | $this->publishes(
19 | [
20 | __DIR__.'/../../config/passwordless.php' => config_path('passwordless.php'),
21 | __DIR__.'/../../lang' => resource_path('lang/vendor/passwordless'),
22 | ]
23 | );
24 | }
25 |
26 | /**
27 | * Regsiter application components
28 | */
29 | public function register()
30 | {
31 | $this->registerEvents();
32 | }
33 |
34 | /**
35 | * Register application event listeners
36 | */
37 | public function registerEvents()
38 | {
39 | // Delete user tokens after login
40 | if (config('passwordless.empty_tokens_after_login') === true) {
41 | Event::listen(
42 | Authenticated::class,
43 | function (Authenticated $event) {
44 | $event->user->tokens()
45 | ->delete();
46 | }
47 | );
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Traits/Passwordless.php:
--------------------------------------------------------------------------------
1 | isValidToken($token)) {
20 | throw new InvalidTokenException(trans("passwordless.errors.invalid_token"));
21 | }
22 | }
23 |
24 | /**
25 | * Validate attributes.
26 | *
27 | * @param string $token Token
28 | * @return bool
29 | */
30 | public function isValidToken($token)
31 | {
32 | if (! $token) {
33 | return false;
34 | }
35 |
36 | /**
37 | * @var $tokenModel Token
38 | */
39 | $tokenModel = $this->tokens()->where('token', $token)->first();
40 |
41 | return $tokenModel ? $tokenModel->isValid() : false;
42 | }
43 |
44 | /**
45 | * Generate a token for the current user.
46 | *
47 | * @param bool $save Generate token and save it.
48 | *
49 | * @return Token
50 | */
51 | public function generateToken($save = false)
52 | {
53 | $attributes = [
54 | 'token' => str_random(16),
55 | 'is_used' => false,
56 | 'user_id' => $this->id,
57 | 'created_at' => time()
58 | ];
59 |
60 | $token = App::make(Token::class);
61 | $token->fill($attributes);
62 | if ($save) {
63 | $token->save();
64 | }
65 |
66 | return $token;
67 | }
68 |
69 | /**
70 | * User tokens relation.
71 | *
72 | * @return mixed
73 | */
74 | public function tokens()
75 | {
76 | return $this->hasMany(Token::class, 'user_id', 'id');
77 | }
78 |
79 | /**
80 | * Identifier name to be used with token.
81 | *
82 | * @return string
83 | */
84 | protected function getIdentifierKey()
85 | {
86 | return 'email';
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/tests/PasswordlessTraitTest.php:
--------------------------------------------------------------------------------
1 | passwordlessStub = new PasswordlessTraitTestStub();
23 | }
24 |
25 |
26 | /**
27 | * @test
28 | * @covers Whyounes\Passwordless\Traits\Passwordless::isValidToken()
29 | */
30 | public function assert_valid_token()
31 | {
32 | $this->assertTrue($this->passwordlessStub->isValidToken('token-1'));
33 | }
34 |
35 |
36 | /**
37 | * @test
38 | * @covers Whyounes\Passwordless\Traits\Passwordless::isValidToken()
39 | */
40 | public function assert_invalid_token()
41 | {
42 | $this->assertFalse($this->passwordlessStub->isValidToken('token-4'));
43 | }
44 |
45 |
46 | /**
47 | * @test
48 | * @covers Whyounes\Passwordless\Traits\Passwordless::validateToken()
49 | */
50 | public function assert_throws_exception_on_invalid_token()
51 | {
52 | $this->expectException(InvalidTokenException::class);
53 | $this->passwordlessStub->validateToken('token-4');
54 | }
55 |
56 |
57 | /**
58 | * @test
59 | * @covers Whyounes\Passwordless\Traits\Passwordless::generateToken()
60 | */
61 | public function assert_it_generates_token()
62 | {
63 | $token = $this->passwordlessStub->generateToken();
64 |
65 | $this->assertInstanceOf(Token::class, $token);
66 | $this->assertInternalType('string', $token->token);
67 | }
68 |
69 |
70 | /**
71 | * @test
72 | * @runTestsInSeparateProcesses
73 | * @preserveGlobalState disabled
74 | * @covers Whyounes\Passwordless\Traits\Passwordless::generateToken()
75 | */
76 | public function assert_it_generates_token_and_save_it()
77 | {
78 | $tokenMock = m::mock(Token::class."[save]");
79 | $tokenMock->shouldDeferMissing()
80 | ->shouldReceive('save')
81 | ->once()
82 | ->andReturn(true);
83 | $this->app->instance(Token::class, $tokenMock);
84 |
85 | $token = $this->passwordlessStub->generateToken(true);
86 |
87 | $this->assertInstanceOf(Token::class, $token);
88 | $this->assertInternalType('string', $token->token);
89 | }
90 |
91 | /**
92 | * @test
93 | * @covers Whyounes\Passwordless\Providers\PasswordlessProvider::registerEvents()
94 | */
95 | public function assert_deletes_tokens_after_auth()
96 | {
97 | // Please contact me if you have a better way to test this :)
98 | // This is called after authentication
99 | $this->passwordlessStub->tokens()->delete();
100 | $this->assertEquals(0, $this->passwordlessStub->tokens->count());
101 | }
102 | }
103 |
104 | class PasswordlessTraitTestStub
105 | {
106 |
107 | use Passwordless;
108 |
109 | public $id;
110 |
111 | public $email;
112 |
113 | public $tokens;
114 |
115 | private $tokensMock;
116 |
117 |
118 | public function __construct()
119 | {
120 | $this->id = 1;
121 | $this->email = 'younes.rafie@gmail.com';
122 | $this->setTokens();
123 | $this->setTokensMock();
124 | }
125 |
126 |
127 | /**
128 | * Fill user tokens
129 | */
130 | private function setTokens()
131 | {
132 | $now = Carbon\Carbon::now();
133 | $this->tokens = collect();
134 |
135 | for ($i = 1; $i < 5; $i++) {
136 | $this->tokens[] = new Token(
137 | [
138 | 'token' => "token-{$i}",
139 | 'user_id' => 1,
140 | 'created_at' => $now->subMinutes(5 * $i),
141 | ]
142 | );
143 | }
144 | }
145 |
146 |
147 | /**
148 | * Mock querying tokens from DB
149 | */
150 | private function setTokensMock()
151 | {
152 | $this->tokensMock = m::mock(stdClass::class);
153 |
154 | $this->tokensMock->shouldReceive('where')
155 | ->andReturnUsing(
156 | function () {
157 | $foundToken = $this->tokens->where('token', func_get_arg(1));
158 |
159 | return $foundToken;
160 | }
161 | );
162 |
163 | $this->tokensMock->shouldReceive('delete')
164 | ->andReturnUsing(function () {
165 | $this->tokens = collect();
166 | });
167 | }
168 |
169 |
170 | /**
171 | * Return tokens mock instead of Eloquent relation
172 | *
173 | * @return mixed
174 | */
175 | public function tokens()
176 | {
177 | return $this->tokensMock;
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/tests/TokenTest.php:
--------------------------------------------------------------------------------
1 | 'token',
20 | 'created_at' => Carbon::now(),
21 | ]
22 | );
23 |
24 | $this->assertTrue($token->isValid());
25 | }
26 |
27 |
28 | /**
29 | * @test
30 | * @covers Whyounes\Passwordless\Models\Token::isValid()
31 | * @covers Whyounes\Passwordless\Models\Token::isExpired()
32 | */
33 | public function assert_token_is_invalid()
34 | {
35 | $token = new Token(
36 | [
37 | 'token' => 'token',
38 | 'created_at' => Carbon::now()
39 | ->subMinutes(20),
40 | ]
41 | );
42 |
43 | $this->assertFalse($token->isValid());
44 | $this->assertTrue($token->isExpired());
45 | }
46 | }
47 |
--------------------------------------------------------------------------------