Basically completed jwt

This commit is contained in:
An Phan 2015-12-30 11:14:47 +07:00
parent 950772a701
commit 12e4bd473f
29 changed files with 493 additions and 166 deletions

View file

@ -6,6 +6,7 @@ use App\Services\Lastfm;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Tymon\JWTAuth\JWTAuth;
class LastfmController extends Controller
{
@ -31,19 +32,27 @@ class LastfmController extends Controller
*
* @param Redirector $redirector
* @param Lastfm $lastfm
* @param JWTAuth $auth
*
* @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
*/
public function connect(Redirector $redirector, Lastfm $lastfm)
public function connect(Redirector $redirector, Lastfm $lastfm, JWTAuth $auth = null)
{
if (!$lastfm->enabled()) {
abort(401, 'Koel is not configured to use with Last.fm yet.');
}
$auth = $auth ?: $this->app['tymon.jwt.auth'];
// A workaround to make sure Tymon's JWTAuth get the correct token via our custom
// "jwt-token" query string instead of the default "token".
// This is due to the problem that Last.fm returns the token via "token" as well.
$auth->parseToken('', '', 'jwt-token');
return $redirector->to(
'https://www.last.fm/api/auth/?api_key='
.$lastfm->getKey()
.'&cb='.route('lastfm.callback')
.'&cb='.urlencode(route('lastfm.callback').'?jwt-token='.$auth->getToken())
);
}

View file

@ -2,10 +2,32 @@
namespace App\Http\Controllers\API;
use App\Http\Streamers\PHPStreamer;
use App\Http\Streamers\XAccelRedirectStreamer;
use App\Http\Streamers\XSendFileStreamer;
use App\Models\Song;
class SongController extends Controller
{
/**
* Play a song.
*
* @link https://github.com/phanan/koel/wiki#streaming-music
*
* @param Song $song
*/
public function play(Song $song)
{
switch (env('STREAMING_METHOD')) {
case 'x-sendfile':
return (new XSendFileStreamer($song))->stream();
case 'x-accel-redirect':
return (new XAccelRedirectStreamer($song))->stream();
default:
return (new PHPStreamer($song))->stream();
}
}
/**
* Get extra information about a song via Last.fm.
*

View file

@ -1,68 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Foundation\Auth\AuthenticatesAndRegistersUsers;
use Illuminate\Foundation\Auth\ThrottlesLogins;
use Validator;
class AuthController extends Controller
{
/*
|--------------------------------------------------------------------------
| Registration & Login Controller
|--------------------------------------------------------------------------
|
| This controller handles the registration of new users, as well as the
| authentication of existing users. By default, this controller uses
| a simple trait to add these behaviors. Why don't you explore it?
|
*/
protected $redirectPath = '/♫';
protected $loginPath = '/login';
use AuthenticatesAndRegistersUsers, ThrottlesLogins;
/**
* Create a new authentication controller instance.
*/
public function __construct()
{
$this->middleware('guest', ['except' => 'getLogout']);
}
/**
* Get a validator for an incoming registration request.
*
* @param array $data
*
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
return Validator::make($data, [
'name' => 'required|max:255',
'email' => 'required|email|max:255|unique:users',
'password' => 'required|confirmed',
]);
}
/**
* Create a new user instance after a valid registration.
*
* @param array $data
*
* @return User
*/
protected function create(array $data)
{
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
]);
}
}

View file

@ -1,30 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
class PasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset requests
| and uses a simple trait to include this behavior. You're free to
| explore this trait and override any methods you wish to tweak.
|
*/
use ResetsPasswords;
/**
* Create a new password controller instance.
*/
public function __construct()
{
$this->middleware('guest');
}
}

View file

