Last active
August 29, 2015 14:02
-
-
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/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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(<ENTER SECRET>) | |
.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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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.") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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