<?php
/**
 * @file
 * The password policy module allows you to enforce a specific level of
 * password complexity for the user passwords on the system.
 */

/****************************************************************************/

define('PASSWORD_POLICY_ENTRIES_PER_PAGE', 20);

/****************************************************************************/
/* Core API hooks                                                           */
/****************************************************************************/

/**
 * Implements hook_help().
 */
function password_policy_help($path, $arg) {
  switch ($path) {
    case "admin/help#password_policy":
      return '<p>' . t('The password policy module allows you to enforce a specific level of password complexity for the user passwords on the system.') . '</p>';
  }
}

/**
 * Implements hook_init().
 */
function password_policy_init() {
  global $user;

  // Check password reset status and force a reset if needed.
  if (_password_policy_is_password_change_forced($user->uid) &&
      !_password_policy_is_path_allowed_when_password_change_forced()) {
    _password_policy_set_password_change_forced_message();
    _password_policy_go_to_password_change_page();
  }
}

/**
 * Determines whether user is to be forced to change their password.
 *
 * Uses static variable to avoid redundantly querying database in single
 * request, which can happen when both password_policy_init() and
 * password_policy_user($op='load') are called.
 *
 * @param $uid
 *   User ID.
 *
 * @return
 *   TRUE if a password change is to be forced, FALSE otherwise.
 */
function _password_policy_is_password_change_forced($uid) {
  static $force_change = array();
  if ($uid == 0) {
    return FALSE;
  }
  if (!isset($force_change[$uid])) {
    $force_change[$uid] = db_result(db_query('SELECT force_change FROM {password_policy_force_change} WHERE uid=%d', $uid));
  }
  return $force_change[$uid];
}

/**
 * Determines whether access to path allowed when user password change forced.
 *
 * @return
 *   TRUE if the path is allowed, FALSE otherwise.
 */
function _password_policy_is_path_allowed_when_password_change_forced() {
  $current_path = $_GET['q'];
  $excluded_paths = _password_policy_get_excluded_page_paths();
  $password_change_paths = _password_policy_get_password_change_paths();
  $allowed_paths = array_merge($excluded_paths, $password_change_paths);
  return in_array($current_path, $allowed_paths);
}

/**
 * Gets paths of pages excluded from redirection to change password.
 */
function _password_policy_get_excluded_page_paths() {
  $excluded_paths = variable_get('password_policy_exclude_pages', 'logout');
  $excluded_paths = explode("\n", $excluded_paths);
  return array_map('trim', $excluded_paths);
}

/**
 * Sets message indicating a password change is forced.
 */
function _password_policy_set_password_change_forced_message() {
  drupal_set_message(t('Your password has expired. You must change your password to proceed on the site.'), 'error', FALSE);
}

/**
 * Redirects user to password change page.
 *
 * The password change page path depends on whether Password Policy Password
 * Tab is enabled.  When there are multiple paths, the first is assumed to be
 * preferred.
 */
function _password_policy_go_to_password_change_page() {
  $password_change_paths = _password_policy_get_password_change_paths();
  $preferred_password_change_path = $password_change_paths[0];
  $destination = drupal_get_destination();
  unset($_GET['destination']);
  drupal_goto($preferred_password_change_path, $destination);
}

/**
 * Gets paths that allow user to change their password.
 */
function _password_policy_get_password_change_paths() {
  global $user;
  $custom_path = _password_policy_get_custom_password_change_path();
  if (isset($custom_path)) {
    return array($custom_path);
  }
  else {
    return array(
      "user/{$user->uid}/edit/account",
      "user/{$user->uid}/edit",
    );
  }
}

/**
 * Gets custom password change path.
 *
 * This is used in particular by the Password Policy Password Tab module to set
 * an alternate password change page path.
 *
 * @return
 *   The custom path if a custom path is set, NULL otherwise.
 */
function _password_policy_get_custom_password_change_path() {
  global $user;
  $subpath = variable_get('password_policy_change_url', NULL);
  if (isset($subpath)) {
    return "user/{$user->uid}/edit/$subpath";
  }
  else {
    return NULL;
  }
}

/**
 * Implements hook_theme().
 */

function password_policy_theme() {
  return array(
    'password_policy_admin_list' => array(
      'arguments' => array('form' => NULL),
      'file' => 'password_policy.theme.inc',
    ),
  );
}

/**
 * Implements hook_perm().
 */
function password_policy_perm() {
  return array('unblock expired accounts', 'force password change');
}

/**
 * Implements hook_menu().
 */