@ -5,7 +5,7 @@ namespace App\Http;
use App\Http\Middleware\Authenticate;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Tymon\JWTAuth\Middleware\GetUserFromToken;
use App\Http\Middleware\GetUserFromToken;
class Kernel extends HttpKernel
{

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use App\JWTAuth;
use Illuminate\Events\Dispatcher;
use Illuminate\Routing\ResponseFactory;
use Tymon\JWTAuth\Middleware\BaseMiddleware as JWTBaseMiddleware;
abstract class BaseMiddleware extends JWTBaseMiddleware
{
/**
* {@inheritdoc}
*/
public function __construct(ResponseFactory $response, Dispatcher $events, JWTAuth $auth)
{
parent::__construct($response, $events, $auth);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
class GetUserFromToken extends BaseMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, \Closure $next)
{
if (! $token = $this->auth->setRequest($request)->getToken()) {
return $this->respond('tymon.jwt.absent', 'token_not_provided', 400);
}
try {
$user = $this->auth->authenticate($token);
} catch (TokenExpiredException $e) {
return $this->respond('tymon.jwt.expired', 'token_expired', $e->getStatusCode(), [$e]);
} catch (JWTException $e) {
return $this->respond('tymon.jwt.invalid', 'token_invalid', $e->getStatusCode(), [$e]);
}
if (! $user) {
return $this->respond('tymon.jwt.user_not_found', 'user_not_found', 404);
}
$this->events->fire('tymon.jwt.valid', $user);
return $next($request);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\API;
class UserLoginRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'email' => 'required|email',
'password' => 'required',
];
}
}

View file

