diff --git a/main/inc/lib/userportal.lib.php b/main/inc/lib/userportal.lib.php index 03e4600a58..9cde0316d9 100755 --- a/main/inc/lib/userportal.lib.php +++ b/main/inc/lib/userportal.lib.php @@ -946,6 +946,19 @@ class IndexManager ]; } + if (true === api_get_configuration_value('whispeak_auth_enabled')) { + //if (!WhispeakAuthPlugin::checkUserIsEnrolled($userId)) { + $itemTitle = WhispeakAuthPlugin::create()->get_title(); + + $items[] = [ + 'class' => 'whispeak-enrollment', + 'icon' => Display::return_icon('addworkuser.png', $itemTitle), + 'link' => WhispeakAuthPlugin::getEnrollmentUrl(), + 'title' => $itemTitle, + ]; + //} + } + return $items; } diff --git a/plugin/whispeakauth/README.md b/plugin/whispeakauth/README.md new file mode 100644 index 0000000000..2031255d3d --- /dev/null +++ b/plugin/whispeakauth/README.md @@ -0,0 +1,7 @@ +# Speech authentication with Whispeak + +Instructions: +1. Install plugin in Chamilo. +2. Set the plugin configuration with the token and API url. And enable the plugin. +3. Set the `login_bottom` region to the plugin. +4. Add `$_configuration['whispeak_auth_enabled'] = true;` to `configuration.php` file. diff --git a/plugin/whispeakauth/WhispeakAuthPlugin.php b/plugin/whispeakauth/WhispeakAuthPlugin.php new file mode 100644 index 0000000000..81582d2dc2 --- /dev/null +++ b/plugin/whispeakauth/WhispeakAuthPlugin.php @@ -0,0 +1,289 @@ + 'boolean', + self::SETTING_API_URL => 'text', + self::SETTING_TOKEN => 'text', + self::SETTING_INSTRUCTION => 'html', + ] + ); + } + + /** + * @return WhispeakAuthPlugin + */ + public static function create() + { + static $result = null; + + return $result ? $result : $result = new self(); + } + + public function install() + { + UserManager::create_extra_field( + self::EXTRAFIELD_AUTH_UID, + \ExtraField::FIELD_TYPE_TEXT, + $this->get_lang('Whispeak uid'), + '' + ); + } + + public function uninstall() + { + $extraField = self::getAuthUidExtraField(); + + if (empty($extraField)) { + return; + } + + $em = Database::getManager(); + $em->remove($extraField); + $em->flush(); + } + + /** + * @return ExtraField + */ + public static function getAuthUidExtraField() + { + $em = Database::getManager(); + $efRepo = $em->getRepository('ChamiloCoreBundle:ExtraField'); + + /** @var ExtraField $extraField */ + $extraField = $efRepo->findOneBy( + [ + 'variable' => self::EXTRAFIELD_AUTH_UID, + 'extraFieldType' => ExtraField::USER_FIELD_TYPE, + ] + ); + + return $extraField; + } + + /** + * @param int $userId + * + * @return ExtraFieldValues + */ + public static function getAuthUidValue($userId) + { + $extraField = self::getAuthUidExtraField(); + $em = Database::getManager(); + $efvRepo = $em->getRepository('ChamiloCoreBundle:ExtraFieldValues'); + + /** @var ExtraFieldValues $value */ + $value = $efvRepo->findOneBy(['field' => $extraField, 'itemId' => $userId]); + + return $value; + } + + /** + * @param int $userId + * + * @return bool + */ + public static function checkUserIsEnrolled($userId) + { + $value = self::getAuthUidValue($userId); + + if (empty($value)) { + return false; + } + + return !empty($value->getValue()); + } + + /** + * @return string + */ + public static function getEnrollmentUrl() + { + return api_get_path(WEB_PLUGIN_PATH).'whispeakauth/enrollment.php'; + } + + /** + * @param User $user + * @param string $filePath + * + * @return array + */ + public function requestEnrollment(User $user, $filePath) + { + $metadata = [ + 'motherTongue' => $user->getLanguage(), + 'spokenTongue' => $user->getLanguage(), + 'audioType' => 'pcm', + ]; + + return $this->sendRequest( + 'enrollment', + $metadata, + $user, + $filePath + ); + } + + /** + * @param User $user + * @param string $uid + * + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function saveEnrollment(User $user, $uid) + { + $em = Database::getManager(); + $value = self::getAuthUidValue($user->getId()); + + if (empty($value)) { + $ef = self::getAuthUidExtraField(); + $now = new DateTime('now', new DateTimeZone('UTC')); + + $value = new ExtraFieldValues(); + $value + ->setField($ef) + ->setItemId($user->getId()) + ->setUpdatedAt($now); + } + + $value->setValue($uid); + + $em->persist($value); + $em->flush(); + } + + public function requestAuthentify(User $user, $filePath) + { + $value = self::getAuthUidValue($user->getId()); + + if (empty($value)) { + return null; + } + + $metadata = [ + 'uid' => $value->getValue(), + 'audioType' => 'pcm', + ]; + + return $this->sendRequest( + 'authentify', + $metadata, + $user, + $filePath + ); + } + + /** + * @return string + */ + public function getAuthentifySampleText() + { + $phrases = []; + + for ($i = 1; $i <= 6; $i++) { + $phrases[] = $this->get_lang("AuthentifySampleText$i"); + } + + $rand = array_rand($phrases, 1); + + return $phrases[$rand]; + } + + /** + * @return bool + */ + public function toolIsEnabled() + { + return 'true' === $this->get(self::SETTING_ENABLE); + } + + /** + * Access not allowed when tool is not enabled. + * + * @param bool $printHeaders Optional. Print headers. + */ + public function protectTool($printHeaders = true) + { + if ($this->toolIsEnabled()) { + return; + } + + api_not_allowed($printHeaders); + } + + /** + * @return string + */ + private function getApiUrl() + { + $url = $this->get(self::SETTING_API_URL); + + return trim($url, " \t\n\r \v/"); + } + + /** + * @param string $endPoint + * @param array $metadata + * @param User $user + * @param string $filePath + * + * @return array + */ + private function sendRequest($endPoint, array $metadata, User $user, $filePath) + { + $moderator = $user->getCreatorId() ?: $user->getId(); + $apiUrl = $this->getApiUrl()."/$endPoint"; + $headers = [ + //"Content-Type: application/x-www-form-urlencoded", + "Authorization: Bearer ".$this->get(self::SETTING_TOKEN), + ]; + $post = [ + 'metadata' => json_encode($metadata), + 'moderator' => "moderator_$moderator", + 'client' => base64_encode($user->getUserId()), + 'voice' => new CURLFile($filePath), + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $apiUrl); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $post); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + $result = curl_exec($ch); + curl_close($ch); + + $result = json_decode($result, true); + + if (!empty($result['error'])) { + return null; + } + + return json_decode($result, true); + } +} diff --git a/plugin/whispeakauth/ajax/record_audio.php b/plugin/whispeakauth/ajax/record_audio.php new file mode 100644 index 0000000000..6c20a632e8 --- /dev/null +++ b/plugin/whispeakauth/ajax/record_audio.php @@ -0,0 +1,136 @@ +protectTool(false); + +if ($isAuthentify) { + $em = Database::getManager(); + /** @var User|null $user */ + $user = $em->getRepository('ChamiloUserBundle:User')->findOneBy(['username' => $_POST['username']]); +} else { + /** @var User $user */ + $user = api_get_user_entity(api_get_user_id()); +} + +if (empty($user)) { + echo Display::return_message(get_lang('NoUser'), 'error'); + + exit; +} + +$path = api_upload_file('whispeakauth', $_FILES['audio'], $user->getId()); + +if (false === $path) { + echo Display::return_message(get_lang('UploadError'), 'error'); + + exit; +} + +$originFullPath = api_get_path(SYS_UPLOAD_PATH).'whispeakauth'.$path['path_to_save']; +$fileType = mime_content_type($originFullPath); + +if ('wav' !== substr($fileType, -3)) { + $directory = dirname($originFullPath); + $newFullPath = $directory.'/audio.wav'; + + try { + $ffmpeg = FFMpeg::create(); + + $audio = $ffmpeg->open($originFullPath); + $audio->save(new Wav(), $newFullPath); + } catch (Exception $exception) { + echo Display::return_message($exception->getMessage(), 'error'); + + exit; + } +} + +if ($isEnrollment) { + $result = $plugin->requestEnrollment($user, $newFullPath); + + if (empty($result)) { + echo Display::return_message($plugin->get_lang('EnrollmentFailed')); + + exit; + } + + $reliability = (int) $result['reliability']; + + if ($reliability <= 0) { + echo Display::return_message($plugin->get_lang('EnrollmentSignature0'), 'error'); + + exit; + } + + $plugin->saveEnrollment($user, $result['uid']); + + $message = ''.$plugin->get_lang('EnrollmentSuccess').''; + $message .= PHP_EOL; + $message .= $plugin->get_lang("EnrollmentSignature$reliability"); + + echo Display::return_message($message, 'success', false); + + exit; +} + +if ($isAuthentify) { + $result = $plugin->requestAuthentify($user, $newFullPath); + + if (empty($result)) { + echo Display::return_message($plugin->get_lang('AuthentifyFailed'), 'error'); + + exit; + } + + $success = (bool) $result['audio'][0]['result']; + + if (!$success) { + echo Display::return_message($plugin->get_lang('TryAgain'), 'warning'); + + exit; + } + + $loggedUser = [ + 'user_id' => $user->getId(), + 'status' => $user->getStatus(), + 'uidReset' => true, + ]; + + ChamiloSession::write('_user', $loggedUser); + Login::init_user($user->getId(), true); + + echo Display::return_message($plugin->get_lang('AuthentifySuccess'), 'success'); + echo ''; + + exit; +} diff --git a/plugin/whispeakauth/assets/js/RecordAudio.js b/plugin/whispeakauth/assets/js/RecordAudio.js new file mode 100644 index 0000000000..a221a00df5 --- /dev/null +++ b/plugin/whispeakauth/assets/js/RecordAudio.js @@ -0,0 +1,139 @@ +/* For licensing terms, see /license.txt */ + +window.RecordAudio = (function () { + function useRecordRTC(rtcInfo) { + $(rtcInfo.blockId).show(); + + var mediaConstraints = {audio: true}, + localStream = null, + recordRTC = null, + btnStart = $(rtcInfo.btnStartId), + btnStop = $(rtcInfo.btnStopId), + btnSave = $(rtcInfo.btnSaveId), + tagAudio = $(rtcInfo.plyrPreviewId); + + function saveAudio() { + var recordedBlob = recordRTC.getBlob(); + + if (!recordedBlob) { + return; + } + + var btnSaveText = btnSave.html(); + var fileExtension = recordedBlob.type.split('/')[1]; + + var formData = new FormData(); + formData.append('audio', recordedBlob, 'audio.' + fileExtension); + + for (var prop in rtcInfo.data) { + if (!rtcInfo.data.hasOwnProperty(prop)) { + continue; + } + + formData.append(prop, rtcInfo.data[prop]); + } + + $.ajax({ + url: _p.web_plugin + 'whispeakauth/ajax/record_audio.php', + data: formData, + processData: false, + contentType: false, + type: 'POST', + beforeSend: function () { + btnStart.prop('disabled', true); + btnStop.prop('disabled', true); + btnSave.prop('disabled', true).text(btnSave.data('loadingtext')); + } + }).done(function (response) { + $('#messages-deck').append(response); + }).always(function () { + btnSave.prop('disabled', true).html(btnSaveText).parent().addClass('hidden'); + btnStop.prop('disabled', true).parent().addClass('hidden'); + btnStart.prop('disabled', false).parent().removeClass('hidden'); + }); + } + + btnStart.on('click', function () { + tagAudio.prop('src', ''); + + function successCallback(stream) { + localStream = stream; + + recordRTC = RecordRTC(stream, { + recorderType: StereoAudioRecorder, + numberOfAudioChannels: 1, + type: 'audio' + }); + recordRTC.startRecording(); + + btnSave.prop('disabled', true).parent().addClass('hidden'); + btnStop.prop('disabled', false).parent().removeClass('hidden'); + btnStart.prop('disabled', true).parent().addClass('hidden'); + tagAudio.removeClass('show').parents('#audio-wrapper').addClass('hidden'); + } + + function errorCallback(error) { + alert(error.message); + } + + if (navigator.mediaDevices.getUserMedia) { + navigator.mediaDevices.getUserMedia(mediaConstraints) + .then(successCallback) + .catch(errorCallback); + + return; + } + + navigator.getUserMedia = navigator.getUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia; + + if (navigator.getUserMedia) { + navigator.getUserMedia(mediaConstraints, successCallback, errorCallback); + } + }); + + btnStop.on('click', function () { + if (!recordRTC) { + return; + } + + recordRTC.stopRecording(function (audioURL) { + btnStart.prop('disabled', false).parent().removeClass('hidden'); + btnStop.prop('disabled', true).parent().addClass('hidden'); + btnSave.prop('disabled', false).parent().removeClass('hidden'); + + tagAudio + .prop('src', audioURL) + .parents('#audio-wrapper') + .removeClass('hidden') + .addClass('show'); + + localStream.getTracks()[0].stop(); + }); + }); + + btnSave.on('click', function () { + if (!recordRTC) { + return; + } + + saveAudio(); + }); + } + + return { + init: function (rtcInfo) { + $(rtcInfo.blockId).hide(); + + var userMediaEnabled = (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) || + !!navigator.webkitGetUserMedia || + !!navigator.mozGetUserMedia || + !!navigator.getUserMedia; + + if (!userMediaEnabled) { + return; + } + + useRecordRTC(rtcInfo); + } + } +})(); \ No newline at end of file diff --git a/plugin/whispeakauth/authentify.php b/plugin/whispeakauth/authentify.php new file mode 100644 index 0000000000..f63ac7bef3 --- /dev/null +++ b/plugin/whispeakauth/authentify.php @@ -0,0 +1,26 @@ +protectTool(); + +$form = new FormValidator('enter_username', 'post', '#'); +$form->addText('username', get_lang('Username')); + +$htmlHeadXtra[] = api_get_js('rtc/RecordRTC.js'); +$htmlHeadXtra[] = api_get_js_simple(api_get_path(WEB_PLUGIN_PATH).'whispeakauth/assets/js/RecordAudio.js'); + +$template = new Template(); +$template->assign('form', $form->returnForm()); +$template->assign('sample_text', $plugin->getAuthentifySampleText()); + +$content = $template->fetch('whispeakauth/view/authentify_recorder.html.twig'); + +$template->assign('header', $plugin->get_title()); +$template->assign('content', $content); +$template->display_one_col_template(); diff --git a/plugin/whispeakauth/enrollment.php b/plugin/whispeakauth/enrollment.php new file mode 100644 index 0000000000..9ea8192e11 --- /dev/null +++ b/plugin/whispeakauth/enrollment.php @@ -0,0 +1,28 @@ +protectTool(); + +$sampleText = $plugin->get_lang('EnrollmentSampleText'); + +$htmlHeadXtra[] = api_get_js('rtc/RecordRTC.js'); +$htmlHeadXtra[] = api_get_js_simple(api_get_path(WEB_PLUGIN_PATH).'whispeakauth/assets/js/RecordAudio.js'); + +$template = new Template(); +$template->assign('is_authenticated', WhispeakAuthPlugin::checkUserIsEnrolled($userId)); +$template->assign('sample_text', $sampleText); + +$content = $template->fetch('whispeakauth/view/record_audio.html.twig'); + +$template->assign('header', $plugin->get_title()); +$template->assign('content', $content); +$template->display_one_col_template(); diff --git a/plugin/whispeakauth/index.php b/plugin/whispeakauth/index.php new file mode 100644 index 0000000000..420d242d3d --- /dev/null +++ b/plugin/whispeakauth/index.php @@ -0,0 +1,14 @@ +toolIsEnabled()) { + echo Display::toolbarButton( + $plugin->get_lang('SpeechAuthentication'), + api_get_path(WEB_PLUGIN_PATH).'whispeakauth/authentify.php', + 'sign-in', + 'info', + ['class' => 'btn-block'] + ); +} diff --git a/plugin/whispeakauth/install.php b/plugin/whispeakauth/install.php new file mode 100644 index 0000000000..adf5155af7 --- /dev/null +++ b/plugin/whispeakauth/install.php @@ -0,0 +1,4 @@ +install(); diff --git a/plugin/whispeakauth/lang/english.php b/plugin/whispeakauth/lang/english.php new file mode 100644 index 0000000000..2fb49f9f3f --- /dev/null +++ b/plugin/whispeakauth/lang/english.php @@ -0,0 +1,33 @@ +Add $_configuration[\'whispeak_auth_enabled\'] = true;'. + 'in the configuration.php file

'; + +$strings['EnrollmentSampleText'] = 'The famous Mona Lisa painting was painted by Leonardo Da Vinci.'; +$strings['AuthentifySampleText1'] = 'Dropping Like Flies.'; +$strings['AuthentifySampleText2'] = 'Keep Your Eyes Peeled.'; +$strings['AuthentifySampleText3'] = 'The fox screams at midnight.'; +$strings['AuthentifySampleText4'] = 'Go Out On a Limb.'; +$strings['AuthentifySampleText5'] = 'Under the Water.'; +$strings['AuthentifySampleText6'] = 'Barking Up The Wrong Tree.'; + +$strings['RepeatThisPhrase'] = 'Repeat this phrase three times after allowing audio recording:'; +$strings['EnrollmentSignature0'] = 'Unsustainable signature requires a new enrollment.'; +$strings['EnrollmentSignature1'] = 'Passable signature, advice to make a new enrollment.'; +$strings['EnrollmentSignature2'] = 'Correct signature.'; +$strings['EnrollmentSignature3'] = 'Good signature.'; +$strings['SpeechAuthAlreadyEnrolled'] = 'Speech authentication already enrolled previously.'; +$strings['SpeechAuthentication'] = 'Speech authentication'; +$strings['EnrollmentFailed'] = 'Enrollment failed.'; +$strings['EnrollmentSuccess'] = 'Enrollment success.'; +$strings['AuthentifyFailed'] = 'Login failed.'; +$strings['AuthentifySuccess'] = 'Authentication success!'; +$strings['TryAgain'] = 'Try again'; diff --git a/plugin/whispeakauth/lang/french.php b/plugin/whispeakauth/lang/french.php new file mode 100644 index 0000000000..bd9ba48364 --- /dev/null +++ b/plugin/whispeakauth/lang/french.php @@ -0,0 +1,18 @@ +Agrega $_configuration[\'whispeak_auth_enabled\'] = true;'. + 'al archivo configuration.php

'; + +$strings['EnrollmentSampleText'] = 'El famoso cuadro de Mona Lisa fue pintado por Leonardo Da Vinci.'; +$strings['AuthentifySampleText1'] = 'Cayendo como moscas.'; +$strings['AuthentifySampleText2'] = 'Mantén tus ojos abiertos.'; +$strings['AuthentifySampleText3'] = 'El zorro grita a medianoche.'; +$strings['AuthentifySampleText4'] = 'Ir por las ramas.'; +$strings['AuthentifySampleText5'] = 'Debajo del agua.'; +$strings['AuthentifySampleText6'] = 'Ladrando al árbol equivocado.'; + +$strings['RepeatThisPhrase'] = 'Repita esta frase tres veces después de permitir la grabación de audio:'; +$strings['EnrollmentSignature0'] = 'Firma insostenible, requiere una nueva inscripción.'; +$strings['EnrollmentSignature1'] = 'Firma aceptable, pero se aconseja hacer una nueva inscripción.'; +$strings['EnrollmentSignature2'] = 'Firma correcta.'; +$strings['EnrollmentSignature3'] = 'Buena firma.'; +$strings['SpeechAuthAlreadyEnrolled'] = 'Autenticación de voz registrada anteriormente.'; +$strings['SpeechAuthentication'] = 'Atenticación con voz'; +$strings['EnrollmentFailed'] = 'Inscripción fallida.'; +$strings['EnrollmentSuccess'] = 'Inscripción correcta.'; +$strings['AuthentifyFailed'] = 'Inicio de sesión fallido.'; +$strings['AuthentifySuccess'] = '¡Autenticación correcta!'; +$strings['TryAgain'] = 'Intente de nuevo.'; diff --git a/plugin/whispeakauth/plugin.php b/plugin/whispeakauth/plugin.php new file mode 100644 index 0000000000..c990a9158d --- /dev/null +++ b/plugin/whispeakauth/plugin.php @@ -0,0 +1,4 @@ +get_info(); diff --git a/plugin/whispeakauth/uninstall.php b/plugin/whispeakauth/uninstall.php new file mode 100644 index 0000000000..4f811df4d3 --- /dev/null +++ b/plugin/whispeakauth/uninstall.php @@ -0,0 +1,4 @@ +uninstall(); diff --git a/plugin/whispeakauth/view/authentify_recorder.html.twig b/plugin/whispeakauth/view/authentify_recorder.html.twig new file mode 100644 index 0000000000..3c26b389e0 --- /dev/null +++ b/plugin/whispeakauth/view/authentify_recorder.html.twig @@ -0,0 +1,38 @@ +{% extends 'whispeakauth/view/record_audio.html.twig' %} + +{% block intro %} +
+
+ +
+ +
+
+
+ +
+ + {{ parent() }} +{% endblock %} + +{% block config_data %} + $('#username').on('change', function () { + $('#record-audio-recordrtc, #btn-start-record, #btn-stop-record, #btn-save-record').off('click', ''); + + RecordAudio.init( + { + blockId: '#record-audio-recordrtc', + btnStartId: '#btn-start-record', + btnStopId: '#btn-stop-record', + btnSaveId: '#btn-save-record', + plyrPreviewId: '#record-preview', + data: { + action: 'authentify', + username: $('#username').val() + } + } + ); + }); +{% endblock %} diff --git a/plugin/whispeakauth/view/record_audio.html.twig b/plugin/whispeakauth/view/record_audio.html.twig new file mode 100644 index 0000000000..608721d1d3 --- /dev/null +++ b/plugin/whispeakauth/view/record_audio.html.twig @@ -0,0 +1,67 @@ +
+
+ {% block intro %} +

{{ 'RepeatThisPhrase'|get_plugin_lang('WhispeakAuthPlugin') }}

+
+
+
+ + {{ 'RecordAudio'|get_lang }} +
+
+

{{ sample_text }}

+
+
+
+ {% endblock %} + + + +
+
+ {% if is_authenticated %} +
+ + {{ 'SpeechAuthAlreadyEnrolled'|get_plugin_lang('WhispeakAuthPlugin') }} +
+ {% endif %} +
+
+ +