MediawikiShibAuthWithPersistentID

Innen: KIFÜ Wiki

Extension:Shibboleth Authentication with persistent-id support

This extension is based on the original Extension:Shibboleth Authentication, the basic information will not be copied, here you can find the differences and the explanation of these differences.

The main object of this development is to make the extension support opaque persistent-id. Persistent-id could come from the Identity Provider (IdP), where the user has been authenticated, as value of persistent nameid, or as value of eduPersonTargetedID attribute. From the view of the mediawiki the route is, how the persistent-id is coming, irrelevant, for the mediawiki it is given by the Service Provider (SP). More about persistent-id

The main point is that persistent-id meets the privacy requirements much better than e.g. if the mediawiki gets e-mail address of the user, and it is used as local username.

Preparation

You have to add an SQL table to be able to pair persistent-id and the local-id of the user.

CREATE TABLE IF NOT EXISTS `wm_user_persistentid` (
  `userID` int(10) NOT NULL,
  `persistentID` varchar(255) NOT NULL,
  UNIQUE KEY `userID` (`userID`,`persistentID`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1;

LocalSettings.php

I made only a little change to make easier to configure the modul with different Shibboleth variable names, and set a working logout link.

// Shibboleth Authentication Stuff
// Load ShibAuthPlugin
require_once('extensions/ShibAuthPlugin.php');


// Last portion of the shibboleth WAYF url for lazy sessions.
// This value is found in your shibboleth.xml file on the setup for your SP
// WAYF url will look something like: /Shibboleth.sso/WAYF/$shib_WAYF
//$shib_WAYF = "idp.example.org";
 
//Are you using an old style WAYF (Shib 1.3) or new style Discover Service (Shib 2.x)?
//Values are WAYF or DS, defaults to WAYF
$shib_WAYFStyle = "DS";
 
 
// Is the assertion consumer service located at an https address (highly recommended)
// Default for compatibility with previous version: false
$shib_Https = true;
 
// Prompt for user to login
$shib_LoginHint = "Login via Single Sign-on";
 
// Where is the assertion consumer service located on the website?
// Default: "/Shibboleth.sso"
$shib_AssertionConsumerServiceURL = "/Shibboleth.sso";
 
//Config headernames
$SHIB_PERSISTENTID = 'persistent-id'; // persistent-id (eduPersonTargetedID)
$SHIB_EPPN = 'eppn'; // eduPersonPrincipalName
$SHIB_MAIL = 'mail'; // E-mail
$SHIB_CN = 'cn'; // Common Name

// Map persistent-id
$shib_persistentid = ( isset( $_SERVER[$SHIB_PERSISTENTID] ) ) ? $_SERVER[$SHIB_PERSISTENTID] : null;

// Map username
$shib_UN = ( isset( $_SERVER[$SHIB_EPPN] ) ) ? $_SERVER[$SHIB_EPPN] : null; 

// Map real name
$shib_RN = ( isset( $_SERVER[$SHIB_CN] ) ) ? $_SERVER[$SHIB_CN] : null; 

// Map e-mail
$shib_email = ( isset( $_SERVER[$SHIB_MAIL] ) ) ? $_SERVER[$SHIB_MAIL] : null;

// The ShibUpdateUser hook is executed on login.
// It has two arguments:
// - $existing: True if this is an existing user, false if it is a new user being added
// - &$user: A reference to the user object. 
//           $user->updateUser() is called after the function finishes.
// In the event handler you can change the user object, for instance set the email address or the real name
// The example function shown here should match behavior from previous versions of the extension:
 
$wgHooks['ShibUpdateUser'][] = 'ShibUpdateTheUser';

function ShibUpdateTheUser($existing, &$user) {
	global $shib_email;
	global $shib_RN;
	if (! $existing) {
		if($shib_email != null)
			$user->setEmail($shib_email);
		if($shib_RN != null)
			$user->setRealName($shib_RN);
	}
	return true;
}

// Shibboleth doesn't really support logging out very well.  To take care of
// this we simply get rid of the logout link when a user is logged in through
// Shib.  Alternatively, you can uncomment and set the variable below to a link
// that will either clear the user's cookies or log the user out of the Idp and
// instead of deleting the logout link, the extension will change it instead.
$shib_logout = "/Shibboleth.sso/Logout?return=https://" . $_SERVER['SERVER_NAME'] .
        "/%3Ftitle=Special:Userlogout&returnto=Main_Page";

// Activate Shibboleth Plugin
SetupShibAuth();

ShibAuthPlugin.php

I rewrote the ShibUserLoadFromSession function, extended with a few new function, and comments. :) I did not change ShibAuthPlugin class, and made only a little changes in SetupShibAuth function.

I did not change the version information neither.


<?php

/**
 * Version 1.2.3 (Works out of box with MW 1.7 or above)
 *
 * Authentication Plugin for Shibboleth (http://shibboleth.internet2.edu)
 * Derived from AuthPlugin.php
 * Much of the commenting comes straight from AuthPlugin.php
 *
 * Portions Copyright 2006, 2007 Regents of the University of California.
 * Portions Copyright 2007, 2008 Steven Langenaken
 * Released under the GNU General Public License
 *
 * Documentation at http://www.mediawiki.org/wiki/Extension:Shibboleth_Authentication
 * Project IRC Channel: #sdcolleges on irc.freenode.net
 *
 * Extension Maintainer:
 *	* Steven Langenaken - Added assertion support, more robust https checking, bugfixes for lazy auth, ShibUpdateUser hook
 * Extension Developers:
 *	* D.J. Capelis - Developed initial version of the extension
 */


require_once('AuthPlugin.php');

class ShibAuthPlugin extends AuthPlugin {
    var $existingUser = false;
    
    /**
     * Check whether there exists a user account with the given name.
     * The name will be normalized to MediaWiki's requirements, so
     * you might need to munge it (for instance, for lowercase initial
     * letters).
     *
     * @param string $username
     * @return bool
     * @access public
     */
    function userExists( $username ) {
        return true;
    }
    
    
    /**
     * Check if a username+password pair is a valid login.
     * The name will be normalized to MediaWiki's requirements, so
     * you might need to munge it (for instance, for lowercase initial
     * letters).
     *
     * @param string $username
     * @param string $password
     * @return bool
     * @access public
     */
    function authenticate( $username, $password) {
        global $shib_UN;
        
        return $username == $shib_UN;
    }
    
    /**
     * Modify options in the login template.
     *
     * @param UserLoginTemplate $template
     * @access public
     */
    function modifyUITemplate( &$template ) {
        $template->set( 'usedomain', false );
    }
    
    /**
     * Set the domain this plugin is supposed to use when authenticating.
     *
     * @param string $domain
     * @access public
     */
    function setDomain( $domain ) {
        $this->domain = $domain;
    }
    
    /**
     * Check to see if the specific domain is a valid domain.
     *
     * @param string $domain
     * @return bool
     * @access public
     */
    function validDomain( $domain ) {
        return true;
    }
    
    /**
     * When a user logs in, optionally fill in preferences and such.
     * For instance, you might pull the email address or real name from the
     * external user database.
     *
     * The User object is passed by reference so it can be modified; don't
     * forget the & on your function declaration.
     *
     * @param User $user
     * @access public
     */
    function updateUser( &$user ) {
        wfRunHooks('ShibUpdateUser', array($this->existingUser, $user));
        
        //For security, set password to a non-existant hash.
        if ($user->mPassword != "nologin") {
            $user->mPassword = "nologin";
        }
        
        $user->setOption('rememberpassword', 0);
        $user->saveSettings();
        return true;
    }
    
    
    /**
     * Return true if the wiki should create a new local account automatically
     * when asked to login a user who doesn't exist locally but does in the
     * external auth database.
     *
     * If you don't automatically create accounts, you must still create
     * accounts in some way. It's not possible to authenticate without
     * a local account.
     *
     * This is just a question, and shouldn't perform any actions.
     *
     * @return bool
     * @access public
     */
    function autoCreate() {
        return true;
    }
    
    /**
     * Can users change their passwords?
     *
     * @return bool
     */
    function allowPasswordChange() {
        global $shib_pretend;
        
        return $shib_pretend;
        
    }
    
    /**
     * Set the given password in the authentication database.
     * Return true if successful.
     *
     * @param string $password
     * @return bool
     * @access public
     */
    function setPassword( $password ) {
        global $shib_pretend;
        
        return $shib_pretend;
    }
    
    /**
     * Update user information in the external authentication database.
     * Return true if successful.
     *
     * @param User $user
     * @return bool
     * @access public
     */
    function updateExternalDB( $user ) {
        //Not really, but wiki thinks we did...
        return true;
    }
    
    /**
     * Check to see if external accounts can be created.
     * Return true if external accounts can be created.
     * @return bool
     * @access public
     */
    function canCreateAccounts() {
        return false;
    }
    
    /**
     * Add a user to the external authentication database.
     * Return true if successful.
     *
     * @param User $user
     * @param string $password
     * @return bool
     * @access public
     */
    function addUser( $user, $password ) {
        return false;
    }
    
    
    /**
     * Return true to prevent logins that don't authenticate here from being
     * checked against the local database's password fields.
     *
     * This is just a question, and shouldn't perform any actions.
     *
     * @return bool
     * @access public
     */
    function strict() {
        return false;
    }
    
    /**
     * When creating a user account, optionally fill in preferences and such.
     * For instance, you might pull the email address or real name from the
     * external user database.
     *
     * The User object is passed by reference so it can be modified; don't
     * forget the & on your function declaration.
     *
     * @param User $user
     * @access public
     */
    function initUser( &$user, $autocreate ) {
        $this->updateUser($user);
    }
    
    /**
     * If you want to munge the case of an account name before the final
     * check, now is your chance.
     */
    function getCanonicalName( $username ) {
        return $username;
    }
}

function ShibGetUserIDFromPersistentID( $persistentID ) {
    
    $dbr = wfGetDB( DB_SLAVE );
    $res = $dbr->selectRow( "user_persistentid", array( "userID" ), array( "persistentID" => $persistentID ) );
    
    // If the user already has been here, the return value is the user_id, otherwise false
    return ( $res  ) ? $res->userID : false;
}


function ShibAddUserPersistentID( $userID, $persistentID ) {
    
    $dbr = wfGetDB( DB_MASTER );
    
    // If adding the persistent-id to the databas was success, return true, otherwise false
    return $dbr->insert( "user_persistentid",  array( "userID" => $userID, "persistentID" => $persistentID ) );
    
}

function ifUserAlreadyInLocalDB ( $username ) {
    
    // Does the user already have local account?
    return ( User::idFromName( $username ) != null && User::idFromName( $username ) != 0 ) ? User::idFromName( $username ) : false;
    
}

function setupUserAlreadyInLocalDB ( $username ) {
    
    // We know the user, so it is high time to set him up
    
    global $wgAuth;
    
    $user = User::newFromName( ucfirst( $username ) );
    $user->load();
    $wgAuth->existingUser = true;
    $wgAuth->updateUser( $user ); //Make sure password is nologin
    $user->setupSession();
    $user->setCookies();
    
    return true;
    
}

function addUserLocalDBwithBlackMagic ( &$user, $username ) {
    
    global $shib_pretend;
    global $wgRedirectOnLogin;  
    
    /* We have to add a new local user to the database. We know 
     * the username, and have a prepared user object. We "make" a login
     * form, fill and send it, then set the user up.
    */
    
    require_once('specials/SpecialUserlogin.php');
    
    //This section contains a silly hack for MW
    global $wgLang;
    global $wgContLang;
    global $wgRequest;
    $wgLangUnset = false;
    
    if(!isset($wgLang)) {
        $wgLang = $wgContLang;
        $wgLangUnset = true;
    }
    
    ShibKillAA();
    
    // This creates our form that'll do black magic
    $lf = new LoginForm($wgRequest);
    
    //And now we clean up our hack
    if($wgLangUnset == true) {
        unset($wgLang);
        unset($wgLangUnset);
    }
    
    // Okay, kick this up a notch then...
    $user->setName( ucfirst( $username ) );
    
    // The mediawiki developers entirely broke use of this the
    // straightforward way in 1.9, so now we just lie...
    $shib_pretend = true;
    
    // Now we _do_ the black magic
    $lf->mRemember = false;
    $user->loadDefaults( ucfirst( $username ) );
    $lf->initUser($user, true);
    
    //Stop pretending now
    $shib_pretend = false;
    
    //Finish it off
    $user->saveSettings();
    $user->setupSession();
    $user->setCookies();
    $lf->successfulLogin();
    return true;
}

function ShibKillAA() {
    global $wgHooks;
    
    //Temporarily kill The AutoAuth Hook to prevent recursion
    foreach ($wgHooks[ShibGetAuthHook()] as $key => $value) {
        if($value == "Shib".ShibGetAuthHook())
            $wgHooks[ShibGetAuthHook()][$key] = 'ShibBringBackAA';
    }
}
/* Puts the auto-auth hook back into the hooks array */
function ShibBringBackAA() {
    global $wgHooks;
    
    foreach ($wgHooks[ShibGetAuthHook()] as $key => $value) {
        if($value == 'ShibBringBackAA')
            $wgHooks[ShibGetAuthHook()][$key] = "Shib".ShibGetAuthHook();
    }
    return true;
}

/* Add login link */
function ShibLinkAdd(&$personal_urls, $title) {
    global $shib_WAYF, $shib_LoginHint, $shib_Https, $shib_AssertionConsumerServiceURL;
    global $shib_WAYFStyle;
    if (! isset($shib_AssertionConsumerServiceURL) || $shib_AssertionConsumerServiceURL == '')
        $shib_AssertionConsumerServiceURL = "/Shibboleth.sso";
    if (! isset($shib_Https))
        $shib_Https = false;
    if (! isset($shib_WAYFStyle))
        $shib_WAYFStyle = 'DS';
    if ($shib_WAYFStyle == 'DS')
        $shib_ConsumerPrefix = 'DS';
    else
        $shib_ConsumerPrefix = '';
    $pageurl = $title->getLocalUrl();
    if (! isset($shib_LoginHint))
        $shib_LoginHint = "Login via Single Sign-on";
    
    $personal_urls['SSOlogin'] = array(
            'text' => $shib_LoginHint,
            'href' => ($shib_Https ? 'https' :  'http') .'://' . $_SERVER['HTTP_HOST'] .
                    $shib_AssertionConsumerServiceURL . "/" . $shib_ConsumerPrefix . $shib_WAYF .
                    '?target=' . (isset($_SERVER['HTTPS']) ? 'https' : 'http') .
                    '://' . $_SERVER['HTTP_HOST'] . $pageurl, );
    return true;
}	

/* Kill logout link */
function ShibActive(&$personal_urls) {
    
    global $shib_logout;
    global $shib_RN;
    
    if($shib_logout == null)
        $personal_urls['logout'] = null;
    else
        $personal_urls['logout']['href'] = $shib_logout;
    
    if ($shib_RN) $personal_urls['userpage']['text'] = $shib_RN;
    
    return true;
}

function ShibAutoAuthenticate(&$user) {
    ShibUserLoadAfterLoadFromSession($user);
}

/*
 * Tries to be magical about when to log in users and when not to.
 *
 * This is the main function.
 *
*/

function ShibUserLoadFromSession($user, &$result = true) {
    global $shib_persistentid;
    global $shib_UN;
    global $wgRedirectOnLogin;
    
    ShibKillAA();
    
    //For versions of mediawiki which enjoy calling AutoAuth with null users
    if ($user === null) {
        $user = User::loadFromSession();
    }
    
    //They already with us?  If so, nix this function, we're good.
    if($user->isLoggedIn()) {
        
        ShibBringBackAA();
        return true;
    }
    
    
    if ( $shib_persistentid != null && $shib_UN == null ) {
        
        // If the user only has persistentID, and his persistentID has not been in the DB yet,
        // he will be handled as new user, even if he had local id created from username earlier.
        // It is not possible to link the new account persID account with the old one without $shib_UN
        
        if (  $userID = ShibGetUserIDFromPersistentID( $shib_persistentid )  ) {
            
            //We know his persistentID and he has a linked local account
            
            return setupUserAlreadyInLocalDB( User::whoIs( $userID ));
            
        } else {
            
            // We have to choose a local name for the new user, or develop
            // to be able to modify userCreateTemplate and redirect the user there
            
            $tempName = "TEMPUserName".substr(date("YssHIs")*rand(1,30)+"10101",0,10);
            $wgRedirectOnLogin = "Special:RenameUser";
            
            // Add and load the new user
            addUserLocalDBwithBlackMagic( $user, $tempName );
            
            // Add the persistentID
            ShibAddUserPersistentID( ifUserAlreadyInLocalDB( $tempName ), $shib_persistentid );
            
            return true;
            
        }
        
    } elseif ( $shib_persistentid != null && $shib_UN != null ) {
        
        if ( ShibGetUserIDFromPersistentID( $shib_persistentid ) == ifUserAlreadyInLocalDB( $shib_UN )  ) {
            
            // If so, the user has a local account and it has been already
            // linked with his persistentID, so he can log in
            return setupUserAlreadyInLocalDB( $shib_UN );
            
        } elseif ( ShibAddUserPersistentID( ifUserAlreadyInLocalDB( $shib_UN ), $shib_persistentid ) ) {
            
            // If we made the link between local account and persistentID
            // then the user can log in
            
            return setupUserAlreadyInLocalDB( $shib_UN );
            
        } else {
            
            // A brand new user
            addUserLocalDBwithBlackMagic( $user, $shib_UN );
            
            // If we managed to add a new local user, we have to link to the persistentID
            ShibAddUserPersistentID( ifUserAlreadyInLocalDB( $shib_UN ), $shib_persistentid );
            
            return true;
            
        }
        
    } elseif ( $shib_persistentid == null && $shib_UN != null ) {
        
        //The old way...
        if ( ifUserAlreadyInLocalDB( $shib_UN ) ) {
            
            return setupUserAlreadyInLocalDB( $shib_UN );
            
        } else {
            
            return addUserLocalDBwithBlackMagic ( $user, $shib_UN );
            
        }
        
    }
    
    return false;
    
}

function ShibGetAuthHook() {
    global $wgVersion;
    global $wgUser;
    
    if ($wgVersion >= "1.13") {
        if ($wgUser) {
            return 'UserLoadAfterLoadFromSession';
        } else {
            return 'UserLoadFromSession';
        }
    } else {
        return 'AutoAuthenticate';
    }
    
}

function SetupShibAuth() {
    global $shib_UN;
    global $shib_persistentid;
    global $wgHooks;
    global $wgAuth;
    global $wgCookieExpiration;
    
    if ( $shib_persistentid == null && $shib_UN == null ) {
        
        $wgHooks['PersonalUrls'][] = 'ShibLinkAdd';
        
    } else {
        
        $wgCookieExpiration = -3600;
        $wgHooks[ShibGetAuthHook()][] = "ShibUserLoadFromSession";
        $wgHooks['PersonalUrls'][] = 'ShibActive';
        $wgAuth = new ShibAuthPlugin();
        
    }
    
}

$wgExtensionFunctions[] = 'SetupShibAuth';
$wgExtensionCredits['other'][] = array(
        'name' => 'Shibboleth Authentication',
        'version' => '1.2.3',
        'author' => "Regents of the University of California, Steven Langenaken",
        'url' => "http://www.mediawiki.org/wiki/Extension:Shibboleth_Authentication",
        'description' => "Allows logging in through Shibboleth",
);

After login

If the user only has persistent-id, and it is the first time to login, he is given a temporary username, so he will be supposed to change it. To change username mediawiki needs an extension, called RenameUser.

We have to make a small modification on the extension. You can see the patch below, and download from here for the SpecialRenameuser_body.php.

77,78c77,78
< <td class='mw-input'><i>" . $wgUser->mName . "</i>" .
<        Xml::input( 'oldusername', 20, $wgUser->mName, array( 'type' => 'hidden' ) ) . ' ' .
---
> <td class='mw-input'>" .
> 	 Xml::input( 'oldusername', 20, $oun, array( 'type' => 'text', 'tabindex' => '1' ) ) . ' ' .
86c86,94
< 	 Xml::input( 'newusername', 20, $nun, array( 'type' => 'text', 'tabindex' => '1' ) ) .
---
> 	 Xml::input( 'newusername', 20, $nun, array( 'type' => 'text', 'tabindex' => '2' ) ) .
>  "</td>
>  </tr>
>  <tr>
>   <td class='mw-label'>" .
> 	 Xml::label( wfMsg( 'renameuserreason' ), 'reason' ) .
>  "</td>
>  <td class='mw-input'>" .
> 	 Xml::input( 'reason', 40, $reason, array( 'type' => 'text', 'tabindex' => '3', 'maxlength' => 255 ) ) .

ToDo

  • Testing - I tested only on MediaWiki 1.15.3, it worked properly
  • Bugfix - If I modify UserLoadFromSession to UserLoadAfterLoadFromSession, the "reload bug" is still with us :S
  • Cleaning the code
  • Write SQL install script
  • Anything else?