The IoL citizens love to stay connected, but the cellular network is not delivering the coverage they need and data plans are expensive for the tourists.

The local cafe, bed & breakfast has a solution, a WiFi hotspot!

By using the Cisco Meraki access points, they can easily provide a cloud managed wireless LAN but they also want to deliver a bit of branding, terms of service and maybe collect some client details before providing this service. Luckily, Meraki offers an external captive portal API (a.k.a. ExCap) to do just that.

Goal

  • Build a click-through guest splash page to deliver a customized user experience for client on-boarding.
  • Build a sign-on restricted splash page with authentication which could support enhanced access.

Technologies

Meraki Dashboard

First you need to have a Meraki wireless access point. If you are a business and would like to obtain an AP to get a feel for the technology, just watch a webinar for an hour and Meraki will send you one for FREE and include a 3 year license. They can also get you trial gear to test out the full stack of products such as switches and firewalls for your next project.

Once you have a Meraki Dashboard account and have the access point connected to the cloud, its time to setup the guest SSID.

Login to the dashboard and navigate to:
Wireless —> Configure —> SSIDs

There are two major options for a splash page configuration. You can either have a “click-through” splash page or “sign-on” splash page. This post will cover both concepts and build a single NodeJS app to handle either method.

Name your SSIDs. In this example, there is an IoL-click and IoL-signon to support the different requirements.

 

Click-through Splash Page

The Click-through splash page is the most basic option but accomplishes general requirements for providing a guest WiFi hotspot. A guest will simply look for an open wireless network, connect to it and a splash page will be presented. This page will likely have the branding and maybe a form. By clicking the login button, the Meraki network will grant access to the guest and the session data can be stored as needed.

Click-through splash page diagram

Configure SSID

Set Association and Splash options

Dashboard —> Wireless —> Configure —> Access Control —> SSID: IoL-click

 

Configured the walled garden, to provide access to the host server before login has completed. Be sure to block all access until sign-on is complete, or guests can go to non HTTP sites without authenticating.

Configure the splash page options to redirect to a custom URL.

Dashboard —> Wireless —> Configure —> Splash —> SSID: IoL-click

 

Code

Complete source code download 

The following code section stores the session data passed by Meraki and additional information such as time stamps and form data.

