XSCE Moodle autologin

Written by Miguel González on 2013-09-26

In this document I'm going to explain in detail how Moodle autologin for XO works. This is the result of team work, I'm just joining the dots.

TL;DR: Browse activity has a magic cookie that identifies the XO. Moodle validates cookie content against idmgr service for a previously registered XO.

User story

Since XS 0.6, XO users can autologin to Moodle after they registrate their laptops on the schoolserver.

The user story is straightforward:

But how does this self defined automagic authentication mechanism really work?

Registration

The XO main screen, has the option of "Register". This command will send to the schoolserver XO's serial number, user's nick, a generated UUID and the pubkey:

data = server.register(sn, nick, uuid_, profile.pubkey)

If you want to analyze in depth what does the code you can review register_laptop function in schoolserver.py module in sugar.

def register_laptop(url=_REGISTER_URL):

    profile = get_profile()
    client = GConf.Client.get_default()

    if _have_ofw_tree():
        sn = _read_mfg_data(os.path.join(_OFW_TREE, _MFG_SN))
        uuid_ = _read_mfg_data(os.path.join(_OFW_TREE, _MFG_UUID))
    elif _have_proc_device_tree():
        sn = _read_mfg_data(os.path.join(_PROC_TREE, _MFG_SN))
        uuid_ = _read_mfg_data(os.path.join(_PROC_TREE, _MFG_UUID))
    else:
        sn = _generate_serial_number()
        uuid_ = str(uuid.uuid1())
    sn = sn or 'SHF00000000'
    uuid_ = uuid_ or '00000000-0000-0000-0000-000000000000'

    setting_name = '/desktop/sugar/collaboration/jabber_server'
    jabber_server = client.get_string(setting_name)
    _store_identifiers(sn, uuid_, jabber_server)

    if jabber_server:
        url = 'http://' + jabber_server + ':8080/'

    nick = client.get_string('/desktop/sugar/user/nick')

    if sys.hexversion < 0x2070000:
        server = xmlrpclib.ServerProxy(url, _TimeoutTransport())
    else:
        socket.setdefaulttimeout(_REGISTER_TIMEOUT)
        server = xmlrpclib.ServerProxy(url)
    try:
        data = server.register(sn, nick, uuid_, profile.pubkey)
    except (xmlrpclib.Error, TypeError, socket.error):
        logging.exception('Registration: cannot connect to server')
        raise RegisterError(_('Cannot connect to the server.'))
    finally:
        socket.setdefaulttimeout(None)

    if data['success'] != 'OK':
        logging.error('Registration: server could not complete request: %s',
                      data['error'])
        raise RegisterError(_('The server could not complete the request.'))

    client.set_string('/desktop/sugar/collaboration/jabber_server',
                      data['jabberserver'])
    client.set_string('/desktop/sugar/backup_url', data['backupurl'])

    return True

The invocation uses a remote procedure call to schoolserver host on port 8080 as default.

But, who is listening in the other side of this connection? The answer is idmgr service, more accurately, registration-server daemon.

In a nutshell, it does the next tasks:

  1. sanitize input
  2. create a new system user
  3. store data in the service database
  4. return information about jabber server and data backup path

Let's see the function register.

def register(serial, nickname, uuid, pubkey):
    try:
        #Sanitise all input
        if not serialre.match(serial):
            raise ServerError("Invalid serial: %s" % (serial,))
        if not uuidre.match(uuid):
            raise ServerError( "Invalid UUID: %s" % (uuid,))
        if "\n" in nickname:
            raise ServerError( "Invalid nickname: %s" % nickname)
        if "\n" in pubkey:
            raise ServerError("Invalid public key: %s" % pubkey)

        # first try creating the system user. If this fails, the
        # database will be untouched.
        _create_new_user(serial, nickname, uuid, pubkey)
        database.save_laptop({'serial':    serial,
                              'nickname':  nickname,
                              'full_name': '',
                              'pubkey':    pubkey,
                              'uuid':      uuid,
                              })

    except Exception, e:
        log(syslog.LOG_ERR, str(e))
        return {'success': 'ERR',
                'error': str(e),
                }

    # OK, the laptop is registered. Now we just need to let it know.
    log(syslog.LOG_NOTICE, "Registered user %s with serial %s"
        % (nickname.encode('utf-8'), serial))

    response = {
        'success': 'OK',
        'backupurl': "%s@%s:%s" % (serial, config.BACKUP, config.BACKUP_PATH),
        'jabberserver': config.PRESENCE,
        'backuppath': config.BACKUP_PATH,
        }
    return response

Magic cookie

But how the client provides moodle its identity. For me this was the ticky part.

The Browse activity elaborates a magic cookie that contains a sha1 hash of XO public key.

Note that the cookie domain is set to jabber_server hostname so a server in other host won't be able to access.

Here in detail:

