Captive Portal via Facebook Login, Voucher, and/or User login



  • facebook_login.zip
    Re: OAuth 2.0 and Captive Portal

    This can probably be modified to implement other OAuth 2.0 (haven't researched any but Facebook as yet), but here is a modified version of https://github.com/marcoiai/webdevelopment/tree/master/pfSense that leaves the ability to use other authentication sources (ie local/radius, the original required turning off all authentication backends).

    This is accomplished by grabbing a valid voucher code from a roll # [VOUCHER_ROLL] upon successful postback after Facebook auth (I've set this one to require an email address) and then authenticating to pfSense CP using that voucher code. CP writes an entry into CP log to let you associate user to voucher (a requirement we have as an ISP for DMCA, etc).

    The only thing really 'innovative' in this is the function to grab a valid voucher, which is a mashup & modification of how the pfSense interface allows list download and tests codes.

    To keep it simple, I tried to leave all necessary customization at the top.
    Zip file contains login page and Facebook-required Graph SDK files (from getcomposer.org) as of ~ 2019-06-02

    <?php
    // ---------------------------------------------------------------------------------------------------
    //  Constants  -- Customize as needed
    // ---------------------------------------------------------------------------------------------------
    const IS_DEBUG 		= false;		// Debug mode?
    const VOUCHER_ROLL	= 65534;		// Captive portal voucher roll #. For best results, use dedicated roll.
    //
    // Facebook API Info. Create an "App" at https://developers.facebook.com/ ...
    // add the "Facebook Login" product to it and mark it live
    // be sure to populate "Valid OAuth Redirect URIs" under Facebook Login/Client OAuth settings,
    // most likely it will be something like https://[yourhostname]:8003/index.php?zone=guests
    //
    const FB_APPID 		= 'xxx';
    const FB_APPSECRET	= 'xxxx';
    const FB_GRAPHVER	= 'v3.3';		// As of this writing, v3.3 is preferred
    const FB_POSTBACK	= ''; 			// OAuth Postback base URI. Should match OAuth Redirect URI, or auto-generates (see $tmpUrl below) if blank.
    //
    // ---------------------------------------------------------------------------------------------------
    session_start();  // Facebook requires maybe?
    //
    // Requires Facebook GraphAPI SDK... might be simplest to use getcomposer.org on a desktop ...
    // and then copy files with SFTP to /usr/local/captiveportal/vendor/ on pfSense device.
    require_once '/usr/local/captiveportal/vendor/autoload.php';  
    //
    // ---------------------------------------------------------------------------------------------------
    global $g,$config;	// Bring in some pfSense globals
    $voucher_roll=VOUCHER_ROLL;
    $dbg=IS_DEBUG;
    $cpzone = strtolower(htmlspecialchars($_REQUEST['zone']));
    $cpcfg = $config['captiveportal'][$cpzone];
    $cpzoneid = $cpcfg['zoneid'];
    $clientip = $_SERVER['REMOTE_ADDR'];
    $cpsession = captiveportal_isip_logged($clientip);
    $ourhostname = portal_hostname_from_client_ip($clientip);
    $protocol = (isset($config['captiveportal'][$cpzone]['httpslogin'])) ? 'https://' : 'http://';
    $tmpUrl = (FB_POSTBACK == '') ? "{$protocol}{$ourhostname}/index.php?zone={$cpzone}" : FB_POSTBACK;
    if (!isset($cpcfg['nomacfilter']) || isset($cpcfg['passthrumacadd'])) {$tmpres = pfSense_ip_to_mac($clientip);$clientmac = $tmpres['macaddr'];unset($tmpres);}
    // ---------------------------------------------------------------------------------------------------
    //  Custom function(s)
    // ---------------------------------------------------------------------------------------------------
    function get_voucher_code($roll) {
    	// Reverse engineered from voucher.inc function voucher_auth() 
    	// and the 'csv' export from services_captiveportal_vouchers.php
    	global $g, $config, $cpzone;
    	$dbg=IS_DEBUG;
    	if ($dbg): syslog(LOG_DEBUG,"get_voucher_code called for roll#{$roll}"); endif;
    	$privkey = base64_decode($config['voucher'][$cpzone]['privatekey']);
    	if (stristr($privkey, "begin rsa private key")) {
    		$fd = fopen("{$g['varetc_path']}/voucher_{$cpzone}.private", "w");
    		if ($fd) {
    			chmod("{$g['varetc_path']}/voucher_{$cpzone}.private", 0600);
    			fwrite($fd, $privkey);
    			fclose($fd);
    			if ($dbg): syslog(LOG_DEBUG,"Found private key, wrote to {$g['varetc_path']}/voucher_{$cpzone}.private"); endif;
    			$a_voucher = $config['voucher'][$cpzone]['roll'];
    			$id = array_search($roll, array_column($a_voucher, 'number'));
    			if (isset($id) && $a_voucher[$id]) {
    				$count = $a_voucher[$id]['count'];
    				$minutes = $a_voucher[$id]['minutes'];
    				if ($dbg): syslog(LOG_DEBUG,"Roll# {$roll} is RollID {$id}, Count {$count}, Minutes {$minutes}"); endif;
    				if (file_exists("{$g['varetc_path']}/voucher_{$cpzone}.cfg")) {
    					$active_vouchers = voucher_read_active_db($roll);
    					$active_vouchers_cnt = count($active_vouchers);
    					if ($dbg): syslog(LOG_DEBUG,"Roll# {$roll} has {$active_vouchers_cnt} active vouchers."); endif;
    					$bitstring = voucher_read_used_db($roll);
    					exec("/usr/local/bin/voucher -c {$g['varetc_path']}/voucher_{$cpzone}.cfg -p {$g['varetc_path']}/voucher_{$cpzone}.private $roll $count",$vcodes);
    					foreach ($vcodes as $voucher) {
    						if (substr($voucher,0,1)=="\"") { 
    							$voucher = preg_replace("/[^a-zA-Z0-9]/", "", $voucher);
    							if (!empty($active_vouchers[$voucher])) {
    								if ($dbg) {
    									list($timestamp, $minutes) = explode(",", $active_vouchers[$voucher]);
    									$remaining = intval((($timestamp + (60*$minutes)) - time())/60);
    									syslog(LOG_DEBUG,sprintf(gettext('%1$s (%2$s) active and good for %3$d Minutes'), $voucher, $roll, $remaining));
    								}
    							} else {
    								$v = escapeshellarg($voucher);
    								$result = exec("/usr/local/bin/voucher -c {$g['varetc_path']}/voucher_{$cpzone}.cfg -k {$g['varetc_path']}/voucher_{$cpzone}.public -- $v");
    								list($status, $rollchk, $nr) = explode(" ", $result);
    								$pos = $nr >> 3;
    								$mask = 1 << ($nr % 8);
    								if (($status == "OK" && $rollchk=$roll) && ($count && ($nr <= $count)) && (!(ord($bitstring[$pos]) & $mask))) {
    									if ($dbg): syslog(LOG_DEBUG,"Voucher {$voucher} is believed good and unused"); endif;
    									@unlink("{$g['varetc_path']}/voucher_{$cpzone}.private");
    									return $voucher;
    								}
    							}
    						}
    					}
    				} 
    			}
    		}
    	} 
    }
    
    // ---------------------------------------------------------------------------------------------------
    //  Facebook/Graph (modified from https://github.com/marcoiai/webdevelopment/tree/master/pfSense)
    // ---------------------------------------------------------------------------------------------------
    //
    try {
    	$fb = new \Facebook\Facebook([
    		'app_id' => FB_APPID,
    		'app_secret' => FB_APPSECRET,
    		'default_graph_version' => FB_GRAPHVER,
    		'persistent_data_handler'=>'session'
    	]);
    	$helper = $fb->getRedirectLoginHelper();
    	if (isset($_GET['state'])) {$helper->getPersistentDataHandler()->set('state', $_GET['state']);}
        $permissions = ['email']; // Optional permissions (ask Facebook for user's email address)
    	$loginUrl = $helper->getLoginUrl($tmpUrl, $permissions);
    } catch (\Exception $e) {
    	$errmsg = $e->getMessage();
    	if ($dbg): syslog(LOG_DEBUG,"Facebook init error: {$errmsg}"); endif;
    }
    $user = array ('email');	// Probably to avoid a NULL reference in original version, as it used that for auth_user.  Too lazy to test.
    try {
    	$accessToken = $helper->getAccessToken();
    	if (!empty($accessToken)) {
    		// get logged user info
            $response = $fb->get('/me?fields=id,name,email,first_name,middle_name,last_name', $accessToken);
            $user = $response->getGraphUser();
    		$voucher = ($user['email']=='') ? NULL : get_voucher_code($voucher_roll);
    		// Log PII for compliance. (DMCA, CALEA requirements for ISP)
    		captiveportal_syslog("AUTH.Facebook,{$clientmac},{$clientip},{$user['last_name']},{$user['first_name']},{$user['middle_name']},{$user['email']},{$user['id']},{$voucher}");
    	} 
    } catch(Facebook\Exceptions\FacebookResponseException $e) {
    	$errmsg = $e->getMessage();
    	if ($dbg): syslog(LOG_DEBUG,"GraphAPI returned an error: {$errmsg}"); endif;
        //exit;
    } catch(Facebook\Exceptions\FacebookSDKException $e) {
    	$errmsg = $e->getMessage();
    	if ($dbg): syslog(LOG_DEBUG,"FacebookSDK returned an error: {$errmsg}"); endif;
    }
    catch (Exception $e)
    {
    	$errmsg = $e->getMessage();
    	if ($dbg): syslog(LOG_DEBUG,"Exception: {$errmsg}"); endif;
    }
    
    // ---------------------------------------------------------------------------------------------------
    // Start rendering the html output
    // ---------------------------------------------------------------------------------------------------
    header("Expires: 0");
    header("Cache-Control: no-cache, no-store, must-revalidate");
    header("Pragma: no-cache");
    header("Connection: close");
    ?>
    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="UTF-8" />
            <meta name="viewport" content="width=480" />
            <meta name="MobileOptimized" content="480" />
    		<title>Guest Portal</title>
    		<link href="captiveportal-bootstrap.min.css" rel="stylesheet" />
        </head>
        <body>
    	    <div class="container">
    			<div class="row">
    				<div class="col-xs-12">
    					<img class="img img-responsive img-rounded center-block" src="captiveportal-logo-<?php echo($cpzone); ?>.png" />
    				</div>
    			</div>
    			<div class="row">
    				<div class="col-xs-12">
    					<p class="h3">
    						Select your authentication method below.
    					</p>
    				</div>
    			</div>
    
    			<div class="row">
    				<div class="col-xs-12">
    					<div class="well well-sm">
    						<p class="h3">
    							Login via social network
    						</p>
    						<span class="center-block"><?php echo '<a href="' . htmlspecialchars($loginUrl) . '"><img src="captiveportal-facebook-login.png" /></a>'; ?></span>
    					</div>
    				</div>
    			</div>
    			<div class="row">
    				<div class="col-md-12">
    					<div class="well well-sm">
    						<form id="loginForm" name="loginForm" method="post" action="$PORTAL_ACTION$">
    							<input name="zone" type="hidden" value="$PORTAL_ZONE$" />
    							<input name="redirurl" type="hidden" value="$PORTAL_REDIRURL$" />
    							<div class="form-group">
    								<label class="control-label" for="form-group-user">Username</label>
    								<input name="auth_user" type="text" class="form-control" id="form-group-user" placeholder="Enter your username..." />
    								<label class="control-label" for="form-group-pass">Password</label>
    								<input name="auth_pass" type="password" class="form-control" id="form-group-pass" placeholder="Enter your password..." />
    							</div>
    							<div class="form-group">
    								<label class="control-label" for="form-group-vouch">Voucher code</label>
    								<input name="auth_voucher" type="text" autocomplete="off" class="form-control" id="form-group-vouch" <?php if (empty($voucher)) : ?> placeholder="Enter your voucher code..." /> <?php else: ?> value="<?php echo ($voucher); ?>"<?php endif; ?>
    							</div>
    	                        <input <?php if (!empty($voucher)) : ?>style="display: none" <?php endif; ?> id="submitbtn" name="accept" type="submit" value="Continue" class="btn btn-lg btn-success" />
    						</form>
    					</div>
    				</div>
    			</div>
    		</div>
            <?php if (!empty($voucher)) : ?><script type="text/javascript">document.getElementById("submitbtn").click();</script><?php endif; ?>
        </body>
    </html>
    
    

  • Rebel Alliance

    Yeah...but your code does require to set up facebook.com as allowed hostname in order to work correctly (unless i am understanding this wrong?)

    As far as i know is the main reason why google/facebook auth are not implemented in captive portals (all of them, not specifically pfSense's one) : because you need to allow google / facebook in order to allow access to "sign in with google" / "connect with facebook" buttons.



  • @free4 I believe you can use LDAP with Google auth, but yes, Facebook is a gigantic pain. Not much I can do to fix them, this was just a requirement for us as an ISP to allow our customers to offer free WiFi.

    You might be able use pass-thru credits to reduce exposure, although I think that would be a whole other can of worms.


Log in to reply