When the access point intercepts a guest attempting to open an HTTP page (non-secure site), the AP will redirect the user to the specified splash page. The URL will be that of the host server and the page “/click” as defined in the app.get('/click', function (req, res) statement. Any Meraki defined parameters will be available in the “req” variable and any messages sent back to the client browser will be stored in the “res” variable. Note: There are other bits of code, such as the “express” web server and mongodb-session modules that allow this code to work. The full source code will have this included with explanation.

Example of URL received by client via Meraki access point:

http://myserver:8181/click?base_grant_url=https%3A%2F%2Fn143.network-auth.com%2Fsplash%2Fgrant&user_continue_url=http%3A%2F%2Fask.co.uk%2F&node_id=1301936&node_mac=00:18:0a:13:dd:b0&gateway_id=1301936&client_ip=10.162.50.40&client_mac=FF:FF:96:d5:d5

 

Once the data has been stored, a splash page will be sent to the client browser. By using the Handlebars template system, a dynamic HTML page will be generated with any supporting “session” data needed.

...
// ################################################################
// Click-through Splash Page
// ################################################################


// serving the static click-through HTML file
app.get('/click', function (req, res) {

 // extract parameters (queries) from URL
 // this has been done literaly to illustrate what data is being exchanged
 req.session.host = req.headers.host;
 req.session.base_grant_url = req.query.base_grant_url;
 req.session.user_continue_url = req.query.user_continue_url;
 req.session.node_mac = req.query.node_mac;
 req.session.client_ip = req.query.client_ip;
 req.session.client_mac = req.query.client_mac;
 req.session.splashclick_time = new Date().toString();

 // display session data for debugging purposes
 console.log("Session data at click page = " + util.inspect(req.session, false, null));

 // render login page using handlebars template and send in session data
 res.render('click-through', req.session);

});
...

Here is an example HTML section that includes a form and will post back to the “/login” page for final processing.

<div id="content">
  <div id="container">
    <div class="header">
      <div id="icon_cell">
        <img class="text-center" src="/img/barista-lego.jpg" style="width:40%; margin:10px;">
      </div>
    </div>

    <div id="continue">
      <h1>IoL Cafe</h1>
      <p>Please enjoy our complimentary WiFi and a cup of joe.</p>
      <p>
        Brought to you by <a href="http://www.internetoflego.com" target="blank">InternetOfLego.com</a>
      </p>
      <form action="/login" method="post" class="form col-md-12 center-block">
        <div class="form-group">
          <input class="form-control input-lg" placeholder="Email" type="text" name="form1[email]">
        </div>

        <div class="form-group">
          <button class="btn btn-primary btn-lg btn-block">Sign In</button>
          <span class="pull-left"><a href="#">Terms and Conditions</a></span>

        </div>
      </form>
    </div>
  </div>
  <div class="footer">
    <h3>POWERED BY</h3>
    <img class="text-center" src="/img/cisco-meraki-gray.png" style="width:10%; margin:10px;">
  </div>
</div>

Once the submit button has been pressed, the “/login” action will post the data back to the NodeJS server to store the form data and complete the login to Meraki and redirect the user to their intended URL.

 

...
// handle form submit button and send data to Cisco Meraki - Click-through
app.post('/login', function(req, res){

 // save data from HTML form
 req.session.form = req.body.form1;
 req.session.splashlogin_time = new Date().toString();

 // do something with the session and form data (i.e. console, database, file, etc. )
 // write to console
 console.log("Session data at login page = " + util.inspect(req.session, false, null));


 // forward request onto Cisco Meraki to grant access.
 res.writeHead(302, {'Location': req.session.base_grant_url + "?continue_url="+req.session.user_continue_url});
 res.end();

});
...

 

Guest Experience
Click-through Splash Page

 

Server Console Output

ubuntu@ip-172-31-23-99:~/excap$ forever app.js start
warn: --minUptime not set. Defaulting to: 1000ms
warn: --spinSleepTime not set. Your script will exit if it does not stay up for at least 1000ms
Log path = log/sessions.log
Server listening on port 8181
Session data at click page = { host: '127.0.0.1:8181',
 base_grant_url: 'https://n143.network-auth.com/splash/grant',
 user_continue_url: 'https://n143.network-auth.com/splash/connected?hide_terms=true',
 node_mac: '00:18:0a:13:FF:FF',
 client_ip: '10.162.50.40',
 client_mac: '54:26:96:d5:FF:FF',
 splashclick_time: 'Sat Nov 21 2015 00:47:04 GMT+0000 (UTC)' }
Session data at login page = { host: '127.0.0.1:8181',
 base_grant_url: 'https://n143.network-auth.com/splash/grant',
 user_continue_url: 'https://n143.network-auth.com/splash/connected?hide_terms=true',
 node_mac: '00:18:0a:13:FF:FF',
 client_ip: '10.162.50.40',
 client_mac: '54:26:96:d5:FF:FF',
 splashclick_time: 'Sat Nov 21 2015 00:47:55 GMT+0000 (UTC)',
 _locals: {},
 form: { email: 'legoman@test.com' },
 splashlogin_time: 'Sat Nov 21 2015 00:48:04 GMT+0000 (UTC)' }
null

 

Sign-on Splash Page

The sign-on splash page allows for user authentication, by sending the username and password to a RADIUS server or the built-in Meraki Authentiction system. This provides a better way of controlling access and the user details will be stored in both the Meraki dashboard client details and within the authentication server.

Configure SSID

Set Association and Splash options

Dashboard —> Wireless —> Configure —> Access Control —> SSID: IoL-signon

Configured the walled garden, to provide access to hosted server before login has completed.
Be sure to block all access until sign-on is complete, or guests can go to non HTTP sites without authenticating.


Configure the splash page options to redirect to a custom URL.

Dashboard —> Wireless —> Configure —> Splash —> SSID: IoL-signon

Create a user and authorize the account to access the SSID to test the login with Meraki Authentication.

Dashboard —> Network-wide —> Configure —> Users

Code

Complete source code download 

This code snippet uses the defined session object to store the data passed by Meraki.

When the access point intercepts a guest attempting to open an HTTP page (non-secure site), the AP will redirect the user to the specified splash page. The URL will be that of the host server and the page “/signon” as defined in the “app.get(‘/signon’, function (req, res)” statement. Any Meraki defined parameters will be available in the “req” variable and any messages sent back to the client browser will be stored in the “res” variable.

http://myserver:8181/signon?login_url=https%3A%2F%2Fn143.network-auth.com%2Fsplash%2Flogin%3Fmauth%3DMMLPTNJtQq7STm6B5y6QZFHXI_lDenJ3maBliEapz0lbdgEI70G4YNyz3mVx6vtDTzOY8RfaJTcxlZyasyp6ZY2tkzQB6-VRUMOlrB6f18RL_HvDr08ZDTIRgaD64xqINz1PrtQJQ5lICWCA95FEmTI2CZ3eLB79D6KPymySVJMU4MovzTpHVhscwhxz6xTLJ_jI9yme3-yR0%26continue_url%3Dhttp%253A%252F%252Fwww.ask.com%252F&continue_url=http%3A%2F%2Fwww.ask.com%2F&ap_mac=00%3A18%3A0a%3A13%3Add%3Ab0&ap_name=AP01&ap_tags=cafe&client_mac=54%3A26%3A96%3Ad5%3Ad5%3A47&client_ip=10.162.50.40

 

Note, the parameter names with the sign-on option are slightly different than the click-through names.

This code also defines the “success_url” to point back to the NodeJS server so it can deliver a success page, additional messages/branding/advertisements and a logout button.

...
// ################################################################
// Sign-on Splash Page
// ################################################################

// #######
// signon page
// #######
app.get('/signon', function (req, res) {

 // extract parameters (queries) from URL
 req.session.host = req.headers.host;
 req.session.login_url = req.query.login_url;
 req.session.continue_url = req.query.continue_url;
 req.session.ap_name = req.query.ap_name;
 req.session.ap_tags = req.query.ap_tags;
 req.session.client_ip = req.query.client_ip;
 req.session.client_mac = req.query.client_mac;
 req.session.success_url = 'http://' + req.session.host + "/success";
 req.session.signon_time = new Date();

 // render login page using handlebars template and send in session data
 res.render('sign-on', req.session);

});
...

A dynamic HTML page will again be generated using Handlebars where the session data will be passed to this file. This data will be important because it contains the login_url that is needed in the “post” action for the form submission. By placing the variable in mustache braces, the page will substitue the text with the desired data which is a parameter of the session object in our case.

 

<div id="content">
  <div id="container">
    <div class="header">
      <div id="icon_cell">
        <img class="text-center" src="/img/barista-lego.jpg" style="width:40%; margin:10px;">
      </div>
    </div>

    <div id="continue">
      <h1>IoL Cafe</h1>
      <p>Please enjoy our complimentary WiFi and a cup of joe.</p>
      <p>
        Brought to you by <a href="http://www.internetoflego.com" target="blank">InternetOfLego.com</a>
      </p>
      <form action={{login_url}} method="post" class="form col-md-12 center-block">
        <input type="hidden" name="success_url" value={{success_url}} />
        <div class="form-group">
          <input class="form-control input-lg" type="text" name="username" placeholder="Username or email">
          <i class="icon-user icon-large"></i>
        </div>
        <div class="form-group">
          <input class="form-control input-lg" type="password" name="password" placeholder="Password">
          <i class="icon-lock icon-large"></i>
        </div>

        <div class="form-group">
          <button class="btn btn-primary btn-lg btn-block">Sign In</button>
          <span class="pull-left"><a href="#">Terms and Conditions</a></span>

        </div>
      </form>
    </div>
    <div class="row">
      <div class=class="col-md-4">
        <ul>Client IP: {{client_ip}}</ul>
        <ul>Client MAC: {{client_mac}}</ul>
      </div>
      <div class=class="col-md-4">
        <ul>AP Tags: {{ap_tags}}</ul>
        <ul>AP Name: {{ap_name}}</ul>
        <ul>AP MAC: {{node_mac}}</ul>
      </div>
    </div>
  </div>
  <div class="footer">
    <h3>POWERED BY</h3>
    <img class="text-center" src="/img/cisco-meraki-gray.png" style="width:10%; margin:10px;">
  </div>
</div>

 

Once the form has been submitted to Meraki, Meraki will then use the “success_url” to send the client back to the NodeJS server for final processing. There will be an additional “logout_url” parameter that will be received. This can be used to create a logout button.

...
// #############
// success page
// #############
app.get('/success', function (req, res) {
 // extract parameters (queries) from URL
 req.session.host = req.headers.host;
 req.session.logout_url = req.query.logout_url + "&continue_url=" + 'http://' + req.session.host + "/logout";
 req.session.success_time = new Date();

 // render sucess page using handlebars template and send in session data
 res.render('success', req.session);
});
...

Success HTML page which creates a continue link and a logout link using the session data parameters.

 

<div id="content">
  <div id="container">
    <div class="header">
      <div id="icon_cell">
        <h1>Success!</h1>
        <p>
          Continue on to
          <br>
          <a href={{continue_url}}>{{continue_url}}</a>
        </p>
      </div>
      <div>
          <h1><a href={{logout_url}}>LOGOUT</a></h1>
      </div>
    </div>

    <div id="continue">
      <p>
        Brought to you by <a href="http://www.internetoflego.com" target="blank">InternetOfLego.com</a>
      </p>
      <p>
        <img class="text-center" src="/img/lego-cafe.jpg" style="width:80%; margin:10px;">
      </p>
    </div>
  </div>
  <!-- Small modal -->


  <div class="footer">
    <h3>POWERED BY</h3>
    <img class="text-center" src="/img/cisco-meraki-gray.png" style="width:10%; margin:10px;">
  </div>
</div>

 

Finally, the logout code will submit the logout_url, log the session time and redirect the user to a logged out page.

...
// #############
// logged-out page
// #############
app.get('/logout', function (req, res) {
 // determine session duration
 req.session.loggedout_time = new Date();
 req.session.duration = {};
 req.session.duration.ms = Math.abs(req.session.loggedout_time - req.session.success_time); // total milliseconds
 req.session.duration.sec = Math.floor((req.session.duration.ms/1000) % 60);
 req.session.duration.min = (req.session.duration.ms/1000/60) << 0;

 // extract parameters (queries) from URL
 req.session.host = req.headers.host;
 req.session.logout_url = req.query.logout_url + "?continue_url=" + 'http://' + req.session.host + "/logged-out";

 // render sucess page using handlebars template and send in session data
 res.render('logged-out', req.session);
});
...

The HTML logged-out page.

 

<div id="content">
  <div id="container">
    <div class="header">
      <div id="icon_cell">
        <h1>Logged Out!</h1>
        <p>
          Total session duration: {{duration.min}} minutes {{duration.sec}} seconds
        </p>
      </div>
    </div>

    <div id="continue">
      <p>
        Brought to you by <a href="http://www.internetoflego.com" target="blank">InternetOfLego.com</a>
      </p>
      <p>
        <img class="text-center" src="/img/lego-cafe-building.jpg" style="width:80%; margin:10px;">
      </p>
    </div>
  </div>
  <div class="footer">
    <h3>POWERED BY</h3>
    <img class="text-center" src="/img/cisco-meraki-gray.png" style="width:10%; margin:10px;">
  </div>
</div>

Server Console Output

Session data at signon page = { host: '127.0.0.1:8181',
 base_grant_url: 'https://n143.network-auth.com/splash/grant',
 user_continue_url: 'https://n143.network-auth.com/splash/connected?hide_terms=true',
 node_mac: '00:18:0a:13:FF:FF',
 client_ip: '10.162.50.40',
 client_mac: '54:26:96:d5:FF:FF',
 splashclick_time: 'Sat Nov 21 2015 01:01:06 GMT+0000 (UTC)',
 _locals: {},
 form: { email: 'legoman@test.com' },
 splashlogin_time: 'Sat Nov 21 2015 00:48:04 GMT+0000 (UTC)',
 login_url: 'https://n143.network-auth.com/splash/login?mauth=MM0rQDhGLvqKQ_Ir6R3Qa1B1c_yVDMAfo56_frbAwof5u0o6FkX6ci5R07dBCSknLfHAW90aMr2HRPgwEpXSIdCoLNm8x7NUpjhTGzYRDra0jh6_Z_ZHyUSY4SBLTdQrrOD0pZRI7JJb4SQTmUOsBVAtoQHvRAxTMzpJNemyPZOokYS8b5MBx6THAxnzqvft16WWxqcDGR1U0&continue_url=http%3A%2F%2Fwww.ask.com%2F',
 continue_url: 'http://www.ask.com/',
 ap_name: 'AP01',
 ap_tags: 'cafe',
 success_url: 'http://127.0.0.1:8181/success',
 signon_time: Sat Nov 21 2015 01:06:45 GMT+0000 (UTC) }
null
Session data at success page = { host: '127.0.0.1:8181',
 base_grant_url: 'https://n143.network-auth.com/splash/grant',
 user_continue_url: 'https://n143.network-auth.com/splash/connected?hide_terms=true',
 node_mac: '00:18:0a:13:FF:FF',
 client_ip: '10.162.50.40',
 client_mac: '54:26:96:d5:FF:FF',
 splashclick_time: 'Sat Nov 21 2015 01:01:06 GMT+0000 (UTC)',
 _locals: {},
 form: { email: 'legoman@test.com' },
 splashlogin_time: 'Sat Nov 21 2015 00:48:04 GMT+0000 (UTC)',
 login_url: 'https://n143.network-auth.com/splash/login?mauth=MM0rQDhGLvqKQ_Ir6R3Qa1B1c_yVDMAfo56_frbAwof5u0o6FkX6ci5R07dBCSknLfHAW90aMr2HRPgwEpXSIdCoLNm8x7NUpjhTGzYRDra0jh6_Z_ZHyUSY4SBLTdQrrOD0pZRI7JJb4SQTmUOsBVAtoQHvRAxTMzpJNemyPZOokYS8b5MBx6THAxnzqvft16WWxqcDGR1U0&continue_url=http%3A%2F%2Fwww.ask.com%2F',
 continue_url: 'http://www.ask.com/',
 ap_name: 'AP01',
 ap_tags: 'cafe',
 success_url: 'http://127.0.0.1:8181/success',
 signon_time: Sat Nov 21 2015 01:06:45 GMT+0000 (UTC),
 logout_url: 'https://n143.network-auth.com/splash/logout?key=MMoW1TsVFXFGSeZiWtDLkYcSgk5E030DRiy-fTs45kWUzV5pF2ZifT65jutDRYW1Ll7owBJW-JyRg&continue_url=http://myserver:8181/logged-out',
 success_time: Sat Nov 21 2015 01:07:52 GMT+0000 (UTC) }
null

 

Guest Experience

excap-signon-splash

 

excap-signon-success

Screen Shot 2015-12-02 at 5.26.49 PM

 

Success!

Final Thoughts

Wow, That was a serious project! What started out as a blog idea turned into a full scale web application. I learned a tremendous amount and could easily turn this into a production application! The complete source code allows you to return all the data by going to http://yourserver:8181/excapData/excap which dumps a JSON output which could be imported into a reporting tool. There are still some security concerns around that, but this was just a PoC 😉

Node-RED

I have also created a Node-RED version, which I may create a separate blog post for. It is stateless, in that no database is used for session data. I simply pass all parameters in the various GET/POST responses. I also included custom terms of service and extra error handling in that version.

http://flows.nodered.org/flow/e80275ccd499c2edaf43

Download the Complete Source Code

Github

 

Please leave a comment and share future ideas! I would love to see people contribute to this github project. There is a ton of cool features that could be built into this, such as social web authentication, advertising, reporting and an admin console.