From: Calin Crisan Date: Wed, 24 Dec 2014 15:34:02 +0000 (+0200) Subject: the authentication mechanism is now based on js-computed signature X-Git-Url: http://www.vanbest.org/gitweb/?a=commitdiff_plain;h=114d9105d4933abe5a129c06923d8ff01b5a36eb;p=motioneye-debian the authentication mechanism is now based on js-computed signature rather than HTTP Basic Auth --- diff --git a/src/handlers.py b/src/handlers.py index 7c6e191..b91ed5a 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import base64 import datetime import json import logging @@ -58,13 +57,8 @@ class BaseHandler(RequestHandler): return data def render(self, template_name, content_type='text/html', **context): - import motioneye - self.set_header('Content-Type', content_type) - context['USER'] = self.current_user - context['VERSION'] = motioneye.VERSION - content = template.render(template_name, **context) self.finish(content) @@ -75,26 +69,26 @@ class BaseHandler(RequestHandler): def get_current_user(self): main_config = config.get_main() - try: - scheme, token = self.request.headers.get('Authorization', '').split() - if scheme.lower() == 'basic': - user, pwd = base64.decodestring(token).split(':') - - if user == main_config.get('@admin_username') and pwd == main_config.get('@admin_password'): - return 'admin' - - elif user == main_config.get('@normal_username') and pwd == main_config.get('@normal_password'): - return 'normal' - - else: - logging.error('authentication failed for user %(user)s' % {'user': user}) - - except: # no authentication info provided - if not main_config.get('@normal_password') and not self.get_argument('logout', None): - return 'normal' - + username = self.get_argument('username', None) + signature = self.get_argument('signature', None) + if (username == main_config.get('@admin_username') and + signature == utils.compute_signature(self.request.method, self.request.uri, self.request.body, main_config.get('@admin_password'))): + + return 'admin' + + elif not username and not main_config.get('@normal_password'): # no authentication required for normal user + return 'normal' + + elif (username == main_config.get('@normal_username') and + signature == utils.compute_signature(self.request.method, self.request.uri, self.request.body, main_config.get('@normal_password'))): + + return 'normal' + + elif username: + logging.error('authentication failed for user %(user)s' % {'user': username}) + return None - + def _handle_request_exception(self, exception): try: if isinstance(exception, HTTPError): @@ -116,13 +110,9 @@ class BaseHandler(RequestHandler): def wrapper(self, *args, **kwargs): user = self.current_user if (user is None) or (user != 'admin' and admin): - self.set_status(401) - if prompt: - self.set_header('WWW-Authenticate', 'basic realm="%(realm)s"' % { - 'realm': 'motionEye authentication'}) - - return self.finish('Authentication required.') - + self.set_header('Content-Type', 'application/json') + return self.finish_json({'error': 'unauthorized', 'prompt': prompt}) + return func(self, *args, **kwargs) return wrapper @@ -145,10 +135,8 @@ class NotFoundHandler(BaseHandler): class MainHandler(BaseHandler): - @BaseHandler.auth() def get(self): - if self.get_argument('logout', None): - return self.redirect('/') + import motioneye timezones = [] if settings.LOCAL_TIME_FILE: @@ -156,10 +144,12 @@ class MainHandler(BaseHandler): timezones = pytz.common_timezones self.render('main.html', + version=motioneye.VERSION, wpa_supplicant=settings.WPA_SUPPLICANT_CONF, enable_reboot=settings.ENABLE_REBOOT, timezones=timezones, - hostname=socket.gethostname()) + hostname=socket.gethostname(), + admin_username=config.get_main().get('@admin_username')) class ConfigHandler(BaseHandler): @@ -1180,3 +1170,14 @@ class VersionHandler(BaseHandler): self.render('version.html', version=update.get_version(), hostname=socket.gethostname()) + + post = get + + +# this will only trigger the login mechanism on the client side, if required +class LoginHandler(BaseHandler): + @BaseHandler.auth() + def get(self): + self.finish_json() + + post = get diff --git a/src/server.py b/src/server.py index de82013..3098ac5 100644 --- a/src/server.py +++ b/src/server.py @@ -52,6 +52,7 @@ application = Application( (r'^/update/?$', handlers.UpdateHandler), (r'^/power/(?Pshutdown)/?$', handlers.PowerHandler), (r'^/version/?$', handlers.VersionHandler), + (r'^/login/?$', handlers.LoginHandler), (r'^.*$', handlers.NotFoundHandler), ], debug=False, diff --git a/src/utils.py b/src/utils.py index a7de78d..c768105 100644 --- a/src/utils.py +++ b/src/utils.py @@ -16,8 +16,11 @@ # along with this program. If not, see . import datetime +import hashlib import logging import os +import urllib +import urlparse from tornado.httpclient import AsyncHTTPClient, HTTPRequest @@ -285,3 +288,15 @@ def test_netcam_url(data, callback): header_callback=on_header) http_client.fetch(request, on_response) + + +def compute_signature(method, uri, body, key): + parts = list(urlparse.urlsplit(uri)) + query = [q for q in urlparse.parse_qsl(parts[3]) if (q[0] != 'signature')] + query.sort(key=lambda q: q[0]) + query = urllib.urlencode(query) + parts[0] = parts[1] = '' + parts[3] = query + uri = urlparse.urlunsplit(parts) + + return hashlib.sha1('%s:%s:%s:%s' % (method, uri, body or '', key)).hexdigest().lower() diff --git a/static/css/main.css b/static/css/main.css index 60e4376..f20544c 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -133,6 +133,18 @@ div.button.logout-button { height: 48px; } +body.admin div.logout-button { + display: none; +} + +body.admin div.settings-top-bar div.logout-button { + display: inline-block; +} + +body:not(.admin) div.settings-top-bar div.logout-button { + display: none; +} + div.button.rem-camera-button { display: none; margin: 1px; @@ -218,6 +230,10 @@ div.settings.open { min-width: 360px; } +body:not(.admin) div.settings { + display: none !important; +} + div.settings-container { position: relative; padding-top: 10px; @@ -252,6 +268,10 @@ div.settings-top-bar.open { min-width: 360px; } +body:not(.admin) div.settings-top-bar { + display: none !important; +} + div.settings-top-bar.closed div.apply-button { display: none !important; } @@ -443,6 +463,10 @@ tr.hidden { /* dialogs */ +table.login-dialog { + margin: auto; +} + table.add-camera-dialog { margin: auto; } diff --git a/static/js/main.js b/static/js/main.js index 61df3d4..8ad2974 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,63 +1,228 @@ + var pushConfigs = {}; var refreshDisabled = {}; /* dictionary indexed by cameraId, tells if refresh is disabled for a given camera */ var fullScreenCameraId = null; var inProgress = false; var refreshInterval = 50; /* milliseconds */ +var username = null; +var password = null; /* utils */ -function ajax(method, url, data, callback) { - var options = { - type: method, - url: url, - data: data, - cache: false, - success: callback, - error: function (request, options, error) { - showErrorMessage(); - if (callback) { - callback(); +var sha1 = (function () { + function hash(msg) { + var K = [0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xca62c1d6]; + + msg += String.fromCharCode(0x80); + + var l = msg.length / 4 + 2; + var N = Math.ceil(l / 16); + var M = new Array(N); + + for (var i = 0; i < N; i++) { + M[i] = new Array(16); + for (var j = 0; j < 16; j++) { + M[i][j] = (msg.charCodeAt(i * 64 + j * 4) << 24) | (msg.charCodeAt(i * 64 + j * 4 + 1) << 16) | + (msg.charCodeAt(i * 64 + j * 4 + 2) << 8) | (msg.charCodeAt(i * 64 + j * 4 + 3)); } } - }; + M[N-1][14] = ((msg.length-1) * 8) / Math.pow(2, 32); + M[N-1][14] = Math.floor(M[N-1][14]); + M[N-1][15] = ((msg.length-1) * 8) & 0xffffffff; + + var H0 = 0x67452301; + var H1 = 0xefcdab89; + var H2 = 0x98badcfe; + var H3 = 0x10325476; + var H4 = 0xc3d2e1f0; + + var W = new Array(80); + var a, b, c, d, e; + for (var i = 0; i < N; i++) { + for (var t = 0; t < 16; t++) W[t] = M[i][t]; + for (var t = 16; t < 80; t++) W[t] = ROTL(W[t-3] ^ W[t-8] ^ W[t-14] ^ W[t-16], 1); + + a = H0; b = H1; c = H2; d = H3; e = H4; + + for (var t = 0; t < 80; t++) { + var s = Math.floor(t / 20); + var T = (ROTL(a, 5) + f(s, b, c, d) + e + K[s] + W[t]) & 0xffffffff; + e = d; + d = c; + c = ROTL(b, 30); + b = a; + a = T; + } + + H0 = (H0 + a) & 0xffffffff; + H1 = (H1 + b) & 0xffffffff; + H2 = (H2 + c) & 0xffffffff; + H3 = (H3 + d) & 0xffffffff; + H4 = (H4 + e) & 0xffffffff; + } + + return toHexStr(H0) + toHexStr(H1) + toHexStr(H2) + toHexStr(H3) + toHexStr(H4); + } + + function f(s, x, y, z) { + switch (s) { + case 0: return (x & y) ^ (~x & z); + case 1: return x ^ y ^ z; + case 2: return (x & y) ^ (x & z) ^ (y & z); + case 3: return x ^ y ^ z; + } + } + + function ROTL(x, n) { + return (x << n) | (x >>> (32 - n)); + } + + function toHexStr(n) { + var s = "", v; + for (var i = 7; i >= 0; i--) { + v = (n >>> (i * 4)) & 0xf; + s += v.toString(16); + } + return s; + } - if (data && method === 'POST' && typeof data === 'object') { - options['contentType'] = 'application/json'; - options['data'] = JSON.stringify(options['data']); + return hash; +}()); + +function splitUrl(url) { + if (!url) { + url = window.location.href; } - $.ajax(options); + var parts = url.split('?'); + if (parts.length < 2 || parts[1].length === 0) { + return {baseUrl: parts[0], params: {}}; + } + + var baseUrl = parts[0]; + var paramStr = parts[1]; + + parts = paramStr.split('&'); + var params = {}; + + for (var i = 0; i < parts.length; i++) { + var pair = parts[i].split('='); + params[pair[0]] = pair[1]; + } + + return {baseUrl: baseUrl, params: params}; } -function showErrorMessage(message) { - if (message == null || message == true) { - message = 'An error occurred. Refreshing is recommended.'; +function qualifyUrl(url) { + var a = document.createElement('a'); + a.href = url; + return a.href; +} + +function qualifyUri(uri) { + var url = qualifyUrl(uri); + var pos = url.indexOf('//'); + if (pos === -1) { /* not a full url */ + return url; } - showPopupMessage(message, 'error'); + url = url.substring(pos + 2); + pos = url.indexOf('/'); + if (pos === -1) { /* root with no trailing slash */ + return ''; + } + + return url.substring(pos); +} + +function computeSignature(method, uri, body) { + uri = qualifyUri(uri); + + var parts = splitUrl(uri); + var query = parts.params; + var baseUrl = parts.baseUrl; + + query = Object.keys(query).map(function (key) {return {key: key, value: query[key]};}); + query = query.filter(function (q) {return q.key !== 'signature';}); + query.sortKey(function (q) {return q.key;}); + query = query.map(function (q) {return q.key + '=' + q.value;}).join('&'); + uri = baseUrl + '?' + query; + + return sha1(method + ':' + uri + ':' + (body || '') + ':' + window.password).toLowerCase(); } -function doLogout() { - /* IE is always a breed apart */ - if (window.ActiveXObject && document.execCommand) { - if (document.execCommand('ClearAuthenticationCache')) { - window.location.href = '/?logout=true'; - } +function addAuthParams(method, url, body) { + if (url.indexOf('?') < 0) { + url += '?'; } else { - var username = ' '; - var password = 'logout' + new Date().getTime(); - - $.ajax({ - url: '/?logout=true', - username: username, - password: password, - complete: function () { - window.location.href = '/?logout=true'; - } - }); + url += '&';; } + + url += 'username=' + window.username; + var signature = computeSignature(method, url, body); + url += '&signature=' + signature; + + return url; +} + +function ajax(method, url, data, callback, error) { + var origUrl = url; + var origData = data; + + if (url.indexOf('?') < 0) { + url += '?'; + } + else { + url += '&'; + } + + url += '_=' + new Date().getTime(); + + var json = false; + if (method == 'POST') { + if (typeof data == 'object') { + data = JSON.stringify(data); + json = true; + } + } + else { /* assuming GET */ + if (data) { + url += $.param(data); + data = null; + } + } + + url = addAuthParams(method, url, data); + + var options = { + type: method, + url: url, + data: data, + success: function (data) { + if (data && data.error == 'unauthorized') { + if (data.prompt) { + runLoginDialog(function () { + ajax(method, origUrl, origData, callback, error); + }); + } + } + else if (callback) { + $('body').toggleClass('admin', username === adminUsername); + callback(data); + } + }, + contentType: json ? 'application/json' : null, + error: error || function (request, options, error) { + showErrorMessage(); + if (callback) { + callback(); + } + } + }; + + $.ajax(options); } Object.keys = Object.keys || (function () { @@ -212,7 +377,38 @@ function getCookie(name) { return unescape(document.cookie.substring(start, end)); } - + +function setCookie(name, value, days) { + var date, expires; + if (days) { + date = new Date(); + date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); + expires = '; expires=' + date.toGMTString(); + } + else { + expires = ''; + } + + document.cookie = name + '=' + value + expires + '; path=/'; +} + +function remCookie(name) { + document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT;'; +} + +function showErrorMessage(message) { + if (message == null || message == true) { + message = 'An error occurred. Refreshing is recommended.'; + } + + showPopupMessage(message, 'error'); +} + +function doLogout() { + remCookie('username'); + window.location.reload(true); +} + /* UI initialization */ @@ -1047,7 +1243,7 @@ function beginProgress(cameraIds) { inProgress = true; /* replace the main page message with a progress indicator */ - $('div.add-camera-message').html(''); + $('div.add-camera-message').replaceWith(''); /* show the apply button progress indicator */ $('#applyButton').html(''); @@ -1073,9 +1269,6 @@ function endProgress() { inProgress = false; - /* remove any existing message on the main page */ - $('div.add-camera-message').remove(); - /* deal with the apply button */ if (Object.keys(pushConfigs).length === 0) { hideApply(); @@ -1099,6 +1292,7 @@ function downloadFile(uri) { var url = window.location.href; var parts = url.split('/'); url = parts.slice(0, 3).join('/') + uri; + url = addAuthParams('GET', url); /* download the file by creating a temporary iframe */ var frame = $(''); @@ -1195,13 +1389,11 @@ function doApply() { if (data.reboot) { var count = 0; function checkServerReboot() { - $.ajax({ - type: 'GET', - url: '/config/0/get/', - success: function () { + ajax('GET', '/config/0/get/', null, + function () { window.location.reload(true); }, - error: function () { + function () { if (count < 25) { count += 1; setTimeout(checkServerReboot, 2000); @@ -1210,7 +1402,7 @@ function doApply() { window.location.reload(true); } } - }); + ); } setTimeout(checkServerReboot, 15000); @@ -1248,20 +1440,17 @@ function doShutDown() { showModalDialog(''); function checkServer() { - $.ajax({ - type: 'GET', - url: '/', - cache: false, - success: function () { + ajax('GET', '/', null, + function () { setTimeout(checkServer, 1000); }, - error: function () { + function () { showModalDialog('Powered Off'); setTimeout(function () { $('div.modal-glass').animate({'opacity': '1', 'background-color': '#212121'}, 200); },100); } - }); + ); } checkServer(); @@ -1318,15 +1507,13 @@ function doUpdate() { ajax('POST', '/update/?version=' + data.update_version, null, function () { var count = 0; function checkServerUpdate() { - $.ajax({ - type: 'GET', - url: '/config/0/get/', - success: function () { + ajax('GET', '/config/0/get/', null, + function () { runAlertDialog('motionEye was successfully updated!', function () { window.location.reload(true); }); }, - error: function () { + function () { if (count < 25) { count += 1; setTimeout(checkServerUpdate, 2000); @@ -1337,7 +1524,7 @@ function doUpdate() { }); } } - }); + ); } setTimeout(checkServerUpdate, 10000); @@ -1394,7 +1581,7 @@ function fetchCurrentConfig(onFetch) { var i, cameras = data.cameras; - if (user === 'admin') { + if (username === adminUsername) { var cameraSelect = $('#cameraSelect'); cameraSelect.html(''); for (i = 0; i < cameras.length; i++) { @@ -1435,8 +1622,11 @@ function fetchCurrentConfig(onFetch) { } }); } - - if (user === 'admin') { + + /* add a progress indicator */ + $('div.page-container').append(''); + + if (username === adminUsername) { /* fetch the main configuration */ ajax('GET', '/config/main/get/', null, function (data) { if (data == null || data.error) { @@ -1595,6 +1785,65 @@ function runConfirmDialog(message, onYes, options) { runModalDialog(params); } +function runLoginDialog(retry) { + /* a workaround so that browsers will remember the credentials */ + var tempFrame = $(''); + $('body').append(tempFrame); + + var form = + $('
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
'); + + var usernameEntry = form.find('#usernameEntry'); + var passwordEntry = form.find('#passwordEntry'); + + var params = { + title: 'Login', + content: form, + buttons: [ + {caption: 'Cancel', isCancel: true, click: function () { + setTimeout(function () { + tempFrame.remove(); + }, 5000); + + if (retry) { + retry(); + } + + //return false; + }}, + {caption: 'Login', isDefault: true, click: function () { + window.username = usernameEntry.val(); + window.password = passwordEntry.val(); + + setCookie('username', window.username); + + form.submit(); + setTimeout(function () { + tempFrame.remove(); + }, 5000); + + if (retry) { + retry(); + } + + //return false; + }} + ], + }; + + runModalDialog(params); +} + function runPictureDialog(entries, pos, mediaType) { var content = $('
'); @@ -1627,7 +1876,7 @@ function runPictureDialog(entries, pos, mediaType) { progressImg.css('left', (img.parent().width() - progressImg.width()) / 2); progressImg.css('top', (img.parent().height() - progressImg.height()) / 2); - img.attr('src', '/' + mediaType + '/' + entry.cameraId + '/preview' + entry.path); + img.attr('src', addAuthParams('GET', '/' + mediaType + '/' + entry.cameraId + '/preview' + entry.path)); img.load(function () { var aspectRatio = this.naturalWidth / this.naturalHeight; var sizeWidth = width * width / aspectRatio; @@ -2154,14 +2403,14 @@ function runMediaDialog(cameraId, mediaType) { var previewImg = $(''); entryDiv.append(previewImg); - previewImg[0]._src = '/' + mediaType + '/' + cameraId + '/preview' + entry.path + '?height=' + height; + previewImg[0]._src = addAuthParams('GET', '/' + mediaType + '/' + cameraId + '/preview' + entry.path + '?height=' + height); var downloadButton = $('
Download
'); entryDiv.append(downloadButton); var deleteButton = $('
Delete
'); - if (user === 'admin') { + if (username === adminUsername) { entryDiv.append(deleteButton); } @@ -2366,7 +2615,7 @@ function addCameraFrameUi(cameraConfig) { var progressImg = cameraFrameDiv.find('img.camera-progress'); /* no camera buttons if not admin */ - if (user !== 'admin') { + if (username !== adminUsername) { configureButton.hide(); } @@ -2493,7 +2742,7 @@ function recreateCameraFrames(cameras) { addCameraFrameUi(camera); } - if ($('#cameraSelect').find('option').length < 2 && user === 'admin' && $('#motionEyeSwitch')[0].checked) { + if ($('#cameraSelect').find('option').length < 2 && username === adminUsername && $('#motionEyeSwitch')[0].checked) { /* invite the user to add a camera */ var addCameraLink = $(''); @@ -2613,6 +2862,8 @@ function refreshCameraFrames() { uri += '&width=' + img.width; } + uri = addAuthParams('GET', uri); + img.src = uri; img.loading = 1; } @@ -2692,9 +2943,16 @@ $(document).ready(function () { } }); + /* restore the username from cookie */ + window.username = getCookie('username'); + initUI(); beginProgress(); - fetchCurrentConfig(endProgress); + + ajax('GET', 'login/', null, function () { + fetchCurrentConfig(endProgress); + }); + refreshCameraFrames(); checkCameraErrors(); }); diff --git a/static/js/ui.js b/static/js/ui.js index 55d1e79..f53df84 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -699,6 +699,8 @@ function makeModalDialogButtons(buttonsInfo) { } hideModalDialog(); + + return false; }; } else { @@ -762,6 +764,7 @@ function runModalDialog(options) { var titleBar = null; var buttonsDiv = null; var defaultClick = null; + var cancelClick = null; /* add title bar */ if (options.title) { @@ -785,14 +788,14 @@ function runModalDialog(options) { } if (options.buttons === 'yesnocancel') { options.buttons = [ - {caption: 'Cancel', click: options.onCancel}, + {caption: 'Cancel', isCancel: true, click: options.onCancel}, {caption: 'No', click: options.onNo}, {caption: 'Yes', isDefault: true, click: options.onYes} ]; } else if (options.buttons === 'okcancel') { options.buttons = [ - {caption: 'Cancel', click: options.onCancel}, + {caption: 'Cancel', isCancel:true, click: options.onCancel}, {caption: 'OK', isDefault: true, click: options.onOk} ]; } @@ -810,6 +813,9 @@ function runModalDialog(options) { if (info.isDefault) { defaultClick = info.click; } + else if (info.isCancel) { + cancelClick = info.click; + } }); } @@ -832,10 +838,19 @@ function runModalDialog(options) { if (defaultClick && defaultClick() == false) { return; } - /* intentionally no break */ + + hideModalDialog(); + + break; case 27: + if (cancelClick && cancelClick() == false) { + return; + } + hideModalDialog(); + + break; } }; diff --git a/templates/base.html b/templates/base.html index 085a047..5883061 100644 --- a/templates/base.html +++ b/templates/base.html @@ -19,7 +19,6 @@ {% endblock %} diff --git a/templates/main.html b/templates/main.html index eb70c5a..8534a75 100644 --- a/templates/main.html +++ b/templates/main.html @@ -12,12 +12,15 @@ {{super()}} + + {% endblock %} {% block body %}
- {% if USER == 'admin' %}
@@ -26,10 +29,7 @@
Apply
{% if hostname %}
{{hostname}}
{% endif %}
- {% else %} -
- {% if hostname %}
{{hostname}}
{% endif %} - {% endif %} +
- {% if USER == 'admin' %}
General Settings
@@ -85,7 +84,7 @@ Current Version - {{VERSION}} + {{version}} Software Update @@ -602,10 +601,7 @@
- {% endif %} -
- -
+