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