def _seed_xs_cookie(cookie_jar):
    """Create a HTTP Cookie to authenticate with the Schoolserver.

    Do nothing if the laptop is not registered with Schoolserver, or
    if the cookie already exists.

    """
    client = GConf.Client.get_default()
    backup_url = client.get_string('/desktop/sugar/backup_url')
    if backup_url == '':
        _logger.debug('seed_xs_cookie: Not registered with Schoolserver')
        return

    jabber_server = client.get_string(
        '/desktop/sugar/collaboration/jabber_server')

    soup_uri = Soup.URI()
    soup_uri.set_scheme('xmpp')
    soup_uri.set_host(jabber_server)
    soup_uri.set_path('/')
    xs_cookie = cookie_jar.get_cookies(soup_uri, for_http=False)
    if xs_cookie is not None:
        _logger.debug('seed_xs_cookie: Cookie exists already')
        return

    pubkey = profile.get_profile().pubkey
    cookie_data = {'color': profile.get_color().to_string(),
                   'pkey_hash': sha1(pubkey).hexdigest()}

    expire = int(time.time()) + 10 * 365 * 24 * 60 * 60

    xs_cookie = Soup.Cookie()
    xs_cookie.set_name('xoid')
    xs_cookie.set_value(json.dumps(cookie_data))
    xs_cookie.set_domain(jabber_server)
    xs_cookie.set_path('/')
    xs_cookie.set_max_age(expire)
    cookie_jar.add_cookie(xs_cookie)
    _logger.debug('seed_xs_cookie: Updated cookie successfully')

Moodle, aid and abet

So we have all the ingredients, here we have the final act.

Moodle has an OLPC-XS authentication plugin.

Moodle auth process reads the public key hash from the cookie data and searchs in its database for a user with her idnumber equals to public key.

But, when was this user created? We are near the end... the function idmgr_sync is our last stage. It reads directly from idmgr database and creates or updates the moodle user.

Next it's some of the code involved.

<?php
...

class auth_plugin_olpcxs extends auth_plugin_base {

        /**
         * Constructor.
         */
        function auth_plugin_olpcxs() {
                $this->authtype = 'olpcxs';
                $this->config = get_config('auth/olpcxs');
        }

        function loginpage_hook() {
        global $CFG;
        global $USER;
        global $SESSION;

        if ( (isguestuser() || !isloggedin())
             && !empty($_COOKIE['xoid'])) {
            // on 1.9 and earlier, $_COOKIE is badly mangled
            // by addslashes_deep() so...
            $xoid = json_decode(stripslashes($_COOKIE['xoid']));

            $user = get_record('user', 'idnumber', addslashes($xoid->pkey_hash));
            if (empty($user)) { // maybe the account is new!
                $this->idmgr_sync(true);
                $user = get_record('user', 'idnumber', addslashes($xoid->pkey_hash));
                if (empty($user)) {
                    // We failed to login the user
                    // even though we saw a cookie.
                    // Probably the client side is confused.
                    // Log the problem, let things continue...
                    trigger_error('auth/olpcxs: user with pkey_hash ' . $xoid->pkey_hash . ' was not found after idmgr_sync()');
                    return true;
                }
            }

            // Check if this account should be aliased to a different one...
            $user_alias = get_record('user_preferences', 'name', 'olpcxs_alias',
                                     'value', "$user->id");
            if ($user_alias) {
                $olduser = $user;
                $user = get_record('user', 'id', $user_alias->userid);
                add_to_log(SITEID, 'user', 'login_alias', "../user/view.php?user={$user->id}", "ids:{$olduser->id} -> {$user->id}");
            }

            //
            // we have the user acct, complete login dance now
            //
            $this->maybe_assign_coursecreator($user);

            $sitectx   = get_context_instance(CONTEXT_SYSTEM);
            $roles = $this->get_user_roles_in_context($user->id, $sitectx);
            if (in_array('coursecreator', $roles)) {
                $this->fixup_roles();
            }

            // icon for the user - we cannot do this in
            // create_update_user() because it's only
            // in the cookie, and not in idmgr. grumble...
            // .. expecting something like "#FF8F00,#00A0FF"
            // rough regex
            if (!empty($xoid->color) && preg_match('/#[A-F0-9]{1,6},#[A-F0-9]{1,6}/', $xoid->color)) {
                $iconpref = get_user_preferences('xoicon', $user->id);
                if (empty($iconpref) || $iconpref !== $xoid->color) {
                    set_user_preference('xoicon',$xoid->color, $user->id);
                }
            }

            // complete login
            $USER = get_complete_user_data('id', $user->id);
            complete_user_login($USER);

            add_to_log(SITEID, 'user', 'login', "../user/view.php?user={$user->id}", 'autologin');

            // redirect
            if (isset($SESSION->wantsurl) and (strpos($SESSION->wantsurl, $CFG->wwwroot) === 0)) {
                $urltogo = $SESSION->wantsurl;    /// Because it's an address in this site
                unset($SESSION->wantsurl);
            } else {
                // no wantsurl stored or external - go to homepage
                $urltogo = $CFG->wwwroot.'/';
                unset($SESSION->wantsurl);
            }
            redirect($urltogo);
        }
        }

  ...

      function idmgr_sync($fast=false) {
          global $CFG;

          if (empty($CFG->olpcxsdb) || !file_exists($CFG->olpcxsdb)) {
              return false;
          }
          $dbh = new PDO('sqlite:' . $CFG->olpcxsdb);

          //
          // new accounts to create...
          //
          $sql = 'SELECT *
                    FROM laptops';
          $tslastrun = get_config('enrol/olpcxs', 'idmgr_sync_ts');
          $tsnow   = time();
          if ($fast && !empty($tslastrun)) {
              $sql .= " WHERE lastmodified >= '"
                  . gmdate('Y-m-d H:i:s', $tslastrun) ."'";
          }

          $rs = $dbh->query($sql);
          foreach ($rs as $idmgruser) {
              $this->create_update_user($idmgruser);
          }

          set_config('idmgr_sync_ts', $tsnow, 'enrol/olpcxs');
          unset($dbh); unset($rs);

          //
          // TODO - consider cleanup account scenario...?
          //
      }

Links

Social