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