function password_policy_menu() {
  $items['admin/settings/password_policy'] = array(
    'title' => 'Password policies',
    'description' => 'Configures policies for user account passwords.',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('password_policy_admin_settings'),
    'access arguments' => array('administer site configuration'),
    'file' => 'password_policy.admin.inc',
  );
  $items['admin/settings/password_policy/configure'] = array(
    'title' => 'Settings',
    'type' => MENU_DEFAULT_LOCAL_TASK,
  );
  $items['admin/settings/password_policy/list'] = array(
    'title' => 'List',
    'type' => MENU_LOCAL_TASK,
    'page callback' => 'drupal_get_form',
    'page arguments' => array('password_policy_admin_list'),
    'access arguments' => array('administer site configuration'),
    'weight' => 1,
    'file' => 'password_policy.admin.inc',
  );
  $items['admin/settings/password_policy/add'] = array(
    'title' => 'Add',
    'type' => MENU_LOCAL_TASK,
    'page callback' => 'drupal_get_form',
    'page arguments' => array('password_policy_admin_form', NULL),
    'access arguments' => array('administer site configuration'),
    'weight' => 2,
    'file' => 'password_policy.admin.inc',
  );
  $items['admin/settings/password_policy/%password_policy_policy'] = array(
    'title callback' => 'password_policy_format_title',
    'title arguments' => array(3),
    'type' => MENU_CALLBACK,
    'page callback' => 'password_policy_admin_view',
    'page arguments' => array(3),
    'access arguments' => array('administer site configuration'),
    'file' => 'password_policy.admin.inc',
  );
  $items['admin/settings/password_policy/%password_policy_policy/view'] = array(
    'title' => 'View',
    'type' => MENU_DEFAULT_LOCAL_TASK,
  );
  $items['admin/settings/password_policy/%password_policy_policy/edit'] = array(
    'title' => 'Edit',
    'type' => MENU_LOCAL_TASK,
    'page callback' => 'drupal_get_form',
    'page arguments' => array('password_policy_admin_form', 3),
    'access arguments' => array('administer site configuration'),
    'weight' => 1,
    'file' => 'password_policy.admin.inc',
  );
  $items['admin/settings/password_policy/delete'] = array(
    'title' => 'Delete',
    'type' => MENU_CALLBACK,
    'page callback' => 'drupal_get_form',
    'page arguments' => array('password_policy_admin_delete'),
    'access arguments' => array('administer site configuration'),
    'file' => 'password_policy.admin.inc',
  );
  $items['admin/settings/password_policy/password_change'] = array(
    'title' => 'Force password change',
    'description' => 'Force users to change their password',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('password_policy_password_change_settings'),
    'access arguments' => array('force password change'),
    'file' => 'password_policy.admin.inc',
    'type' => MENU_LOCAL_TASK,
    'weight' => 10,
  );
  $items['admin/user/expired'] = array(
    'title' => 'Expired accounts',
    'description' => 'Lists all expired accounts.',
    'page callback' => 'password_policy_expired_list',
    'page arguments' => array('password_policy_list_expired'),
    'access arguments' => array('unblock expired accounts'),
  );
  $items['admin/user/expired/unblock/%password_policy_uid'] = array(
    'title' => 'Unblock',
    'type' => MENU_CALLBACK,
    'page callback' => 'drupal_get_form',
    'page arguments' => array('password_policy_expired_unblock_confirm', 4),
    'access arguments' => array('unblock expired accounts'),
  );
  return $items;
}

/**
 * Load policy array from the database.
 *
 * @param $pid
 *   The policy id
 *
 * @return
 *   A populated policy array or NULL if not found.
 */
function password_policy_policy_load($pid) {
  static $policies = array();

  if (is_numeric($pid)) {
    if (isset($policies[$pid])) {
      return $policies[$pid];
    }
    else {
      $policy = _password_policy_load_policy_by_pid($pid);
      if ($policy) {
        $policies[$pid] = $policy;
        return $policy;
      }
    }
  }
  return FALSE;
}

/**
 * Load user object from the database.
 *
 * @param $id
 *   The user id
 *
 * @return
 *   A populated user object or NULL if not found.
 */
function password_policy_uid_load($id) {
  if (is_numeric($id)) {
    $account = user_load(array('uid' => $id));
    if ($account) {
      return $account;
    }
  }
  return FALSE;
}

/**
 * Display a password policy form title.
 *
 * @param $policy
 *   Policy array
 *
 * @return
 *   A policy's title string
 */
function password_policy_format_title($policy) {
  return $policy['name'];
}

/**
 * Implements hook_user().
 */
function password_policy_user($op, &$edit, &$account, $category = NULL) {
  global $user;

  switch ($op) {
    case 'load':
      $account->force_password_change = _password_policy_is_password_change_forced($account->uid);
      break;

    case 'validate':
      global $user;
      if (isset($account->uid) && $account->force_password_change == 1) {
        // Admins can edit accounts without having to reset passwords.
        if ($edit['pass'] == '' && $user->uid == $account->uid) {
          form_set_error('pass', t('Your password has expired. You must change your password to proceed on the site.'));
        }
      }
      break;

    case 'insert':
      $force = isset($edit['force_password_change']) ? $edit['force_password_change'] : variable_get('password_policy_new_login_change', 0);
      db_query('INSERT INTO {password_policy_force_change} VALUES(%d, %d)', $account->uid, $force);
      if (array_key_exists('pass', $edit)) {
        // New users do not yet have an uid during the validation step, but
        // they do have at this insert step.  Store their first password in the
        // system for use with the history constraint (if used).
        if ($account->uid) {
          _password_policy_store_password($account->uid, $edit['pass']);
        }
      }
      break;

    case 'update':
      if (!empty($account->force_password_change) && array_key_exists('pass', $edit) && $user->uid == $account->uid) {
        db_query('UPDATE {password_policy_force_change} SET force_change = 0 WHERE uid = %d', $account->uid);
      }
      elseif (!empty($edit['force_password_change'])) {
        db_query('UPDATE {password_policy_force_change} SET force_change = 1 WHERE uid = %d', $account->uid);
        if ($user->uid != $account->uid) {
          drupal_set_message(t('@user will be required to change their password the next time they log in.', array('@user' => $account->name)));
        }
        watchdog('password policy', '@user flagged to change password on next login by @admin', array('@user' => $account->name, '@admin' => $user->name), WATCHDOG_NOTICE);
      }
      elseif (isset($edit['force_password_change'])) {
        db_query('UPDATE {password_policy_force_change} SET force_change = 0 WHERE uid = %d', $account->uid);
      }

      if (isset($edit['status']) && $edit['status'] != $account->status && $edit['status'] == 1) {
        // Account is being unblocked.
        db_query('UPDATE {password_policy_expiration} SET unblocked = %d WHERE uid = %d', time(), $account->uid);
      }
      break;

    case 'after_update':
      // Store hash of newly set password
      if (array_key_exists('pass', $edit)) {
        _password_policy_store_password($account->uid, $edit['pass']);
      }
      break;

    case 'login':
      $roles = is_array($account->roles) ? array_keys($account->roles) : array();
      $policy = _password_policy_load_active_policy($roles);
      // A value $edit['name'] is NULL for a one time login
      if ($policy && ((!empty($account->uid) && $account->uid > 1) || variable_get('password_policy_admin', 0)) && !empty($edit['name'])) {
        // Calculate expiration and warning times.
        $expiration = $policy['expiration'];
        $warning = max(explode(',', $policy['warning']));
        $expiration_seconds = $expiration*60*60*24;
        $warning_seconds = $warning*60*60*24;
        // The policy was enabled
        $policy_start = $policy['created'];
        if (variable_get('password_policy_begin', 0) == 1) {
          $policy_start -= $expiration_seconds;
        }
        if (!empty($expiration)) {
          // Account expiration is active.
          // Get the last password change time.
          $result = db_query_range("SELECT * FROM {password_policy_history} WHERE uid = %d ORDER BY created DESC", $account->uid, 0, 1);
          if ($row = db_fetch_object($result)) {
            $last_change = $row->created;
          }
          else {
            // User has not changed their password since this module was
            // enabled.
            $last_change = $account->created;
          }
          $time = time();
          if ($time > max($policy_start, $last_change) + $expiration_seconds) {
            if (variable_get('password_policy_block', 0) == 0) {
              // User is blocked immediately and cannot change his password
              // after expiration.
              _password_policy_block_account($account);
            }
            else {
              // Redirect user and let password force change handle.
              db_query('UPDATE {password_policy_force_change} SET force_change=%d WHERE uid=%d', 1, $account->uid);
              _password_policy_set_password_change_forced_message();
              _password_policy_go_to_password_change_page();
            }
          }
          elseif ($time > max($policy_start, $last_change) + $expiration_seconds - $warning_seconds) {
            // The warning is shown on login and the user is transferred to the
            // password change page.
            $days_left = ceil((max($policy_start, $last_change) + $expiration_seconds - $time)/(60*60*24));
            drupal_set_message(format_plural($days_left, 'Your password will expire in less than one day. Please change it.', 'Your password will expire in less than @count days. Please change it.'));
            $destination = drupal_get_destination();
            unset($_REQUEST['destination']);
            drupal_goto('user/' . $account->uid . '/edit' . (module_exists('password_policy_password_tab') ? '/password' : ''), $destination);
          }
        }
      }
      break;

    case 'delete':
      db_query("DELETE FROM {password_policy_history} WHERE uid = %d", $account->uid);
      db_query("DELETE FROM {password_policy_expiration} WHERE uid = %d", $account->uid);
      db_query("DELETE FROM {password_policy_force_change} WHERE uid = %d", $account->uid);
      break;

    case 'register':
    case 'form':
      // Force Password Change on user account.
      if (($category == 'account' || $op == 'register') && user_access('force password change')) {
        if ($category == 'account') {
          $force_change = db_result(db_query_range('SELECT force_change FROM {password_policy_force_change} WHERE uid=%d', $account->uid, 0, 1));
        }
        else {
          $force_change = variable_get('password_policy_new_login_change', 0);
        }

        $form['password_policy'] = array(
          '#type' => 'fieldset',
          '#title' => t('Password settings'),
        );
        $form['password_policy']['force_password_change'] = array(
          '#type' => 'checkbox',
          '#title' => t('Force password change on next login'),
          '#description' => t('If already logged in, the user will be forced to change their password upon their next page request.'),
          '#default_value' => $force_change
        );
        return $form;
      }
      break;
  }
}

/**
 * Implements hook_form_alter().
 */
function password_policy_form_alter(&$form, $form_state, $form_id) {
  switch ($form_id) {
    case "user_profile_form":
    case "user_register":
      // Password change form.
      $uid = isset($form['#uid']) ? $form['#uid'] : NULL;
      //if ($uid == 1 && !variable_get('password_policy_admin', 0)) { break; }
      $roles = isset($form['_account']['#value']) ? array_keys($form['_account']['#value']->roles) : array();
      $policy = _password_policy_load_active_policy($roles);
      $translate = array();
      if (!empty($policy['policy'])) {
        // Some policy constraints are active.
        password_policy_add_policy_js($policy, $uid);
        foreach ($policy['policy'] as $key => $value) {
          $translate['constraint_' . $key] = _password_policy_constraint_error($key, $value);
        }
      }

      // Printing out the restrictions.
      if (variable_get('password_policy_show_restrictions', 0) && !empty($translate)) {
        $restriction_html = '<div id="account-pass-restrictions">' . theme('item_list', $translate, t('Password Requirements')) . '</div>';
        if (isset($form['account']) && is_array($form['account'])) {
          $form['account']['pass']['#prefix'] = $restriction_html;
        }
        else {
          $form['pass']['#prefix'] = $restriction_html;
        }
      }

      // Set a custom form validate and submit handlers.
      $form['#validate'][] = 'password_policy_password_validate';
      break;
    case 'password_policy_password_tab':
      $form['submit']['#weight'] = 10;
      break;
  }
}

/**
 * Implements hook_cron().
 */
