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: * Queries the last login data for the current user.
590: *
591: * Response will be empty on first login, otherwise an associative array
592: * of indexes date and IP are returned containing the date as an
593: * integer (unix timestamp) and IP address in in conventional IPv4 fashion
594: *
595: * @return array
596: *
597: */
598: public function get_last_login(): array
599: {
600: $login = $this->get_login_history(1);
601: if (!$login) {
602: return array();
603: }
604:
605: return $login[0];
606: }
607:
608: /**
609: * Retrieves all login requests for a user
610: *
611: * Return is NULL if this is the first time logging in,
612: * otherwise an associative array of indexes date and IP are returned
613: * containing the date as an integer (unix timestamp) and IP address in
614: * in conventional IPv4 fashion
615: *
616: * @param integer $limit limit results retrieved to N resultsm
617: *
618: * @return array
619: *
620: */
621: public function get_login_history(int $limit = null): array
622: {
623: $logins = array();
624: // don't display all IP addresses for security
625: if ($this->is_demo()) {
626: $logins[] = array(
627: 'ip' => \Auth::client_ip(),
628: 'ts' => \Auth::login_time()
629: );
630:
631: return $logins;
632: }
633: if (!is_null($limit) && $limit < 100) {
634: $limit = (int)$limit;
635: } else {
636: $limit = 10;
637: }
638: $limitStr = 'LIMIT ' . ($limit + 1);
639: $handler = \MySQL::initialize();
640: $q = $handler->query("SELECT
641: UNIX_TIMESTAMP(`login_date`) AS login_date,
642: INET_NTOA(`ip`) AS ip FROM `login_log`
643: WHERE
644: `domain` = '" . $this->domain . "'
645: AND `username` = '" . $this->username . "'
646: ORDER BY id DESC " . $limitStr);
647: $q->fetch_object();
648:
649: while (($data = $q->fetch_object()) !== null) {
650: $logins[] = array(
651: 'ip' => $data->ip,
652: 'ts' => $data->login_date
653: );
654: }
655: /** dummy request to get rid of the current session */
656: //if (sizeof($logins) == 0 || !isset($logins[0]['ip']))
657: // return array();
658: return $logins;
659:
660: }
661:
662: /**
663: * Change primary account domain
664: *
665: * @param string $domain
666: * @return bool|mixed
667: */
668: public function change_domain(string $domain): bool
669: {
670: if (!IS_CLI) {
671: $olddomain = $this->domain;
672: $ret = $this->query('auth_change_domain', $domain);
673: if ($ret) {
674: parent::sendNotice(
675: 'domain',
676: [
677: 'email' => $this->getConfig('siteinfo', 'email'),
678: 'ip' => \Auth::client_ip(),
679: 'domain' => $olddomain
680: ]
681: );
682: $this->_purgeLoginKey($this->username, $olddomain);
683: }
684:
685: return $ret;
686: }
687:
688: if ($this->is_demo()) {
689: return error('domain change disabled for demo');
690: }
691:
692: $domain = strtolower($domain);
693: if (0 === strncmp($domain, "www.", 4)) {
694: $domain = substr($domain, 4);
695: }
696: if ($domain === $this->domain) {
697: return error('new domain is equivalent to old domain');
698: }
699: if (!preg_match(Regex::DOMAIN, $domain)) {
700: return error("`%s': invalid domain", $domain);
701: }
702: if ($this->dns_domain_hosted($domain, true)) {
703: // permit user to rehost a previously hosted domain if it is on the same account
704: return error("`%s': cannot add domain - hosted on another " .
705: 'account elsewhere', $domain);
706: }
707:
708: if ($this->web_subdomain_exists($domain)) {
709: return error("cannot promote subdomain `%s' to domain", $domain);
710: }
711:
712: if (\Opcenter\License::get()->isDevelopment() && substr($domain, -5) !== '.test') {
713: return error("License permits only .test TLDs. `%s' provided.", $domain);
714: }
715:
716: if (!$this->aliases_bypass_exists($domain) &&
717: $this->dns_gethostbyname_t($domain) != $this->dns_get_public_ip() &&
718: $this->dns_get_records_external('', 'any', $domain) &&
719: !$this->dns_domain_uses_nameservers($domain) // whois check in the future
720: ) {
721: $currentns = join(',', (array)$this->dns_get_authns_from_host($domain));
722: $hostingns = join(',', $this->dns_get_hosting_nameservers($domain));
723:
724: return error('domain uses third-party nameservers - %s, change nameservers to %s before promoting ' .
725: 'this domain to primary domain status', $currentns, $hostingns);
726: }
727: // alternatively use $this->set_config_journal() and require a sync
728: $proc = new Util_Account_Editor($this->getAuthContext()->getAccount(), $this->getAuthContext());
729: $proc->setConfig('siteinfo', 'domain', $domain)->
730: setConfig(\Opcenter\SiteConfiguration::getModuleRemap('proftpd'), 'ftpserver', 'ftp' . $domain)->
731: setConfig(\Opcenter\SiteConfiguration::getModuleRemap('apache'), 'webserver', 'www.' . $domain)->
732: setConfig(\Opcenter\SiteConfiguration::getModuleRemap('sendmail'), 'mailserver', 'mail.' . $domain);
733:
734: return $proc->edit();
735: }
736:
737: /**
738: * Purge browser security key
739: *
740: * @param string $user
741: * @param string $domain
742: * @return void
743: */
744: private function _purgeLoginKey(string $user = '', string $domain = ''): void
745: {
746: // needs to be broken out into separate support function...
747: $userkey = md5($user . $domain);
748: $arrkey = self::SECURITY_TOKEN . '.' . $userkey;
749: $prefs = Preferences::factory($this->getAuthContext());
750: $prefs->unlock($this->getApnscpFunctionInterceptor());
751: unset($prefs[$arrkey]);
752: }
753:
754: /**
755: * Change primary account username
756: *
757: * @param string $user
758: * @return bool
759: */
760: public function change_username(string $user): bool
761: {
762: if (!IS_CLI) {
763: $email = $this->common_get_email();
764: \Preferences::factory($this->getAuthContext())->sync();
765: $ret = $this->query('auth_change_username', $user);
766: if ($ret && $email) {
767: // admin password changed
768: $this->getAuthContext()->username = $user;
769:
770: parent::sendNotice(
771: 'username',
772: [
773: 'email' => $email,
774: 'ip' => \Auth::client_ip()
775: ]
776: );
777:
778: $db = \apnscpSession::db();
779: // @TODO move to utility class
780: $stmt = $db->prepare("UPDATE " . apnscpSession::TABLE . " SET
781: username = :user WHERE session_id = :session_id");
782: $params = ['user' => $user, 'session_id' => $this->session_id];
783:
784: if (!$stmt->execute($params)) {
785: \Error_Reporter::report($db->errorInfo()[0]);
786: fatal('failed to access credentials database');
787: }
788:
789: $this->_purgeLoginKey($user, $this->domain);
790: }
791:
792: return $ret;
793: }
794:
795: if ($this->is_demo()) {
796: return error('username change disabled for demo');
797: }
798: $user = strtolower($user);
799: if (!preg_match(Regex::USERNAME, $user)) {
800: return error("invalid new username `%s'", $user);
801: }
802:
803: /** @var User_Module $class */
804: $class = \a23r::get_autoload_class_from_module('user');
805: if (strlen($user) > $class::USER_MAXLEN) {
806: return error('user max length %d', $class::USER_MAXLEN);
807: }
808:
809: if ($this->permission_level & PRIVILEGE_ADMIN) {
810: // @todo convert to Opcenter
811:
812: if (!($fp = fopen(static::ADMIN_AUTH, 'r+')) || !flock($fp, LOCK_EX | LOCK_NB)) {
813: fclose($fp);
814:
815: return error("unable to gain exclusive lock on `%s'", static::ADMIN_AUTH);
816: }
817: $lines = [];
818: while (false !== ($line = fgets($fp))) {
819: $lines[] = explode(':', rtrim($line));
820: }
821: if (false !== ($pos = array_search($user, array_column($lines, 0), true))) {
822: flock($fp, LOCK_UN);
823: fclose($fp);
824:
825: return error("user `%s' already exists", $user);
826: }
827: if (false === ($pos = array_search($this->username, array_column($lines, 0), true))) {
828: flock($fp, LOCK_UN);
829: fclose($fp);
830:
831: return error("original user `%s' does not exist", $this->username);
832: }
833: $lines[$pos][0] = $user;
834: if (!ftruncate($fp, 0)) {
835: flock($fp, LOCK_UN);
836: fclose($fp);
837:
838: return error("failed to truncate `%s'", static::ADMIN_AUTH);
839: }
840: rewind($fp);
841: fwrite($fp, implode("\n", array_map(static function ($a) {
842: return join(':', $a);
843: }, $lines)));
844:
845: // @todo extract to Support\Common module?
846: $oldprefs = implode(DIRECTORY_SEPARATOR,
847: [\Admin_Module::ADMIN_HOME, \Admin_Module::ADMIN_CONFIG, $this->username]);
848: $newprefs = implode(DIRECTORY_SEPARATOR,
849: [\Admin_Module::ADMIN_HOME, \Admin_Module::ADMIN_CONFIG, $user]);
850: if (file_exists($oldprefs)) {
851: if (file_exists($newprefs)) {
852: unlink($newprefs);
853: }
854: rename($oldprefs, $newprefs) || warn("failed to rename preferences from `%s' to `%s'", $oldprefs, $newprefs);
855: }
856: \apnscpSession::invalidate_by_user(null, $this->username);
857: return flock($fp, LOCK_UN) && fclose($fp);
858: }
859: // make sure user list is not cached
860: $this->user_flush();
861: if (!$this->_username_unique($user)) {
862: return error("requested username `%s' in use on another account", $user);
863: }
864: if ($this->user_exists($user)) {
865: return error("requested username `%s' already exists on this account", $user);
866: }
867:
868: $this->getAuthContext()->username = $user;
869: $proc = new Util_Account_Editor($this->getAuthContext()->getAccount(), $this->getAuthContext());
870: $proc->setConfig('siteinfo', 'admin_user', $user)
871: ->setConfig('mysql', 'dbaseadmin', $user);
872: $ret = $proc->edit();
873:
874: if (!$ret) {
875: return error('failed to change admin user');
876: }
877:
878: return true;
879: }
880:
881: /**
882: * Username is unique to a server or across all servers
883: *
884: * @param string $user
885: * @return int -1 if not globally unique
886: * 0 if not unique on server
887: * 1 if globally unique and unique on server
888: */
889: private function _username_unique($user)
890: {
891:
892: $user = strtolower($user);
893: if (\Auth::get_admin_from_site_id($user)) {
894: return 0;
895: }
896:
897: if (!Auth_Lookup::extendedAvailable()) {
898: return 1;
899: }
900:
901: $db = self::_connect_db();
902: if (!$db) {
903: return error('cannot connect to db');
904: }
905: $q = "SELECT 1 FROM account_cache where admin = '" .
906: $db->real_escape_string($user) . "'";
907: $rs = $db->query($q);
908:
909: return $rs->num_rows > 0 ? -1 : 1;
910: }
911:
912: private static function _connect_db()
913: {
914: if (!is_null(self::$domain_db) && self::$domain_db->ping()) {
915: return self::$domain_db;
916: }
917: try {
918: $db = new mysqli(AUTH_USERNAME_HOST, AUTH_USERNAME_USER, AUTH_USERNAME_PASSWORD, AUTH_USERNAME_DB);
919: } catch (\mysqli_sql_exception $e) {
920: return error('Cannot connect to domain server at this time');
921: }
922:
923: self::$domain_db = &$db;
924:
925: return $db;
926: }
927:
928: /**
929: * Set a temporary password for an account
930: *
931: * @param string $item site or user
932: * @param int $duration duration
933: * @param string|null $password optional password
934: * @return bool
935: */
936: public function set_temp_password(string $item, int $duration = 120/** time in seconds */, string $password = null)
937: {
938: if (!IS_CLI) {
939: return $this->query('auth_set_temp_password', $item, $duration, $password);
940: }
941:
942: if (!$password) {
943: $password = Password::generate();
944: }
945: if ($duration < 1) {
946: return error("invalid duration `%d'", $duration);
947: }
948:
949: $user = null;
950: if ($this->permission_level & PRIVILEGE_ADMIN) {
951: if (0 !== strpos($item, 'site')) {
952: $tmp = \Auth::get_site_id_from_domain($item);
953: if (!$tmp) {
954: return error("domain `%s' not found on server", $item);
955: }
956: $item = 'site' . $tmp;
957: } else {
958: $tmp = \Auth::get_domain_from_site_id(substr($item, 4));
959: if (!$tmp) {
960: return error("site `%s' not found on server", $item);
961: }
962: }
963: $site = $item;
964: $user = \Auth::get_admin_from_site_id(substr($site, 4));
965: } else {
966: if (!\array_key_exists($item, $this->user_get_users())) {
967: return error("Unknown user `%s'", $item);
968: }
969: $site = $this->site;
970: $user = $item;
971: }
972:
973: $ctx = \Auth::context($user, $site);
974: if (!($oldcrypted = Shadow::bindTo($ctx->domain_fs_path())->getspnam($user))) {
975: return error("Failed to locate shadow for `%s'", $user);
976: }
977:
978: $crypted = $this->crypt($password);
979:
980: $accountMeta = $ctx->getAccount();
981: if ($this->permission_level & PRIVILEGE_ADMIN) {
982: $editor = new Util_Account_Editor($accountMeta, $ctx);
983: $ret = $editor->setMode('edit')->setConfig(self::getAuthService(), self::PWOVERRIDE_KEY, true)
984: ->setConfig(self::getAuthService(), 'cpasswd', $crypted)->edit();
985: } else {
986: $ret = Shadow::bindTo($ctx->domain_fs_path())->set_cpasswd($crypted, $user);
987: }
988: if (!$ret) {
989: return error("failed to set temp password: `%s'", Error_Reporter::get_last_msg());
990: }
991:
992: // shim a response if run multiple times
993: $status = array(
994: 'success' => true
995: );
996:
997: $dt = new DateTime("now + {$duration} seconds");
998: $proc = new Util_Process_Schedule($dt);
999: $key = 'RESET-' . $ctx->user_id;
1000: if (!$proc->idPending($key, $this->getAuthContext())) {
1001: $proc->setID($key, $this->getAuthContext());
1002: if ($this->permission_level & PRIVILEGE_ADMIN) {
1003: $editor = new Util_Account_Editor($accountMeta, $ctx);
1004: $editor->setMode('edit')->setConfig(self::getAuthService(), 'cpasswd', $oldcrypted['shadow'])->
1005: setConfig(self::getAuthService(), self::PWOVERRIDE_KEY, false);
1006: $cmd = $editor->getCommand();
1007: $args = null;
1008: } else {
1009: $chrtcmd = 'usermod -p ' .
1010: escapeshellarg($oldcrypted['shadow']) . ' ' .
1011: '"$(id -nu ' . $ctx->user_id . ')"';
1012: $cmd = "chroot %(path)s /bin/sh -c '%(command)s'";
1013: $args = [
1014: 'command' => escapeshellarg($chrtcmd),
1015: 'path' => $ctx->domain_fs_path()
1016: ];
1017: }
1018: $status = $proc->run($cmd, $args);
1019: }
1020:
1021: if ($status['success']) {
1022: info("Password set on `%s'@`%s' to `%s' for %d seconds",
1023: $ctx->username,
1024: $ctx->domain,
1025: $password,
1026: $duration
1027: );
1028: }
1029:
1030: return $password;
1031: }
1032:
1033: /**
1034: * Get shadow entry for site admin
1035: *
1036: * A nasty kludge
1037: *
1038: * @param int $site_id
1039: * @return string
1040: * @todo remove once user role switching is implemented
1041: */
1042: private function _get_site_admin_shadow($site_id): string
1043: {
1044: $site = 'site' . (int)$site_id;
1045: $base = FILESYSTEM_VIRTBASE . "/{$site}/fst";
1046: $file = '/etc/shadow';
1047: $admin = \Auth::get_admin_from_site_id($site_id);
1048: if (!file_exists($base . $file)) {
1049: fatal("shadow not found for `%s'", $site);
1050: }
1051: $shadow = null;
1052: $fp = fopen($base . $file, 'r');
1053: while (false !== ($line = fgets($fp))) {
1054: $tok = strtok($line, ':');
1055: if ($tok != $admin) {
1056: continue;
1057: }
1058: $shadow = strtok(':');
1059: break;
1060: }
1061: fclose($fp);
1062: if (!$shadow) {
1063: fatal("admin `%s' not found for `%s'", $admin, $site);
1064: }
1065:
1066: return $shadow;
1067: }
1068:
1069: public function _create()
1070: {
1071: static::rebuildMap();
1072: }
1073:
1074: public function _edit()
1075: {
1076: $conf_new = $this->getAuthContext()->getAccount()->new;
1077: $conf_old = $this->getAuthContext()->getAccount()->old;
1078: $user = array(
1079: 'old' => $conf_old['siteinfo']['admin_user'],
1080: 'new' => $conf_new['siteinfo']['admin_user']
1081: );
1082: static::rebuildMap();
1083: if ($user['old'] === $user['new']) {
1084: return;
1085: }
1086:
1087: return $this->_edit_wrapper($user['old'], $user['new']);
1088: }
1089:
1090: /**
1091: * General user edit for admin and users
1092: *
1093: * @param $userold old username
1094: * @param $usernew new username
1095: * @return bool
1096: */
1097: private function _edit_wrapper($userold, $usernew)
1098: {
1099: if ($userold === $usernew) {
1100: return;
1101: }
1102: $db = \MySQL::initialize();
1103: foreach ($this->_get_api_keys_real($userold) as $key) {
1104: if (!$db->query("UPDATE api_keys SET `username` = '" . $db->escape_string($usernew) . "' " .
1105: "WHERE api_key = '" . $key['key'] . "' AND `username` = '" . $db->escape_string($userold) . "'"
1106: )) {
1107: warn("failed to rename API keys for user `%s' to `%s'", $userold, $usernew);
1108: }
1109: }
1110: // @XXX centralize logins
1111: $invoice = $this->billing_get_invoice();
1112: if (!$db->query("UPDATE login_log SET `username` = '" . $db->escape_string($usernew) . "' " .
1113: "WHERE `username` = '" . $db->escape_string($userold) . "' AND invoice = '" . $db->escape_string($invoice) . "'")) {
1114: warn("failed to rename login history for user `%s' to `%s'", $userold, $usernew);
1115: }
1116:
1117:
1118: /**
1119: * _edit() is called before Ensim processes any config changes
1120: * including renaming the user. Pam::add_user() will elicit a
1121: * warning if the user does not exist (which it doesn't yet)
1122: *
1123: */
1124: mute_warn();
1125: foreach (static::PAM_SERVICES as $svc) {
1126: if ($this->user_permitted($userold, $svc)) {
1127: $this->deny_user($userold, $svc);
1128: $this->permit_user($usernew, $svc);
1129: }
1130: }
1131: unmute_warn();
1132: // flush getpwnam() cache
1133: $this->user_flush();
1134:
1135: return true;
1136: }
1137:
1138: protected function _get_api_keys_real($user)
1139: {
1140: $db = Auth_SOAP::get_api_db();
1141: $qfrag = $this->_getAPIQueryFragment();
1142: /**
1143: * make sure only 1 key is pulled if account resides elsewhere
1144: * e.g. during migration
1145: */
1146: $q = 'SELECT `api_key`,
1147: UNIX_TIMESTAMP(`last_used`) as last_used,
1148: comment
1149: FROM `api_keys`
1150: ' . $qfrag['join'] . "
1151: WHERE
1152: `username` = '" . $db->escape_string($user) . "' AND " .
1153: $qfrag['where'] . ' GROUP BY (api_key)';
1154: $rs = $db->query($q);
1155: if (!$rs) {
1156: return error('failed to get keys');
1157: }
1158: $keys = array();
1159: while ($row = $rs->fetch_object()) {
1160: $keys[] = array(
1161: 'key' => $row->api_key,
1162: 'last_used' => $row->last_used,
1163: 'comment' => $row->comment
1164: );
1165: }
1166:
1167: return $keys;
1168: }
1169:
1170: /**
1171: * User permitted to service
1172: *
1173: * @see self::user_enabled()
1174: *
1175: * @param string $user
1176: * @param string $svc
1177: * @return bool
1178: */
1179: public function user_permitted(string $user = null, string $svc = 'cp'): bool
1180: {
1181: if (!in_array($svc, static::PAM_SERVICES, true)) {
1182: return error("unknown service `$svc'");
1183: }
1184: // admin is always permitted to CP
1185: if ($this->permission_level & (PRIVILEGE_ADMIN | PRIVILEGE_RESELLER)) {
1186: return $svc === 'cp';
1187: }
1188:
1189: if ($this->permission_level & PRIVILEGE_USER) {
1190: $user = $this->username;
1191: }
1192:
1193: return (new Util_Pam($this->getAuthContext()))->check($user ?? $this->username, $svc);
1194: }
1195:
1196: /**
1197: * User permitted to service
1198: *
1199: * @param string $user
1200: * @param string $svc
1201: * @return bool
1202: */
1203: public function user_enabled(string $user = null, string $svc = 'cp'): bool
1204: {
1205: deprecated_func('Use user_permitted');
1206: return $this->user_permitted($user, $svc);
1207: }
1208:
1209:
1210: /**
1211: * Deny user access
1212: * @param string $user
1213: * @param string $svc
1214: * @return bool
1215: */
1216: public function deny_user(string $user, string $svc = 'cp'): bool
1217: {
1218: return (new Util_Pam($this->getAuthContext()))->remove($user, $svc);
1219: }
1220:
1221: /**
1222: * Permit user access to apnscp
1223: *
1224: * @param string $user username
1225: * @return bool
1226: */
1227: public function permit_user($user, $svc = 'cp'): bool
1228: {
1229: if (!in_array($svc, static::PAM_SERVICES, true)) {
1230: return error("unknown service `$svc'");
1231: }
1232:
1233: return (new Util_Pam($this->getAuthContext()))->add($user, $svc);
1234: }
1235:
1236: /**
1237: * Restrict login to IP
1238: *
1239: * @param string $ip IPv4, IPv6, or CIDR
1240: * @param string|null $gate optional authentication gate
1241: * @return bool
1242: */
1243: public function restrict_ip(string $ip, string $gate = null): bool
1244: {
1245: if ($this->is_demo()) {
1246: return error('Cannot restrict IP in demo mode');
1247: }
1248: return \Auth\IpRestrictor::instantiateContexted($this->getAuthContext())->add($ip, $gate);
1249: }
1250:
1251: /**
1252: * Remove IP restriction
1253: *
1254: * @param string $ip IPv4, IPv6, or CIDR
1255: * @param string|null $gate optional authentication gate
1256: * @return bool
1257: */
1258: public function remove_ip_restriction(string $ip, string $gate = null): bool
1259: {
1260: return \Auth\IpRestrictor::instantiateContexted($this->getAuthContext())->remove($ip, $gate);
1261: }
1262:
1263: /**
1264: * Get authorized IPs
1265: *
1266: * @return array
1267: */
1268: public function get_ip_restrictions(): array
1269: {
1270: return \Auth\IpRestrictor::instantiateContexted($this->getAuthContext())->list();
1271: }
1272:
1273: public function _edit_user(string $userold, string $usernew, array $oldpwd)
1274: {
1275: return $this->_edit_wrapper($userold, $usernew);
1276: }
1277:
1278: public function _reset(\Util_Account_Editor &$editor = null)
1279: {
1280: $module = self::getAuthService();
1281: $crypted = $this->_get_site_admin_shadow($this->site_id);
1282: if (!$crypted) {
1283: fatal('call _reset() in auth from backend');
1284: }
1285: $params = array(
1286: 'cpasswd' => $crypted
1287: );
1288: if ($editor) {
1289: foreach ($params as $k => $v) {
1290: $editor->setConfig($module, $k, $v);
1291: }
1292: }
1293:
1294: return array($module => $params);
1295:
1296: }
1297:
1298: public function _delete()
1299: {
1300: /*
1301: * @todo check if account listed elsewhere, don't delete keys if
1302: */
1303: $server = \Auth_Redirect::lookup($this->domain);
1304: if (!$server || $server === SERVER_NAME_SHORT) {
1305: foreach ($this->get_api_keys() as $key) {
1306: $this->delete_api_key($key['key']);
1307: }
1308: }
1309: }
1310:
1311: /**
1312: * array get_api_keys (void)
1313: *
1314: * listing all keys associated to an account:
1315: * - key: the generated key
1316: * - last_used: an integer representation of the last date the key was used.
1317: * If the key was never used, null is set for that value.
1318: * Returns the list of SOAP keys associated to an account
1319: *
1320: * @return array|false
1321: */
1322: public function get_api_keys(string $user = null)
1323: {
1324: if (!$user || !($this->permission_level & PRIVILEGE_SITE)) {
1325: $user = $this->username;
1326: } else if ($user && !$this->user_exists($user)) {
1327: return error("user `%s' does not exist", $user);
1328: }
1329:
1330: return $this->_get_api_keys_real($user);
1331: }
1332:
1333: /**
1334: * Delete SOAP key
1335: *
1336: * The key should be in hexadecimal strictly without dashes,
1337: * case does not matter.
1338: *
1339: * @param string $key key to delete from keyring
1340: * @return bool
1341: */
1342: public function delete_api_key(string $key, string $user = null): bool
1343: {
1344: $key = str_replace('-', '', strtolower($key));
1345: if (!ctype_xdigit($key)) {
1346: return error($key . ': invalid key');
1347: }
1348: // verify key via get_api_keys() since _getAPIQueryFragment()
1349: // won't work in a DELETE clause
1350: $keys = $this->get_api_keys($user);
1351: if (!$keys) {
1352: return false;
1353: }
1354: $found = false;
1355: foreach ($keys as $k) {
1356: if ($k['key'] === $key) {
1357: $found = true;
1358: break;
1359: }
1360: }
1361: if (!$found) {
1362: return error("unknown key `%s'", $key);
1363: }
1364: $db = Auth_SOAP::get_api_db();
1365: $rs = $db->query("DELETE FROM `api_keys`
1366: WHERE `api_key` = '" . strtolower($key) . "'");
1367:
1368: return (bool)$rs;
1369: }
1370:
1371: public function _housekeeping()
1372: {
1373: // convert domain map over to TokyoCabinet
1374: static::rebuildMap();
1375: // check if we need reissue
1376: if (\Opcenter\License::get()->needsReissue()) {
1377: info('Attempting to renew apnscp license');
1378: \Opcenter\License::get()->reissue();
1379: }
1380:
1381: (new SecureAccessKey)->check();
1382: }
1383:
1384: public function _cron(Cronus $cron)
1385: {
1386: $cron->schedule(FRONTEND_SECKEY_TTL, 'seckey.roll', static function () {
1387: (new SecureAccessKey)->reset();
1388: });
1389:
1390: $cron->schedule((int)FRONTEND_SECKEY_TTL/4, 'seckey.check', static function () {
1391: (new SecureAccessKey)->check();
1392: });
1393: }
1394:
1395: public function _create_user(string $user)
1396: {
1397: // TODO: Implement _create_user() method.
1398: }
1399:
1400: public function _delete_user(string $user)
1401: {
1402: // TODO: Implement _delete_user() method.
1403: }
1404:
1405: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
1406: {
1407: return true;
1408: }
1409: }