1
0

BxAzrB2CModule.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup AzureB2CConnect Azure B2C Connect
  7. * @ingroup UnaModules
  8. *
  9. * @{
  10. */
  11. $aPathInfo = pathinfo(__FILE__);
  12. require_once ($aPathInfo['dirname'] . '/../vendor/autoload.php');
  13. use Alancting\Microsoft\JWT\AzureAd\AzureAdConfiguration;
  14. use Alancting\Microsoft\JWT\AzureAd\AzureAdAccessTokenJWT;
  15. use Alancting\Microsoft\JWT\AzureAd\AzureAdIdTokenJWT;
  16. /**
  17. * This Azude AD connect code was created with instructions from:
  18. * https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-b2c-overview#supported-app-types-and-scenarios
  19. *
  20. * https://www.sipponen.com/archives/4024
  21. * And official docs:
  22. * https://docs.microsoft.com/en-us/azure/active-directory-b2c/add-web-api-application?tabs=app-reg-ga
  23. * https://docs.microsoft.com/en-us/azure/active-directory-b2c/configure-tokens?pivots=b2c-user-flow
  24. * https://docs.microsoft.com/en-us/azure/active-directory-b2c/access-tokens
  25. * https://docs.microsoft.com/en-us/azure/active-directory-b2c/authorization-code-flow
  26. * https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/configure-user-consent?tabs=azure-portal
  27. * https://docs.microsoft.com/en-us/azure/active-directory-b2c/openid-connect
  28. * https://docs.microsoft.com/en-us/azure/active-directory-b2c/tokens-overview
  29. *
  30. * https://docs.microsoft.com/en-us/azure/active-directory-b2c/microsoft-graph-get-started?tabs=app-reg-ga
  31. */
  32. class BxAzrB2CModule extends BxBaseModConnectModule
  33. {
  34. function __construct(&$aModule)
  35. {
  36. parent::__construct($aModule);
  37. }
  38. /**
  39. * Redirect to remote site login form
  40. *
  41. * @return n/a - redirect or HTML page in case of error
  42. */
  43. function actionStart()
  44. {
  45. if (isset($_GET["error"])) {
  46. $this->_oTemplate->getPage(_t($this->_oConfig->sDefaultTitleLangKey), DesignBoxContent(_t($this->_oConfig->sDefaultTitleLangKey), MsgBox(bx_get('error'))));
  47. exit;
  48. }
  49. if (isLogged()) {
  50. $this->_redirect ($this -> _oConfig -> sDefaultRedirectUrl);
  51. }
  52. if (!$this->_oConfig->sClientID || !$this->_oConfig->sSecret) {
  53. require_once(BX_DIRECTORY_PATH_INC . 'design.inc.php');
  54. bx_import('BxDolLanguages');
  55. $sCode = MsgBox( _t('_bx_azrb2c_profile_error_api_keys') );
  56. $this->_oTemplate->getPage(_t('_bx_azrb2c'), $sCode);
  57. }
  58. else {
  59. // First stage of the authentication process; This is just a simple redirect (first load of this page)
  60. $verifier_bytes = random_bytes(64);
  61. $code_verifier = rtrim(strtr(base64_encode($verifier_bytes), "+/", "-_"), "=");
  62. BxDolSession::getInstance()->setValue('code_verifier', $code_verifier);
  63. $challenge_bytes = hash("sha256", $code_verifier, true);
  64. $code_challenge = rtrim(strtr(base64_encode($challenge_bytes), "+/", "-_"), "=");
  65. $sBaseUrl = "https://" . $this->_oConfig->sDomain . "/" . $this->_oConfig->sTenant . "/" . $this->_oConfig->sPolicy;//B2C_1_login_una";
  66. $sUrl = bx_append_url_params($sBaseUrl . "/oauth2/v2.0/authorize", [
  67. //'state' => $this->_genToken(), // This at least semi-random string is likely good enough as state identifier
  68. 'scope' => $this->_oConfig->sScope,
  69. 'response_type' => 'code',
  70. 'response_mode' => 'query',
  71. 'prompt' => 'login',
  72. 'client_id' => $this->_oConfig->sClientID,
  73. 'redirect_uri' => $this->_oConfig->sPageHandle,
  74. 'client_info' => 1,
  75. 'code_challenge_method' => 'S256',
  76. 'code_challenge' => $code_challenge,
  77. ]);
  78. $this->_redirect($sUrl); // So off you go my dear browser and welcome back for round two after some redirects at Azure end
  79. }
  80. }
  81. function actionHandle()
  82. {
  83. require_once(BX_DIRECTORY_PATH_INC . 'design.inc.php');
  84. /*
  85. // check CSRF token
  86. if ($this->_getToken() != bx_get('state')) {
  87. $this->_oTemplate->getPage(_t('_Error'), MsgBox(_t('_sys_connect_state_invalid')));
  88. return;
  89. }
  90. */
  91. // check code
  92. $sCode = bx_get('code');
  93. if (!$sCode || bx_get('error')) {
  94. $sErrorDescription = bx_get('error_description') ? bx_get('error_description') : _t('_error occured');
  95. $this->_oTemplate->getPage(_t('_Error'), MsgBox($sErrorDescription));
  96. return;
  97. }
  98. // Verifying the received tokens with Azure and finalizing the authentication part
  99. $sBaseUrl = "https://" . $this->_oConfig->sDomain . "/" . $this->_oConfig->sTenant . "/" . $this->_oConfig->sPolicy;
  100. $s = bx_file_get_contents($sBaseUrl . "/oauth2/v2.0/token", [
  101. 'grant_type' => 'authorization_code',
  102. 'client_id' => $this->_oConfig->sClientID,
  103. 'scope' => $this->_oConfig->sScope,
  104. 'redirect_uri' => $this->_oConfig->sPageHandle,
  105. 'code' => $sCode,
  106. 'client_secret' => $this->_oConfig->sSecret,
  107. 'code_verifier' => BxDolSession::getInstance()->getValue('code_verifier'),
  108. ], 'post', array ('Content-Type: application/x-www-form-urlencoded'));
  109. $aAuthData = $this->_decodeResponseAndHandleError($s);
  110. // get the data, especially id_token
  111. $sIdToken = $aAuthData['id_token'];
  112. // parse token to get the data
  113. //
  114. // to make library to with B2C correctly please comment out the following lines in
  115. // vendor/alancting/php-microsoft-jwt/src/Base/MicrosoftConfiguration.php
  116. // file near ~240 line
  117. // @code
  118. // !array_key_exists('userinfo_endpoint', $data)
  119. // !array_key_exists('device_authorization_endpoint', $data)
  120. // @endcode
  121. // then further in the code add checking for not set values for these values
  122. $aConfigOptions = [
  123. 'tenant' => $this->_oConfig->sTenantID,
  124. 'tenant_id' => $this->_oConfig->sTenantID,
  125. 'client_id' => $this->_oConfig->sClientID,
  126. 'config_uri' => "https://" . $this->_oConfig->sDomain . "/" . $this->_oConfig->sTenant . "/" . $this->_oConfig->sPolicy . "/v2.0/.well-known/openid-configuration",
  127. ];
  128. $oConfig = new AzureAdConfiguration($aConfigOptions);
  129. $oIdTokenJWT = new AzureAdIdTokenJWT($oConfig, $sIdToken);
  130. $aUserData = [
  131. 'id' => $oIdTokenJWT->get('oid'),
  132. 'name' => $oIdTokenJWT->get('given_name'),
  133. 'last_name' => $oIdTokenJWT->get('family_name'),
  134. 'email' => @reset($oIdTokenJWT->get('emails')),
  135. ];
  136. $aRemoteProfileInfo = $aUserData;
  137. if (getParam('bx_azrb2c_group')) {
  138. // get access token to call MS Graph API
  139. $s = bx_file_get_contents("https://login.microsoftonline.com/" . $this->_oConfig->sTenantID . "/oauth2/v2.0/token", [
  140. 'grant_type' => 'client_credentials',//'authorization_code',
  141. 'client_id' => $this->_oConfig->sClientID,
  142. 'scope' => "https://graph.microsoft.com/.default",
  143. 'client_secret' => $this->_oConfig->sSecret,
  144. ], 'post', array ('Content-Type: application/x-www-form-urlencoded'));
  145. $aAuthData2 = $this->_decodeResponseAndHandleError($s);
  146. // call MS Graph API
  147. $bMemberOfGroup = false;
  148. if (isset($aAuthData2['access_token'])) {
  149. $sBaseUrl = "https://graph.microsoft.com/v1.0/users/" . $oIdTokenJWT->get('oid') . "/memberOf";
  150. $s = bx_file_get_contents($sBaseUrl, [], 'GET', array ('Authorization: Bearer ' . $aAuthData2['access_token']));
  151. $aUserGroups = $this->_decodeResponseAndHandleError($s);
  152. if (isset($aUserGroups['value'])) {
  153. foreach ($aUserGroups['value'] as $r) {
  154. if (getParam('bx_azrb2c_group') == $r['displayName']) {
  155. $bMemberOfGroup = true;
  156. break;
  157. }
  158. }
  159. }
  160. }
  161. if (!$bMemberOfGroup) {
  162. $this->_oTemplate->getPage(_t('_Error'), MsgBox(_t("_bx_azrb2c_error_not_member_of_group")));
  163. exit;
  164. }
  165. }
  166. if ($aRemoteProfileInfo) {
  167. // check if user logged in before
  168. $iLocalProfileId = $this->_oDb->getProfileId($aRemoteProfileInfo['id']);
  169. if ($iLocalProfileId && $oProfile = BxDolProfile::getInstance($iLocalProfileId)) {
  170. // user already exists
  171. $this->setLogged($oProfile ->id());
  172. }
  173. else {
  174. // register new user
  175. $this->_createProfile($aRemoteProfileInfo);
  176. }
  177. }
  178. else {
  179. $this->_oTemplate->getPage(_t('_Error'), MsgBox(_t('_sys_connect_profile_error_info')));
  180. }
  181. }
  182. /**
  183. * @param $aProfileInfo - remote profile info
  184. * @param $sAlternativeName - suffix to add to NickName to make it unique
  185. * @return profile array info, ready for the local database
  186. */
  187. protected function _convertRemoteFields($aProfileInfo, $sAlternativeName = '')
  188. {
  189. $aProfileFields = $aProfileInfo;
  190. $aProfileFields['fullname'] = !empty($aProfileInfo['last_name']) ? $aProfileInfo['name'] . ' ' . $aProfileInfo['last_name'] : $aProfileFields['name'];
  191. $aProfileFields['picture'] = ''; // isset($aProfileInfo['picture']) ? $aProfileInfo['picture'] : '';
  192. $aProfileFields['allow_view_to'] = getParam('bx_azrb2c_privacy');
  193. return $aProfileFields;
  194. }
  195. protected function _decodeResponseAndHandleError($s, $bDisplayErrorPage = true)
  196. {
  197. if (!$s || NULL === ($aData = json_decode($s, true)) || !$aData || isset($aData['error'])) {
  198. if (is_array($aData['error']) && !empty($aData['error']['message']))
  199. $sErrorDescription = $aData['error']['message'];
  200. else
  201. $sErrorDescription = isset($aData['error_description']) ? $aData['error_description'] : _t('_error occured');
  202. if ($bDisplayErrorPage) {
  203. $this->_oTemplate->getPage(_t('_Error'), MsgBox($sErrorDescription));
  204. exit;
  205. }
  206. else {
  207. return false;
  208. }
  209. }
  210. return $aData;
  211. }
  212. }
  213. /** @} */