@ -4,39 +4,41 @@ Route::get('/', function () {
return view('index');
});
Route::get('{song}/play', 'PlaybackController@play');
Route::group(['prefix' => 'api', 'namespace' => 'API'], function () {
Route::group(['prefix' => 'api', 'middleware' => 'jwt.auth', 'namespace' => 'API'], function () {
Route::get('/', function () {
// Just acting as a ping service.
});
Route::get('data', 'DataController@index');
Route::post('settings', 'SettingController@save');
Route::post('{song}/scrobble/{timestamp}', 'SongController@scrobble')->where([
'timestamp' => '\d+',
]);
Route::get('{song}/info', 'SongController@getInfo');
Route::post('interaction/play', 'InteractionController@play');
Route::post('interaction/like', 'InteractionController@like');
Route::post('interaction/batch/like', 'InteractionController@batchLike');
Route::post('interaction/batch/unlike', 'InteractionController@batchUnlike');
Route::resource('playlist', 'PlaylistController', ['only' => ['store', 'update', 'destroy']]);
Route::put('playlist/{playlist}/sync', 'PlaylistController@sync')->where(['playlist' => '\d+']);
Route::resource('user', 'UserController', ['only' => ['store', 'update', 'destroy']]);
Route::post('me', 'UserController@login');
Route::put('me', 'UserController@updateProfile');
Route::get('lastfm/connect', 'LastfmController@connect');
Route::get('lastfm/callback', [
'as' => 'lastfm.callback',
'uses' => 'LastfmController@callback',
]);
Route::delete('lastfm/disconnect', 'LastfmController@disconnect');
Route::group(['middleware' => 'jwt.auth'], function () {
Route::get('/', function () {
// Just acting as a ping service.
});
Route::get('data', 'DataController@index');
Route::post('settings', 'SettingController@save');
Route::get('{song}/play', 'SongController@play');
Route::post('{song}/scrobble/{timestamp}', 'SongController@scrobble')->where([
'timestamp' => '\d+',
]);
Route::get('{song}/info', 'SongController@getInfo');
Route::post('interaction/play', 'InteractionController@play');
Route::post('interaction/like', 'InteractionController@like');
Route::post('interaction/batch/like', 'InteractionController@batchLike');
Route::post('interaction/batch/unlike', 'InteractionController@batchUnlike');
Route::resource('playlist', 'PlaylistController', ['only' => ['store', 'update', 'destroy']]);
Route::put('playlist/{playlist}/sync', 'PlaylistController@sync')->where(['playlist' => '\d+']);
Route::resource('user', 'UserController', ['only' => ['store', 'update', 'destroy']]);
Route::put('me', 'UserController@updateProfile');
Route::get('lastfm/connect', 'LastfmController@connect');
Route::get('lastfm/callback', [
'as' => 'lastfm.callback',
'uses' => 'LastfmController@callback',
]);
Route::delete('lastfm/disconnect', 'LastfmController@disconnect');
});
});

29
app/JWTAuth.php Normal file
View file

@ -0,0 +1,29 @@
<?php
namespace App;
use Illuminate\Http\Request;
use Tymon\JWTAuth\JWTAuth as BaseJWTAuth;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\JWTManager;
use Tymon\JWTAuth\Providers\Auth\AuthInterface;
use Tymon\JWTAuth\Providers\User\UserInterface;
class JWTAuth extends BaseJWTAuth
{
/**
* {@inheritdoc}
*/
public function __construct(JWTManager $manager, UserInterface $user, AuthInterface $auth, Request $request)
{
return parent::__construct($manager, $user, $auth, $request);
}
/**
* {@inheritdoc}
*/
public function parseToken($method = 'bearer', $header = 'authorization', $query = 'jwt-token')
{
return parent::parseToken($method, $header, $query);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Listeners;
class JWTEventListener
{
/**
* Handle user login events.
*/
public function onValidUser($event)
{
auth()->setUser($event->user);
}
/**
* Register the listeners for the subscriber.
*
* @param Illuminate\Events\Dispatcher $events
*/
public function subscribe($events)
{
$events->listen(
'tymon.jwt.valid',
'App\Listeners\JWTEventListener@onValidUser'
);
}
}

168
config/jwt.php Normal file
View file

@ -0,0 +1,168 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| JWT Authentication Secret
|--------------------------------------------------------------------------
|
| Don't forget to set this, as it will be used to sign your tokens.
| A helper command is provided for this: `php artisan jwt:generate`
|
*/
'secret' => env('JWT_SECRET', 'YTAOH41t3QYSCVYuJfGKzSSJ6l1sBtvG'),
/*
|--------------------------------------------------------------------------
| JWT time to live
|--------------------------------------------------------------------------
|
| Specify the length of time (in minutes) that the token will be valid for.
| Defaults to 1 hour
|
*/
'ttl' => 60 * 24 * 7,
/*
|--------------------------------------------------------------------------
| Refresh time to live
|--------------------------------------------------------------------------
|
| Specify the length of time (in minutes) that the token can be refreshed
| within. I.E. The user can refresh their token within a 2 week window of
| the original token being created until they must re-authenticate.
| Defaults to 2 weeks
|
*/
'refresh_ttl' => 20160,
/*
|--------------------------------------------------------------------------
| JWT hashing algorithm
|--------------------------------------------------------------------------
|
| Specify the hashing algorithm that will be used to sign the token.
|
| See here: https://github.com/namshi/jose/tree/2.2.0/src/Namshi/JOSE/Signer
| for possible values
|
*/
'algo' => 'HS256',
/*
|--------------------------------------------------------------------------
| User Model namespace
|--------------------------------------------------------------------------
|
| Specify the full namespace to your User model.
| e.g. 'Acme\Entities\User'
|
*/
'user' => App\Models\User::class,
/*
|--------------------------------------------------------------------------
| User identifier
|--------------------------------------------------------------------------
|
| Specify a unique property of the user that will be added as the 'sub'
| claim of the token payload.
|
*/
'identifier' => 'id',
/*
|--------------------------------------------------------------------------
| Required Claims
|--------------------------------------------------------------------------
|
| Specify the required claims that must exist in any token.
| A TokenInvalidException will be thrown if any of these claims are not
| present in the payload.
|
*/
'required_claims' => ['iss', 'iat', 'exp', 'nbf', 'sub', 'jti'],
/*
|--------------------------------------------------------------------------
| Blacklist Enabled
|--------------------------------------------------------------------------
|
| In order to invalidate tokens, you must have the the blacklist enabled.
| If you do not want or need this functionality, then set this to false.
|
*/
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Providers
|--------------------------------------------------------------------------
|
| Specify the various providers used throughout the package.
|
*/
'providers' => [
/*
|--------------------------------------------------------------------------
| User Provider
|--------------------------------------------------------------------------
|
| Specify the provider that is used to find the user based
| on the subject claim
|
*/
'user' => Tymon\JWTAuth\Providers\User\EloquentUserAdapter::class,
/*
|--------------------------------------------------------------------------
| JWT Provider
|--------------------------------------------------------------------------
|
| Specify the provider that is used to create and decode the tokens.
|
*/
'jwt' => Tymon\JWTAuth\Providers\JWT\NamshiAdapter::class,
/*
|--------------------------------------------------------------------------
| Authentication Provider
|--------------------------------------------------------------------------
|
| Specify the provider that is used to authenticate users.
|
*/
'auth' => function ($app) {
return new Tymon\JWTAuth\Providers\Auth\IlluminateAuthAdapter($app['auth']);
},
/*
|--------------------------------------------------------------------------
| Storage Provider
|--------------------------------------------------------------------------
|
| Specify the provider that is used to store tokens in the blacklist
|
*/
'storage' => function ($app) {
return new Tymon\JWTAuth\Providers\Storage\IlluminateCacheAdapter($app['cache']);
}
]
];

View file

@ -29,6 +29,7 @@
import loginForm from './components/auth/login-form.vue';
import sharedStore from './stores/shared';
import queueStore from './stores/queue';
import preferenceStore from './stores/preference';
import playback from './services/playback';
import ls from './services/ls';
@ -203,6 +204,18 @@
setOverlayDimissable() {
this.overlayState.dismissable = true;
},
/**
* Log the current user out and reset the application state.
*/
logout() {
ls.remove('jwt-token');
this.authenticated = false;
playback.stop();
queueStore.clear();
this.loadMainView('queue');
this.$broadcast('koel:teardown');
},
},
events: {

View file

@ -48,6 +48,19 @@
}
},
methods: {
/**
* Reset all applicable child components' states
*/
resetChildrenStates() {
_.each(this.$refs, child => {
if (typeof child.resetState === 'function') {
child.resetState();
}
});
},
},
events: {
'main-content-view:load': function (view) {
// Hide the panel away if a main view is triggered on mobile.
@ -57,17 +70,17 @@
},
'song:play': function (song) {
// Reset all applicable child components' states
_.each(this.$refs, child => {
if (typeof child.resetState === 'function') {
child.resetState();
}
});
this.resetChildrenStates();
songStore.getInfo(song, () => {
this.$broadcast('song:info-loaded', song);
});
},
'koel:teardown': function () {
this.currentView = 'lyrics';
this.resetChildrenStates();
},
},
};
</script>

