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