Skip to content

Instantly share code, notes, and snippets.

@blindman2k
Last active August 29, 2015 14:02
Show Gist options
  • Save blindman2k/8f613fb8149e185cc26d to your computer and use it in GitHub Desktop.
Save blindman2k/8f613fb8149e185cc26d to your computer and use it in GitHub Desktop.
These files are to support the blog post on Firebase security at https://community.electricimp.com/blog/firebase-security/
<!DOCTYPE html>
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Create or test Firebase tokens - by katowulf</title>
<script type="text/javascript" src="https:////cdnjs.cloudflare.com/ajax/libs/jquery/2.0.2/jquery.min.js"></script>
<script type="text/javascript" src="https://static.firebase.com/v0/firebase.js"></script>
<script type="text/javascript" src="https://cdn.firebase.com/v0/firebase-token-generator.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.4.0/moment.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/Base64/0.2.0/base64.min.js"></script>
<style type="text/css">
label, button {
display: block;
margin-top: 8px;
}
body > fieldset {
float: left;
min-width: 250px;
margin: 10px;
}
fieldset.collapsible {
overflow: hidden;
position: relative;
border: none;
padding-left: 0;
padding-right: 0;
margin: 0;
}
fieldset.collapsible legend {
text-align: right;
position: absolute;
bottom: 0px;
right: 20px;
}
fieldset.collapsible legend a:after {
content: "+";
}
pre {
padding: 10px;
font-size: 16px;
border-radius: 15px;
background-color: #fafafa;
border: 1px solid #999;
max-width: 95%;
word-wrap: break-word;
white-space: pre-wrap;
word-break: break-all;
}
br {
clear: both;
}
.error {
color: red;
}
</style>
<script type="text/javascript">//<![CDATA[
$(window).load(function(){
$('#createToken').on('submit', createToken);
$('#testToken').on('submit', testToken);
$('#data').on('keyup change', validateJson);
$('#id').on('keyup change', setJsonId);
$('#createToken')
.on('submit', createToken)
.find('input,textarea')
.on('keyup change', setCreateButton)
.on('keyup change', logCreateFormat);
$('#testToken')
.on('submit', testToken)
.find('input,textarea')
.on('keyup change', setTestButton);
$('fieldset.collapsible legend a').click(function(e) {
e.preventDefault();
toggleMoreInfo($(this).closest('fieldset'));
});
toggleMoreInfo($('fieldset.collapsible'));
setCreateButton();
setTestButton();
setJsonId();
logCreateFormat();
function setCreateButton() {
var b = !$('#secret').val();
$('#createToken').find('button').prop('disabled', b);
}
function setTestButton() {
var b = !$('#instance').val() || !$('#token').val();
$('#testToken').find('button').prop('disabled', b);
}
function setJsonId() {
var id = $('#id').val();
var out = $('#data').val();
try {
var json = JSON.parse(out || '{}');
json.id = id;
out = JSON.stringify(json, null, 2);
} catch (e) {
logErr(e);
}
$('#data').val(out);
}
function validateJson() {
log('');
var data = parseData();
if( data !== false ) {
logCreateFormat();
}
}
function createToken(e) {
e.preventDefault();
var secret = $('#secret').val();
var data = parseData();
if( data !== false ) {
var props = getAdminProps();
var tokGen = new FirebaseTokenGenerator(secret);
var token = tokGen.createToken(data, props);
log(token);
$('#token').val(token);
selectLog();
}
return false;
}
function getAdminProps() {
var exp = parseDate($('#expires').val());
var notBefore = parseDate($('#notBefore').val());
var admin = $('#admin').prop('checked');
var debug = $('#debug').prop('checked');
var out = {};
out.expires = exp||0;
if( notBefore ) out.notBefore = notBefore;
if( admin ) out.admin = admin;
if( debug ) out.debug = debug;
return out;
}
function logCreateFormat() {
var secret = $('#secret').val();
var data = parseData();
if( data === false ) return;
log("new FirebaseTokenGenerator("
+(secret? "'"+secret+"'" : '<ENTER SECRET>')
+")\n.createToken("
+ JSON.stringify(parseData()||{}, null, 2)
+ ", "
+ JSON.stringify(getAdminProps(), null, 2)
+ ')');
}
function parseData() {
try {
return JSON.parse($('#data').val() || '{}');
}
catch(e) {
logErr(e);
return false;
}
}
function testToken(e) {
e.preventDefault();
log('');
var fb = new Firebase('https://' + $('#instance').val() + '.firebaseio.com');
var tok = $('#token').val();
var dat = decodeURIComponent(escape(window.atob(tok.split('.')[1])));
fb.auth(tok, function (err) {
if (err) {
logErr(err + "\n\n" + dat);
} else {
log("Authenticated!\n\n" + dat);
}
});
return false;
}
function log(txt) {
$('pre').removeClass('error').text(txt);
}
function logErr(e) {
$('pre').addClass('error').text(e);
}
function parseDate(v) {
if( !v || v === '0' ) { return 0; }
var m, matches = (v || '').match(/^([+-])(\d+) (\w+)$/);
if (matches) {
m = moment().add((matches[1] === '-'? -1 : 1)*parseInt(matches[2], 10), matches[3]);
} else {
m = moment(v);
}
return m.isValid() ? m.unix() : 0;
}
function selectLog() {
function selectElementContents(el) {
var range = document.createRange();
range.selectNodeContents(el);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
var el = document.getElementById("log");
selectElementContents(el);
}
function toggleMoreInfo($fs) {
if( !$fs.data('origHeight') ) {
$fs.data('origHeight', $fs.height());
$fs.data('minHeight', $fs.find('label').outerHeight() + $fs.find('input').outerHeight()+5);
}
$fs.toggleClass('active');
var h = $fs.data($fs.hasClass('active')? 'origHeight' : 'minHeight');
$fs.animate({height: h});
}
});//]]>
</script>
<body>
<h2>Token Generator</h2>
<p>Create Firebase tokens for use in testing, REST URLs, or just for admiring their beauty. Test them against your Firebase instance.</p>
<fieldset>
<legend>Create a Token</legend>
<form id="createToken">
<label for="secret">secret:</label>
<input type="text" id="secret">
<label for="id">id:</label>
<input type="text" id="id" value="agent">
<label for="data">json data:</label>
<textarea id="data" cols="50" rows="6"></textarea>
<fieldset class="collapsible" style="height: 42px;">
<label for="expires">expires:</label>
<input type="text" id="expires" value="+30 years" placeholder="+30 years">
<label for="notBefore">notBefore:</label>
<input type="text" id="notBefore" value="" placeholder="+0 minutes">
</fieldset>
<button type="submit" disabled="">create token</button>
</form>
</fieldset>
<fieldset>
<legend>Read/try a Token</legend>
<form id="testToken">
<label>https://
<input type="text" id="instance" placeholder="instance">.firebaseio.com</label>
<label for="token">token:</label>
<textarea id="token" cols="50" rows="6"></textarea>
<button type="submit" disabled="">test token</button>
</form>
</fieldset>
<br>
<pre id="log">new FirebaseTokenGenerator(&lt;ENTER SECRET&gt;)
.createToken({
"id": "agent"
}, {
"expires": 2400000000
})</pre>
<br>
<div>You can put any value into the json data you want and it will appear as part of the auth variable in security. The following administrative options are submitted as a second argument (try clicking "more options" and adding one):
<ul>
<li><strong>expires</strong>: A timestamp (as number of seconds since epoch) denoting the time after which this token should no longer be valid. (0 means forever)</li>
<li><strong>notBefore</strong>: A timestamp (as number of seconds since epoch) denoting the time before which this token should be rejected by the server.</li>
<li><strong>admin</strong>: Set to true if you want to disable all security rules for this client.</li>
<li><strong>debug</strong>: Set to true to enable debug output from your Security Rules.</li>
</ul>
</div>
</body></html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>How to use Firebase to secure your Electric Imp application</title>
</head>
<body>
<div class="container">
<h1><a href="#">Using Firebase to secure your Electric Imp application</a></h1>
<h3>Sign In</h3>
<p class="lead">Use your twitter account to access Firebase</p>
<div class="row">
<button class="btn btn-large" id="login" href="#" onclick="login('twitter', {rememberMe: true});">Login</button>
<button class="btn btn-info" id="logout" href="#" onclick="logout();">Logout</button>
</div>
<div class="row-fluid">
<div class="span 12">
<h3>Devices</h3>
<ul id="device_list" class="nav nav-list">
<li class="nav-header">Devices</li>
<li class="active"><a href="#">No devices yet</a></li>
<li class='list-group-item'>No devices yet ....</li>
</ul>
</div>
</div>
<br>
<div class="row-fluid">
<div class="span 12">
<button type="button" class="btn btn-info" id="blinkup" onclick="blinkup();">Simulate BlinkUp</button>
</div>
</div>
</div>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.0/jquery.js"></script>
<script type="text/javascript" src='https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.1.1/js/bootstrap.min.js'></script>
<script type="text/javascript" src="https://cdn.firebase.com/v0/firebase.js"></script>
<script type='text/javascript' src='https://cdn.firebase.com/js/simple-login/1.3.0/firebase-simple-login.js'></script>
<link href='https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.1.1/css/bootstrap.min.css' rel='stylesheet' type='text/css' />
<link href='https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.1.1/css/bootstrap-theme.min.css' rel='stylesheet' type='text/css' />
<script type="text/javascript">
//------------------------------------------------------------------------------------------------------------------------------
var auth = null;
var fbauth = null;
var handlers = [];
const FB_URL = "https://your-firebase.firebaseio.com";
//------------------------------------------------------------------------------------------------------------------------------
function bootstrap() {
// Authorise and update when it changes
var fb = new Firebase(FB_URL);
fbauth = new FirebaseSimpleLogin(fb, function(error, _auth) {
if (error) {
// an error occurred while attempting login
alert(error);
} else if (_auth) {
// auth authenticated with Firebase (Twitter, Google, etc)
unbind();
auth = _auth;
draw(auth);
} else {
// auth is logged out
auth = null;
$('#device_list').empty();
$('#device_list').append("<li class='list-group-item'>No devices yet</li>");
}
if (auth == null) {
$("#login").show();
$("#logout").hide();
$('#blinkup').hide();
} else {
$("#login").hide();
$("#logout").show();
$('#blinkup').show();
}
});
}
//------------------------------------------------------------------------------------------------------------------------------
function login(provider, extra) {
fbauth.login(provider, extra);
}
//------------------------------------------------------------------------------------------------------------------------------
function logout() {
unbind();
fbauth.logout();
auth = null;
}
//------------------------------------------------------------------------------------------------------------------------------
function unbind() {
// Log out so we can log in again with a different provider.
for (var i = 0; i < handlers.length; i++) {
handlers[i].off()
}
handlers = [];
}
//------------------------------------------------------------------------------------------------------------------------------
function blinkup() {
if (!auth) return;
var deviceid = prompt("Enter the device_id", "");
if (!deviceid) return;
var agenturl = prompt("Enter the agent URL", "https://agents.electricimp.com/.......");
if (!agenturl) return;
$.ajax({
url : agenturl + "/blinkup",
headers : { "X-Firebase-UID" : auth.uid},
statusCode: {
200 : function() {
var fb = new Firebase(FB_URL + "/users/" + auth.uid + "/devices/" + deviceid);
fb.set(true);
draw(auth);
},
202 : function() {
alert('You will need to reboot the device to take ownership.')
},
401 : function() {
alert('Invalid authenticated was supplied in the headers.')
},
403 : function() {
alert('The device is locked by the owner.')
},
500 : function(err) {
console.log(err)
alert('Blinkup failed');
}
}
});
}
//------------------------------------------------------------------------------------------------------------------------------
function draw(auth) {
if (!auth) return;
$("#logout").html("Logged in to " + auth.provider + " as: " + auth.displayName);
// Store the data that came from the provider
var fb = new Firebase(FB_URL + "/users/" + auth.uid);
fb.off();
var userdata = {provider:auth.provider};
if ("displayName" in auth) userdata.displayName = auth.displayName;
if ("email" in auth) userdata.email = auth.email;
fb.update(userdata);
// Draw the devices
var fb = new Firebase(FB_URL + "/users/" + auth.uid + "/devices");
handlers.push(fb);
fb.off();
fb.on('value', function (snapshot) {
$('#device_list').empty();
var devicelist = snapshot.val();
if (devicelist != null) {
for (var deviceid in devicelist) {
draw_device(deviceid);
}
}
if ($('#device_list li').length == 0) {
$('#device_list').append("<li class='list-group-item'>No devices yet</li>");
}
})
}
//------------------------------------------------------------------------------------------------------------------------------
function draw_device(deviceid) {
if (!auth) return;
var onoff = " <button id='c_" + deviceid + "' class='btn btn-success pull-right' >command</button> ";
var lock = " <button id='l_" + deviceid + "' onclick=\"toggle_lock('" + deviceid + "');\" class='btn btn-success pull-right'>not yours</button> ";
var data = " <span id='d_" + deviceid + "'></span> ";
$('#device_list').append("<li class='list-group-item'>" + deviceid + " " + onoff + lock + "<br/>" + data + "</li>");
var fb = new Firebase(FB_URL + "/devices/" + deviceid + "/owner");
handlers.push(fb);
fb.on('value', function (owner) {
// Are we the owner? If so we can see more things than if we were not
if (owner.val() == auth.uid) {
// Draw the device lock status
var fb = new Firebase(FB_URL + "/devices/" + deviceid + "/lock");
handlers.push(fb);
fb.off();
fb.on('value', function (snapshot) {
var lock = snapshot.val();
if (lock == "" || lock == null) lock = "closed";
$('#l_'+deviceid).html(lock);
}, function() {
$('#l_'+deviceid).html("not yours");
});
// We can see the state even if we are not the owner
var fb = new Firebase(FB_URL + "/devices/" + deviceid + "/last_boot");
handlers.push(fb);
fb.off();
fb.on('value', function (snapshot) {
// Draw the device state
var last_boot = snapshot.val();
var message = "";
if (last_boot == null) {
message = "Last boot time: unknown";
} else {
message = "Last boot time: " + last_boot;
}
$('#d_'+deviceid).html("<i>" + message + "</i>");
// Update the button
$('#c_'+deviceid).off('click').on('click', function() {
command(deviceid);
});
// Visually indicate that something has happened
$('#d_' + deviceid).css('color', 'green');
setTimeout(function() {
if ($('#d_' + deviceid).css('color') == 'rgb(0, 128, 0)') $('#d_' + deviceid).css('color', 'black');
}, 2000)
}, function(err) {
$('#d_'+deviceid).html("<i>Unavailable</i>");
});
} else {
$('#l_'+deviceid).html("not yours");
$('#c_'+deviceid).html("not yours");
}
});
}
//------------------------------------------------------------------------------------------------------------------------------
function toggle_lock(deviceid) {
if (!auth) return;
var fb = new Firebase(FB_URL + "/devices/" + deviceid + "/lock");
fb.transaction(function(lock) {
if (lock == null || lock == "" || lock == "closed") return "open";
else return "closed";
})
}
//------------------------------------------------------------------------------------------------------------------------------
function command(deviceid) {
if (!auth) return;
var ts = new Date().getTime();
var data = { command : "Web stuff", ts : ts, auth : auth.uid, ".priority" : ts };
var fb = new Firebase(FB_URL + "/devices/" + deviceid + "/commands");
fb.push(data, function (err) {
if (err) return alert("You don't have write access to this device.");
// Visually indicate that something has happened
$('#d_' + deviceid).css('color', 'red');
setTimeout(function() {
if ($('#d_' + deviceid).css('color') == 'rgb(255, 0, 0)') $('#d_' + deviceid).css('color', 'black');
}, 2000)
});
}
//------------------------------------------------------------------------------------------------------------------------------
bootstrap();
</script>
</body>
</html>
/*
* Security Demo - Agent code
* This is designed to demonstrate the use of Firebase as a security layer for
* protecting Electric Imp agents.
*
*/
// -----------------------------------------------------------------------------
class Firebase {
// General
db = null; // the name of your firebase
auth = null; // Auth key (if auth is enabled)
baseUrl = null; // Firebase base url
prefixUrl = ""; // Prefix added to all url paths (after the baseUrl and before the Path)
// For REST calls:
defaultHeaders = { "Content-Type": "application/json" };
// For Streaming:
streamingHeaders = { "accept": "text/event-stream" };
streamingRequest = null; // The request object of the streaming request
data = null; // Current snapshot of what we're streaming
callbacks = null; // List of callbacks for streaming request
keepAliveTimer = null; // Wakeup timer that watches for a dead Firebase socket
/***************************************************************************
* Constructor
* Returns: FirebaseStream object
* Parameters:
* baseURL - the base URL to your Firebase (https://username.firebaseio.com)
* auth - the auth token for your Firebase
**************************************************************************/
constructor(_db, _auth) {
const KEEP_ALIVE = 120;
db = _db;
baseUrl = "https://" + db + ".firebaseio.com";
auth = _auth;
data = {};
callbacks = {};
}
/***************************************************************************
* Attempts to open a stream
* Returns:
* false - if a stream is already open
* true - otherwise
* Parameters:
* path - the path of the node we're listending to (without .json)
* autoReconnect - set to false to close stream after first timeout
* onError - custom error handler for streaming API
**************************************************************************/
function stream(path = "", autoReconnect = true, onError = null) {
// if we already have a stream open, don't open a new one
if (isStreaming()) return false;
if (onError == null) onError = _defaultErrorHandler.bindenv(this);
streamingRequest = http.get(_buildUrl(path), streamingHeaders);
streamingRequest.sendasync(
function(resp) {
streamingRequest = null;
if (resp.statuscode == 307) {
if("location" in resp.headers) {
// set new location
local location = resp.headers["location"];
local p = location.find(".firebaseio.com")+16;
baseUrl = location.slice(0, p);
return stream(path, autoReconnect, onError);
}
} else if (resp.statuscode == 28 && autoReconnect) {
// if we timed out and have autoreconnect set
return stream(path, autoReconnect, onError);
} else {
server.error("Stream Closed (" + resp.statuscode + ": " + resp.body +")");
}
}.bindenv(this),
function(messageString) {
// server.log("MessageString: " + messageString);
local message = _parseEventMessage(messageString);
if (message) {
// Update the internal cache
_updateCache(message);
// Check out every callback for matching path
foreach (path,callback in callbacks) {
if (path == "/" || path == message.path || message.path.find(path + "/") == 0) {
// This is an exact match or a subbranch
callback(message.path, message.data);
} else if (message.event == "patch") {
// This is a patch for a (potentially) parent node
foreach (head,body in message.data) {
local newmessagepath = ((message.path == "/") ? "" : message.path) + "/" + head;
if (newmessagepath == path) {
// We have found a superbranch that matches, rewrite this as a PUT
local subdata = _getDataFromPath(newmessagepath, message.path, data);
callback(newmessagepath, subdata);
}
}
} else if (message.path == "/" || path.find(message.path + "/") == 0) {
// This is the root or a superbranch for a put or delete
local subdata = _getDataFromPath(path, message.path, data);
callback(path, subdata);
} else {
// server.log("No match for: " + path + " vs. " + message.path);
}
}
}
}.bindenv(this)
);
// Tickle the keepalive timer
if (keepAliveTimer) imp.cancelwakeup(keepAliveTimer);
keepAliveTimer = imp.wakeup(KEEP_ALIVE, _keepAliveExpired.bindenv(this))
// Return true if we opened the stream
return true;
}
/***************************************************************************
* Returns whether or not there is currently a stream open
* Returns:
* true - streaming request is currently open
* false - otherwise
**************************************************************************/
function isStreaming() {
return (streamingRequest != null);
}
/***************************************************************************
* Closes the stream (if there is one open)
**************************************************************************/
function closeStream() {
if (streamingRequest) {
// server.log("Closing stream")
streamingRequest.cancel();
streamingRequest = null;
}
}
/***************************************************************************
* Registers a callback for when data in a particular path is changed.
* If a handler for a particular path is not defined, data will change,
* but no handler will be called
*
* Returns:
* nothing
* Parameters:
* path - the path of the node we're listending to (without .json)
* callback - a callback function with two parameters (path, change) to be
* executed when the data at path changes
**************************************************************************/
function on(path, callback) {
if (path.len() > 0 && path.slice(0, 1) != "/") path = "/" + path;
if (path.len() > 1 && path.slice(-1) == "/") path = path.slice(0, -1);
callbacks[path] <- callback;
}
/***************************************************************************
* Reads a path from the internal cache. Really handy to use in an .on() handler
**************************************************************************/
function fromCache(path = "/") {
local _data = data;
foreach (step in split(path, "/")) {
if (step == "") continue;
if (step in _data) _data = _data[step];
else return null;
}
return _data;
}
/***************************************************************************
* Reads data from the specified path, and executes the callback handler
* once complete.
*
* NOTE: This function does NOT update firebase.data
*
* Returns:
* nothing
* Parameters:
* path - the path of the node we're reading
* callback - a callback function with one parameter (data) to be
* executed once the data is read
**************************************************************************/
function read(path, callback = null) {
http.get(_buildUrl(path), defaultHeaders).sendasync(function(res) {
if (res.statuscode != 200) {
server.error("Read: Firebase response: " + res.statuscode + " => " + res.body)
} else {
local data = null;
try {
data = http.jsondecode(res.body);
} catch (err) {
server.error("Read: JSON Error: " + res.body);
return;
}
if (callback) callback(data);
}
}.bindenv(this));
}
/***************************************************************************
* Pushes data to a path (performs a POST)
* This method should be used when you're adding an item to a list.
*
* NOTE: This function does NOT update firebase.data
* Returns:
* nothing
* Parameters:
* path - the path of the node we're pushing to
* data - the data we're pushing
**************************************************************************/
function push(path, data, priority = null, callback = null) {
if (priority != null && typeof data == "table") data[".priority"] <- priority;
http.post(_buildUrl(path), defaultHeaders, http.jsonencode(data)).sendasync(function(res) {
if (res.statuscode != 200) {
server.error("Push: Firebase responded " + res.statuscode + " to changes to " + path)
}
if (callback) callback(res);
}.bindenv(this));
}
/***************************************************************************
* Writes data to a path (performs a PUT)
* This is generally the function you want to use
*
* NOTE: This function does NOT update firebase.data
*
* Returns:
* nothing
* Parameters:
* path - the path of the node we're writing to
* data - the data we're writing
**************************************************************************/
function write(path, data, callback = null) {
http.put(_buildUrl(path), defaultHeaders, http.jsonencode(data)).sendasync(function(res) {
if (res.statuscode != 200) {
server.error("Write: Firebase responded " + res.statuscode + " to changes to " + path)
}
if (callback) callback(res);
}.bindenv(this));
}
/***************************************************************************
* Updates a particular path (performs a PATCH)
* This method should be used when you want to do a non-destructive write
*
* NOTE: This function does NOT update firebase.data
*
* Returns:
* nothing
* Parameters:
* path - the path of the node we're patching
* data - the data we're patching
**************************************************************************/
function update(path, data, callback = null) {
http.request("PATCH", _buildUrl(path), defaultHeaders, http.jsonencode(data)).sendasync(function(res) {
if (res.statuscode != 200) {
server.error("Update: Firebase responded " + res.statuscode + " to changes to " + path)
}
if (callback) callback(res);
}.bindenv(this));
}
/***************************************************************************
* Deletes the data at the specific node (performs a DELETE)
*
* NOTE: This function does NOT update firebase.data
*
* Returns:
* nothing
* Parameters:
* path - the path of the node we're deleting
**************************************************************************/
function remove(path, callback = null) {
http.httpdelete(_buildUrl(path), defaultHeaders).sendasync(function(res) {
if (res.statuscode != 200) {
server.error("Delete: Firebase responded " + res.statuscode + " to changes to " + path)
}
if (callback) callback(res);
});
}
/************ Private Functions (DO NOT CALL FUNCTIONS BELOW) ************/
// Builds a url to send a request to
function _buildUrl(path) {
// Normalise the /'s
// baseURL = <baseURL>
// prefixUrl = <prefixURL>/
// path = <path>
if (baseUrl.len() > 0 && baseUrl[baseUrl.len()-1] == '/') baseUrl = baseUrl.slice(0, -1);
if (prefixUrl.len() > 0 && prefixUrl[0] == '/') prefixUrl = prefixUrl.slice(1);
if (prefixUrl.len() > 0 && prefixUrl[prefixUrl.len()-1] != '/') prefixUrl += "/";
if (path.len() > 0 && path[0] == '/') path = path.slice(1);
local url = baseUrl + "/" + prefixUrl + path + ".json";
url += "?ns=" + db;
if (auth != null) url = url + "&auth=" + auth;
return url;
}
// Default error handler
function _defaultErrorHandler(errors) {
foreach(error in errors) {
server.error("ERROR " + error.code + ": " + error.message);
}
}
// parses event messages
function _parseEventMessage(text) {
// split message into parts
local lines = split(text, "\n");
if (lines.len() < 2) return null;
// Check for error conditions
if (lines.len() == 3 && lines[0] == "{" && lines[2] == "}") {
local error = http.jsondecode(text);
server.error("Firebase error message: " + error.error);
return null;
}
// Tickle the keep alive timer
if (keepAliveTimer) imp.cancelwakeup(keepAliveTimer);
keepAliveTimer = imp.wakeup(KEEP_ALIVE, _keepAliveExpired.bindenv(this))
// get the event
local eventLine = lines[0];
local event = eventLine.slice(7);
if(event.tolower() == "keep-alive") return null;
// get the data
local dataLine = lines[1];
local dataString = dataLine.slice(6);
// pull interesting bits out of the data
local d = http.jsondecode(dataString);
// return a useful object
return { "event": event, "path": d.path, "data": d.data };
}
// Updates the local cache
function _updateCache(message) {
// base case - refresh everything
if (message.event == "put" && message.path == "/") {
data = (message.data == null) ? {} : message.data;
return data
}
local pathParts = split(message.path, "/");
local key = pathParts.len() > 0 ? pathParts[pathParts.len()-1] : null;
local currentData = data;
local parent = data;
local lastPart = "";
// Walk down the tree following the path
foreach (part in pathParts) {
if (typeof currentData != "array" && typeof currentData != "table") {
// We have orphaned a branch of the tree
if (lastPart == "") {
data = {};
parent = data;
currentData = data;
} else {
parent[lastPart] <- {};
currentData = parent[lastPart];
}
}
parent = currentData;
if (!(part in currentData)) {
// This is a new branch
currentData[part] <- {};
}
currentData = currentData[part];
lastPart = part;
}
// Make the changes to the found branch
if (message.event == "put") {
if (message.data == null) {
if (key != null) {
delete parent[key];
} else {
data = {};
}
} else {
if (key != null) parent[key] <- message.data;
else data[key] <- message.data;
}
} else if (message.event == "patch") {
foreach(k,v in message.data) {
if (key != null) parent[key][k] <- v
else data[k] <- v;
}
}
// Now clean up the tree, removing any orphans
_cleanTree(data);
}
// Cleans the tree by deleting any empty nodes
function _cleanTree(branch) {
foreach (k,subbranch in branch) {
if (typeof subbranch == "array" || typeof subbranch == "table") {
_cleanTree(subbranch)
if (subbranch.len() == 0) delete branch[k];
}
}
}
// Steps through a path to get the contents of the table at that point
function _getDataFromPath(c_path, m_path, m_data) {
// Make sure we are on the right branch
if (m_path.len() > c_path.len() && m_path.find(c_path) != 0) return null;
// Walk to the base of the callback path
local new_data = m_data;
foreach (step in split(c_path, "/")) {
if (step == "") continue;
if (step in new_data) {
new_data = new_data[step];
} else {
new_data = null;
break;
}
}
// Find the data at the modified branch but only one step deep at max
local changed_data = new_data;
if (m_path.len() > c_path.len()) {
// Only a subbranch has changed, pick the subbranch that has changed
local new_m_path = m_path.slice(c_path.len())
foreach (step in split(new_m_path, "/")) {
if (step == "") continue;
if (step in changed_data) {
changed_data = changed_data[step];
} else {
changed_data = null;
}
break;
}
}
return changed_data;
}
// No keep alive has been seen for a while, lets reconnect
function _keepAliveExpired() {
closeStream();
}
}
// -----------------------------------------------------------------------------
class Persist {
cache = null;
// -------------------------------------------------------------------------
function read(key = null, def = null) {
if (cache == null) {
cache = server.load();
}
return (key in cache) ? cache[key] : def;
}
// -------------------------------------------------------------------------
function write(key, value) {
if (cache == null) {
cache = server.load();
}
if (key in cache) {
if (cache[key] != value) {
cache[key] <- value;
server.save(cache);
}
} else {
cache[key] <- value;
server.save(cache);
}
return value;
}
}
// -----------------------------------------------------------------------------
// When the device reboots, we rebind everything (http, firebase) and wait for commands
boot_finished <- null;
function receive_boot(boot) {
// Capture the device id, start/restart the firebase stream
state.write("device_id", boot.device_id);
// Start the http handler. For extra security, limit this to when boot.reason == WAKEREASON_SW_RESET
http.onrequest(http_onrequest);
// .. but shut it down 1 minute after device boot.
if (boot_finished) imp.cancelwakeup(boot_finished);
boot_finished = imp.wakeup(60, function() {
boot_finished = null;
http.onrequest(http_onrequest_blocked);
})
// Update the device status
fb.write("/devices/" + boot.device_id + "/last_boot", time());
// Start tracking the firebase commands
track_commands();
}
// -----------------------------------------------------------------------------
function track_commands() {
// Start tracking /thisdevice/commands branch in Firebase
local device_id = state.read("device_id");
if (device_id != null && !fb.isStreaming()) {
fb.on("/", device_command);
fb.stream("/devices/" + device_id + "/commands");
server.log("Tracking commands for " + device_id);
}
}
// -----------------------------------------------------------------------------
// This is fired every time a new command is added to the command queue
function device_command(path, data) {
if (data == null) return;
local commands = {};
if (path == "/" || path == "" || path == null) {
// Catch the bootup scenario where we get the whole branch of data
commands = data;
} else {
// Catch the regular scenario of a new command being pushed into the branch
commands[path] <- data;
}
// Process and delete each command in the queue
foreach (id,command in commands) {
// If we have a valid command, then send it to the imp
if ("command" in command) {
device.send("command", command);
} else {
server.log("We received an incomplete command.")
}
// Normally you would wait for confirmation that the command was successfully received on the imp
local device_id = state.read("device_id");
fb.remove("/devices/" + device_id + "/commands/" + id);
}
}
// -----------------------------------------------------------------------------
function blinkup(uid, res) {
local device_id = state.read("device_id");
// Write the device owner
fb.write("/devices/" + device_id + "/owner", uid, function(_res) {
if (_res.statuscode != 200) {
// Firebase has rejected the request to change owner
return res.send(403, "The device is locked by the owner or you are not authorised.");
} else {
// Firebase has accepted the request to change owner
res.send(200, "OK")
}
}.bindenv(this))
}
// -----------------------------------------------------------------------------
function http_onrequest_blocked(req, res) {
// The opportunity to blinkup has passed, reject all the requests
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "X-Firebase-UID");
res.header("Access-Control-Allow-Methods", "GET,POST");
res.send(202, "The device is not ready for requests at this point in time and space");
}
// -----------------------------------------------------------------------------
function http_onrequest(req, res) {
try {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "X-Firebase-UID");
res.header("Access-Control-Allow-Methods", "GET,POST");
if (req.method == "OPTIONS") {
// Accept OPTIONS requests to keep the browsers happy
res.send(200, "OK") ;
} else if (req.path == "/blinkup") {
// Handle blinkup requests
if ("x-firebase-uid" in req.headers) {
blinkup(req.headers["x-firebase-uid"], res);
} else {
res.send(401, "Access denied")
}
} else {
res.send(404, "File not found");
}
} catch (e) {
server.error("Exception in http.onrequest: " + e);
res.send(500, e)
}
}
// -----------------------------------------------------------------------------
agentid <- split(http.agenturl(), "/").pop();
server.log("Agent " + agentid + " started")
device.on("boot", receive_boot);
http.onrequest(http_onrequest_blocked);
state <- Persist();
fb <- Firebase("firebase-name", "security-token-NOT-secret");
track_commands();
/*
* Security Demo - Device code
* This is designed to demonstrate the use of Firebase as a security layer for
* protecting Electric Imp agents. It is intentionally simple.
*
*/
// Handle incoming commands
agent.on("command", function(command) {
server.log("The imp received the command: " + command.command)
})
// Notify the agent that the device is online
agent.send("boot", { device_id = hardware.getimpeeid(), reason = hardware.wakereason(), ts = time() });
// We are online, say cheers.
server.log("Hello world.")
{
"rules": {
// Devices
"devices" : {
"$deviceid" : {
// Only the owner of the device can read this block
".read" : "auth != null && auth.uid == data.child('owner').val()",
// Only the owner can update the lock to open or closed.
"lock" : {
".write" : "auth != null && auth.uid == data.parent().child('owner').val()",
".validate" : "newData.val() == 'open' || newData.val() == 'closed'"
},
// The owner can only be updated by the agent but only when the device is unlocked or unowned
"owner" : {
".write" : "auth.id == 'agent'",
".validate" : "root.hasChild('/users/' + newData.val())
&& (data.val() == null
|| data.parent().child('lock').val() == 'open'
|| newData.val() == data.val())"
},
// The agent updates this field to keep the device's data active
"last_boot" : {
".write" : "auth.id == 'agent'"
},
// The owner can add commands into the command queue and delete them.
// The agent can read and remove them.
"commands" : {
".write" : "auth != null
&& (auth.id == 'agent'
|| auth.uid == data.parent().child('owner').val())",
".read" : "auth != null
&& (auth.id == 'agent'
|| auth.uid == data.parent().child('owner').val())"
},
// There should be no other fields inside the device block
"$other" : {
".validate": false
}
}
},
// Users
"users" : {
"$userid" : {
// Only the user can read or write in this area.
// For now, we don't care what is here, just that the user id is valid.
".write" : "auth != null && auth.uid == $userid",
".read" : "auth != null && auth.uid == $userid"
}
},
// There should be no other top level branches
"$other" : {
".validate": false
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment