<?php
require_once 'modules/openid/openid.inc';

function openid_server_help($path, $arg) {
  switch ($path) {
    // TODO: The help page keeps displaying:
    // No help is available for module OpenID Server.
    case 'admin/help/openid_server':
      return t('Allows Drupal to act as an OpenID server.');
  }
}

function openid_server_theme() {
  return array(
    'openid_server_authorized_sites_form' => array(
      'arguments' => array('form' => NULL),
      'file' => 'openid_server.pages.inc',
    ),
  );  
}

function openid_server_init() {
  drupal_add_link(array('rel' => 'openid.server', 'href' => url('openid/server', array('absolute' => TRUE))));
}

function openid_server_menu() {
  $items = array();
  $items['admin/settings/openid-server'] = array(
    'title' => 'OpenID Server',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('openid_server_settings'),
    'access arguments' => array('access administration pages'),
    'type' => MENU_NORMAL_ITEM,
  );
  $items['user/%user/authorized-sites'] = array(
    'title' => 'OpenID authorized sites',
    'page callback' => 'openid_server_authorized_sites',
    'page arguments' => array(1), // TODO: I'm not sure why this has to be 1.
    'access callback' => 'user_edit_access',
    'access arguments' => array(1), // TODO: Find out why this is here.
    'type' => MENU_LOCAL_TASK,
    'file' => 'openid_server.pages.inc',
  );
  $items['openid/server'] = array(
    'title' => 'OpenID login',
    'page callback' => 'openid_server_endpoint',
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  );
  $items['openid/continue'] = array(
    'page callback' => 'openid_server_continue',
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  );
  $items['openid/send'] = array(
    'page callback' => 'openid_server_send',
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  );
  return $items;
}

function openid_server_settings() {
  $form = array();
  $form['openid_server_assoc_expires_in'] = array(
    '#type' => 'textfield',
    '#title' => t('Associations expire in this many seconds'),
    '#default_value' => variable_get('openid_server_assoc_expires_in', '3600'),
    '#size' => 10,
    '#maxlength' => 10);
  // TODO: Delegation does not work.
  $form['openid_delegate_enabled'] = array(
    '#type' => 'checkbox',
    '#title' => t('Allow users to delegate their OpenID to another server'),
    '#default_value' => variable_get('openid_delegate_enabled', 0));
  
  return system_settings_form($form);
}

// TODO: I'm not sure this is completely correct.
// http://drupal.org/node/235460
function openid_server_user($op, &$edit, &$account, $category = NULL) {
  switch ($op) {
    case 'categories':
      return array(array('name' => 'openid', 'title' => 'OpenID'));
    case 'view':
      if (variable_get('openid_delegate_enabled', 0)) {
        drupal_add_link(array('rel' => 'openid.delegate', 'href' => ($account->openid_delegate ? $account->openid_delegate : url('user/' . $account->uid . '/xrds', array('absolute' => TRUE)))));
      }
      $account->content['openid'] = array(
          '#type' => 'user_profile_category',
          '#title' => t('OpenID'),
      );
      $account->content['openid']['url'] = array(
		  '#type' => 'user_profile_item',
      	  '#title' => 'URL',
      	  '#value' => t('You may login to other OpenID enabled sites using %url',
                        array('%url' => url('user/' . $account->uid, array('absolute' => true))))
      );
      break;
  }
}

function openid_server_endpoint() {
  // PHP "helpfully" converts dots to underscores
  // which breaks the protocol, so we must fix it.
  _openid_server_fix_post($_REQUEST);

  if (isset($_REQUEST['openid.mode'])) {
    switch ($_REQUEST['openid.mode']) {
      case 'associate':
        openid_server_association_response($_REQUEST);
        return;
      case 'checkid_immediate':
      case 'checkid_setup':
        return openid_server_authentication_response($_REQUEST);
      case 'check_authentication':
        openid_server_verification_response($_REQUEST);
        break;
    }
  }
}

// TODO: Why is the request passed as a parameter since it's already a global?
function openid_server_association_response($request) {
  $session_type = $request['openid.session_type'];
  $assoc_type = $request['openid.assoc_type'];
  $dh_modulus = $request['openid.dh_modulus'];
  $dh_gen = $request['openid.dh_gen'];
  $dh_consumer_public = $request['openid.dh_consumer_public'];
  
  $assoc_handle = _openid_nonce();
  $expires_in = variable_get('openid_server_assoc_expires_in', '3600');

  // CLEAR STALE ASSOCIATIONS
  db_query("DELETE FROM {openid_server_association} WHERE created + expires_in < %d", time());
  $response = array(
    'ns' => 'http://openid.net/signon/2.0',
    'session_type' => $session_type,
    'assoc_handle' => $assoc_handle,
    'assoc_type' => $assoc_type,
    'expires_in' => $expires_in
  );

  $secret = _openid_get_bytes(20);
  if ($session_type == '' || $session_type == 'no-encryption') {
    if ($assoc_type == 'HMAC-SHA1') {
      $mac_key = _openid_hmac($secret, $response['assoc_handle']);
      $response['mac_key'] = $mac_key;
    }
    elseif ($assoc_type == 'HMAC-SHA256') {
      error_log('HMAC-SHA256 association type not yet supported.');
    }
  }
  elseif ($session_type == 'DH-SHA1' || $session_type == 'DH-SHA256') {
    $dh_assoc = openid_dh_server_assoc($request, $secret);
    $mac_key = base64_encode($secret);
    $response['dh_server_public'] = $dh_assoc['dh_server_public'];
    $response['enc_mac_key'] = $dh_assoc['enc_mac_key'];
  }

  // Save the association for reference when dealing
  // with future requests from the same RP.
  db_query("INSERT INTO {openid_server_association} (assoc_handle, assoc_type, session_type, mac_key, created, expires_in) VALUES ('%s', '%s', '%s', '%s', %d, %d)",
           $assoc_handle, $assoc_type, $session_type, $mac_key, time(), $expires_in);

  // PHP can only handle SHA1 at the moment
  // need to find or write a library for it
  if ($assoc_type == 'HMAC-SHA256' || $session_type == 'DH-SHA256') {
    $message = _openid_create_message(openid_server_association_error());
  }
  else {
    $message = _openid_create_message($response);
  }

  header('HTTP/1.1 200 OK');
  header("Content-Type: text/plain");
  print $message;
  // TODO: exit() ?
}

// 7.4.5. Unsuccessful Response Parameters
function openid_server_association_error() {
  return array(
    'error' => '', // optional
    'error_code' => 'unsupported-type',
    'session_type' => '', // optional
    'assoc_type' => '' // optional
  );
}

// TODO: Passing global variables around is a choice but why?
function openid_dh_server_assoc($request, $secret) {
  if (empty($request['openid.dh_consumer_public'])) {
    return FALSE;
  }
  
  if (isset($request['openid.dh_modulus'])) {
    $mod = _openid_dh_base64_to_long($request['openid.dh_modulus']);
  }
  else {
    $mod = OPENID_DH_DEFAULT_MOD;
  }

  if (isset($request['openid.dh_gen'])) {
    $gen = _openid_dh_base64_to_long($request['openid.dh_gen']);
  }
  else {
    $gen = OPENID_DH_DEFAULT_GEN;
  }

  $r = _openid_dh_rand($mod);
  $private = bcadd($r, 1);
  $public = bcpowmod($gen, $private, $mod);
  
  $cpub = _openid_dh_base64_to_long($request['openid.dh_consumer_public']);
  $shared = bcpowmod($cpub, $private, $mod);
  $mac_key = _openid_dh_xorsecret($shared, $secret);
  $enc_mac_key = base64_encode($mac_key);
  $spub64 = _openid_dh_long_to_base64($public);
  return array(
    'dh_server_public' => $spub64,
    'enc_mac_key' => $enc_mac_key
    );
}

// 9. Responding to Authentication Requests
// 9.1. Positive Assertions
function openid_server_authentication_response($request) {
  global $user;
  $logged_in = FALSE;
  $uid = 0;
  // If not in immediate mode, the user will have to be
  // logged in before they can authenticate back to the RP
  $response = FALSE;
  if (empty($request['openid.realm'])) {
    // OpenID 1.1 backwards compatibility
    $request['openid.realm'] = $request['openid.trust_root'];
  }

  // Is the RP requesting Immediate or Indirect mode?
  if ($request['openid.mode'] == 'checkid_immediate') {
    // find uid in the identity string
    // then check the sessions table to see whether
    // that user is currently logged in
    // NOTE: Doesn't this mean that anyone can assert to be any logged in user??
    $id_url = @parse_url($request['openid.identity']);
    if ($id_url) {
      $id_url = variable_get('clean_urls', 0) ? $id_url['path'] : $id_url['query'];
      if (preg_match('/user\/(\d+)/', $id_url, $matches) > 0) {
        $uid = $matches[1];
        if ($uid > 0) {
          $session_user = db_fetch_object(db_query("SELECT uid FROM {sessions} WHERE uid = %d", $uid));
          if ($session_user && $session_user->uid > 0) {
            $logged_in = TRUE;
          }
        }
      }
    }
  }
  else {
    if ($user->uid > 0) {
      $uid = $user->uid;
      $logged_in = TRUE;
    }
    else { //if ($request['openid.mode'] == 'checkid_setup')
      $_SESSION['openidrequest'] = $request;
      drupal_goto('user/login', 'destination=openid/continue');
    }
  }

  // Choose the identity URL for the user automatically
  if ($request['openid.identity'] == 'http://openid.net/identifier_select/2.0') {
    $identity = url('user/' . $uid, array('absolute' => TRUE));
  }
  else {
    $identity = $request['openid.identity'];

    // TODO
    // Check the identity link vs the currently logged in
    // user's id.. so people can't claim other user pages
    // as their own! :)
    if (preg_match('/user\/(\d+)/', $identity, $matches) > 0) {
      if ($matches[1] != $uid) {
        $logged_in = FALSE;
        $response = openid_server_authentication_error($request['openid.mode']);
      }
    }
    else {
      $logged_in = FALSE;
      $response = openid_server_authentication_error($request['openid.mode']);
    }
  }

  if ($logged_in) {
    $response = array(
      'openid.mode' => 'id_res',
      'openid.op_endpoint' => url('openid/server', array('absolute' => TRUE)),
      'openid.identity' => $identity,
      'openid.claimed_id' => $identity,
      'openid.return_to' => $request['openid.return_to'],
      'openid.response_nonce' => _openid_nonce(),
      'openid.assoc_handle' => $request['openid.assoc_handle'],
      'openid.realm' => $request['openid.realm'],
    );

    // Check for a 1.1 nonce
    $parts = parse_url($request['openid.return_to']);
    if (preg_match('/nonce=([^&]+)/', $parts['query'], $matches)) {
      $response['nonce'] = $matches[1];
    }
    
    // Track the user's interaction with this RP
    openid_server_rp_save($uid, $request['openid.realm']); //, $autorelease);
  }
  elseif (!$response) {
    if ($request['openid.mode'] == 'checkid_immediate') {
      $response = openid_server_authentication_error($request['openid.mode']);
    }
  }

  $user_rp_association = openid_server_rp_load($uid, $request['openid.realm']);
  if ($request['openid.mode'] == 'checkid_immediate') {
    header('Content-Type: text/plain');
    print $message;
    exit;
  }
  else {
    if ($user_rp_association->auto_release == 1) {
      $response = _openid_server_sign($response);
      $message = _openid_create_message($response);
      $message_encoded = _openid_encode_message($message);
      $message = _openid_server_decode_message($message);
      openid_redirect($request['openid.return_to'], $message);
    }
    else {
      return drupal_get_form('openid_server_form', $request, $response);
    }
  }
}

// 11.2. Negative Assertions
function openid_server_authentication_error($mode) {
  if ($mode == 'checkid_immediate') {
    return array(
      'openid.mode' => 'id_res',
      'openid.user_setup_url' => url('user/login', array('absolute' => TRUE)),
    );
  }
  else { // checkid_setup
    return array(
      'openid.mode' => 'cancel'
    );
  }
}

// 9.2.2.2. Verifying Directly with the Identity Provider
// 9.2.2.2.2. Response Parameters
// Request is: Exact copies of all fields from the authentication response
function openid_server_verification_response($request) {
  $is_valid = TRUE;
  
  // Use the request openid.assoc_handle to look up
  // how this message should be signed, based on
  // a previously-created association.
  $assoc_type = 'HMAC-SHA1';
  $assoc = db_fetch_object(db_query("SELECT * FROM {openid_server_association} WHERE assoc_handle = '%s'", 
    $request['openid.assoc_handle']));
  if ($assoc && $assoc->assoc_type != '') {
    $assoc_type = $assoc->assoc_type;
  }

  $signed_keys = explode(',', $request['openid.signed']);
  $signature = _openid_signature($assoc, $request, $signed_keys);

  if ($signature != $request['openid.sig']) {
    $is_valid = FALSE;
  }

  if ($is_valid) {
    $response = array('is_valid' => 'true');
  }
  else {
    $response = array(
      'is_valid' => 'false',
      'invalidate_handle' => $request['openid.assoc_handle'] // optional, An association handle sent in the request
    );
  }

  $message = _openid_create_message($response);
  header("Content-Type: text/plain");
  print $message;  
}

// 11.2.2. In Response to Non-Immediate Requests
function openid_server_cancel_authentication_response($mode = 'checkid_immediate') {
  $response = array();
  if ($mode == 'checkid_immediate') {
    $response = array(
      'openid.mode' => 'id_res',
      'openid.user_setup_url' => url('user', array('absolute' => TRUE)),
    );
  }
  else {
    $response = array('openid.mode' => 'cancel');
  }
  return $response;
}

/**
 * Support continuing an OpenID authentication request
 * say, if the user must log in first.
 */
function openid_server_continue() {
  if (isset($_SESSION['openidrequest'])) {
    $message = _openid_create_message($_SESSION['openidrequest']);
    $message = _openid_server_decode_message($message);
    unset($_SESSION['openidrequest']);
    openid_redirect(url('openid/server', array('absolute' => TRUE)), $message);
  } else { error_log('session not init'); }
}

/**
 * Indirect communication .. give the user
 * a chance to make changes to any data
 * being requested by an RP.
 */
function openid_server_form($form_state, $request, &$response) {
  global $user;

  $realm = $request['openid.realm'];
  
  $form = array();
  
  $header = array('', '', t('Release'));
  $rows = array();
  $form['intro'] = array(
      '#value' => t('You are being logged into %site', array('%site' => theme('placeholder', $realm))));
  
  foreach ($response as $key => $value) {
    if (strpos($key, 'openid.') === 0 || $key == 'nonce') {
      $form[$key] = array(
        '#type' => 'hidden',
        '#value' => $value);
    }
  }

  $form = array_merge($form, module_invoke_all('openid', 'form', $request, $response));

  // Check the user's auto-submit preference for this RP
  // (default to on)
  $auto_submit_checked = TRUE;
  $user_rp_association = openid_server_rp_load($user->uid, $realm);
  if ($user_rp_association && $user_rp_association->auto_release == 0) {
    $auto_submit_checked = FALSE;
  }
  $form['autosubmit'] = array(
    '#type' => 'checkbox',
    '#title' => t('Automatically send my information to this site for any future requests.'),
    '#default_value' => $auto_submit_checked);

  $form['submit'] = array(
    '#type' => 'submit',
    '#value' => t('OK'));

  $form['#action'] = url('openid/send');
  return $form;
}

/**
 * 2nd part of 2-step user interaction
 * take the values they have updated
 * in the previous form and return them
 * to the original RP that had requested them.
 */
function openid_server_send($edit = NULL) {
  // Save any changes back to their profile
  global $user;
  $user = user_load(array('uid' => $user->uid));
  $uid = $user->uid;

  // Optionally pass edit in the command line
  // so this can run either by a form post to
  // it's URL, or programmatically as called
  // by another function.
  // TODO: This can't be how forms are handled in D6.
  // Changed it from $_POST['edit']. Should investigate D6 form handling some more...
  if (!$edit) {
    $edit = $_POST;
  }
  _openid_server_fix_post($edit);
  //echo "<pre>"; var_dump($_POST); exit;
  
  // Create the new response array
  $response = $edit;
  // Validate the updated details, and save.
  if (!form_get_errors()) {
    $user_core_properties = array('uid', 'name', 'pass', 'mail', 'mode', 'sort', 'threshold', 'theme', 'signature', 'created', 'changed', 'status', 'timezone', 'language', 'picture', 'init', 'data', 'roles');
    $user_updates = array();
    foreach ($user as $key => $value) {
      if (!in_array($key, $user_core_properties)) {
        $user_updates[$key] = $value;
      }
    }
  }
  else {
    // If there are errors, redisplay the form to the user
    drupal_set_message('Use the back button to return to the form and correct any errors.');

    $error_string = '';
    $saved_errors = form_get_errors();
    if (is_array($saved_errors)) {
      foreach ($saved_errors as $error_name => $error_message) {
        $error_string .= $error_message . "<br />";
      }
    }
  }
  // TODO: The realm is lost here. It isn't available in the previous form.
  // That's the form where you click OK on "You are being logged into....
  // I'm pretty sure this is an issue with the server in 4.7 as well.
  // Unless you want to re-save the information without realm but that seems unlikely.
  // FIXED: I've added openid.realm in the response in openid_server_authentication_response()
  openid_server_rp_save($uid, $edit['openid.realm'], $edit['autosubmit']);
  unset($response['autosubmit']);

  $response = _openid_server_sign($response);
  $message = _openid_create_message($response);
  $message = _openid_server_decode_message($message);
  openid_redirect($response['openid.return_to'], $message);
}

/**
 * Wrapper for saving and loading Relying Parties
 * with which users have interacted.
 */
function openid_server_rp_save($uid, $realm, $auto_release = FALSE) {
  $now = time();
  $old_rp = openid_server_rp_load($uid, $realm);
  if (!$old_rp) {
    if ($auto_release === FALSE) {
      $auto_release = 0;
    }
    db_query("INSERT INTO {openid_server_relying_party} (uid, realm, first_time, last_time, auto_release) VALUES(%d, '%s', %d, %d, %d)",
      $uid, $realm, $now, $now, $auto_release);
  }
  else {
    // If no auto-release value has been included
    // in the call to this function, then don't
    // update the value in the database.
    if ($auto_release === FALSE) {
      $auto_release = $old_rp->auto_release;
    }
    db_query("UPDATE {openid_server_relying_party} SET last_time = %d, auto_release = %d WHERE uid = %d AND realm = '%s'",
      $now, $auto_release, $uid, $realm);
  }
}

function openid_server_rp_load($uid, $realm) {
  return db_fetch_object(db_query(
    "SELECT * FROM {openid_server_relying_party} WHERE uid = %d AND realm = '%s'",
    $uid, $realm
  ));
}

function openid_server_rp_load_all($uid) {
  $rps = array();
  $result = db_query("SELECT * FROM {openid_server_relying_party} WHERE uid = %d ORDER BY last_time DESC", $uid);
  while ($rp = db_fetch_object($result)) {
    $rps[] = $rp;
  }
  return $rps;
}

/**
 * The openid_redirect_form() function in openid.inc needs an array for the message.
 * In D4.7 it converted the message created from openid_create_message
 * @param string $message
 * @return array
 */
function _openid_server_decode_message($message) {
  $result = array();
  $items = explode("\n", $message);
  foreach ($items as $item) {
    list($key, $value) = explode(':', $item, 2);
    $result[$key] = $value;
  }
  return $result;
}

// Fix PHP's braindead handling of POST data
function _openid_server_fix_post(&$post) {
  $extensions = module_invoke_all('openid', 'extension');
  foreach ($post as $key => $value) {
    if (strpos($key, 'openid_') === 0) {
      $fixed_key = str_replace('openid_', 'openid.', $key);
      $fixed_key = str_replace('openid.ns_', 'openid.ns.', $fixed_key);
      foreach ($extensions as $ext) {
        $fixed_key = str_replace('openid.'.$ext.'_', 'openid.'.$ext.'.', $fixed_key);
      }
      unset($post[$key]);
      $post[$fixed_key] = $value;
    }
  }
}

function _openid_server_sign($response) {
  $signed_keys = array('op_endpoint', 'return_to', 'response_nonce', 'assoc_handle', 'identity');
  $signed_keys = array_merge($signed_keys, module_invoke_all('openid', 'signed', $response));
  $response['openid.signed'] = implode(',', $signed_keys);
  
  // Use the request openid.assoc_handle to look up
  // how this message should be signed, based on
  // a previously-created association.
  $assoc = db_fetch_object(db_query("SELECT * FROM {openid_server_association} WHERE assoc_handle = '%s'", 
                                    $response['openid.assoc_handle']));
  
  // Generate signature for this message
  $response['openid.sig'] = _openid_signature($assoc, $response, $signed_keys);
  return $response;
}