function password_policy_cron() {
  // Short circuit if no policies are active that use expiration.
  if (!db_result(db_query("SELECT COUNT(*) FROM {password_policy} WHERE enabled = 1 AND expiration > 0"))) {
    return;
  }

  // Get all users' last password change time. We don't touch blocked accounts
  $result = db_query('
    SELECT u.uid AS uid,
           u.created AS created_u,
           p.created AS created_p,
           e.pid AS pid,
           e.warning AS warning,
           e.unblocked AS unblocked
      FROM {users} u
           LEFT JOIN {password_policy_history} p ON u.uid = p.uid
           LEFT JOIN {password_policy_expiration} e ON u.uid = e.uid
     WHERE u.uid > 0 AND u.status = 1 ORDER BY p.created ASC
  ');
  while ($row = db_fetch_object($result)) {
    if ($row->uid == 1 && !variable_get('password_policy_admin', 0))
      continue;

    // Use account creation timestamp if there is no entry in password history
    // table.
    $accounts[$row->uid] = empty($row->created_p) ? $row->created_u : $row->created_p;
    // Last time a warning was mailed out (if was). We need it because we send
    // warnings only once a day, not on all cron runs.
    $warns[$row->uid] = $row->warning;
    // The user was last time unblocked (if was). We don't block this account
    // again for some period of time.
    $unblocks[$row->uid] = $row->unblocked;
    // The user was last time unblocked (if was). We don't block this account
    // again for some period of time.
    $pids[$row->uid] = $row->pid;
  }

  if ($accounts) {
    foreach ($accounts as $uid => $last_change) {
      /* Alternative: $result = db_query("SELECT p.* FROM {password_policy} p INNER JOIN {password_policy_role} r ON p.pid = r.pid INNER JOIN {users_roles} u ON r.rid = u.rid WHERE p.enabled = 1 AND u.uid = %d ORDER BY p.weight LIMIT 1", $uid); */
      $roles = array(DRUPAL_AUTHENTICATED_RID);
      $result = db_query("SELECT rid FROM {users_roles} WHERE uid = %d ORDER BY rid", $uid);
      while ($row = db_fetch_object($result)) {
        $roles[] = $row->rid;
      }
      $policy = _password_policy_load_active_policy($roles);
      if ($policy) {
        $expiration = $policy['expiration'];
        $warnings = !empty($policy['warning']) ? explode(',', $policy['warning']) : array();
        if (!empty($expiration)) {
          // Calculate expiration time.
          $expiration_seconds = $expiration*60*60*24;
          $policy_start = $policy['created'];
          if (variable_get('password_policy_begin', 0) == 1) {
            $policy_start -= $expiration_seconds;
          }
          rsort($warnings, SORT_NUMERIC);
          $time = time();
          // Check expiration and warning days for each account.
          if (!empty($warnings)) {
            foreach ($warnings as $warning) {
              // Loop through all configured warning send out days. If today is
              // the day we send out the warning.
              $warning_seconds = $warning*60*60*24;
              // Warning start time.
              $start_period = max($policy_start, $last_change) + $expiration_seconds - $warning_seconds;
              // Warning end time. We create a one day window for cron to run.
              $end_period = $start_period + 60*60*24;
              if ($warns[$uid] && $warns[$uid] > $start_period && $warns[$uid] < $end_period) {
                // A warning was already mailed out
                continue;
              }
              if ($time > $start_period && $time < $end_period) {
                // A warning falls in the one day window, so we send out the
                // warning.
                $account = user_load(array('uid' => $uid));
                $message = drupal_mail('password_policy', 'warning', $account->mail, user_preferred_language($account), array('account' => $account, 'days_left' => $warning));
                if ($message['result']) {
                  // The mail was sent out successfully.
                  watchdog('password_policy', 'Password expiration warning mailed to %username at %email.', array('%username' => $account->name, '%email' => $account->mail));
                }
                if ($pids[$uid]) {
                  db_query("UPDATE {password_policy_expiration} SET warning = %d WHERE uid = %d", $time, $uid);
                }
                else {
                  db_query("INSERT INTO {password_policy_expiration} (uid, warning) VALUES (%d, %d)", $uid, $time);
                }
              }
            }
          }
          if ($time > max($policy_start, $last_change) + $expiration_seconds && $time > $unblocks[$uid] + 60*60*24 && variable_get('password_policy_block', 0) == 0) {
            // Block expired accounts. Unblocked accounts are not blocked for
            // 24h.  One time login lasts for a 24h.
            db_query("UPDATE {users} SET status = 0 WHERE uid = %d", $uid);
            if ($pids[$uid]) {
              db_query("UPDATE {password_policy_expiration} SET blocked = %d WHERE uid = %d", $time, $uid);
            }
            else {
              db_query("INSERT INTO {password_policy_expiration} (uid, blocked) VALUES (%d, %d)", $uid, $time);
            }

            $account = user_load(array('uid' => $uid));
            watchdog('password_policy', 'Password for user %name has expired.', array('%name' => $account->name), WATCHDOG_NOTICE, l(t('edit'), 'user/' . $account->uid . '/edit'));
          }
        }
      }
    }
  }
}

/**
 * Implements hook_mail().
 */
function password_policy_mail($key, &$message, $params) {
  $language = $message['language'];
  $variables = password_policy_mail_tokens($params, $language);
  $message['subject'] .= _password_policy_mail_text($key . '_subject', $language, $variables);
  $message['body'][] = _password_policy_mail_text($key . '_body', $language, $variables);
}

/**
 * Implements hook_simpletest().
 */
function password_policy_simpletest() {
  // Scan through mymodule/tests directory for any .test files to tell
  // SimpleTest module.
  $tests = file_scan_directory(drupal_get_path('module', 'password_policy') . '/tests', '\.test');
  return array_keys($tests);
}

/****************************************************************************/
/* FAPI                                                                     */
/****************************************************************************/

/**
 * Password save validate handler.
 */
function password_policy_password_validate($form, &$form_state) {
  $values = $form_state['values'];
  $account = isset($form['_account']['#value']) ? $form['_account']['#value'] : (object)array('uid' => 0);

  if (!empty($values['pass']) && !isset($values['auth_openid'])) {
    $error = _password_policy_constraint_validate($values['pass'], $account);
    if ($error) {
      form_set_error('pass', t('Your password has not met the following requirement(s):') . '<ul><li>' . implode('</li><li>', $error) . '</li></ul>');
    }
  }
}

/****************************************************************************/
/* Expired accounts UI                                                      */
/****************************************************************************/

/**
 * Lists all expired accounts.
 */
function password_policy_expired_list() {
  $header[] = array('data' => t('Username'), 'field' => 'name');
  $header[] = array('data' => t('Blocked'), 'field' => 'blocked', 'sort' => 'desc');
  $header[] = array('data' => t('Unblocked'), 'field' => 'unblocked');
  $header[] = array('data' => t('Action'));

  $result = pager_query("SELECT p.*, u.name FROM {password_policy_expiration} p INNER JOIN {users} u ON p.uid = u.uid WHERE p.blocked > 0" . tablesort_sql($header), PASSWORD_POLICY_ENTRIES_PER_PAGE, 0, NULL);
  while ($row = db_fetch_object($result)) {
    $entry[$row->uid]['name'] = l($row->name, 'user/' . $row->uid);
    $entry[$row->uid]['blocked'] = format_date($row->blocked, 'medium');
    $entry[$row->uid]['unblocked'] = $row->unblocked < $row->blocked ? '' : format_date($row->unblocked, 'medium');
    $entry[$row->uid]['action'] = $row->unblocked < $row->blocked ? l(t('unblock'), 'admin/user/expired/unblock/' . $row->uid, array('query' => array('destination' => 'admin/user/expired'))) : '';
  }
  if (!isset($entry)) {
    $colspan = '4';
    $entry[] = array(array('data' => t('No entries'), 'colspan' => $colspan));
  }
  $page = theme('table', $header, $entry);
  $page .= theme('pager', NULL, PASSWORD_POLICY_ENTRIES_PER_PAGE, 0);

  return $page;
}

/**
 * Confirm unblocking the expired account.
 */
function password_policy_expired_unblock_confirm($form, $account) {
  return confirm_form(
    array(
      'account' => array(
        '#type' => 'value',
        '#value' => $account,
      ),
    ),
    t('Are you sure you would like to unblock the user %user?', array('%user' => $account->name)),
    'admin/user/expired',
    t('This action cannot be undone.'),
    t('Unblock user'),
    t('Cancel')
  );
}

/**
 * Unblocks the expired account.
 */
function password_policy_expired_unblock_confirm_submit($form, &$form_state) {
  // Unblock the user
  _password_policy_unblock($form_state['values']['account']);
  drupal_goto('admin/user/expired');
}

/****************************************************************************/
/* Mail handling                                                            */
/****************************************************************************/

/**
 * Returns a mail string for a variable name.
 *
 * Used by password_policy_mail() and the settings forms to retrieve strings.
 */
function _password_policy_mail_text($key, $language = NULL, $variables = array()) {
  $langcode = isset($language) ? $language->language : NULL;

  if ($admin_setting = variable_get('password_policy_' . $key, FALSE)) {
    // An admin setting overrides the default string.
    return strtr($admin_setting, $variables);
  }
  else {
    // No override, return with default strings.
    switch ($key) {
      case 'warning_subject':
        return t('Password expiration warning for !username at !site', $variables, $langcode);
      case 'warning_body':
        return t("!username,\n\nYour password at !site will expire in less than !days_left day(s).\n\nPlease go to !edit_uri to change your password.", $variables, $langcode);
    }
  }
}

/**
 * Return an array of token to value mappings for user e-mail messages.
 *
 * @param $params
 *  Structured array with the parameters.
 * @param $language
 *  Language object to generate the tokens with.
 *
 * @return
 *  Array of mappings from token names to values (for use with strtr()).
 */
function password_policy_mail_tokens($params, $language) {
  global $base_url;
  $account = $params['account'];
  $tokens = array(
   '!username' => $account->name,
   '!mailto' => $account->mail,
   '!site' => variable_get('site_name', 'Drupal'),
   '!uri' => $base_url,
   '!uri_brief' => drupal_substr($base_url, drupal_strlen('http://')),
   '!date' => format_date(time(), 'medium', '', NULL, $language->language),
   '!login_uri' => url('user', array('absolute' => TRUE)),
   '!edit_uri' => url('user/' . $account->uid . '/edit' . (module_exists('password_policy_password_tab') ? '/password' : ''), array('absolute' => TRUE)),
   '!days_left' => isset($params['days_left']) ? $params['days_left'] : NULL,
   '!login_url' => isset($params['login_url']) ? $params['login_url'] : NULL,
  );
    return $tokens;
}

/****************************************************************************/
/* Constraints API                                                          */
/****************************************************************************/

/**
 * Validates user password. Returns NULL on success or array with error messages
 * from the constraints on failure.
 *
 * @param $pass
 *   Clear text password.
 * @param &$account
 *   Populated user object.
 *
 * @return
 *   NULL or array with error messages.
 */
function _password_policy_constraint_validate($pass, &$account) {
  _password_policy_constraints();

  $error = NULL;
  $roles = (isset($account->roles) && is_array($account->roles)) ? array_keys($account->roles) : array();
  $policy = _password_policy_load_active_policy($roles);
  if (!empty($policy['policy'])) {
    foreach ($policy['policy'] as $key => $value) {
      if (!call_user_func('password_policy_constraint_' . $key . '_validate', $pass, $value, $account->uid)) {
        $error[] = call_user_func('password_policy_constraint_' . $key . '_error', $value);
      }
    }
  }
  return $error;
}

/**
 * Gets the constraint's name and description.
 *
 * @param $name
 *   Name of the constraint.
 *
 * @return
 *   Array containing the name and description.
 */
function _password_policy_constraint_description($name) {
  _password_policy_constraints();
  return call_user_func('password_policy_constraint_' . $name . '_description');
}

/**
 * Gets the constraint's error message.
 *
 * @param $name
 *   Name of the constraint.
 * @param $constraint
 *   Constraint value.
 *
 * @return
 *   Error message.
 */
function _password_policy_constraint_error($name, $constraint) {
  _password_policy_constraints();
  return call_user_func('password_policy_constraint_' . $name . '_error', $constraint);
}

/**
 * Gets the javascript code from the constraint to be added to the password
 * validation.
 *
 * @param $name
 *   Name of the constraint.
 * @param $constraint
 *   Constraint value.
 * @param $uid
 *   User's id.
 *
 * @return
 *   Javascript code snippet for the constraint.
 */
function _password_policy_constraint_js($name, $constraint, $uid) {
  _password_policy_constraints();
  if (function_exists('password_policy_constraint_' . $name . '_js')) {
    return call_user_func('password_policy_constraint_' . $name . '_js', $constraint, $uid);
  }
}

/****************************************************************************/
/* Auxiliary functions                                                      */
/****************************************************************************/

/**
 * Load contraints inc files.
 */
function _password_policy_constraints() {
  static $_password_policy;

  if (!isset($_password_policy)) {
    // Save all available constraints in a static variable.
    $dir = drupal_get_path('module', 'password_policy') . '/constraints';
    $constraints = file_scan_directory($dir, '^constraint.*\.inc$');
    $_password_policy = array();

    foreach ($constraints as $file) {
      if (is_file($file->filename)) {
        include_once($file->filename);
        $_password_policy[] = drupal_substr($file->name, 11);
      }
    }
  }

  return $_password_policy;
}

/**
 * Loads the policy with the specified id.
 *
 * @param $pid
 *   The policy id.
 *
 * @return
 *   A policy array, or NULL if no policy was found.
 */
function _password_policy_load_policy_by_pid($pid) {
  $result = db_query('SELECT * FROM {password_policy} WHERE pid = %d', $pid);
  $row = db_fetch_array($result);
  if (is_array($row)) {
    // fetch and unserialize the serialized policy
    $row['policy'] = unserialize($row['policy']);
    // Fetch roles
    $row['roles'] = array();
    $result = db_query('SELECT rid FROM {password_policy_role} WHERE pid = %d', $pid);
    while ($role = db_fetch_object($result)) {
      $row['roles'][$role->rid] = $role->rid;
    }
    return $row;
  }
  return NULL;
}

/**
 * Loads the first enabled policy that matches the specified roles.
 *
 * @param $roles
 *   An array of role IDs.
 *
 * @return
 *   A policy array, or NULL if no active policy exists.
 */
function _password_policy_load_active_policy($roles) {
  static $cache = array();
  if (empty($roles)) {
    $roles = array(DRUPAL_AUTHENTICATED_RID);
  }
  $key = implode(',', $roles);
  // Use array_key_exists() instead of isset() as NULLs may be in the array.
  if (!array_key_exists($key, $cache)) {
    $result = db_query('SELECT p.* FROM {password_policy} p INNER JOIN {password_policy_role} r ON p.pid = r.pid WHERE p.enabled = 1 AND r.rid IN (' . db_placeholders($roles) . ') ORDER BY p.weight LIMIT 1', $roles);
    $row = db_fetch_array($result);
    if (is_array($row)) {
      // fetch and unserialize the serialized policy
      $row['policy'] = unserialize($row['policy']);
      $cache[$key] = $row;
    }
    else {
      $cache[$key] = NULL;
    }
  }
  return $cache[$key];
}

/**
 * Stores user password hash.
 *
 * @param $uid
 *   User id.
 * @param $pass
 *   Clear text password.
 */
function _password_policy_store_password($uid, $pass) {
  if (module_exists('phpass')) {
    // Get password hash already saved by phpass.  This is only certain to work
    // if this module's weight is less than that of phpass, as it should be.
    $hash = db_result(db_query("SELECT pass FROM {users} WHERE uid = %d", $uid));
  }
  else {
    $hash = md5($pass);
  }
  db_query("INSERT INTO {password_policy_history} (uid, pass, created) VALUES (%d, '%s', %d)", $uid, $hash, time());
}

/**
 * Block the expired account.
 *
 * @param $account
 *   User object.
 */
function _password_policy_block_account($account) {
  if ($account->uid > 1) { // We never block the superuser account.
    db_query("UPDATE {users} SET status = 0 WHERE uid = %d", $account->uid);

    if (db_result(db_query("SELECT pid FROM {password_policy_expiration} WHERE uid = %d", $account->uid))) {
      db_query("UPDATE {password_policy_expiration} SET blocked = %d WHERE uid = %d", time(), $account->uid);
    }
    else {
      db_query("INSERT INTO {password_policy_expiration} (uid, blocked) VALUES (%d, %d)", $account->uid, time());
    }

    watchdog('password_policy', 'Password for user %name has expired.', array('%name' => $account->name), WATCHDOG_NOTICE, l(t('edit'), 'user/' . $account->uid . '/edit'));

    include_once(drupal_get_path('module', 'user') . '/user.pages.inc');
    user_logout();
  }
}

/**
 * Unblocks the expired account.
 *
 * @param $account
 *   User object.
 */
function _password_policy_unblock($account) {
  // Check if user was blocked via this module.
  $pp_blocked = db_result(db_query(
    'SELECT pid FROM {password_policy_expiration} ppe
      WHERE blocked <> 0
      AND unblocked IS NULL
      AND uid = %d', $account->uid
  ));

  if ($pp_blocked) {
    db_query('UPDATE {password_policy_expiration} ppe
      SET unblocked = %d
      WHERE uid = %d AND unblocked IS NULL', time(), $account->uid);
    // Unblock the user.
    user_save($account, array('status' => 1));
    drupal_set_message(t('The user %name has been unblocked.', array('%name' => $account->name)));
  }
}

/**
 * Add password policy JS
 *
 * @param $policy
 *   A policy array.
 * @param $uid
 *   A user ID for which the policy is applied.
 */
function password_policy_add_policy_js($policy, $uid) {

  // Print out the javascript which checks the strength of the password.
  // It overwrites the default core javascript function.
  drupal_add_js(drupal_get_path('module', 'password_policy') . '/password_policy.js', 'module');
  $s = "/**\n";
  $s .= " * Evaluate the strength of a user's password.\n";
  $s .= " *\n";
  $s .= " * Returns the estimated strength and the relevant output message.\n";
  $s .= " */\n";
  $s .= "Drupal.evaluatePasswordStrength = function(value) {\n";
  $s .= "  var strength = \"high\", msg = [], translate = Drupal.settings.password_policy;\n";
  $s .= "  var trimmedSpaces = /^\s+|\s+$/.test(value);\n";
  $s .= "  if (/^\s+$/.test(value)) {\n";
  $s .= "    return { strength: \"low\", message: translate.allSpaces };\n";
  $s .= "  }\n";
  $s .= "  value = value.replace(/^\s+|\s+$/g,'')\n";
  // Print out each constraint's javascript password strength evaluation.
  foreach ($policy['policy'] as $key => $value) {
    $s .= _password_policy_constraint_js($key, $value, $uid);
    // Constraints' error messages are used in javascript.
    $translate['constraint_' . $key] = _password_policy_constraint_error($key, $value);
  }
  $s .= "  msg = msg.length > 0 ? translate.needsMoreVariation +\"<ul><li>\"+ msg.join(\"</li><li>\") +\"</li></ul>\" : \"\";\n";
  $s .= "  if (trimmedSpaces && strength != \"high\") {\n";
  $s .= "    msg = msg.concat(translate.trimmedSpaces);\n";
  $s .= "  }\n";
  $s .= "  return { strength: strength, message: msg };\n";
  $s .= "};\n";
  drupal_add_js($s, 'inline');

  drupal_add_js(array(
    'password_policy' => array_merge(array(
      'strengthTitle' => t('Password quality:'),
      'lowStrength' => t('Bad'),
      'mediumStrength' => t('Good'),
      'highStrength' => t('Good'),
      'trimmedSpaces' => t('The password has spaces at the beginning or end which are ignored.'),
      'allSpaces' => t('The password is all spaces and will not be saved.'),
      'needsMoreVariation' => t('The password does not include enough variation to be secure.'),
      'confirmSuccess' => t('Yes'),
      'confirmFailure' => t('No'),
      'confirmTitle' => t('Passwords match:')), $translate)),
    'setting');
}
