1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use Auth\UI\SecureAccessKey;
16: use Module\Skeleton\Contracts\Hookable;
17: use Module\Skeleton\Contracts\Tasking;
18: use Module\Support\Auth;
19: use Opcenter\Account\State;
20: use Opcenter\Auth\Password;
21: use Opcenter\Auth\Shadow;
22: use Opcenter\Mail\Services\Dovecot;
23:
24: /**
25: * Provides authorization mechanisms and management
26: *
27: * @package core
28: */
29: class Auth_Module extends Auth implements Hookable, Tasking
30: {
31: const DEPENDENCY_MAP = [
32: 'siteinfo',
33: 'users'
34: ];
35:
36: const API_KEY_LIMIT = 10;
37: const API_USER_SYNC_COMMENT = PANEL_BRAND . ' user sync';
38: // override in effect, don't report
39: const PWOVERRIDE_KEY = 'pwoverride';
40: // recognized browser storage key, cookies don't like "."
41: const SECURITY_TOKEN = \Auth\Sectoken::SECURITY_TOKEN;
42: // tunable minimum acceptable password length
43: const MIN_PW_LENGTH = AUTH_MIN_PW_LENGTH;
44:
45: const PAM_SERVICES = ['cp', 'dav'];
46:
47: private static $domain_db;
48:
49: protected $exportedFunctions = [
50: '*' => PRIVILEGE_ALL,
51: 'inactive_reason' => PRIVILEGE_SITE|PRIVILEGE_USER,
52: 'verify_password' => PRIVILEGE_SERVER_EXEC | PRIVILEGE_ALL,
53: 'verify_totp' => PRIVILEGE_SERVER_EXEC | PRIVILEGE_ALL,
54: 'change_domain' => PRIVILEGE_SITE,
55: 'change_username' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
56: 'reset_password' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
57: 'set_temp_password' => PRIVILEGE_ADMIN | PRIVILEGE_SITE
58: ];
59: /**
60: * @ignore
61: */
62: public function __construct()
63: {
64: parent::__construct();
65: if (!AUTH_ALLOW_USERNAME_CHANGE) {
66: $this->exportedFunctions['change_username'] = PRIVILEGE_ADMIN;
67: }
68: if (!AUTH_ALLOW_DOMAIN_CHANGE) {
69: $this->exportedFunctions['change_domain'] = PRIVILEGE_NONE;
70: }
71: }
72:
73: /**
74: * Active session information
75: *
76: * @return array
77: */
78: public function session_info(): array
79: {
80: return (array)$this->getAuthContext();
81: }
82:
83: /**
84: * Change an account password
85: *
86: * user parameter may only be supplied by account admin
87: * domain parameter may only be supplied by server admin
88: *
89: * @param string $password
90: * @param string $user
91: * @param string $domain
92: *
93: * @return bool
94: */
95: public function change_password(string $password, string $user = null, string $domain = null): bool
96: {
97: if (!$this->password_permitted($password, $user)) {
98: return error('weak password disallowed');
99: } else if ($this->is_demo()) {
100: return error('cannot change password in demo mode');
101: }
102: $crypted = $this->crypt($password);
103:
104: return $this->change_cpassword($crypted, $user, $domain);
105: }
106:
107: /**
108: * Generate random password for user
109: *
110: * @param string|null $user
111: * @param string|null $domain
112: * @return string new password on success
113: */
114: public function reset_password(?string $user = null, string $domain = null): ?string
115: {
116: if (!IS_CLI) {
117: return $this->query('auth_reset_password', $user, $domain);
118: }
119:
120: if ($this->is_demo()) {
121: return nerror('cannot change password in demo mode');
122: }
123:
124: if ($this->permission_level & PRIVILEGE_SITE) {
125: $domain = $this->domain;
126: } else if ($domain) {
127: if (null === ($tmp = \Auth::get_site_id_from_anything($domain))) {
128: return nerror("Cannot find `%s'", $domain);
129: }
130: $domain = \Auth::get_domain_from_site_id($tmp);
131: }
132:
133: if (!$user && !$domain) {
134: $user = $this->username;
135: } else if ($domain && !$user) {
136: if (null === ($tmp = \Auth::get_admin_from_site_id(\Auth::get_site_id_from_anything($domain)))) {
137: return nerror("Cannot find admin for `%s'", $domain);
138: }
139: $user = $tmp;
140: }
141:
142: $password = Password::generate(max(self::MIN_PW_LENGTH, 8));
143:
144: if (!$this->setCryptedPassword($this->crypt($password), $user, $domain)) {
145: return null;
146: }
147:
148: $ctx = \Auth::context($user, $domain);
149: $email = \apnscpFunctionInterceptor::factory($ctx)->common_get_email();
150:
151: success("Password set to: `%s'", $password);
152:
153: $this->sendNotice(
154: 'password',
155: [
156: 'email' => $email,
157: 'password' => $password,
158: 'username' => $user,
159: 'domain' => $domain
160: ]
161: );
162:
163: \apnscpSession::invalidate_by_user($this->site_id, $user, true);
164: if ($domain) {
165: $afi = $this->permission_level & PRIVILEGE_ADMIN ? \apnscpFunctionInterceptor::factory($ctx) : $this;
166: $afi->site_kill_user($user) && (!Dovecot::exists() || Dovecot::flushAuth());
167: }
168:
169: return $password;
170: }
171:
172: /**
173: * Password meets minimum security requirements
174: *
175: * @param string $password
176: * @param string|null $user
177: * @return bool
178: */
179: public function password_permitted(string $password, string $user = null): bool
180: {
181: return Password::strong($password, $user);
182: }
183:
184: /**
185: * Account is demo
186: *
187: * @return bool
188: */
189: public function is_demo(): bool
190: {
191: /**
192: * No demo for admin since it just consists of the ticket interface
193: */
194: if ($this->permission_level & PRIVILEGE_ADMIN) {
195: return DEMO_ADMIN_LOCK;
196: }
197:
198: return $this->billing_get_invoice() == DEMO_INVOICE || $this->getConfig('billing', 'parent_invoice') === DEMO_INVOICE;
199: }
200:
201: /**
202: * Encrypt a password using the strongest hash
203: *
204: * @param string $password
205: * @param string|null $salt
206: * @return string
207: */
208: public function crypt(string $password, string $salt = null): string
209: {
210: return Shadow::crypt($password, $salt);
211: }
212:
213: /**
214: * Change password (crypted)
215: *
216: * Notifications are dispatched depending on rules:
217: *
218: * Self-changes alert the affected users.
219: * Changes to subordinate users notify next role down.
220: * - admin changing sub-user notifies site admin
221: * - site admin changing sub-user notifies sub-user
222: * - sub-user changing self notifies self
223: *
224: * @param string $cpassword crypted password
225: * @param string|null $user
226: * @param string|null $domain
227: * @return bool
228: */
229: public function change_cpassword(string $cpassword, string $user = null, string $domain = null): bool
230: {
231: if ($this->is_demo()) {
232: return error('demo account password changes disabled');
233: }
234:
235: $user = $user ?? $this->username;
236: $domain = $domain ?: $this->domain;
237:
238: if (!IS_CLI) {
239: $ret = $this->query('auth_change_cpassword', $cpassword, $user, $domain);
240: if (!$ret) {
241: return $ret;
242: }
243: if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) {
244: if ($this->getServiceValue(self::getAuthService(), self::PWOVERRIDE_KEY)) {
245: return true;
246: }
247: // subuser password changed
248: if ($user !== $this->username) {
249: $email = \apnscpFunctionInterceptor::factory(\Auth::context($user, $this->site))->common_get_email();
250: }
251: $email = $email ?? $this->common_get_email() ?? $this->getConfig('siteinfo', 'email');
252: } else if ($this->permission_level & PRIVILEGE_ADMIN) {
253: if (!$domain) {
254: $email = $this->common_get_email();
255: } else {
256: // notify user their password changed
257: $afi = \apnscpFunctionInterceptor::factory(\Auth::context(null, $domain));
258: if ($afi->common_get_service_value(self::getAuthService(), self::PWOVERRIDE_KEY)) {
259: // unless pwoverride is set and admin is sloppy with API
260: return true;
261: }
262: $email = $afi->common_get_email();
263: }
264: }
265: parent::sendNotice(
266: 'password',
267: [
268: 'email' => $email,
269: 'ip' => \Auth::client_ip(),
270: 'username' => $user,
271: 'domain' => $domain ?: $this->domain
272: ]
273: );
274: \apnscpSession::invalidate_by_user($this->site_id, $user, true);
275:
276: return $ret;
277: }
278:
279: return $this->setCryptedPassword($cpassword, $user, $domain);
280: }
281:
282: /**
283: * Account is inactive
284: *
285: * @return bool
286: */
287: public function is_inactive(): bool
288: {
289: if (!IS_CLI) {
290: return $this->query('auth_is_inactive');
291: }
292: if ($this->permission_level & (PRIVILEGE_USER | PRIVILEGE_SITE)) {
293: return file_exists(State::disableMarker($this->site));
294: }
295:
296: return false;
297: }
298:
299: /**
300: * Get inactivity reason
301: *
302: * @return string|null
303: */
304: public function inactive_reason(): ?string
305: {
306: if (!IS_CLI) {
307: return $this->query('auth_inactive_reason');
308: }
309:
310: if ($this->permission_level & PRIVILEGE_USER ||
311: !AUTH_SHOW_SUSPENSION_REASON || !$this->is_inactive()) {
312: return null;
313: }
314:
315: if (!file_exists($path = State::disableMarker($this->site))) {
316: return null;
317: }
318:
319: return rtrim(implode("\n", array_filter(
320: file($path, FILE_IGNORE_NEW_LINES),
321: static function ($line) {
322: var_dump($line[0] ?? '');
323: $firstChar = ltrim($line)[0] ?? '';
324: return $firstChar !== '#' && $firstChar !== ';';
325: })
326: ));
327: }
328:
329: /**
330: * Generate an API key
331: *
332: * Generates a 256-bit SOAP key for use with invoking the Web Services
333: * in apnscp esprit. The key is a hexadecimal-encoded value traditionally
334: * split into groups of 8, or 96 bits per bunch, delimited by a '-'. When
335: * authenticating, this is the format preferred, but this function will
336: * instead return the 512-bit key gumped into one big string. At this time
337: * you are limited to just 10 keys.
338: *
339: * @param string $comment optional comment
340: * @param string $user optional user for site admin
341: *
342: * @return null|string 256-bit SOAP key
343: */
344: public function create_api_key(string $comment = '', string $user = null): ?string
345: {
346: if (!$user || !($this->permission_level & PRIVILEGE_SITE)) {
347: $user = $this->username;
348: } else if (!$this->user_exists($user)) {
349: error("cannot set comment for key, user `%s' does not exist", $user);
350: return null;
351: }
352:
353: if (strlen($comment) > 255) {
354: warn('api key comment truncated beyond 255 characters');
355: }
356: $key = hash('sha256', uniqid((string)random_int(PHP_INT_MIN, PHP_INT_MAX), true));
357: $invoice = null;
358: if (!($this->permission_level & PRIVILEGE_ADMIN)) {
359: $invoice = $this->billing_get_invoice();
360: if (!$invoice) {
361: error('unable to find invoice for account');
362: return null;
363: }
364: }
365: $db = Auth_SOAP::get_api_db();
366: $qfrag = $this->_getAPIQueryFragment();
367: $rs = $db->query('SELECT
368: `api_key`
369: FROM `api_keys` ' .
370: $qfrag['join'] .
371: "WHERE
372: `username` = '" . $user . "'
373: AND " . $qfrag['where'] . ' GROUP BY (api_key)');
374:
375: if ((!$this->permission_level & PRIVILEGE_ADMIN) && ($rs->num_rows >= self::API_KEY_LIMIT)) {
376: error('%d key limit reached', self::API_KEY_LIMIT);
377: return null;
378: }
379: $q = 'INSERT INTO `api_keys` ' .
380: '(`api_key`, `server_name`, `username`, `site_id`, `invoice`)' .
381: "VALUES (?,'" . SERVER_NAME_SHORT . "',?,?,?)";
382: $stmt = $db->prepare($q);
383: if ($this->permission_level & PRIVILEGE_ADMIN) {
384: $site_id = null;
385: $invoice = null;
386: } else if ($this->permission_level & PRIVILEGE_RESELLER) {
387: $site_id = null;
388: $invoice = $this->billing_get_invoice();
389: } else {
390: $site_id = $this->site_id;
391: $invoice = $this->billing_get_invoice();
392: }
393: $stmt->bind_param('ssds', $key, $this->username, $site_id, $invoice);
394: if (!$stmt->execute()) {
395: error('unable to add key - %s', $stmt->error);
396: return null;
397: }
398: if ($comment) {
399: $this->set_api_key_comment($key, $comment, $user);
400: }
401:
402: return $key;
403: }
404:
405: /**
406: * Assemble additional API key query restrictions
407: *
408: * @return array
409: */
410: private function _getAPIQueryFragment(): array
411: {
412: $qfrag = array('where' => '1 = 1', 'join' => '');
413: if ($this->permission_level & PRIVILEGE_ADMIN) {
414: $qfrag['where'] = 'api_keys.invoice IS NULL AND site_id IS NULL';
415: } else {
416: $invoice = $this->billing_get_invoice();
417: if (!$invoice) {
418: error('cannot get billing invoice for API key');
419: $qfrag['where'] = '1 = 0';
420:
421: return $qfrag;
422: }
423: $qfrag['where'] = "api_keys.invoice = '" . Auth_SOAP::get_api_db()->real_escape_string($invoice) . "'";
424: }
425:
426: return $qfrag;
427: }
428: /* }}} */
429:
430: /**
431: * Alter a comment attached to an API key
432: *
433: * @param string $key
434: * @param string $comment
435: * @param string $user optional username for site admin
436: * @return bool
437: */
438: public function set_api_key_comment(string $key, string $comment = null, string $user = null): bool
439: {
440: $key = str_replace('-', '', strtolower($key));
441: if (!ctype_xdigit($key)) {
442: return error($key . ': invalid key');
443: }
444:
445:
446: if (strlen($comment) > 255) {
447: warn('comment truncated to max length 255 characters');
448: }
449: if (!$user || !($this->permission_level & PRIVILEGE_SITE)) {
450: $user = $this->username;
451: } else if (!$this->user_exists($user)) {
452: return error("cannot set comment for key, user `%s' does not exist", $user);
453: }
454: $db = Auth_SOAP::get_api_db();
455: $qfrag = $this->_getAPIQueryFragment();
456: $rs = $db->query('UPDATE `api_keys` ' . $qfrag['join'] .
457: "SET comment = '" . $db->escape_string($comment) . "'
458: WHERE `api_key` = '" . strtolower($key) . "'
459: AND " . $qfrag['where'] . "
460: AND `username` = '" . $user . "';");
461:
462: return $rs && $db->affected_rows > 0;
463: }
464:
465: /* }}} */
466:
467: /**
468: * Verify account password
469: *
470: * @param string $password user password
471: *
472: * May not be called via SOAP. Exclusively internal method.
473: *
474: * @return bool
475: */
476: public function verify_password(#[SensitiveParameter] string $password): bool
477: {
478: $file = static::ADMIN_AUTH;
479: if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) {
480: if (!$this->site) {
481: return false;
482: }
483: $file = $this->domain_fs_path('/etc/shadow');
484: }
485: $fp = fopen($file, 'r');
486: if (!$fp) {
487: return false;
488: }
489: $data = array();
490: while (false !== ($line = fgets($fp))) {
491: if (0 === strpos($line, $this->username . ':')) {
492: $data = explode(':', rtrim($line));
493: break;
494: }
495: }
496: fclose($fp);
497: if (!$data) {
498: return false;
499: }
500:
501: if (!isset($data[1])) {
502: $str = 'Corrupted shadow: ' . $file . "\r\n" .
503: $this->username . "\r\n";
504: Error_Reporter::report($str . "\r\n" . var_export($data, true));
505:
506: return false;
507: }
508: $salt = implode('$', explode('$', $data[1]));
509: return Shadow::verify($password, $salt);
510: }
511:
512: /**
513: * Verify TOTP passcode
514: *
515: * @param string $code
516: * @return bool
517: */
518: public function verify_totp(#[SensitiveParameter] string $code): bool
519: {
520: return \Auth\TOTP::instantiateContexted($this->getAuthContext())->verify($code);
521: }
522:
523: /**
524: * Role has TOTP
525: *
526: * @return bool
527: */
528: public function totp_exists(): bool
529: {
530: if (!IS_CLI) {
531: return $this->query('auth_totp_exists');
532: }
533:
534: return \Auth\TOTP::instantiateContexted($this->getAuthContext())->exists();
535: }
536:
537: /**
538: * Enable TOTP on account {@link totp_code}
539: *
540: * @param string $secret initialization key
541: * @param string $code
542: * @return bool
543: */
544: public function totp_enable(string $secret, string $code): bool
545: {
546: if (!IS_CLI) {
547: return $this->query('auth_totp_enable', $secret, $code);
548: }
549:
550: if ($this->is_demo()) {
551: return error("Feature disabled in demo");
552: }
553:
554: $totp = \Auth\TOTP::instantiateContexted($this->getAuthContext());
555: return $totp->verify($code, $secret) &&
556: $totp->set($secret) &&
557: \apnscpSession::invalidate_by_user($this->site_id, $this->username, $this->getAuthContext()->id) || error(\Auth\TOTP::ERR_TOTP_INVALID);
558: }
559:
560: /**
561: * Disable TOTP
562: *
563: * @return bool
564: */
565: public function totp_disable(): bool
566: {
567: if (!IS_CLI) {
568: return $this->query('auth_totp_disable');
569: }
570:
571: return \Auth\TOTP::instantiateContexted($this->getAuthContext())->disable();
572: }
573:
574: /**
575: * Create new TOTP authentication
576: *
577: * @return string|null
578: */
579: public function totp_code(): ?string
580: {
581: if (!IS_CLI) {
582: return $this->query('auth_totp_code');
583: }
584:
585: return $this->totp_exists() ? null : \Auth\TOTP::instantiateContexted($this->getAuthContext())->generate();
586: }
587:
588: /**
589: * Flag an existent session with EXTAUTH privileges
590: *
591: * @param string|null $session
592: * @return bool
593: */
594: public function set_extended_auth_flag(string $session = null): bool
595: {
596: if (posix_getuid()) {
597: return error("Setting EXTAUTH requires elevated privileges");
598: }
599:
600: if (!\apnscpSession::init()->read($session ?? $this->session_id)) {
601: return error(\apnscpSession::ERR_INVALID_SESSION);
602: }
603:
604: $profile = \Auth_Info_User::factory($session ?? $this->session_id);
605: $profile->overrideLevel($profile->level | PRIVILEGE_EXTAUTH);
606:
607: return true;
608: }
609:
610: /**
611: * Queries the last login data for the current user.
612: *
613: * Response will be empty on first login, otherwise an associative array
614: * of indexes date and IP are returned containing the date as an
615: * integer (unix timestamp) and IP address in in conventional IPv4 fashion
616: *
617: * @return array
618: *
619: */
620: public function get_last_login(): array
621: {
622: $login = $this->get_login_history(1);
623: if (!$login) {
624: return array();
625: }
626:
627: return $login[0];
628: }
629:
630: /**
631: * Retrieves all login requests for a user
632: *
633: * Return is NULL if this is the first time logging in,
634: * otherwise an associative array of indexes date and IP are returned
635: * containing the date as an integer (unix timestamp) and IP address in
636: * in conventional IPv4 fashion
637: *
638: * @param integer $limit limit results retrieved to N resultsm
639: *
640: * @return array
641: *
642: */
643: public function get_login_history(int $limit = null): array
644: {
645: $logins = array();
646: // don't display all IP addresses for security
647: if ($this->is_demo()) {
648: $logins[] = array(
649: 'ip' => \Auth::client_ip(),
650: 'ts' => \Auth::login_time()
651: );
652:
653: return $logins;
654: }
655: if (!is_null($limit) && $limit < 100) {
656: $limit = (int)$limit;
657: } else {
658: $limit = 10;
659: }
660: $limitStr = 'LIMIT ' . ($limit + 1);
661: $handler = \MySQL::initialize();
662: $q = $handler->query("SELECT
663: UNIX_TIMESTAMP(`login_date`) AS login_date,
664: INET_NTOA(`ip`) AS ip FROM `login_log`
665: WHERE
666: `domain` = '" . $this->domain . "'
667: AND `username` = '" . $this->username . "'
668: ORDER BY id DESC " . $limitStr);
669: $q->fetch_object();
670:
671: while (($data = $q->fetch_object()) !== null) {
672: $logins[] = array(
673: 'ip' => $data->ip,
674: 'ts' => $data->login_date
675: );
676: }
677: /** dummy request to get rid of the current session */
678: //if (sizeof($logins) == 0 || !isset($logins[0]['ip']))
679: // return array();
680: return $logins;
681:
682: }
683:
684: /**
685: * Change primary account domain
686: *
687: * @param string $domain
688: * @return bool|mixed
689: */
690: public function change_domain(string $domain): bool
691: {
692: if (!IS_CLI) {
693: $olddomain = $this->domain;
694: $ret = $this->query('auth_change_domain', $domain);
695: if ($ret) {
696: parent::sendNotice(
697: 'domain',
698: [
699: 'email' => $this->getConfig('siteinfo', 'email'),
700: 'ip' => \Auth::client_ip(),
701: 'domain' => $olddomain
702: ]
703: );
704: $this->_purgeLoginKey($this->username, $olddomain);
705: }
706:
707: return $ret;
708: }
709:
710: if ($this->is_demo()) {
711: return error('domain change disabled for demo');
712: }
713:
714: $domain = strtolower($domain);
715: if (0 === strncmp($domain, "www.", 4)) {
716: $domain = substr($domain, 4);
717: }
718: if ($domain === $this->domain) {
719: return error('new domain is equivalent to old domain');
720: }
721: if (!preg_match(Regex::DOMAIN, $domain)) {
722: return error("`%s': invalid domain", $domain);
723: }
724: if ($this->dns_domain_hosted($domain, true)) {
725: // permit user to rehost a previously hosted domain if it is on the same account
726: return error("`%s': cannot add domain - hosted on another " .
727: 'account elsewhere', $domain);
728: }
729:
730: if ($this->web_subdomain_exists($domain)) {
731: return error("cannot promote subdomain `%s' to domain", $domain);
732: }
733:
734: if (\Opcenter\License::get()->isDevelopment() && substr($domain, -5) !== '.test') {
735: return error("License permits only .test TLDs. `%s' provided.", $domain);
736: }
737:
738: if (!$this->aliases_bypass_exists($domain) &&
739: $this->dns_gethostbyname_t($domain) != $this->dns_get_public_ip() &&
740: $this->dns_get_records_external('', 'any', $domain) &&
741: !$this->dns_domain_uses_nameservers($domain) // whois check in the future
742: ) {
743: $currentns = join(',', (array)$this->dns_get_authns_from_host($domain));
744: $hostingns = join(',', $this->dns_get_hosting_nameservers($domain));
745:
746: return error('domain uses third-party nameservers - %s, change nameservers to %s before promoting ' .
747: 'this domain to primary domain status', $currentns, $hostingns);
748: }
749: // alternatively use $this->set_config_journal() and require a sync
750: $proc = new Util_Account_Editor($this->getAuthContext()->getAccount(), $this->getAuthContext());
751: $proc->setConfig('siteinfo', 'domain', $domain)->
752: setConfig(\Opcenter\SiteConfiguration::getModuleRemap('proftpd'), 'ftpserver', 'ftp' . $domain)->
753: setConfig(\Opcenter\SiteConfiguration::getModuleRemap('apache'), 'webserver', 'www.' . $domain)->
754: setConfig(\Opcenter\SiteConfiguration::getModuleRemap('sendmail'), 'mailserver', 'mail.' . $domain);
755:
756: return $proc->edit();
757: }
758:
759: /**
760: * Purge browser security key
761: *
762: * @param string $user
763: * @param string $domain
764: * @return void
765: */
766: private function _purgeLoginKey(string $user = '', string $domain = ''): void
767: {
768: // needs to be broken out into separate support function...
769: $userkey = md5($user . $domain);
770: $arrkey = self::SECURITY_TOKEN . '.' . $userkey;
771: $prefs = Preferences::factory($this->getAuthContext());
772: $prefs->unlock($this->getApnscpFunctionInterceptor());
773: unset($prefs[$arrkey]);
774: }
775:
776: /**
777: * Change primary account username
778: *
779: * @param string $user
780: * @return bool
781: */
782: public function change_username(string $user): bool
783: {
784: if (!IS_CLI) {
785: $email = $this->common_get_email();
786: \Preferences::factory($this->getAuthContext())->sync();
787: $ret = $this->query('auth_change_username', $user);
788: if ($ret && $email) {
789: // admin password changed
790: $this->getAuthContext()->username = $user;
791:
792: parent::sendNotice(
793: 'username',
794: [
795: 'email' => $email,
796: 'ip' => \Auth::client_ip()
797: ]
798: );
799:
800: $db = \apnscpSession::db();
801: // @TODO move to utility class
802: $stmt = $db->prepare("UPDATE " . apnscpSession::TABLE . " SET
803: username = :user WHERE session_id = :session_id");
804: $params = ['user' => $user, 'session_id' => $this->session_id];
805:
806: if (!$stmt->execute($params)) {
807: \Error_Reporter::report($db->errorInfo()[0]);
808: fatal('failed to access credentials database');
809: }
810:
811: $this->_purgeLoginKey($user, $this->domain);
812: }
813:
814: return $ret;
815: }
816:
817: if ($this->is_demo()) {
818: return error('username change disabled for demo');
819: }
820: $user = strtolower($user);
821: if (!preg_match(Regex::USERNAME, $user)) {
822: return error("invalid new username `%s'", $user);
823: }
824:
825: /** @var User_Module $class */
826: $class = \a23r::get_autoload_class_from_module('user');
827: if (strlen($user) > $class::USER_MAXLEN) {
828: return error('user max length %d', $class::USER_MAXLEN);
829: }
830:
831: if ($this->permission_level & PRIVILEGE_ADMIN) {
832: // @todo convert to Opcenter
833:
834: if (!($fp = fopen(static::ADMIN_AUTH, 'r+')) || !flock($fp, LOCK_EX | LOCK_NB)) {
835: fclose($fp);
836:
837: return error("unable to gain exclusive lock on `%s'", static::ADMIN_AUTH);
838: }
839: $lines = [];
840: while (false !== ($line = fgets($fp))) {
841: $lines[] = explode(':', rtrim($line));
842: }
843: if (false !== ($pos = array_search($user, array_column($lines, 0), true))) {
844: flock($fp, LOCK_UN);
845: fclose($fp);
846:
847: return error("user `%s' already exists", $user);
848: }
849: if (false === ($pos = array_search($this->username, array_column($lines, 0), true))) {
850: flock($fp, LOCK_UN);
851: fclose($fp);
852:
853: return error("original user `%s' does not exist", $this->username);
854: }
855: $lines[$pos][0] = $user;
856: if (!ftruncate($fp, 0)) {
857: flock($fp, LOCK_UN);
858: fclose($fp);
859:
860: return error("failed to truncate `%s'", static::ADMIN_AUTH);
861: }
862: rewind($fp);
863: fwrite($fp, implode("\n", array_map(static function ($a) {
864: return join(':', $a);
865: }, $lines)));
866:
867: // @todo extract to Support\Common module?
868: $oldprefs = implode(DIRECTORY_SEPARATOR,
869: [\Admin_Module::ADMIN_HOME, \Admin_Module::ADMIN_CONFIG, $this->username]);
870: $newprefs = implode(DIRECTORY_SEPARATOR,
871: [\Admin_Module::ADMIN_HOME, \Admin_Module::ADMIN_CONFIG, $user]);
872: if (file_exists($oldprefs)) {
873: if (file_exists($newprefs)) {
874: unlink($newprefs);
875: }
876: rename($oldprefs, $newprefs) || warn("failed to rename preferences from `%s' to `%s'", $oldprefs, $newprefs);
877: }
878: \apnscpSession::invalidate_by_user(null, $this->username);
879: return flock($fp, LOCK_UN) && fclose($fp);
880: }
881: // make sure user list is not cached
882: $this->user_flush();
883: if (!$this->_username_unique($user)) {
884: return error("requested username `%s' in use on another account", $user);
885: }
886: if ($this->user_exists($user)) {
887: return error("requested username `%s' already exists on this account", $user);
888: }
889:
890: $this->getAuthContext()->username = $user;
891: $proc = new Util_Account_Editor($this->getAuthContext()->getAccount(), $this->getAuthContext());
892: $proc->setConfig('siteinfo', 'admin_user', $user)
893: ->setConfig('mysql', 'dbaseadmin', $user);
894: $ret = $proc->edit();
895:
896: if (!$ret) {
897: return error('failed to change admin user');
898: }
899:
900: return true;
901: }
902:
903: /**
904: * Username is unique to a server or across all servers
905: *
906: * @param string $user
907: * @return int -1 if not globally unique
908: * 0 if not unique on server
909: * 1 if globally unique and unique on server
910: */
911: private function _username_unique($user)
912: {
913:
914: $user = strtolower($user);
915: if (\Auth::get_admin_from_site_id($user)) {
916: return 0;
917: }
918:
919: if (!Auth_Lookup::extendedAvailable()) {
920: return 1;
921: }
922:
923: $db = self::_connect_db();
924: if (!$db) {
925: return error('cannot connect to db');
926: }
927: $q = "SELECT 1 FROM account_cache where admin = '" .
928: $db->real_escape_string($user) . "'";
929: $rs = $db->query($q);
930:
931: return $rs->num_rows > 0 ? -1 : 1;
932: }
933:
934: private static function _connect_db()
935: {
936: if (!is_null(self::$domain_db) && self::$domain_db->ping()) {
937: return self::$domain_db;
938: }
939: try {
940: $db = new mysqli(AUTH_USERNAME_HOST, AUTH_USERNAME_USER, AUTH_USERNAME_PASSWORD, AUTH_USERNAME_DB);
941: } catch (\mysqli_sql_exception $e) {
942: return error('Cannot connect to domain server at this time');
943: }
944:
945: self::$domain_db = &$db;
946:
947: return $db;
948: }
949:
950: /**
951: * Set a temporary password for an account
952: *
953: * @param string $item site or user
954: * @param int $duration duration
955: * @param string|null $password optional password
956: * @return bool
957: */
958: public function set_temp_password(string $item, int $duration = 120/** time in seconds */, string $password = null)
959: {
960: if (!IS_CLI) {
961: return $this->query('auth_set_temp_password', $item, $duration, $password);
962: }
963:
964: if (!$password) {
965: $password = Password::generate();
966: }
967: if ($duration < 1) {
968: return error("invalid duration `%d'", $duration);
969: }
970:
971: $user = null;
972: if ($this->permission_level & PRIVILEGE_ADMIN) {
973: if (0 !== strpos($item, 'site')) {
974: $tmp = \Auth::get_site_id_from_domain($item);
975: if (!$tmp) {
976: return error("domain `%s' not found on server", $item);
977: }
978: $item = 'site' . $tmp;
979: } else {
980: $tmp = \Auth::get_domain_from_site_id(substr($item, 4));
981: if (!$tmp) {
982: return error("site `%s' not found on server", $item);
983: }
984: }
985: $site = $item;
986: $user = \Auth::get_admin_from_site_id(substr($site, 4));
987: } else {
988: if (!\array_key_exists($item, $this->user_get_users())) {
989: return error("Unknown user `%s'", $item);
990: }
991: $site = $this->site;
992: $user = $item;
993: }
994:
995: $ctx = \Auth::context($user, $site);
996: if (!($oldcrypted = Shadow::bindTo($ctx->domain_fs_path())->getspnam($user))) {
997: return error("Failed to locate shadow for `%s'", $user);
998: }
999:
1000: $crypted = $this->crypt($password);
1001:
1002: $accountMeta = $ctx->getAccount();
1003: if ($this->permission_level & PRIVILEGE_ADMIN) {
1004: $editor = new Util_Account_Editor($accountMeta, $ctx);
1005: $ret = $editor->setMode('edit')->setConfig(self::getAuthService(), self::PWOVERRIDE_KEY, true)
1006: ->setConfig(self::getAuthService(), 'cpasswd', $crypted)->edit();
1007: } else {
1008: $ret = Shadow::bindTo($ctx->domain_fs_path())->set_cpasswd($crypted, $user);
1009: }
1010: if (!$ret) {
1011: return error("failed to set temp password: `%s'", Error_Reporter::get_last_msg());
1012: }
1013:
1014: // shim a response if run multiple times
1015: $status = array(
1016: 'success' => true
1017: );
1018:
1019: $dt = new DateTime("now + {$duration} seconds");
1020: $proc = new Util_Process_Schedule($dt);
1021: $key = 'RESET-' . $ctx->user_id;
1022: if (!$proc->idPending($key, $this->getAuthContext())) {
1023: $proc->setID($key, $this->getAuthContext());
1024: if ($this->permission_level & PRIVILEGE_ADMIN) {
1025: $editor = new Util_Account_Editor($accountMeta, $ctx);
1026: $editor->setMode('edit')->setConfig(self::getAuthService(), 'cpasswd', $oldcrypted['shadow'])->
1027: setConfig(self::getAuthService(), self::PWOVERRIDE_KEY, false);
1028: $cmd = $editor->getCommand();
1029: $args = null;
1030: } else {
1031: $chrtcmd = 'usermod -p ' .
1032: escapeshellarg($oldcrypted['shadow']) . ' ' .
1033: '"$(id -nu ' . $ctx->user_id . ')"';
1034: $cmd = "chroot %(path)s /bin/sh -c '%(command)s'";
1035: $args = [
1036: 'command' => escapeshellarg($chrtcmd),
1037: 'path' => $ctx->domain_fs_path()
1038: ];
1039: }
1040: $status = $proc->run($cmd, $args);
1041: }
1042:
1043: if ($status['success']) {
1044: info("Password set on `%s'@`%s' to `%s' for %d seconds",
1045: $ctx->username,
1046: $ctx->domain,
1047: $password,
1048: $duration
1049: );
1050: }
1051:
1052: return $password;
1053: }
1054:
1055: /**
1056: * Get shadow entry for site admin
1057: *
1058: * A nasty kludge
1059: *
1060: * @param int $site_id
1061: * @return string
1062: * @todo remove once user role switching is implemented
1063: */
1064: private function _get_site_admin_shadow($site_id): string
1065: {
1066: $site = 'site' . (int)$site_id;
1067: $base = FILESYSTEM_VIRTBASE . "/{$site}/fst";
1068: $file = '/etc/shadow';
1069: $admin = \Auth::get_admin_from_site_id($site_id);
1070: if (!file_exists($base . $file)) {
1071: fatal("shadow not found for `%s'", $site);
1072: }
1073: $shadow = null;
1074: $fp = fopen($base . $file, 'r');
1075: while (false !== ($line = fgets($fp))) {
1076: $tok = strtok($line, ':');
1077: if ($tok != $admin) {
1078: continue;
1079: }
1080: $shadow = strtok(':');
1081: break;
1082: }
1083: fclose($fp);
1084: if (!$shadow) {
1085: fatal("admin `%s' not found for `%s'", $admin, $site);
1086: }
1087:
1088: return $shadow;
1089: }
1090:
1091: public function _create()
1092: {
1093: static::rebuildMap();
1094: }
1095:
1096: public function _edit()
1097: {
1098: $conf_new = $this->getAuthContext()->getAccount()->new;
1099: $conf_old = $this->getAuthContext()->getAccount()->old;
1100: $user = array(
1101: 'old' => $conf_old['siteinfo']['admin_user'],
1102: 'new' => $conf_new['siteinfo']['admin_user']
1103: );
1104: static::rebuildMap();
1105: if ($user['old'] === $user['new']) {
1106: return;
1107: }
1108:
1109: return $this->_edit_wrapper($user['old'], $user['new']);
1110: }
1111:
1112: /**
1113: * General user edit for admin and users
1114: *
1115: * @param $userold old username
1116: * @param $usernew new username
1117: * @return bool
1118: */
1119: private function _edit_wrapper($userold, $usernew)
1120: {
1121: if ($userold === $usernew) {
1122: return;
1123: }
1124: $db = \MySQL::initialize();
1125: foreach ($this->_get_api_keys_real($userold) as $key) {
1126: if (!$db->query("UPDATE api_keys SET `username` = '" . $db->escape_string($usernew) . "' " .
1127: "WHERE api_key = '" . $key['key'] . "' AND `username` = '" . $db->escape_string($userold) . "'"
1128: )) {
1129: warn("failed to rename API keys for user `%s' to `%s'", $userold, $usernew);
1130: }
1131: }
1132: // @XXX centralize logins
1133: $invoice = $this->billing_get_invoice();
1134: if (!$db->query("UPDATE login_log SET `username` = '" . $db->escape_string($usernew) . "' " .
1135: "WHERE `username` = '" . $db->escape_string($userold) . "' AND invoice = '" . $db->escape_string($invoice) . "'")) {
1136: warn("failed to rename login history for user `%s' to `%s'", $userold, $usernew);
1137: }
1138:
1139:
1140: /**
1141: * _edit() is called before Ensim processes any config changes
1142: * including renaming the user. Pam::add_user() will elicit a
1143: * warning if the user does not exist (which it doesn't yet)
1144: *
1145: */
1146: mute_warn();
1147: foreach (static::PAM_SERVICES as $svc) {
1148: if ($this->user_permitted($userold, $svc)) {
1149: $this->deny_user($userold, $svc);
1150: $this->permit_user($usernew, $svc);
1151: }
1152: }
1153: unmute_warn();
1154: // flush getpwnam() cache
1155: $this->user_flush();
1156:
1157: return true;
1158: }
1159:
1160: protected function _get_api_keys_real($user)
1161: {
1162: $db = Auth_SOAP::get_api_db();
1163: $qfrag = $this->_getAPIQueryFragment();
1164: /**
1165: * make sure only 1 key is pulled if account resides elsewhere
1166: * e.g. during migration
1167: */
1168: $q = 'SELECT `api_key`,
1169: UNIX_TIMESTAMP(`last_used`) as last_used,
1170: comment
1171: FROM `api_keys`
1172: ' . $qfrag['join'] . "
1173: WHERE
1174: `username` = '" . $db->escape_string($user) . "' AND " .
1175: $qfrag['where'] . ' GROUP BY (api_key)';
1176: $rs = $db->query($q);
1177: if (!$rs) {
1178: return error('failed to get keys');
1179: }
1180: $keys = array();
1181: while ($row = $rs->fetch_object()) {
1182: $keys[] = array(
1183: 'key' => $row->api_key,
1184: 'last_used' => $row->last_used,
1185: 'comment' => $row->comment
1186: );
1187: }
1188:
1189: return $keys;
1190: }
1191:
1192: /**
1193: * User permitted to service
1194: *
1195: * @see self::user_enabled()
1196: *
1197: * @param string $user
1198: * @param string $svc
1199: * @return bool
1200: */
1201: public function user_permitted(string $user = null, string $svc = 'cp'): bool
1202: {
1203: if (!in_array($svc, static::PAM_SERVICES, true)) {
1204: return error("unknown service `$svc'");
1205: }
1206: // admin is always permitted to CP
1207: if ($this->permission_level & (PRIVILEGE_ADMIN | PRIVILEGE_RESELLER)) {
1208: return $svc === 'cp';
1209: }
1210:
1211: if ($this->permission_level & PRIVILEGE_USER) {
1212: $user = $this->username;
1213: }
1214:
1215: return (new Util_Pam($this->getAuthContext()))->check($user ?? $this->username, $svc);
1216: }
1217:
1218: /**
1219: * User permitted to service
1220: *
1221: * @param string $user
1222: * @param string $svc
1223: * @return bool
1224: */
1225: public function user_enabled(string $user = null, string $svc = 'cp'): bool
1226: {
1227: deprecated_func('Use user_permitted');
1228: return $this->user_permitted($user, $svc);
1229: }
1230:
1231:
1232: /**
1233: * Deny user access
1234: * @param string $user
1235: * @param string $svc
1236: * @return bool
1237: */
1238: public function deny_user(string $user, string $svc = 'cp'): bool
1239: {
1240: return (new Util_Pam($this->getAuthContext()))->remove($user, $svc);
1241: }
1242:
1243: /**
1244: * Permit user access to apnscp
1245: *
1246: * @param string $user username
1247: * @return bool
1248: */
1249: public function permit_user($user, $svc = 'cp'): bool
1250: {
1251: if (!in_array($svc, static::PAM_SERVICES, true)) {
1252: return error("unknown service `$svc'");
1253: }
1254:
1255: return (new Util_Pam($this->getAuthContext()))->add($user, $svc);
1256: }
1257:
1258: /**
1259: * Restrict login to IP
1260: *
1261: * @param string $ip IPv4, IPv6, or CIDR
1262: * @param string|null $gate optional authentication gate
1263: * @return bool
1264: */
1265: public function restrict_ip(string $ip, string $gate = null): bool
1266: {
1267: if ($this->is_demo()) {
1268: return error('Cannot restrict IP in demo mode');
1269: }
1270: return \Auth\IpRestrictor::instantiateContexted($this->getAuthContext())->add($ip, $gate);
1271: }
1272:
1273: /**
1274: * Remove IP restriction
1275: *
1276: * @param string $ip IPv4, IPv6, or CIDR
1277: * @param string|null $gate optional authentication gate
1278: * @return bool
1279: */
1280: public function remove_ip_restriction(string $ip, string $gate = null): bool
1281: {
1282: return \Auth\IpRestrictor::instantiateContexted($this->getAuthContext())->remove($ip, $gate);
1283: }
1284:
1285: /**
1286: * Get authorized IPs
1287: *
1288: * @return array
1289: */
1290: public function get_ip_restrictions(): array
1291: {
1292: return \Auth\IpRestrictor::instantiateContexted($this->getAuthContext())->list();
1293: }
1294:
1295: public function _edit_user(string $userold, string $usernew, array $oldpwd)
1296: {
1297: return $this->_edit_wrapper($userold, $usernew);
1298: }
1299:
1300: public function _reset(\Util_Account_Editor &$editor = null)
1301: {
1302: $module = self::getAuthService();
1303: $crypted = $this->_get_site_admin_shadow($this->site_id);
1304: if (!$crypted) {
1305: fatal('call _reset() in auth from backend');
1306: }
1307: $params = array(
1308: 'cpasswd' => $crypted
1309: );
1310: if ($editor) {
1311: foreach ($params as $k => $v) {
1312: $editor->setConfig($module, $k, $v);
1313: }
1314: }
1315:
1316: return array($module => $params);
1317:
1318: }
1319:
1320: public function _delete()
1321: {
1322: /*
1323: * @todo check if account listed elsewhere, don't delete keys if
1324: */
1325: $server = \Auth_Redirect::lookup($this->domain);
1326: if (!$server || $server === SERVER_NAME_SHORT) {
1327: foreach ($this->get_api_keys() as $key) {
1328: $this->delete_api_key($key['key']);
1329: }
1330: }
1331: }
1332:
1333: /**
1334: * array get_api_keys (void)
1335: *
1336: * listing all keys associated to an account:
1337: * - key: the generated key
1338: * - last_used: an integer representation of the last date the key was used.
1339: * If the key was never used, null is set for that value.
1340: * Returns the list of SOAP keys associated to an account
1341: *
1342: * @return array|false
1343: */
1344: public function get_api_keys(string $user = null)
1345: {
1346: if (!$user || !($this->permission_level & PRIVILEGE_SITE)) {
1347: $user = $this->username;
1348: } else if ($user && !$this->user_exists($user)) {
1349: return error("user `%s' does not exist", $user);
1350: }
1351:
1352: return $this->_get_api_keys_real($user);
1353: }
1354:
1355: /**
1356: * Delete SOAP key
1357: *
1358: * The key should be in hexadecimal strictly without dashes,
1359: * case does not matter.
1360: *
1361: * @param string $key key to delete from keyring
1362: * @return bool
1363: */
1364: public function delete_api_key(string $key, string $user = null): bool
1365: {
1366: $key = str_replace('-', '', strtolower($key));
1367: if (!ctype_xdigit($key)) {
1368: return error($key . ': invalid key');
1369: }
1370: // verify key via get_api_keys() since _getAPIQueryFragment()
1371: // won't work in a DELETE clause
1372: $keys = $this->get_api_keys($user);
1373: if (!$keys) {
1374: return false;
1375: }
1376: $found = false;
1377: foreach ($keys as $k) {
1378: if ($k['key'] === $key) {
1379: $found = true;
1380: break;
1381: }
1382: }
1383: if (!$found) {
1384: return error("unknown key `%s'", $key);
1385: }
1386: $db = Auth_SOAP::get_api_db();
1387: $rs = $db->query("DELETE FROM `api_keys`
1388: WHERE `api_key` = '" . strtolower($key) . "'");
1389:
1390: return (bool)$rs;
1391: }
1392:
1393: public function _housekeeping()
1394: {
1395: // convert domain map over to TokyoCabinet
1396: static::rebuildMap();
1397: // check if we need reissue
1398: if (\Opcenter\License::get()->needsReissue()) {
1399: info('Attempting to renew apnscp license');
1400: \Opcenter\License::get()->reissue();
1401: }
1402:
1403: (new SecureAccessKey)->check();
1404: }
1405:
1406: public function _cron(Cronus $cron)
1407: {
1408: $cron->schedule(FRONTEND_SECKEY_TTL, 'seckey.roll', static function () {
1409: (new SecureAccessKey)->reset();
1410: });
1411:
1412: $cron->schedule((int)FRONTEND_SECKEY_TTL/4, 'seckey.check', static function () {
1413: (new SecureAccessKey)->check();
1414: });
1415: }
1416:
1417: public function _create_user(string $user)
1418: {
1419: // TODO: Implement _create_user() method.
1420: }
1421:
1422: public function _delete_user(string $user)
1423: {
1424: // TODO: Implement _delete_user() method.
1425: }
1426:
1427: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
1428: {
1429: return true;
1430: }
1431: }