BxSMTPMailAuthorization.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. <?php
  2. /**
  3. * Retrieve an access token from Microsoft login to authenticate
  4. *
  5. *
  6. * Usage guide:
  7. * - create a configuration array or object with all required parameters (see below).
  8. * - create a singleton instance of the class, provide your config and request the access token.
  9. *
  10. * An example
  11. * $accessToken = BxSMTPMailAuthorization::getInstance()->setConfig($config)->getAccessToken();
  12. * Or as a shorthand
  13. * $accessToken = BxSMTPMailAuthorization::getInstance($config)->getAccessToken();
  14. *
  15. *
  16. * Configuration parameters and their values.
  17. * You must specify one of the following configurations.
  18. * If you have specified both, the class will give preference to the certificate flow over the client secret!
  19. *
  20. * Client ID + Secret authentication flow:
  21. * $config = [
  22. * 'mailAddress' => '',
  23. * 'clientId' => '',
  24. * 'clientSecret' => '',
  25. * 'tenantId' => '',
  26. * 'tenantName' => '',
  27. * ];
  28. *
  29. * Client ID + certificate authentication flow:
  30. * $config = [
  31. * 'mailAddress' => '',
  32. * 'clientId' => '',
  33. * 'tenantId' => '',
  34. * 'tenantName' => '',
  35. * 'x509Cert' => '',
  36. * 'x509Key' => '',
  37. * ];
  38. *
  39. * @var string $mailAddress is the senders mail (not the username) of the Outlook user account.
  40. * @var string $clientId is a UIDv4 hash representing the Outlook user account.
  41. * @var string $cleintSecret is a ~ 320 bit random hash with special chars.
  42. * @var string $tenantId is a UIDv4 hash representing the company account.
  43. * @var string $tenantName is a primary domain name for the company at Microsoft Outlook.
  44. * @var string $x509Cert is the absolute file path of the public x509 certificate.
  45. * @var string $x509Key is the absolute file path of the x509 private key.
  46. *
  47. * If you need to create a new certificate
  48. * - open your terminal
  49. * - navigate to the folder where the files should be stored
  50. * - execute the following command and provide the requested information
  51. * - please stick to the naming convention for a better association with the affected mail account
  52. * - e.g. x509-max.muster@example.com.com-till-05.11.2026.crt
  53. * $ openssl req -newkey rsa:4096 -new -x509 -days 365 -nodes -out x509-<mail-address>-till-<date>.crt -keyout x509-<mail-address>-till-<date>.key
  54. *
  55. * If you want to decode certificates on your own computer, run this OpenSSL command
  56. * $ openssl x509 -in x509-<mail-address>-till-<date>.crt -text -noout
  57. *
  58. */
  59. use Firebase\JWT\JWT;
  60. use GuzzleHttp\Client;
  61. use GuzzleHttp\Exception\ClientException;
  62. use GuzzleHttp\Exception\GuzzleException;
  63. /**
  64. * Microsoft Announcement
  65. * Exchange Online to retire Basic auth for Client Submission (SMTP AUTH)
  66. * https://techcommunity.microsoft.com/blog/exchange/exchange-online-to-retire-basic-auth-for-client-submission-smtp-auth/4114750
  67. *
  68. * General Documentation with focus on Web-Apps
  69. * https://learn.microsoft.com/de-de/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
  70. *
  71. * Documentation MSAL library for server side applications
  72. * https://learn.microsoft.com/de-de/entra/identity-platform/scenario-daemon-app-registration
  73. *
  74. * Documentation for Deamon Apps (serverside logics)
  75. * https://learn.microsoft.com/de-de/entra/identity-platform/scenario-daemon-app-configuration
  76. */
  77. class BxSMTPMailAuthorization {
  78. protected static self|null $instance = null;
  79. /**
  80. * Run time cache for access token buffering
  81. * @var array
  82. */
  83. protected array $runtimeCache = [];
  84. /**
  85. * Email Address (the sending mail address not the username)
  86. * @var string
  87. */
  88. protected string $mailAddress = '';
  89. /**
  90. * Microsoft client ID
  91. * @var string UIDv4 Hash value
  92. */
  93. protected string $clientId = '';
  94. /**
  95. * Microsoft application/client secret
  96. * @var string 320-bit random hash with special chars
  97. */
  98. private string $clientSecret = '';
  99. /**
  100. * Microsoft tenant ID / customer number
  101. * @var string UIDv4 Hash value
  102. */
  103. private string $tenantId = '';
  104. /**
  105. * Microsoft tenant name / customer subdomain
  106. * @var string
  107. */
  108. private string $tenantName = '';
  109. /**
  110. * Absolute path to the x509 public certificate, file extension does not matter (.cer, .crt, .pem etc.)
  111. * @var string
  112. */
  113. private string $x509Cert = '';
  114. /**
  115. * Absolute path to the x509 protected key, file extension does not matter (.key, .cer, crt, etc.)
  116. * @var string
  117. */
  118. private string $x509Key = '';
  119. /**
  120. * Base64 encoded fingerprint of the public x509 certificate
  121. * @var string
  122. */
  123. private string $x509Fingerprint = '';
  124. /**
  125. * URL of the Microsoft authentication API
  126. * @var string
  127. */
  128. private string $authenticationUrl = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token';
  129. /**
  130. * Status value: are the parameters for client secret authentication complete?
  131. * @var bool
  132. */
  133. private bool $clientSecretParams = false;
  134. /**
  135. * Status value: are the parameters for certificate authentication complete?
  136. * @var bool
  137. */
  138. private bool $certificateParams = false;
  139. /**
  140. * List of collected errors
  141. * @var array
  142. */
  143. private array $errors = [];
  144. /**
  145. * Get singleton class instance
  146. *
  147. * Config for client secret login [clientId, clientSecret, tenantId, tenantName]
  148. * Config for certificate login [clientId, tenantId, tenantName, x509Cert, x509Key]
  149. * Extended config for certificate login [clientId, tenantId, tenantName, certificates]
  150. *
  151. * @param object|array|null $config Configuration values
  152. * @return self
  153. */
  154. public static function getInstance(object|array|null $config): self{
  155. if(!self::$instance){
  156. self::$instance = new self();
  157. }
  158. return $config ? self::$instance->setConfig($config) : self::$instance;
  159. }
  160. /**
  161. * Set configuration values and reset error collection.
  162. *
  163. * Config for client secret login [clientId, clientSecret, tenantId, tenantName]
  164. * Config for certificate login [clientId, tenantId, tenantName, x509Cert, x509Key]
  165. * Extended config for certificate login [clientId, tenantId, tenantName, certificates]
  166. *
  167. * @param object|array $config
  168. * @return $this
  169. */
  170. public function setConfig(object|array $config): self{
  171. $config = (object)$config;
  172. $this->mailAddress = $config->mailAddress ?? '';
  173. $this->clientId = $config->clientId ?? '';
  174. $this->clientSecret = $config->clientSecret ?? '';
  175. $this->tenantId = $config->tenantId ?? '';
  176. $this->tenantName = $config->tenantName ?? '';
  177. if(!empty($config->certificates)){
  178. $now = date('Y-m-d');
  179. // convert to array of objects if array of arrays is given
  180. $config->certificates = json_decode(json_encode($config->certificates));
  181. usort($config->certificates, fn($a, $b) => strcmp($b->validFrom ?? '', $a->validFrom ?? ''));
  182. $certificates = array_filter($config->certificates, function($v) use ($now){
  183. return ($v->active ?? false) && ($v->validFrom ?? '') <= $now && ($v->validTill ?? '') >= $now;
  184. }, 0);
  185. if(!empty($certificates[0])){
  186. $this->x509Cert = $certificates[0]->x509Cert ?? '';
  187. $this->x509Key = $certificates[0]->x509Key ?? '';
  188. }
  189. }else{
  190. $this->x509Cert = $config->x509Cert ?? '';
  191. $this->x509Key = $config->x509Key ?? '';
  192. }
  193. $this->errors = [];
  194. $this->checkAuthenticationScopes();
  195. return $this;
  196. }
  197. /**
  198. * Retrieve the current valid access token.
  199. * Serve from cache if available and valid, or generate a new one.
  200. * @return string|null
  201. */
  202. public function getAccessToken(): string|null{
  203. $tokenData = $this->getTokenData($this->mailAddress) ?: $this->retrieveAccessToken__certificate() ?: $this->retrieveAccessToken__clientSecret();
  204. return $tokenData['accessToken'] ?? null;
  205. }
  206. /**
  207. * Check if the authentication has any errors.
  208. * @return bool
  209. */
  210. public function hasErrors(): bool{
  211. return count($this->errors) > 0;
  212. }
  213. /**
  214. * Retrieve a list of all collected errors.
  215. * @return array
  216. */
  217. public function getErrors(): array{
  218. return $this->errors;
  219. }
  220. /**
  221. * Get the name of the certificate for creation routine.
  222. * Can also be used to get the file name if you want to check if it already exists.
  223. * @param string $fileExt file extension (crt, key)
  224. * @param int $lifetime in days
  225. * @return string
  226. */
  227. public function getNewCertFilename(string $fileExt, int $lifetime = 365): string {
  228. $deadline = strtotime('+'.$lifetime.' days');
  229. return sprintf('x509-%s-till-%s.%s', $this->mailAddress, strtolower(date('d.m.Y', $deadline)), $fileExt);
  230. }
  231. /**
  232. * Create a new certificate for an email account.
  233. * If a certificate crt and key files with the same name already exist, they will NOT be overwritten!!
  234. * @param string|null $absDir
  235. * @return array[]
  236. * @throws Exception
  237. */
  238. public function createCertificate(string $absDir = null): array {
  239. $dir = $absDir ?? dirname($this->x509Cert);
  240. if(!is_dir($dir)){
  241. throw new Exception("Directory '{$dir}' does not exist");
  242. }
  243. $certFileName = $this->getNewCertFilename('crt');
  244. $keyFileName = $this->getNewCertFilename('key');
  245. if(file_exists($certFileName) && file_exists($keyFileName)){
  246. goto response;
  247. }
  248. $distinguished_names = [
  249. 'countryName' => 'DE',
  250. 'stateOrProvinceName' => 'Bavaria',
  251. 'localityName' => 'Munich',
  252. 'organizationName' => 'Org',
  253. 'organizationalUnitName' => $this->mailAddress,
  254. 'commonName' => $this->mailAddress,
  255. 'emailAddress' => $this->mailAddress,
  256. ];
  257. $private_key = openssl_pkey_new(["digest_alg" => "sha512",
  258. "private_key_bits" => 4096,
  259. "private_key_type" => OPENSSL_KEYTYPE_RSA,
  260. ]);
  261. $csr = openssl_csr_new($distinguished_names, $private_key, ['digest_alg' => 'sha256']);
  262. $x509 = openssl_csr_sign($csr, null, $private_key, 365, ['digest_alg' => 'sha256']);
  263. openssl_pkey_export_to_file($private_key, $dir.$keyFileName);
  264. openssl_x509_export_to_file($x509, $dir.$certFileName);
  265. response:
  266. return [
  267. 'crt' => [
  268. 'filename' => $certFileName,
  269. 'dir' => $dir,
  270. 'path' => $dir.$certFileName,
  271. ],
  272. 'key' => [
  273. 'filename' => $keyFileName,
  274. 'dir' => $dir,
  275. 'path' => $dir.$keyFileName,
  276. ],
  277. 'details' => $this->getCertificateDetails($dir.$certFileName),
  278. ];
  279. }
  280. /**
  281. * Retrieve the certificate details as an associative array.
  282. * @param string $absPath
  283. * @return array|false
  284. */
  285. public function getCertificateDetails(string $absPath): array|false {
  286. return openssl_x509_parse(file_get_contents($absPath));
  287. }
  288. /**
  289. * Create a new instance
  290. */
  291. protected function __construct(){}
  292. /**
  293. * Load the access token and its expiration from custom caching storage.
  294. * @param string $clientId affected client ID
  295. * @return array|null token data or null on failure
  296. */
  297. protected function getCache(string $clientId): array|null {
  298. // provide your own logic to load the cached data pending on the clientId
  299. /* a simple example for a file based caching
  300. $cacheFile = sprintf('%s/cache/azure-ad-tokens.json', MY_DOC_ROOT);
  301. $cache = json_decode(file_get_contents($cacheFile), true);
  302. */
  303. return $cache[$clientId] ?? null;
  304. }
  305. /**
  306. * Save the access token and its expiration to the custom caching storage.
  307. * @param string $clientId affected client ID
  308. * @param array $tokenData details about the access token
  309. * @return $this
  310. */
  311. protected function setCache(string $clientId, array $tokenData): self{
  312. // implement your own logic to store the token data in your cache pending on the clientId
  313. // keep in mind, that the accessToken is a JWT and needs some space (string 2000)
  314. /* a simple example for a file based caching
  315. $cacheFile = sprintf('%s/cache/azure-ad-tokens.json', MY_DOC_ROOT);
  316. $cache = json_decode(file_get_contents($cacheFile), true);
  317. $cache[$clientId] = $tokenData;
  318. file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT));
  319. */
  320. return $this;
  321. }
  322. /**
  323. * Get the token data array from runtime or storage cache.
  324. * @param string $clientId affected client ID
  325. * @return array|null token data or null on failure
  326. */
  327. protected function getTokenData(string $clientId): array|null {
  328. $tokenData = $this->getRuntimeCache($clientId) ?: $this->getCache($clientId);
  329. if($tokenData && $tokenData['expiresAt'] > time()){
  330. $this->setRuntimeCache($clientId, $tokenData);
  331. return $tokenData;
  332. }
  333. return null;
  334. }
  335. /**
  336. * Set token data to runtime and storage cache.
  337. * @param string $clientId affected client ID
  338. * @param array $tokenData details about the access token
  339. * @return $this
  340. */
  341. protected function setTokenData(string $clientId, array $tokenData): self {
  342. $this->setRuntimeCache($clientId, $tokenData);
  343. $this->setCache($clientId, $tokenData);
  344. return $this;
  345. }
  346. /**
  347. * Get token from runtime cache.
  348. * @param string $clientId affected client ID
  349. * @return array|null token data or null on failure
  350. */
  351. protected function getRuntimeCache(string $clientId): array|null {
  352. return $this->runtimeCache[$clientId] ?? null;
  353. }
  354. /**
  355. * Set token data to runtime cache.
  356. * @param string $clientId affected client ID
  357. * @param array $tokenData details about the access token
  358. * @return $this
  359. */
  360. protected function setRuntimeCache(string $clientId, array $tokenData): self{
  361. $this->runtimeCache[$clientId] = $tokenData;
  362. return $this;
  363. }
  364. /**
  365. * Check if the parameters for the available authentication logics and scope are complete.
  366. * @return void
  367. */
  368. private function checkAuthenticationScopes(): void{
  369. $this->clientSecretParams = false;
  370. $this->certificateParams = false;
  371. if($this->clientId && $this->clientSecret && $this->tenantId && $this->tenantName){
  372. $this->clientSecretParams = true;
  373. }
  374. if($this->clientId && $this->tenantId && $this->tenantName && $this->x509Cert && $this->x509Key){
  375. if(empty(trim($this->x509Cert))){
  376. $this->addError('X509 certificate is not set');
  377. }
  378. if(empty(trim($this->x509Key))){
  379. $this->addError('X509 key is not set');
  380. }
  381. if(!$this->hasErrors()){
  382. if($this->createX509Fingerprint()){
  383. $this->certificateParams = true;
  384. }
  385. }
  386. }
  387. if(!$this->clientSecretParams && !$this->certificateParams){
  388. $this->addError('Configuration values are incomplete or invalid');
  389. }
  390. }
  391. /**
  392. * Add a new error message to the collection.
  393. * @param string $message
  394. * @return void
  395. */
  396. private function addError(string $message): void{
  397. $this->errors[] = $message;
  398. }
  399. /**
  400. * Create the base64 encoded fingerprint fo the x509 certificate.
  401. * @return bool
  402. */
  403. private function createX509Fingerprint(): bool{
  404. try{
  405. $x509 = $this->x509Cert;
  406. // read the cert string
  407. $cert = openssl_x509_read($x509);
  408. // get the sha1 fingerprint --> binary NOT hexadecimal!
  409. $hex = openssl_x509_fingerprint($cert, 'sha1', true);
  410. // convert binary thumbprint to base64 and remove trailing symbols
  411. $this->x509Fingerprint = rtrim(base64_encode($hex), '=');
  412. return true;
  413. }catch(Exception $e){
  414. $this->addError($e->getMessage());
  415. return false;
  416. }
  417. }
  418. /**
  419. * Retrieve a new access token via API, using the clientID + clientSecret authentication logic.
  420. * @return array|null token data or null on failure
  421. */
  422. private function retrieveAccessToken__clientSecret(): array|null{
  423. if(!$this->clientSecretParams) return null;
  424. try{
  425. $params = [
  426. 'form_params' => [
  427. 'grant_type' => 'client_credentials',
  428. 'client_id' => $this->clientId,
  429. 'client_secret' => $this->clientSecret,
  430. 'scope' => sprintf('https://%s/.default', $this->tenantName), // maybe https://outlook.office.com/SMTP.Send, https://outlook.office365.com/.default
  431. ],
  432. ];
  433. $this->fetchTokenFromMicrosoftLogin($params);
  434. return $this->getRuntimeCache($this->mailAddress);
  435. } catch (ClientException $e) {
  436. $errorData = json_decode($e->getResponse()->getBody()->getContents());
  437. $this->addError('Authentication Error: '.print_r($errorData, true));
  438. return null;
  439. }catch(GuzzleException $e){
  440. $this->addError('Authentication Error: '.$e->getMessage());
  441. return null;
  442. }catch(Exception $e){
  443. $this->addError('Authentication Error: '.$e->getMessage());
  444. return null;
  445. }
  446. }
  447. /**
  448. * Retrieve a new access token via API, using the clientID + certificate authentication logic.
  449. * @return array|null token data or null on failure
  450. */
  451. private function retrieveAccessToken__certificate(): array|null{
  452. if(!$this->certificateParams) return null;
  453. try{
  454. $claims = [
  455. // Issued at timestamp
  456. 'iat' => strtotime('now'),
  457. // Expires timestamp
  458. 'exp' => strtotime('+10 minutes'),
  459. // Issuer
  460. 'iss' => $this->clientId,
  461. // Subject Claim
  462. 'sub' => $this->clientId,
  463. // Audience
  464. 'aud' => sprintf($this->authenticationUrl, $this->tenantId),
  465. ];
  466. $headers = [
  467. 'typ' => 'JWT',
  468. 'alg' => 'RS256',
  469. 'x5t' => $this->x509Fingerprint,
  470. ];
  471. $privateKey = $this->x509Key;
  472. $jwt = JWT::encode($claims, $privateKey, 'RS256', null, $headers);
  473. $params = [
  474. 'form_params' => [
  475. 'grant_type' => 'client_credentials',
  476. 'client_id' => $this->clientId,
  477. 'scope' => 'https://outlook.office.com/.default', // full mail access (IMAP, POP, SMTP)
  478. 'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
  479. 'client_assertion' => $jwt,
  480. ]
  481. ];
  482. $this->fetchTokenFromMicrosoftLogin($params);
  483. return $this->getRuntimeCache($this->mailAddress);
  484. } catch (ClientException $e) {
  485. $errorData = json_decode($e->getResponse()->getBody()->getContents());
  486. $this->addError('Authentication Error (client): '.print_r($errorData, true));
  487. return null;
  488. }catch(GuzzleException $e){
  489. $this->addError('Authentication Error (guzzle): '.$e->getMessage());
  490. return null;
  491. }catch(Exception $e){
  492. $this->addError('Authentication Error (php): '.$e->getMessage());
  493. return null;
  494. }
  495. }
  496. /**
  497. * Execute the authentication request and handle the response.
  498. * @param array $params
  499. * @return void
  500. * @throws GuzzleException
  501. */
  502. private function fetchTokenFromMicrosoftLogin(array $params): void{
  503. $client = new Client();
  504. $response = $client->request('POST', sprintf($this->authenticationUrl, $this->tenantId), $params);
  505. $data = $response->getBody()->getContents();
  506. $jsonData = json_decode($data);
  507. $tokenData = [
  508. 'accessToken' => $jsonData->access_token,
  509. 'expiresAt' => strtotime('+'.($jsonData->expires_in - 5).' seconds'),
  510. ];
  511. // update run time scope
  512. $this->setTokenData($this->mailAddress, $tokenData);
  513. }
  514. }