├── .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 | Build status 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 | --------------------------------------------------------------------------------