Add CSRF token to all internal session-authenticated API requests.

This commit is contained in:
Buster "Silver Eagle" Neece 2021-08-27 18:45:25 -05:00
parent 95a9b8c781
commit 5a2f1a42e5
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
50 changed files with 17905 additions and 7967 deletions

View File

@ -7,8 +7,23 @@ release channel, you can take advantage of these new features and fixes.
## Code Quality/Technical Changes
- A number of security fixes are being incorporated into the software as of this version. See below for details.
## Bug Fixes
## Security Fixes
- Session cookies are now marked as HTTP-only, avoiding possible use by custom JavaScript that may be injected into a
given page.
- If the "Always Use HTTPS" setting is enabled, session cookies will be sent as "secure only" as well.
- API calls will now either require API key authentication _or_ both a current active login session and a unique
identifier; if you're calling the API externally, you should _always_ use a generated API key and not count on the
user's existing session.
-
---
# AzuraCast 0.14.1 (Aug 22, 2021)

View File

@ -2,6 +2,8 @@
use App\Environment;
use App\Http\ServerRequest;
use App\Middleware\Auth\ApiAuth;
use App\Session\Csrf;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
@ -40,19 +42,29 @@ return [
],
],
'vue-translations' => [
'vue-base' => [
'order' => 4,
'files' => [
'js' => [
[
'src' => 'dist/VueTranslations.js',
'src' => 'dist/VueBase.js',
],
],
],
'inline' => [
'js' => [
function (Request $request) {
return 'VueTranslations.default(App.locale);';
$csrfJson = 'null';
$csrf = $request->getAttribute(ServerRequest::ATTR_SESSION_CSRF);
if ($csrf instanceof Csrf) {
$csrfToken = $csrf->generate(ApiAuth::API_CSRF_NAMESPACE);
$csrfJson = json_encode($csrfToken, JSON_THROW_ON_ERROR);
}
return <<<JS
VueBase.default(App.locale, ${csrfJson});
JS;
},
],
],
@ -76,7 +88,7 @@ return [
'vue-component-common' => [
'order' => 3,
'require' => ['vue', 'vue-translations'],
'require' => ['vue', 'vue-base'],
'files' => [
'js' => [
[

View File

@ -83,7 +83,6 @@ return function (CallableEventDispatcherInterface $dispatcher) {
$app->add(Middleware\WrapExceptionsWithRequestData::class);
$app->add(Middleware\EnforceSecurity::class);
$app->add(Middleware\GetCurrentUser::class);
// Request injection middlewares.
$app->add(Middleware\InjectRouter::class);

View File

@ -1,13 +1,24 @@
<?php
use App\Middleware;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;
return function (App $app) {
call_user_func(include(__DIR__ . '/routes/admin.php'), $app);
call_user_func(include(__DIR__ . '/routes/api.php'), $app);
call_user_func(include(__DIR__ . '/routes/base.php'), $app);
call_user_func(include(__DIR__ . '/routes/public.php'), $app);
call_user_func(include(__DIR__ . '/routes/stations.php'), $app);
return static function (App $app) {
$app->group(
'',
function (RouteCollectorProxy $group) {
call_user_func(include(__DIR__ . '/routes/admin.php'), $group);
call_user_func(include(__DIR__ . '/routes/base.php'), $group);
call_user_func(include(__DIR__ . '/routes/public.php'), $group);
call_user_func(include(__DIR__ . '/routes/stations.php'), $group);
}
)->add(Middleware\Auth\StandardAuth::class);
$app->group(
'',
function (RouteCollectorProxy $group) {
call_user_func(include(__DIR__ . '/routes/api.php'), $group);
}
)->add(Middleware\Auth\ApiAuth::class);
};

View File

@ -3,10 +3,9 @@
use App\Acl;
use App\Controller;
use App\Middleware;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;
return function (App $app) {
return static function (RouteCollectorProxy $app) {
$app->group(
'/admin',
function (RouteCollectorProxy $group) {

View File

@ -5,10 +5,9 @@ use App\Controller;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Middleware;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;
return function (App $app) {
return static function (RouteCollectorProxy $app) {
$app->group(
'/api',
function (RouteCollectorProxy $group) {

View File

@ -2,10 +2,9 @@
use App\Controller;
use App\Middleware;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;
return function (App $app) {
return static function (RouteCollectorProxy $app) {
$app->get('/', Controller\Frontend\IndexAction::class)
->setName('home');

View File

@ -2,10 +2,9 @@
use App\Controller;
use App\Middleware;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;
return function (App $app) {
return static function (RouteCollectorProxy $app) {
$app->get('/sw.js', Controller\Frontend\PWA\ServiceWorkerAction::class)
->setName('public:sw');

View File

@ -5,10 +5,9 @@ use App\Controller;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Middleware;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;
return function (App $app) {
return static function (RouteCollectorProxy $app) {
$app->group(
'/station/{station_id}',
function (RouteCollectorProxy $group) {

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,7 @@
"store": "^1.3.20",
"sweetalert2": "^10.16.6",
"vue": "^2.6.12",
"vue-axios": "^3.2.5",
"vue-gettext": "^2.1.12",
"vue-loader": "14.2.2",
"vue-template-compiler": "^2.6.12",

View File

@ -135,7 +135,6 @@
<script>
import {validationMixin} from "vuelidate";
import handleAxiosError from "../../Function/handleAxiosError";
import axios from "axios";
import CodemirrorTextarea from "../../Common/CodemirrorTextarea";
import BWrappedFormGroup from "../../Form/BWrappedFormGroup";
@ -196,7 +195,7 @@ export default {
this.$v.form.$reset();
this.loading = true;
axios.get(this.apiUrl).then((resp) => {
this.axios.get(this.apiUrl).then((resp) => {
this.populateForm(resp.data);
this.loading = false;
}).catch((error) => {
@ -224,7 +223,7 @@ export default {
return;
}
axios({
this.axios({
method: 'PUT',
url: this.apiUrl,
data: this.form

View File

@ -18,7 +18,6 @@
</template>
<script>
import axios from 'axios';
import handleAxiosError from "../../Function/handleAxiosError";
export default {
@ -44,7 +43,7 @@ export default {
this.file = null;
this.loading = true;
axios.get(this.apiUrl).then((resp) => {
this.axios.get(this.apiUrl).then((resp) => {
this.isUploaded = resp.data.is_uploaded;
this.url = resp.data.url;
@ -55,7 +54,7 @@ export default {
},
clear() {
axios.delete(this.apiUrl).then((resp) => {
this.axios.delete(this.apiUrl).then((resp) => {
this.relist();
}).catch((error) => {
handleAxiosError(error);
@ -70,7 +69,7 @@ export default {
let formData = new FormData();
formData.append('file', this.file);
axios.post(this.apiUrl, formData).then((resp) => {
this.axios.post(this.apiUrl, formData).then((resp) => {
this.relist();
}).catch((error) => {
handleAxiosError(error);

View File

@ -46,7 +46,6 @@
<script>
import DataTable from '../Common/DataTable';
import axios from 'axios';
import EditModal from './StorageLocations/EditModal';
import Icon from '../Common/Icon';
import handleAxiosError from '../Function/handleAxiosError';
@ -115,7 +114,7 @@ export default {
delay: 3000
});
axios.put(url).then((resp) => {
this.axios.put(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();
@ -135,7 +134,7 @@ export default {
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
this.axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();

View File

@ -20,8 +20,7 @@
</template>
<script>
import axios from 'axios';
import { validationMixin } from 'vuelidate';
import {validationMixin} from 'vuelidate';
import required from 'vuelidate/src/validators/required';
import InvisibleSubmitButton from '../../Common/InvisibleSubmitButton';
import BaseEditModal from '../../Common/BaseEditModal';
@ -126,7 +125,7 @@ export default {
let data = this.form;
data.type = this.type;
axios({
this.axios({
method: (this.isEditMode)
? 'PUT'
: 'POST',

View File

@ -3,8 +3,7 @@
</template>
<script>
import axios from 'axios';
import { validationMixin } from 'vuelidate';
import {validationMixin} from 'vuelidate';
import handleAxiosError from '../Function/handleAxiosError';
export default {
@ -56,7 +55,7 @@ export default {
this.doLoad(recordUrl);
},
doLoad (recordUrl) {
axios.get(recordUrl).then((resp) => {
this.axios.get(recordUrl).then((resp) => {
this.populateForm(resp.data);
this.loading = false;
}).catch((error) => {
@ -88,7 +87,7 @@ export default {
this.error = null;
axios(this.buildSubmitRequest()).then((resp) => {
this.axios(this.buildSubmitRequest()).then((resp) => {
let notifyMessage = this.$gettext('Changes saved.');
notify('<b>' + notifyMessage + '</b>', 'success');

View File

@ -146,7 +146,6 @@ table.b-table-selectable {
</style>
<script>
import axios from 'axios';
import store from 'store';
import _ from 'lodash';
import Icon from './Icon';
@ -385,7 +384,7 @@ export default {
requestConfig = this.requestConfig(requestConfig);
}
axios.get(ctx.apiUrl, requestConfig).then((resp) => {
this.axios.get(ctx.apiUrl, requestConfig).then((resp) => {
this.flushCache = false;
this.totalRows = resp.data.total;

View File

@ -1,7 +1,6 @@
<template></template>
<script>
import NowPlaying from '../Entity/NowPlaying';
import axios from 'axios';
import NchanSubscriber from 'nchan';
export const nowPlayingProps = {
@ -48,7 +47,7 @@ export default {
});
this.nchan_subscriber.start();
} else {
axios.get(this.nowPlayingUri).then((response) => {
this.axios.get(this.nowPlayingUri).then((response) => {
this.setNowPlaying(response.data);
setTimeout(this.checkNowPlaying, 15000);

View File

@ -49,7 +49,6 @@
import WaveSurfer from 'wavesurfer.js';
import timeline from 'wavesurfer.js/dist/plugin/wavesurfer.timeline.js';
import regions from 'wavesurfer.js/dist/plugin/wavesurfer.regions.js';
import axios from 'axios';
import getLogarithmicVolume from '../Function/GetLogarithmicVolume.js';
import store from 'store';
import Icon from './Icon';
@ -92,7 +91,7 @@ export default {
this.$emit('ready');
});
axios.get(this.waveformUrl).then((resp) => {
this.axios.get(this.waveformUrl).then((resp) => {
let waveform = resp.data;
if (waveform.data) {
this.wavesurfer.load(this.audioUrl, waveform.data);

View File

@ -158,7 +158,6 @@
<script>
import TimeSeriesChart from './Common/TimeSeriesChart';
import DataTable from './Common/DataTable';
import axios from 'axios';
import store from 'store';
import Icon from './Common/Icon';
import Avatar, {avatarProps} from './Common/Avatar';
@ -227,7 +226,7 @@ export default {
}
if (this.showCharts) {
axios.get(this.chartsUrl).then((response) => {
this.axios.get(this.chartsUrl).then((response) => {
this.chartsData = response.data;
this.chartsLoading = false;
}).catch((error) => {
@ -235,7 +234,7 @@ export default {
});
}
axios.get(this.notificationsUrl).then((response) => {
this.axios.get(this.notificationsUrl).then((response) => {
this.notifications = response.data;
this.notificationsLoading = false;
}).catch((error) => {
@ -256,7 +255,7 @@ export default {
this.$eventHub.$emit('player_toggle', url);
},
updateNowPlaying () {
axios.get(this.stationsUrl).then((response) => {
this.axios.get(this.stationsUrl).then((response) => {
this.stationsLoading = false;
this.stations = response.data;

View File

@ -26,7 +26,6 @@ img.album_art {
<script>
import DataTable from '../Common/DataTable';
import axios from 'axios';
import _ from 'lodash';
import AlbumArt from '../Common/AlbumArt';
import handleAxiosError from '../Function/handleAxiosError';
@ -92,7 +91,7 @@ export default {
},
methods: {
doSubmitRequest (url) {
axios.post(url).then((resp) => {
this.axios.post(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.$emit('submitted');
}).catch((err) => {

View File

@ -25,8 +25,7 @@
</b-modal>
</template>
<script>
import { validationMixin } from 'vuelidate';
import axios from 'axios';
import {validationMixin} from 'vuelidate';
import required from 'vuelidate/src/validators/required';
import _ from 'lodash';
import MediaFormBasicInfo from './Form/BasicInfo';
@ -136,7 +135,7 @@ export default {
this.recordUrl = recordUrl;
this.audioUrl = audioUrl;
axios.get(recordUrl).then((resp) => {
this.axios.get(recordUrl).then((resp) => {
let d = resp.data;
this.songLength = d.length_text;
@ -189,7 +188,7 @@ export default {
this.error = null;
axios.put(this.recordUrl, this.form).then((resp) => {
this.axios.put(this.recordUrl, this.form).then((resp) => {
let notifyMessage = this.$gettext('Changes saved.');
notify('<b>' + notifyMessage + '</b>', 'success');

View File

@ -24,7 +24,6 @@
</template>
<script>
import axios from 'axios';
export default {
name: 'MediaFormAlbumArt',
@ -55,7 +54,7 @@ export default {
let formData = new FormData();
formData.append('art', this.artFile);
axios.post(this.albumArtUrl, formData).then((resp) => {
this.axios.post(this.albumArtUrl, formData).then((resp) => {
this.reloadArt();
}).catch((err) => {
console.log(err);
@ -63,7 +62,7 @@ export default {
});
},
deleteArt () {
axios.delete(this.albumArtUrl).then((resp) => {
this.axios.delete(this.albumArtUrl).then((resp) => {
this.reloadArt();
}).catch((err) => {
console.log(err);

View File

@ -72,7 +72,6 @@
</div>
</template>
<script>
import axios from 'axios';
import _ from 'lodash';
import Icon from '../../Common/Icon';
import handleAxiosError from '../../Function/handleAxiosError';
@ -162,7 +161,7 @@ export default {
if (this.selectedItems.all.length) {
this.notifyPending();
axios.put(this.batchUrl, {
this.axios.put(this.batchUrl, {
'do': action,
'current_directory': this.currentDirectory,
'files': this.selectedItems.files,
@ -195,7 +194,7 @@ export default {
if (this.selectedItems.all.length) {
this.notifyPending();
axios.put(this.batchUrl, {
this.axios.put(this.batchUrl, {
'do': 'playlist',
'playlists': this.checkedPlaylists,
'new_playlist_name': this.newPlaylist,

View File

@ -42,7 +42,6 @@
</template>
<script>
import DataTable from '../../Common/DataTable.vue';
import axios from 'axios';
import _ from 'lodash';
import Icon from '../../Common/Icon';
import handleAxiosError from '../../Function/handleAxiosError';
@ -79,7 +78,7 @@ export default {
this.$refs.modal.hide();
},
doMove () {
(this.selectedItems.all.length) && axios.put(this.batchUrl, {
(this.selectedItems.all.length) && this.axios.put(this.batchUrl, {
'do': 'move',
'currentDirectory': this.currentDirectory,
'directory': this.destinationDirectory,

View File

@ -23,9 +23,8 @@
</b-modal>
</template>
<script>
import { validationMixin } from 'vuelidate';
import { required } from 'vuelidate/lib/validators';
import axios from 'axios';
import {validationMixin} from 'vuelidate';
import {required} from 'vuelidate/lib/validators';
import handleAxiosError from '../../Function/handleAxiosError';
export default {
@ -35,7 +34,7 @@ export default {
currentDirectory: String,
mkdirUrl: String
},
data () {
data() {
return {
newDirectory: null
};
@ -62,7 +61,7 @@ export default {
return;
}
axios.post(this.mkdirUrl, {
this.axios.post(this.mkdirUrl, {
'currentDirectory': this.currentDirectory,
'name': this.newDirectory
}).then((resp) => {

View File

@ -23,9 +23,8 @@
</b-modal>
</template>
<script>
import { validationMixin } from 'vuelidate';
import { required } from 'vuelidate/lib/validators';
import axios from 'axios';
import {validationMixin} from 'vuelidate';
import {required} from 'vuelidate/lib/validators';
import handleAxiosError from '../../Function/handleAxiosError';
export default {
@ -34,7 +33,7 @@ export default {
props: {
renameUrl: String
},
data () {
data() {
return {
form: {
file: null,
@ -71,7 +70,7 @@ export default {
return;
}
axios.put(this.renameUrl, this.form).then((resp) => {
this.axios.put(this.renameUrl, this.form).then((resp) => {
this.$refs.modal.hide();
this.$emit('relist');
}).catch((err) => {

View File

@ -59,7 +59,6 @@
<script>
import DataTable from '../Common/DataTable';
import axios from 'axios';
import EditModal from './Mounts/EditModal';
import Icon from '../Common/Icon';
import InfoCard from '../Common/InfoCard';
@ -114,7 +113,7 @@ export default {
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
this.axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();

View File

@ -39,7 +39,6 @@
</template>
<script>
import axios from 'axios';
import handleAxiosError from '../../../Function/handleAxiosError';
import FlowUpload from '../../../Common/FlowUpload';
@ -82,7 +81,7 @@ export default {
},
deleteIntro() {
if (this.editIntroUrl) {
axios.delete(this.editIntroUrl).then((resp) => {
this.axios.delete(this.editIntroUrl).then((resp) => {
this.hasIntro = false;
}).catch((err) => {
handleAxiosError(err);

View File

@ -131,7 +131,6 @@ import EditModal from './Playlists/EditModal';
import ReorderModal from './Playlists/ReorderModal';
import ImportModal from './Playlists/ImportModal';
import QueueModal from './Playlists/QueueModal';
import axios from 'axios';
import Icon from '../Common/Icon';
import handleAxiosError from '../Function/handleAxiosError';
import CloneModal from './Playlists/CloneModal';
@ -260,7 +259,7 @@ export default {
delay: 3000
});
axios.put(url).then((resp) => {
this.axios.put(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();
@ -283,7 +282,7 @@ export default {
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
this.axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();

View File

@ -47,9 +47,8 @@
<script>
import required from 'vuelidate/src/validators/required';
import InvisibleSubmitButton from '../../Common/InvisibleSubmitButton';
import axios from 'axios';
import handleAxiosError from '../../Function/handleAxiosError';
import { validationMixin } from 'vuelidate';
import {validationMixin} from 'vuelidate';
export default {
name: 'CloneModal',
@ -102,7 +101,7 @@ export default {
this.error = null;
axios({
this.axios({
method: 'POST',
url: this.cloneUrl,
data: this.form

View File

@ -25,7 +25,6 @@
</template>
<script>
import axios from 'axios';
import InvisibleSubmitButton from '../../Common/InvisibleSubmitButton';
export default {
@ -53,7 +52,7 @@ export default {
let formData = new FormData();
formData.append('playlist_file', this.playlistFile);
axios.post(this.importPlaylistUrl, formData).then((resp) => {
this.axios.post(this.importPlaylistUrl, formData).then((resp) => {
if (resp.data.success) {
notify('<b>' + resp.data.message + '</b>', 'success');
} else {

View File

@ -31,7 +31,6 @@
</template>
<script>
import axios from 'axios';
import handleAxiosError from '../../Function/handleAxiosError';
export default {
@ -54,7 +53,7 @@ export default {
this.queueUrl = queueUrl;
this.loading = true;
axios.get(this.queueUrl).then((resp) => {
this.axios.get(this.queueUrl).then((resp) => {
this.media = resp.data;
this.loading = false;
}).catch((err) => {
@ -62,7 +61,7 @@ export default {
});
},
doClear () {
axios.delete(this.queueUrl).then((resp) => {
this.axios.delete(this.queueUrl).then((resp) => {
notify('<b>' + this.$gettext('Playlist queue cleared.') + '</b>', 'success');
this.close();
}).catch((err) => {

View File

@ -41,7 +41,6 @@ table.sortable {
</style>
<script>
import axios from 'axios';
import Draggable from 'vuedraggable';
import Icon from '../../Common/Icon';
import handleAxiosError from '../../Function/handleAxiosError';
@ -76,7 +75,7 @@ export default {
this.reorderUrl = reorderUrl;
this.loading = true;
axios.get(this.reorderUrl).then((resp) => {
this.axios.get(this.reorderUrl).then((resp) => {
this.media = resp.data;
this.loading = false;
}).catch((err) => {
@ -100,7 +99,7 @@ export default {
newOrder[row.id] = i;
});
axios.put(this.reorderUrl, { 'order': newOrder }).then((resp) => {
this.axios.put(this.reorderUrl, {'order': newOrder}).then((resp) => {
notify('<b>' + this.$gettext('Playlist order set.') + '</b>', 'success');
}).catch((err) => {
handleAxiosError(err);

View File

@ -29,7 +29,6 @@
</template>
<script>
import axios from 'axios';
import handleAxiosError from '../../../Function/handleAxiosError';
export default {
@ -66,7 +65,7 @@ export default {
let formData = new FormData();
formData.append('art', file);
axios.post(url, formData).then((resp) => {
this.axios.post(url, formData).then((resp) => {
this.$emit('input', resp.data);
}).catch((err) => {
handleAxiosError(err);
@ -74,7 +73,7 @@ export default {
},
deleteArt () {
if (this.editArtUrl) {
axios.delete(this.editArtUrl).then((resp) => {
this.axios.delete(this.editArtUrl).then((resp) => {
this.src = null;
}).catch((err) => {
handleAxiosError(err);

View File

@ -38,7 +38,6 @@
</template>
<script>
import axios from 'axios';
import handleAxiosError from '../../../Function/handleAxiosError';
import FlowUpload from '../../../Common/FlowUpload';
@ -82,7 +81,7 @@ export default {
},
deleteMedia () {
if (this.editMediaUrl) {
axios.delete(this.editMediaUrl).then((resp) => {
this.axios.delete(this.editMediaUrl).then((resp) => {
this.hasMedia = false;
}).catch((err) => {
handleAxiosError(err);

View File

@ -70,7 +70,6 @@
<script>
import DataTable from './../../Common/DataTable';
import EditModal from './EpisodeEditModal';
import axios from 'axios';
import Icon from '../../Common/Icon';
import AlbumArt from '../../Common/AlbumArt';
import EpisodeFormBasicInfo from './EpisodeForm/BasicInfo';
@ -142,7 +141,7 @@ export default {
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
this.axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();

View File

@ -62,7 +62,6 @@
<script>
import DataTable from '../../Common/DataTable';
import EditModal from './PodcastEditModal';
import axios from 'axios';
import AlbumArt from '../../Common/AlbumArt';
import handleAxiosError from '../../Function/handleAxiosError';
@ -139,7 +138,7 @@ export default {
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
this.axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();

View File

@ -38,19 +38,18 @@
<script>
import ProfileStreams from './Profile/StreamsPanel';
import ProfileHeader, { profileHeaderProps } from './Profile/HeaderPanel';
import ProfileNowPlaying, { profileNowPlayingProps } from './Profile/NowPlayingPanel';
import ProfileHeader, {profileHeaderProps} from './Profile/HeaderPanel';
import ProfileNowPlaying, {profileNowPlayingProps} from './Profile/NowPlayingPanel';
import ProfileSchedule from './Profile/SchedulePanel';
import ProfileRequests, { profileRequestsProps } from './Profile/RequestsPanel';
import ProfileStreamers, { profileStreamersProps } from './Profile/StreamersPanel';
import ProfilePublicPages, { profilePublicProps } from './Profile/PublicPagesPanel';
import ProfileFrontend, { profileFrontendProps } from './Profile/FrontendPanel';
import ProfileRequests, {profileRequestsProps} from './Profile/RequestsPanel';
import ProfileStreamers, {profileStreamersProps} from './Profile/StreamersPanel';
import ProfilePublicPages, {profilePublicProps} from './Profile/PublicPagesPanel';
import ProfileFrontend, {profileFrontendProps} from './Profile/FrontendPanel';
import ProfileBackendNone from './Profile/BackendNonePanel';
import ProfileBackend, { profileBackendProps } from './Profile/BackendPanel';
import { profileEmbedModalProps } from './Profile/EmbedModal';
import { BACKEND_NONE, FRONTEND_REMOTE } from '../Entity/RadioAdapters.js';
import ProfileBackend, {profileBackendProps} from './Profile/BackendPanel';
import {profileEmbedModalProps} from './Profile/EmbedModal';
import {BACKEND_NONE, FRONTEND_REMOTE} from '../Entity/RadioAdapters.js';
import NowPlaying from '../Entity/NowPlaying';
import axios from 'axios';
export default {
components: {
@ -131,7 +130,7 @@ export default {
},
methods: {
checkNowPlaying () {
axios.get(this.profileApiUri).then((response) => {
this.axios.get(this.profileApiUri).then((response) => {
let np = response.data;
np.loading = false;
this.np = np;

View File

@ -50,7 +50,6 @@
<script>
import DataTable from '../Common/DataTable';
import axios from 'axios';
import QueueLogsModal from './Queue/LogsModal';
import handleAxiosError from '../Function/handleAxiosError';
@ -97,7 +96,7 @@ export default {
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
this.axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.$refs.datatable.refresh();

View File

@ -54,7 +54,6 @@
<script>
import DataTable from '../Common/DataTable';
import axios from 'axios';
import EditModal from './Mounts/EditModal';
import Icon from '../Common/Icon';
import InfoCard from '../Common/InfoCard';
@ -108,7 +107,7 @@ export default {
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
this.axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();

View File

@ -164,7 +164,6 @@
<script>
import TimeSeriesChart from '../../Common/TimeSeriesChart';
import DataTable from '../../Common/DataTable';
import axios from 'axios';
import Icon from '../../Common/Icon';
import Avatar, {avatarProps} from '../../Common/Avatar';
import DayOfWeekChart from './DayOfWeekChart';
@ -220,21 +219,21 @@ export default {
created () {
moment.tz.setDefault('UTC');
axios.get(this.chartsUrl).then((response) => {
this.axios.get(this.chartsUrl).then((response) => {
this.chartsData = response.data;
this.chartsLoading = false;
}).catch((error) => {
console.error(error);
});
axios.get(this.bestAndWorstUrl).then((response) => {
this.axios.get(this.bestAndWorstUrl).then((response) => {
this.bestAndWorst = response.data;
this.bestAndWorstLoading = false;
}).catch((error) => {
console.error(error);
});
axios.get(this.mostPlayedUrl).then((response) => {
this.axios.get(this.mostPlayedUrl).then((response) => {
this.mostPlayed = response.data;
this.mostPlayedLoading = false;
}).catch((error) => {

View File

@ -60,7 +60,6 @@
<script>
import DataTable from '../Common/DataTable';
import axios from 'axios';
import EditModal from './Streamers/EditModal';
import BroadcastsModal from './Streamers/BroadcastsModal';
import Schedule from '../Common/ScheduleView';
@ -129,7 +128,7 @@ export default {
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
this.axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.relist();

View File

@ -39,7 +39,6 @@
</template>
<script>
import DataTable from '../../Common/DataTable.vue';
import axios from 'axios';
import formatFileSize from '../../Function/FormatFileSize.js';
import InlinePlayer from '../../InlinePlayer';
import Icon from '../../Common/Icon';
@ -137,7 +136,7 @@ export default {
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
this.axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.$refs.datatable.refresh();

View File

@ -1,9 +1,14 @@
import axios
from 'axios';
import VueAxios
from 'vue-axios';
import GetTextPlugin
from 'vue-gettext';
import translations
from '../../resources/locale/translations';
export default function (lang) {
export default function (lang, csrf) {
// Configure localization
Vue.use(GetTextPlugin, {
defaultLanguage: 'en_US',
translations: translations,
@ -11,4 +16,9 @@ export default function (lang) {
});
Vue.config.language = lang;
// Configure auto-CSRF on requests
axios.defaults.headers.common['X-API-CSRF'] = csrf;
Vue.use(VueAxios, axios);
}

View File

@ -4,7 +4,7 @@ const WebpackAssetsManifest = require('webpack-assets-manifest');
module.exports = {
mode: 'production',
entry: {
VueTranslations: './vue/VueTranslations.js',
VueBase: './vue/VueBase.js',
InlinePlayer: './vue/InlinePlayer.vue',
Dashboard: './vue/Dashboard.vue',
AdminBranding: './vue/Admin/Branding.vue',

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Middleware\Auth;
use App\Acl;
use App\Customization;
use App\Entity;
use App\Environment;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
abstract class AbstractAuth implements MiddlewareInterface
{
public function __construct(
protected Entity\Repository\SettingsRepository $settingsRepo,
protected Environment $environment,
protected Acl $acl
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Initialize Customization (timezones, locales, etc) based on the current logged in user.
$customization = new Customization(
environment: $this->environment,
settingsRepo: $this->settingsRepo,
request: $request
);
// Initialize ACL (can only be initialized after Customization as it contains localizations).
$acl = $this->acl->withRequest($request);
$request = $request
->withAttribute(ServerRequest::ATTR_LOCALE, $customization->getLocale())
->withAttribute(ServerRequest::ATTR_CUSTOMIZATION, $customization)
->withAttribute(ServerRequest::ATTR_ACL, $acl);
// Set the Audit Log user.
Entity\AuditLog::setCurrentUser($request->getAttribute(ServerRequest::ATTR_USER));
$response = $handler->handle($request);
Entity\AuditLog::setCurrentUser();
return $response;
}
}

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Middleware\Auth;
use App\Acl;
use App\Auth;
use App\Entity;
use App\Environment;
use App\Exception\CsrfValidationException;
use App\Http\ServerRequest;
use App\Session\Csrf;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class ApiAuth extends AbstractAuth
{
public const API_CSRF_NAMESPACE = 'api';
public function __construct(
protected Entity\Repository\UserRepository $userRepo,
protected Entity\Repository\ApiKeyRepository $apiKeyRepo,
Entity\Repository\SettingsRepository $settingsRepo,
Environment $environment,
Acl $acl
) {
parent::__construct($settingsRepo, $environment, $acl);
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Initialize the Auth for this request.
$user = $this->getApiUser($request);
$request = $request->withAttribute(ServerRequest::ATTR_USER, $user);
return parent::process($request, $handler);
}
protected function getApiUser(ServerRequestInterface $request): ?Entity\User
{
$apiKey = $this->getApiKey($request);
if (!empty($apiKey)) {
$apiUser = $this->apiKeyRepo->authenticate($apiKey);
if (null !== $apiUser) {
return $apiUser;
}
}
// Fallback to session login if available.
$csrfKey = $request->getHeaderLine('X-API-CSRF');
if (empty($csrfKey) && !$this->environment->isTesting()) {
return null;
}
$auth = new Auth(
userRepo: $this->userRepo,
session: $request->getAttribute(ServerRequest::ATTR_SESSION),
environment: $this->environment,
);
if ($auth->isLoggedIn()) {
$csrf = $request->getAttribute(ServerRequest::ATTR_SESSION_CSRF);
if ($csrf instanceof Csrf) {
try {
$csrf->verify($csrfKey, self::API_CSRF_NAMESPACE);
return $auth->getLoggedInUser();
} catch (CsrfValidationException) {
}
}
}
return null;
}
protected function getApiKey(ServerRequestInterface $request): ?string
{
// Check authorization header
$auth_headers = $request->getHeader('Authorization');
$auth_header = $auth_headers[0] ?? '';
if (preg_match("/Bearer\s+(.*)$/i", $auth_header, $matches)) {
return $matches[1];
}
// Check API key header
$api_key_headers = $request->getHeader('X-API-Key');
if (!empty($api_key_headers[0])) {
return $api_key_headers[0];
}
// Check cookies
$cookieParams = $request->getCookieParams();
if (!empty($cookieParams['token'])) {
return $cookieParams['token'];
}
// Check URL parameters as last resort
$queryParams = $request->getQueryParams();
$queryApiKey = $queryParams['api_key'] ?? null;
if (!empty($queryApiKey)) {
return $queryApiKey;
}
return null;
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Middleware\Auth;
use App\Acl;
use App\Auth;
use App\Entity;
use App\Environment;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class StandardAuth extends AbstractAuth
{
public function __construct(
protected Entity\Repository\UserRepository $userRepo,
Entity\Repository\SettingsRepository $settingsRepo,
Environment $environment,
Acl $acl
) {
parent::__construct($settingsRepo, $environment, $acl);
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Initialize the Auth for this request.
$auth = new Auth(
userRepo: $this->userRepo,
session: $request->getAttribute(ServerRequest::ATTR_SESSION),
environment: $this->environment,
);
$user = ($auth->isLoggedIn()) ? $auth->getLoggedInUser() : null;
$request = $request
->withAttribute(ServerRequest::ATTR_AUTH, $auth)
->withAttribute(ServerRequest::ATTR_USER, $user);
return parent::process($request, $handler);
}
}

View File

@ -22,7 +22,7 @@ use Symfony\Component\VarDumper\VarDumper;
class Api
{
public function __construct(
protected Entity\Repository\ApiKeyRepository $api_repo,
protected Entity\Repository\ApiKeyRepository $apiKeyRepo,
protected Entity\Repository\SettingsRepository $settingsRepo,
protected Environment $environment
) {
@ -43,35 +43,26 @@ class Api
}
// Attempt API key auth if a key exists.
$api_key = $this->getApiKey($request);
$api_user = (!empty($api_key)) ? $this->api_repo->authenticate($api_key) : null;
// Override the request's "user" variable if API authentication is supplied and valid.
if ($api_user instanceof Entity\User) {
$request = $request->withAttribute(ServerRequest::ATTR_USER, $api_user);
$request->getAcl()->setRequest($request);
Entity\AuditLog::setCurrentUser($api_user);
}
$apiUser = $request->getAttribute(ServerRequest::ATTR_USER);
// Set default cache control for API pages.
$settings = $this->settingsRepo->readSettings();
$prefer_browser_url = $settings->getPreferBrowserUrl();
$preferBrowserUrl = $settings->getPreferBrowserUrl();
$response = $handler->handle($request);
// Check for a user-set CORS header override.
$acao_header = trim($settings->getApiAccessControl());
if (!empty($acao_header)) {
if ('*' === $acao_header) {
$acaoHeader = trim($settings->getApiAccessControl());
if (!empty($acaoHeader)) {
if ('*' === $acaoHeader) {
$response = $response->withHeader('Access-Control-Allow-Origin', '*');
} else {
// Return the proper ACAO header matching the origin (if one exists).
$origin = $request->getHeaderLine('Origin');
if (!empty($origin)) {
$rawOrigins = array_map('trim', explode(',', $acao_header));
$rawOrigins = array_map('trim', explode(',', $acaoHeader));
$baseUrl = $settings->getBaseUrl();
if (null !== $baseUrl) {
@ -97,7 +88,7 @@ class Api
}
}
}
} elseif ($api_user instanceof Entity\User || in_array($request->getMethod(), ['GET', 'OPTIONS'])) {
} elseif ($apiUser instanceof Entity\User || in_array($request->getMethod(), ['GET', 'OPTIONS'])) {
// Default behavior:
// Only set global CORS for GET requests and API-authenticated requests;
// Session-authenticated, non-GET requests should only be made in a same-host situation.
@ -105,7 +96,7 @@ class Api
}
if ($response instanceof Response && !$response->hasCacheLifetime()) {
if ($prefer_browser_url || $request->getAttribute(ServerRequest::ATTR_USER) instanceof Entity\User) {
if ($preferBrowserUrl || $request->getAttribute(ServerRequest::ATTR_USER) instanceof Entity\User) {
$response = $response->withNoCache();
} else {
$response = $response->withCacheLifetime(15);
@ -114,38 +105,4 @@ class Api
return $response;
}
/**
* @param ServerRequest $request
*/
protected function getApiKey(ServerRequest $request): ?string
{
// Check authorization header
$auth_headers = $request->getHeader('Authorization');
$auth_header = $auth_headers[0] ?? '';
if (preg_match("/Bearer\s+(.*)$/i", $auth_header, $matches)) {
return $matches[1];
}
// Check API key header
$api_key_headers = $request->getHeader('X-API-Key');
if (!empty($api_key_headers[0])) {
return $api_key_headers[0];
}
// Check cookies
$cookieParams = $request->getCookieParams();
if (!empty($cookieParams['token'])) {
return $cookieParams['token'];
}
// Check URL parameters as last resort
$queryApiKey = $request->getQueryParam('api-key');
if (!empty($queryApiKey)) {
return $queryApiKey;
}
return null;
}
}