View file

@ -76,6 +76,11 @@
'koel:ready': function () {
this.displayMore();
},
'koel:teardown': function () {
this.q = '';
this.numOfItems = 9;
},
},
};
</script>

View file

@ -76,6 +76,11 @@
'koel:ready': function () {
this.displayMore();
},
'koel:teardown': function () {
this.q = '';
this.numOfItems = 9;
},
},
};
</script>

View file

@ -104,6 +104,7 @@
import preferenceStore from '../../../stores/preference';
import sharedStore from '../../../stores/shared';
import http from '../../../services/http';
import ls from '../../../services/ls';
export default {
data() {
@ -156,7 +157,11 @@
* Koel will reload once the connection is successful.
*/
connectToLastfm() {
window.open('/api/lastfm/connect', '_blank', 'toolbar=no,titlebar=no,location=no,width=1024,height=640');
window.open(
`/api/lastfm/connect?jwt-token=${ls.get('jwt-token')}`,
'_blank',
'toolbar=no,titlebar=no,location=no,width=1024,height=640'
);
},
/**

View file

@ -265,6 +265,12 @@
'main-content-view:load': function (view) {
this.viewingQueue = view === 'queue';
},
'koel:teardown': function () {
this.song = songStore.stub;
this.playing = false;
this.liked = false;
},
},
};
</script>

View file

@ -38,6 +38,10 @@
'search:toggle': function () {
this.showing = !this.showing;
},
'koel:teardown': function () {
this.q = '';
},
},
};
</script>

View file

@ -5,7 +5,7 @@
<span class="name">{{ state.current.name }}</span>
</span>
<a href="/logout" class="logout"><i class="fa fa-sign-out control"></i></a>
<a class="logout" @click.prevent="logout"><i class="fa fa-sign-out control"></i></a>
</span>
</template>
@ -26,6 +26,10 @@
loadProfileView() {
this.$root.loadMainView('profile');
},
logout() {
this.$root.logout();
},
},
};
</script>

View file

@ -3,6 +3,8 @@ import $ from 'jquery';
import ls from './services/ls';
window.Vue = require('vue');
var app = new Vue(require('./app.vue'));
Vue.config.debug = false;
Vue.use(require('vue-resource'));
Vue.http.options.root = '/api';
@ -11,15 +13,15 @@ Vue.http.interceptors.push({
var token = ls.get('jwt-token');
if (token) {
Vue.http.headers.common.Authorization = token;
Vue.http.headers.common.Authorization = `Bearer ${token}`;
}
return request;
},
response(response) {
if (response.status && response.status.code == 401) {
ls.remove('jwt-token');
if (response.status === 400 || response.status === 401) {
app.logout();
}
if (response.headers && response.headers.Authorization) {
@ -27,7 +29,7 @@ Vue.http.interceptors.push({
}
if (response.data && response.data.token && response.data.token.length > 10) {
ls.set('jwt-token', `Bearer ${response.data.token}`);
ls.set('jwt-token', response.data.token);
}
return response;
@ -38,4 +40,4 @@ Vue.http.interceptors.push({
// Enter night,
// Take my hand,
// We're off to never never land.
new Vue(require('./app.vue')).$mount('body');
app.$mount('body');

View file

@ -34,4 +34,10 @@ export default {
}
},
},
events: {
'koel:teardown': function () {
this.numOfItems = 30;
},
},
};

View file

@ -16,9 +16,9 @@ export default {
case 'post':
return Vue.http.post(url, data, options).then(successCb, errorCb);
case 'put':
return Vue.http.put(url, data, cb, options).then(successCb, errorCb);
return Vue.http.put(url, data, options).then(successCb, errorCb);
case 'delete':
return Vue.http.delete(url, data, cb, options).then(successCb, errorCb);
return Vue.http.delete(url, data, options).then(successCb, errorCb);
default:
break;
}
@ -32,10 +32,6 @@ export default {
return this.request('post', url, data, successCb, errorCb, options);
},
patch(url, data, successCb = null, errorCb = null, options = {}) {
return this.request('patch', url, data, successCb, errorCb, options);
},
put(url, data, successCb = null, errorCb = null, options = {}) {
return this.request('put', url, data, successCb, errorCb, options);
},

View file

@ -6,6 +6,7 @@ import songStore from '../stores/song';
import artistStore from '../stores/artist';
import albumStore from '../stores/album';
import preferenceStore from '../stores/preference';
import ls from '../services/ls';
import config from '../config';
export default {
@ -90,7 +91,7 @@ export default {
this.app.$broadcast('song:play', song);
$('title').text(`${song.title} ♫ Koel`);
this.player.source(`/${song.id}/play`);
this.player.source(`/api/${song.id}/play?jwt-token=${ls.get('jwt-token')}`);
this.player.play();
// Register the play to the server

View file

@ -12,6 +12,10 @@ export default {
return this.state.songs;
},
clear() {
this.state.songs = [];
},
/**
* Toggle like/unlike a song.
* A request to the server will be made.

View file

@ -13,7 +13,7 @@ export default {
// How about another song then?
//
// LITTLE WING
// -- by Jimi Fucking Hendrick
// -- by Jimi Fucking Hendrix
//
// Well she's walking
// Through the clouds

View file

@ -27,8 +27,10 @@ export default {
},
init(successCb = null, errorCb = null) {
http.get('data', {}, response => {
assign(this.state, response.data);
this.reset();
http.get('data', data => {
assign(this.state, data);
// If this is a new user, initialize his preferences to be an empty object.
if (!this.state.currentUser.preferences) {
@ -43,15 +45,23 @@ export default {
queueStore.init();
settingStore.init(this.state.settings);
window.useLastfm = this.state.useLastfm = response.data.useLastfm;
window.useLastfm = this.state.useLastfm = data.useLastfm;
}, successCb, errorCb);
},
if (successCb) {
successCb();
}
}, error => {
if (errorCb) {
errorCb();
}
});
reset() {
this.state.songs = [];
this.state.albums = [];
this.state.artists = [];
this.state.favorites = [];
this.state.queued = [];
this.state.interactions = [];
this.state.users = [];
this.state.settings = [];
this.state.currentUser = null;
this.state.playlists = [];
this.state.useLastfm = false;
this.state.currentVersion = '';
this.state.latestVersion = '';
},
};

View file

@ -40,6 +40,8 @@ export default {
* @param {Array} interactions The array of interactions of the current user
*/
initInteractions(interactions) {
favoriteStore.clear();
_.each(interactions, interaction => {
var song = this.byId(interaction.song_id);

View file

@ -17,6 +17,7 @@ use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Mockery as m;
use Tymon\JWTAuth\JWTAuth;
class LastfmTest extends TestCase
{
@ -134,8 +135,12 @@ class LastfmTest extends TestCase
$redirector->shouldReceive('to')->once();
$guard = m::mock(Guard::class, ['user' => factory(User::class)->create()]);
$auth = m::mock(JWTAuth::class, [
'parseToken' => '',
'getToken' => '',
]);
(new LastfmController($guard))->connect($redirector, new Lastfm());
(new LastfmController($guard))->connect($redirector, new Lastfm(), $auth);
}
public function testControllerCallback()