From fab1d051274925d72abef174ddaae8fb7159e2e3 Mon Sep 17 00:00:00 2001 From: jokajak Date: Wed, 8 Apr 2015 17:12:47 -0400 Subject: [PATCH] Introduce new flask based python backend This supports most functionality. Tested the following functionality: * Create account * Delete account * Create a card * Download offline copy (couldn't log in) - needs work * Change passphrase * One time password creation and use --- .../flask/properties/flask.properties.json | 5 + backend/flask/src/README.md | 7 + backend/flask/src/clipperz.py | 9 + backend/flask/src/clipperz/__init__.py | 38 ++ backend/flask/src/clipperz/api.py | 622 ++++++++++++++++++ backend/flask/src/clipperz/exceptions.py | 25 + backend/flask/src/clipperz/models.py | 151 +++++ backend/flask/src/clipperz/views.py | 128 ++++ backend/flask/src/config.py | 35 + backend/flask/src/db_create.py | 13 + backend/flask/src/db_downgrade.py | 8 + backend/flask/src/db_migrate.py | 20 + backend/flask/src/db_repository/README | 4 + backend/flask/src/db_repository/__init__.py | 0 backend/flask/src/db_repository/manage.py | 5 + backend/flask/src/db_repository/migrate.cfg | 25 + .../src/db_repository/versions/__init__.py | 0 backend/flask/src/db_upgrade.py | 7 + backend/flask/src/run.sh | 7 + backend/flask/src/setup.py | 72 ++ scripts/builder/backends/flaskBuilder.py | 18 + scripts/builder/frontends/deltaBuilder.py | 3 +- 22 files changed, 1201 insertions(+), 1 deletion(-) create mode 100644 backend/flask/properties/flask.properties.json create mode 100644 backend/flask/src/README.md create mode 100644 backend/flask/src/clipperz.py create mode 100644 backend/flask/src/clipperz/__init__.py create mode 100644 backend/flask/src/clipperz/api.py create mode 100644 backend/flask/src/clipperz/exceptions.py create mode 100644 backend/flask/src/clipperz/models.py create mode 100644 backend/flask/src/clipperz/views.py create mode 100644 backend/flask/src/config.py create mode 100644 backend/flask/src/db_create.py create mode 100644 backend/flask/src/db_downgrade.py create mode 100644 backend/flask/src/db_migrate.py create mode 100644 backend/flask/src/db_repository/README create mode 100644 backend/flask/src/db_repository/__init__.py create mode 100644 backend/flask/src/db_repository/manage.py create mode 100644 backend/flask/src/db_repository/migrate.cfg create mode 100644 backend/flask/src/db_repository/versions/__init__.py create mode 100644 backend/flask/src/db_upgrade.py create mode 100644 backend/flask/src/run.sh create mode 100644 backend/flask/src/setup.py create mode 100755 scripts/builder/backends/flaskBuilder.py diff --git a/backend/flask/properties/flask.properties.json b/backend/flask/properties/flask.properties.json new file mode 100644 index 0000000..6e44ebd --- /dev/null +++ b/backend/flask/properties/flask.properties.json @@ -0,0 +1,5 @@ +{ + "request.path": "../pm", + "dump.path": "/dump", + "should.pay.toll": "false" +} diff --git a/backend/flask/src/README.md b/backend/flask/src/README.md new file mode 100644 index 0000000..6555e72 --- /dev/null +++ b/backend/flask/src/README.md @@ -0,0 +1,7 @@ +clipperz +======== +A flask based backend for the Clipperz (https://clipperz.is) Password Manager. This backend is for development and personal use only. As such it does not implement any bot protection mechanisms such as tolls. + +Running +------- +Once you have built the backend using the clipperz build process you can use run.sh to create an environment for testing against. The database will be created in the target directory which means it will be over-ridden every time you build. To change this you can specify a `DATABASE_URL` environment variable that points to another location. diff --git a/backend/flask/src/clipperz.py b/backend/flask/src/clipperz.py new file mode 100644 index 0000000..6956950 --- /dev/null +++ b/backend/flask/src/clipperz.py @@ -0,0 +1,9 @@ +from clipperz import app, db + + +def main(): + db.create_all() + app.run(debug=True) + +if __name__ == "__main__": + main() diff --git a/backend/flask/src/clipperz/__init__.py b/backend/flask/src/clipperz/__init__.py new file mode 100644 index 0000000..8a6665c --- /dev/null +++ b/backend/flask/src/clipperz/__init__.py @@ -0,0 +1,38 @@ +import os + +from flask import Flask +from flask.ext.login import LoginManager +from flask.ext.sqlalchemy import SQLAlchemy +from simplekv.db.sql import SQLAlchemyStore +from flask.ext.kvsession import KVSessionExtension +from config import * + +APP_ROOT = os.path.dirname(os.path.abspath(__file__)) +app = Flask(__name__, static_url_path='') +lm = LoginManager() +lm.init_app(app) +app.config.from_object(DevelopmentConfig) +db = SQLAlchemy(app) +store = SQLAlchemyStore(db.engine, db.metadata, 'sessions') +kvsession = KVSessionExtension(store, app) + +if not app.debug and os.environ.get('HEROKU') is None: + import logging + from logging.handlers import RotatingFileHandler + file_handler = RotatingFileHandler('tmp/microblog.log', 'a', + 1 * 1024 * 1024, 10) + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(logging.Formatter( + '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]')) + app.logger.addHandler(file_handler) + app.logger.setLevel(logging.INFO) + app.logger.info('microblog startup') + +if os.environ.get('HEROKU') is not None: + import logging + stream_handler = logging.StreamHandler() + app.logger.addHandler(stream_handler) + app.logger.setLevel(logging.INFO) + app.logger.info('microblog startup') + +from clipperz import views, models, api diff --git a/backend/flask/src/clipperz/api.py b/backend/flask/src/clipperz/api.py new file mode 100644 index 0000000..26d67d0 --- /dev/null +++ b/backend/flask/src/clipperz/api.py @@ -0,0 +1,622 @@ +import json +import random +import hashlib + +from flask import jsonify, session, g +from datetime import datetime +from flask.ext.login import logout_user, current_user, login_user, \ + login_required +from sqlalchemy.orm.exc import NoResultFound +from clipperz import app, db +from .exceptions import InvalidUsage +from .models import User, Record, RecordVersion, OneTimePassword + + +#============================================================================== +# Helpers +#============================================================================== +def randomSeed(): + return hex(random.getrandbits(32*8))[2:-1] + + +def clipperzHash(aString): + firstRound = hashlib.sha256() + firstRound.update(aString) + result = hashlib.sha256() + result.update(firstRound.digest()) + + return result.hexdigest() +#============================================================================== +# Method handlers +#============================================================================== + + +class HandlerMixin: + def handle_request(self, request): + parameters = json.loads(request.form['parameters']) + app.logger.debug('raw parameters: %s', parameters) + parameters = parameters['parameters'] + if 'message' in parameters: + message = parameters['message'] + app.logger.debug('message: %s', message) + app.logger.debug('parameters: %s', parameters) + try: + handler = getattr(self, message) + except AttributeError: + raise InvalidUsage( + 'This message handler is not yet implemented for this method', + status_code=501) + return handler(parameters, request) + + +class registration(HandlerMixin): + def completeRegistration(self, parameters, request): + credentials = parameters['credentials'] + data = parameters['user'] + user = User() + user.updateCredentials(credentials) + user.update(data) + db.session.add(user) + db.session.commit() + return jsonify(lock=user.lock, + result='done') + + +class handshake(HandlerMixin): + srp_n = '115b8b692e0e045692cf280b436735c77a5a9e8a9e7ed56c965f87db5b2a2ece3' + srp_g = 2 + srp_n = long(srp_n, 16) + + def connect(self, parameters, request): + result = {} + session['C'] = parameters['parameters']['C'] + session['A'] = parameters['parameters']['A'] + app.logger.debug('username: %s', session['C']) + + user = User().query.filter_by(username=session['C']).one() + + if user is not None and session['A'] != 0: + session['s'] = user.srp_s + session['v'] = user.srp_v + if 'otpid' in session: + try: + otpId = session['otpId'] + + one_time_password = OneTimePassword().filter_by( + id=otpId + ).one() + + if one_time_password.user.username != user.username: + one_time_password.reset('DISABLED') + raise Exception(("user mismatch between the current " + "session and 'one time password' " + "user")) + elif one_time_password.status != 'requested': + one_time_password.reset('DISABLED') + raise Exception(("Trying to use an 'one time password'" + " in the wrong state")) + + one_time_password.reset("USED") + + result['oneTimePassword'] = one_time_password.reference + db.session.add(one_time_password) + db.session.commit() + + except Exception, detail: + app.logger.error("connect.optid: " + str(detail)) + + else: + # invalid user + invalid = ('112233445566778899aabbccddeeff00112233445566778899' + 'aabbccddeeff00') + session['s'] = invalid + session['v'] = invalid + + session['b'] = randomSeed() + k = '0x64398bff522814e306a97cb9bfc4364b7eed16a8c17c5208a40a2bad2933c8e' + k = long(k, 16) + app.logger.debug('k: %s (%s)', k, hex(k)) + session['B'] = hex(k * long("0x%s" % session['v'], 16) + + pow(self.srp_g, + long("0x%s" % session['b'], 16), + self.srp_n) + )[2:-1] + result['s'] = session['s'] + result['B'] = session['B'] + app.logger.debug('Session: %s', session) + return jsonify({'result': result}) + + def credentialCheck(self, parameters, request): + country = 'US' + # hard-coded for development + result = { + 'accountInfo': { + 'features': [ + 'UPDATE_CREDENTIALS', + 'EDIT_CARD', + 'CARD_DETAILS', + 'ADD_CARD', + 'DELETE_CARD', + 'OFFLINE_COPY', + 'LIST_CARDS' + ], + 'paramentVerificationPending': False, + 'currentSubscriptionType': 'EARLY_ADOPTER', + 'isExpiring': False, + 'latestActiveLevel': 'EARLY_ADOPTER', + 'payments': [], + 'featureSet': 'FULL', + 'latestActiveThreshold': -1.0, + 'referenceDate': datetime.now(), + 'isExpired': False, + 'expirationDate': datetime(4001, 1, 1) + }, + } + + A = long("0x%s" % session['A'], 16) + B = long("0x%s" % session['B'], 16) + b = long("0x%s" % session['b'], 16) + v = long("0x%s" % session['v'], 16) + u = long("0x%s" % clipperzHash(str(A) + str(B)), 16) + s = long("0x%s" % session['s'], 16) + C = session['C'] + n = self.srp_n + + S = pow((A * pow(v, u, n)), b, n) + K = clipperzHash(str(S)) + M1 = '{0}{1}{2}{3}{4}{5}{6}'.format( + '5976268709782868014401975621485889074340014836557888656093758064', + '39877501869636875571920406529', + clipperzHash(str(C)), + str(s), + str(A), + str(B), + str(K) + ) + M1 = clipperzHash(M1) + if M1 == parameters['parameters']['M1']: + session['K'] = K + M2 = clipperzHash(str(A) + M1 + K) + + result['M2'] = M2 + result['connectionId'] = '' + result['loginInfo'] = {} + result['loginInfo']['current'] = { + 'date': datetime.now(), + 'ip': request.remote_addr, + 'browser': request.user_agent.browser, + 'operatingSystem': request.user_agent.platform, + 'disconnectionType': 'STILL_CONNECTED', + 'country': country + }, + result['loginInfo']['latest'] = {} + result['offlineCopyNeeded'] = False + user = User().query.filter_by(username=session['C']).one() + result['lock'] = user.lock + login_user(user) + session['User'] = user + else: + result['error'] = '?' + + result['s'] = session['s'] + result['B'] = session['B'] + return jsonify({'result': result}) + + def oneTimePassword(self, parameters, request): + #"parameters": { + #"message": "oneTimePassword", + #"version": "0.2", + #"parameters": { + # "oneTimePasswordKey": "03bd882...396082c", + # "oneTimePasswordKeyChecksum": "f73f629...041031d" + #} + #} + result = {} + + try: + key = parameters['parameters']['oneTimePasswordKey'] + checksum = parameters['parameters']['oneTimePasswordKeyChecksum'] + otp = OneTimePassword().query.filter_by(key_value=key).one() + if otp.status == 'ACTIVE': + if otp.key_checksum == checksum: + session['userId'] = otp.user.id + session['otpId'] = otp.id + result['data'] = otp.data + result['version'] = otp.version + otp.data = '' + otp.status = 'REQUESTED' + else: + otp.data = '' + otp.status = 'DISABLED' + db.session.add(otp) + db.session.commit() + except NoResultFound, details: + app.logger.debug('OTP No Results Found: ', details) + + return jsonify({'result': result}) + + +class message(HandlerMixin): + @login_required + def getUserDetails(self, parameters, request): + app.logger.debug(parameters) + if 'srpSharedSecret' not in parameters: + raise InvalidUsage( + 'Mal-formed message format.', + status_code=400) + srpSharedSecret = parameters['srpSharedSecret'] + if srpSharedSecret != session['K']: + raise InvalidUsage( + 'Your session is invalid, please re-authenticate', + status_code=401) + # Online results + # {"result": + # { + # "header": "{\"records\":{\"index\":{\"383036...eeefbe48\":\"0\"},\"data\":\"zrhb3/791SDdb48v3vXfPzeDrv0Jhs4rAaOKHx+jDF6pwm/qi9DGSR0JwrprOgwv3bjYJgU2xHA8cuA0bPvABHSHK6fnGwvhSlyYjskY2Cy/WbRJhcA4kw+VUsOjZPRxtM8bSJnSxViAXsghTcya6+5M3MdMJHE=\"},\"directLogins\":{\"index\":{},\"data\":\"s7KYzHwKISmjYufv9h0mpTiM\"},\"preferences\":{\"data\":\"mf8fWjpOQjlV18ukEO9FN3LP\"},\"oneTimePasswords\":{\"data\":\"8tV1yRHv30lsl3FadG9YnTOo\"},\"version\":\"0.1\"}", + # "lock": "3D4B4501-D7A9-6E4F-A487-9428C0B6E79D", + # "version": "0.4", + # "recordsStats": { + # "383036...eeefbe48":{ + # "updateDate": "Sun, 12 April 2015 17:11:01 UTC", + # "accessDate": "Sun, 12 April 2015 17:11:01 UTC" + # } + # }, + # "offlineCopyNeeded":true, + # "statistics":"ByYItDeZMdZ+e/pafp14bGrR" + # } + # } + # Dev results + #{"result": + # {"header": "{\"records\":{\"index\":{\"843a95d8...5f734b\":\"1\"},\"data\":\"fKgc5Jt9JH/CibCIpcRmwyLuLIvufWchNJga7GoFcWT9K8LR+ai0BvzWBUxcPccivE9zPv2Swe5E8wPEIc+Lv0U73NobJEct7WqBcCdLxszBE1SokxPEZDUVdWVQtAiwgOS219inCFmI5CaB\"},\"directLogins\":{\"index\":{},\"data\":\"rnMQBB81ezh6JKNGXkDCyY+q\"},\"preferences\":{\"data\":\"9jzR9Goo5PGpXbAdmsXHuQGp\"},\"oneTimePasswords\":{\"data\":\"iXEUuQGskZhMyHEwU+3tRGQM\"},\"version\":\"0.1\"}", + # "recordStats": { + # "843a95d8...5f734b": { + # "updateDate": "Sun, 12 Apr 2015 13:08:44 GMT" + # } + # }, + # "statistics": "", + # "version": "0.4"}} + result = {} + user = User().query.filter_by(username=session['C']).one() + + records_stats = {} + for record in user.records: + records_stats[record.reference] = { + 'updateDate': record.update_date, + 'accessDate': record.access_date + } + + result['recordsStats'] = records_stats + result['header'] = user.header + result['statistics'] = user.statistics + result['version'] = user.version + result['offlineCopyNeeded'] = not user.offline_saved + result['lock'] = user.lock + return jsonify({'result': result}) + + @login_required + def saveChanges(self, parameters, request): + result = {} + parameters = parameters['parameters'] + if ('user' not in parameters + or 'records' not in parameters): + app.logger.debug('saveChanges parameters: %s', parameters) + raise InvalidUsage( + 'Mal-formed message format.', + status_code=400) + + user_data = parameters['user'] + record_data = parameters['records'] + + user = User().query.filter_by(username=session['C']).one() + app.logger.debug('user_data: %s', user_data) + user.update(user_data) + db.session.add(user) + + if 'updated' in record_data: + for entry in record_data['updated']: + reference = entry['record']['reference'] + try: + record = Record().query.filter_by(reference=reference).one() + except NoResultFound: + record = Record(user=user) + record_version = RecordVersion(record=record) + record_version.update(entry) + db.session.add(record) + db.session.add(record_version) + + if 'deleted' in record_data: + for reference in record_data['deleted']: + try: + record = Record().query.filter_by(reference=reference).one() + db.session.delete(record) + except NoResultFound: + pass + + db.session.commit() + result['lock'] = user.lock + result['result'] = 'done' + + return jsonify({'result': result}) + + @login_required + def getRecordDetail(self, parameters, request): + #{ + # "parameters": { + # "srpSharedSecret": "bf79ad3cf0c1...63462a9fb560", + # "message": "getRecordDetail", + # "parameters": { + # "reference": "e3a5856...20e080fc97f13c14c" + # } + # } + #} + app.logger.debug(parameters) + if 'srpSharedSecret' not in parameters: + raise InvalidUsage( + 'Mal-formed message format.', + status_code=400) + srpSharedSecret = parameters['srpSharedSecret'] + if (srpSharedSecret != session['K'] and session['User'] != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + reference = parameters['parameters']['reference'] + result = {} + + record = Record().query.filter_by(reference=reference).one() + app.logger.debug(record.current_record_version) + record_versions = {} + oldest_encryption_version = None + versions = RecordVersion().query.filter_by(record=record).all() + for record_version in versions: + version_entry = {} + version_entry['reference'] = record_version.reference + version_entry['data'] = record_version.data + version_entry['header'] = record_version.header + version_entry['version'] = record_version.api_version + version_entry['creationDate'] = record_version.creation_date + version_entry['updateDate'] = record_version.update_date + version_entry['accessDate'] = record_version.access_date + try: + previous_version = RecordVersion().query.filter_by( + id=record_version.previous_version_id).one() + reference = previous_version.reference + key = record_version.previous_version_key + version_entry['previousVersion'] = reference + version_entry['previousVersionKey'] = key + except NoResultFound: + pass + if (not oldest_encryption_version + or oldest_encryption_version > record_version.api_version): + oldest_encryption_version = record_version.api_version + record_versions[record_version.reference] = version_entry + + result['reference'] = record.reference + result['data'] = record.data + result['version'] = record.api_version + result['creationDate'] = str(record.creation_date) + result['updateDate'] = str(record.update_date) + result['accessDate'] = str(record.access_date) + result['oldestUsedEncryptedVersion'] = oldest_encryption_version + result['versions'] = record_versions + result['currentVersion'] = record.current_record_version.reference + return jsonify({'result': result}) + + @login_required + def getOneTimePasswordsDetails(self, parameters, request): + #{ + # "parameters": { + # "srpSharedSecret": "bf79ad3cf0c1...63462a9fb560", + # "message": "getOneTimePasswordsDetails", + # "parameters": {} + # } + #} + if 'srpSharedSecret' not in parameters: + raise InvalidUsage( + 'Mal-formed message format.', + status_code=400) + srpSharedSecret = parameters['srpSharedSecret'] + if (srpSharedSecret != session['K'] and session['User'] != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + result = {} + + otps = OneTimePassword().query.filter_by(user=current_user).all() + for otp in otps: + #{"e8541...af0c6b951":{"status":"ACTIVE"}} + result[otp.reference] = {'status': otp.status} + + return jsonify({'result': result}) + + @login_required + def getLoginHistory(self, parameters, request): + #{ + # "parameters": { + # "srpSharedSecret": "bf79ad3cf0c1...63462a9fb560", + # "message": "getOneTimePasswordsDetails", + # "parameters": {} + # } + #} + if 'srpSharedSecret' not in parameters: + raise InvalidUsage( + 'Mal-formed message format.', + status_code=400) + srpSharedSecret = parameters['srpSharedSecret'] + if (srpSharedSecret != session['K'] and session['User'] != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + result = {} + + user = User().query.filter_by(username=session['C']).one() + + if (user != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + return jsonify({'result': result}) + + @login_required + def addNewOneTimePassword(self, parameters, request): + # "parameters": { + # "message": "addNewOneTimePassword", + # "srpSharedSecret": "1e8e037a8...85680f931d45dfc20472cf9d1", + # "parameters": { + # "user": { + # "header":
+ # "statistics": "WxHa6VSMmZunOjLCwAVQrkYI", + # "version": "0.4", + # "lock": "new lock" + # }, + # "oneTimePassword": { + # "reference": "ffaec6f...7b123d39b8965e7e5", + # "key": "496dc431db...faec137698b16c", + # "keyChecksum": "f927c1...eb970552360a311dda", + # "data": "GcfCFsoSc5RT...MF8nstFXXHYSXF+Vyj4w=", + # "version": "0.4" + # } + # } + # } + #} + if 'srpSharedSecret' not in parameters: + raise InvalidUsage( + 'Mal-formed message format.', + status_code=400) + srpSharedSecret = parameters['srpSharedSecret'] + if (srpSharedSecret != session['K'] and session['User'] != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + result = {} + parameters = parameters['parameters'] + + user = User().query.filter_by(username=session['C']).one() + + if (user != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + user_data = parameters['user'] + + app.logger.debug('user_data: %s', user_data) + user.update(user_data) + db.session.add(user) + + one_time_password = parameters['oneTimePassword'] + otp = OneTimePassword( + reference=one_time_password['reference'], + key_value=one_time_password['key'], + key_checksum=one_time_password['keyChecksum'], + data=one_time_password['data'], + version=one_time_password['version'], + user=user, + status='ACTIVE' + ) + db.session.add(otp) + db.session.commit() + + return jsonify({'result': result}) + + def echo(self, parameters, request): + result = {} + if 'srpSharedSecret' not in parameters: + raise InvalidUsage( + 'Mal-formed message format.', + status_code=400) + srpSharedSecret = parameters['srpSharedSecret'] + if (srpSharedSecret != session['K'] and session['User'] != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + user = User().query.filter_by(username=session['C']).one() + + if (user != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + user.offline_saved = True + db.session.add(user) + db.session.commit() + return jsonify({'result': result}) + + @login_required + def deleteUser(self, parameters, request): + result = {} + if 'srpSharedSecret' not in parameters: + raise InvalidUsage( + 'Mal-formed message format.', + status_code=400) + srpSharedSecret = parameters['srpSharedSecret'] + if (srpSharedSecret != session['K'] and session['User'] != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + user = User().query.filter_by(username=session['C']).one() + + if (user != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + db.session.delete(user) + db.session.commit() + return jsonify({'result': result}) + + @login_required + def upgradeUserCredentials(self, parameters, request): + #{"parameters":{"message":"upgradeUserCredentials","srpSharedSecret":"36...d6","parameters":{"credentials":{"C":"59d02038fdb47cee5b7837a697bc8ff41cc66d8844c8fce844cdf45b0b08b1e4","s":"fe40513b99fbaca9bfe51b8d6e9b3eb42b1e01ce8b0ae32461bec0294c1030ed","v":"300b92f4a3e34034d78cd5081f8db36dbf2a4c5f7a41db6954518815a3554278","version":"0.2"},"user":{"header":"{\"records\":{\"index\":{},\"data\":\"VIIDc5vFNoIflyXF8syb8fRS\"},\"directLogins\":{\"index\":{},\"data\":\"9elg3tu2UqsJ0zbUAdQkLE69\"},\"preferences\":{\"data\":\"Sbwar35Ynd/XobuAm4K66lqj\"},\"oneTimePasswords\":{\"data\":\"tAcTsWVTwALSfxXvCChHi4FD\"},\"version\":\"0.1\"}","statistics":"","version":"0.4","lock":null}}}} + result = {} + if 'srpSharedSecret' not in parameters: + raise InvalidUsage( + 'Mal-formed message format.', + status_code=400) + srpSharedSecret = parameters['srpSharedSecret'] + if (srpSharedSecret != session['K'] and session['User'] != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + user = User().query.filter_by(username=session['C']).one() + + if (user != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + parameters = parameters['parameters'] + user.updateCredentials(parameters['credentials']) + user.update(parameters['user']) + + if 'oneTimePasswords' in parameters: + for otpRef in parameters['oneTimePasswords']: + try: + otp = OneTimePassword().query.filter_by( + reference=otpRef).one() + otp.data = parameters['oneTimePasswords'][otpRef] + db.session.add(otp) + except NoResultFound: + pass + + db.session.add(user) + db.session.commit() + result['lock'] = user.lock + result['result'] = 'done' + return jsonify({'result': result}) + + +class logout(HandlerMixin): + def handle_request(self, request): + result = {} + logout_user() + session.clear() + result['method'] = 'logout' + return jsonify({'result': result}) diff --git a/backend/flask/src/clipperz/exceptions.py b/backend/flask/src/clipperz/exceptions.py new file mode 100644 index 0000000..5ada520 --- /dev/null +++ b/backend/flask/src/clipperz/exceptions.py @@ -0,0 +1,25 @@ +from clipperz import app +from flask import jsonify + + +class InvalidUsage(Exception): + status_code = 400 + + def __init__(self, message, status_code=None, payload=None): + Exception.__init__(self) + self.message = message + if status_code is not None: + self.status_code = status_code + self.payload = payload + + def to_dict(self): + rv = dict(self.payload or ()) + rv['message'] = self.message + return rv + + +@app.errorhandler(InvalidUsage) +def handle_invalid_usage(error): + response = jsonify(error.to_dict()) + response.status_code = error.status_code + return response diff --git a/backend/flask/src/clipperz/models.py b/backend/flask/src/clipperz/models.py new file mode 100644 index 0000000..9737f37 --- /dev/null +++ b/backend/flask/src/clipperz/models.py @@ -0,0 +1,151 @@ +import datetime + +from flask.ext.login import UserMixin + +from clipperz import app, db + + +class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(), unique=True, index=True) + srp_s = db.Column(db.String(128)) + srp_v = db.Column(db.String(128)) + header = db.Column(db.Text()) + statistics = db.Column(db.Text()) + auth_version = db.Column(db.String()) + version = db.Column(db.String()) + lock = db.Column(db.String()) + records = db.relationship( + 'Record', + backref='user', + lazy='dynamic', + cascade='save-update, merge, delete, delete-orphan') + otps = db.relationship( + 'OneTimePassword', + backref='user', + lazy='dynamic', + cascade='save-update, merge, delete, delete-orphan') + offline_saved = db.Column(db.Boolean(), default=False) + update_date = db.Column(db.DateTime(), nullable=True) + + def updateCredentials(self, credentials): + self.username = credentials['C'] + self.srp_s = credentials['s'] + self.srp_v = credentials['v'] + self.auth_version = credentials['version'] + + def update(self, data): + self.header = data['header'] + self.statistics = data['statistics'] + self.version = data['version'] + if 'lock' in data: + self.lock = data['lock'] + self.update_date = datetime.datetime.utcnow() + self.offline_saved = False + + def __repr__(self): + return '' % (self.username) + +#------------------------------------------------------------------------------ + + +class RecordVersion(db.Model): + id = db.Column(db.Integer(), primary_key=True) + reference = db.Column(db.String(), unique=True, index=True) + header = db.Column(db.Text()) + data = db.Column(db.Text()) + api_version = db.Column(db.String()) + version = db.Column(db.Integer()) + previous_version_key = db.Column(db.String()) + previous_version_id = db.Column(db.Integer(), + db.ForeignKey('record_version.id')) + creation_date = db.Column(db.DateTime(), default=datetime.datetime.utcnow) + update_date = db.Column(db.DateTime(), default=datetime.datetime.utcnow) + access_date = db.Column(db.DateTime(), default=datetime.datetime.utcnow) + + record_id = db.Column(db.Integer(), + db.ForeignKey('record.id'), + nullable=False) + + record = db.relationship('Record', + backref=db.backref('record_versions', + order_by=id, + cascade='all,delete')) + + def update(self, someData): + app.logger.debug(someData) + recordVersionData = someData['currentRecordVersion'] + self.reference = recordVersionData['reference'] + self.data = recordVersionData['data'] + self.api_version = recordVersionData['version'] + self.version = self.record.version + self.previous_version_key = recordVersionData['previousVersionKey'] + self.update_date = datetime.datetime.utcnow() + + self.record.update(someData['record'], self) +#------------------------------------------------------------------------------ + + +class Record(db.Model): + id = db.Column(db.Integer(), primary_key=True) + user_id = db.Column(db.ForeignKey('user.id')) + reference = db.Column(db.String(), unique=True, index=True) + data = db.Column(db.Text()) + api_version = db.Column(db.String()) + version = db.Column(db.Integer(), default=0) + creation_date = db.Column(db.DateTime(), default=datetime.datetime.utcnow) + update_date = db.Column(db.DateTime(), default=datetime.datetime.utcnow) + access_date = db.Column(db.DateTime(), default=datetime.datetime.utcnow) + + current_record_version = db.relationship( + 'RecordVersion', + uselist=False, + cascade='save-update, merge, delete, delete-orphan') + + def update(self, data, record_version): + self.reference = data['reference'] + self.data = data['data'] + self.api_version = data['version'] + self.update_date = datetime.datetime.now() + self.current_record_version = record_version + if self.version: + self.version += 1 + else: + self.version = 1 + +#------------------------------------------------------------------------------ + + +class OneTimePassword(db.Model): + id = db.Column(db.Integer(), primary_key=True) + user_id = db.Column(db.ForeignKey('user.id')) + status = db.Column(db.String()) + reference = db.Column(db.String(), unique=True) + key_value = db.Column(db.String()) + key_checksum = db.Column(db.String()) + data = db.Column(db.Text()) + version = db.Column(db.String()) + creation_date = db.Column(db.DateTime(), default=datetime.datetime.utcnow) + request_date = db.Column(db.DateTime()) + usage_date = db.Column(db.DateTime()) + + def update(self, someParameters, aStatus): + self.reference = someParameters['reference'] + self.key_value = someParameters['key'] + self.key_checksum = someParameters['keyChecksum'] + self.data = someParameters['data'] + self.version = someParameters['version'] + self.status = aStatus + + def reset(self, aStatus): + self.data = "" + self.status = aStatus + return self + +#------------------------------------------------------------------------------ + + +class Session(db.Model): + id = db.Column(db.Integer(), primary_key=True) + sessionId = db.Column(db.String()) + access_date = db.Column(db.DateTime(), default=datetime.datetime.utcnow) diff --git a/backend/flask/src/clipperz/views.py b/backend/flask/src/clipperz/views.py new file mode 100644 index 0000000..e70f0ac --- /dev/null +++ b/backend/flask/src/clipperz/views.py @@ -0,0 +1,128 @@ +from flask import session, request, g, jsonify +from clipperz import app, db, lm +from .models import User +from .api import * +from .exceptions import InvalidUsage +from flask.ext.login import login_required + + +@lm.user_loader +def load_user(id): + return User.query.get(int(id)) + + +@app.before_request +def before_request(): + g.user = current_user + + +@app.teardown_appcontext +def shutdown_session(exception=None): + db.session.remove() + + +@app.route('/beta/dump/') +@app.route('/gamma/dump/') +@app.route('/delta/dump/') +@login_required +def dump(frontend_version): + user = User().query.filter_by(username=session['C']).one() + + if (user != g.user): + raise InvalidUsage( + 'Your session is incorrect, please re-authenticate', + status_code=401) + + user.offline_saved = True + db.session.add(user) + db.session.commit() + user_data = {} + user_data['users'] = { + 'catchAllUser': { + '__masterkey_test_value__': 'masterkey', + 's': ('112233445566778899aabbccddeeff00112233445566778899' + 'aabbccddeeff00'), + 'v': ('112233445566778899aabbccddeeff00112233445566778899' + 'aabbccddeeff00'), + } + } + + records = {} + for current_record in user.records: + versions = {} + for version in current_record.record_versions: + versions[version.reference] = { + 'header': version.header, + 'data': version.data, + 'version': version.api_version, + 'creationDate': str(version.creation_date), + 'updateDate': str(version.update_date), + 'accessDate': str(version.access_date) + } + + records[current_record.reference] = { + 'data': current_record.data, + 'version': current_record.version, + 'creationDate': str(current_record.creation_date), + 'updateDate': str(current_record.update_date), + 'accessDate': str(current_record.access_date), + 'currentVersion': current_record.current_record_version, + 'versions': versions + } + + user_data['users'][user.username] = { + 's': user.srp_s, + 'v': user.srp_v, + 'version': user.auth_version, + 'maxNumberOfRecords': '100', + 'userDetails': user.header, + 'statistics': user.statistics, + 'userDetailsVersion': user.version, + 'records': records + } + + offline_data_placeholder = ( + '_clipperz_data_ = {user_data}\n' + 'Clipperz.PM.Proxy.defaultProxy = new Clipperz.PM.Proxy.Offline();' + '\n' + 'Clipperz.Crypto.PRNG.defaultRandomGenerator()' + '.fastEntropyAccumulationForTestingPurpose();' + '\n').format(user_data=user_data) + + with open(os.path.join(APP_ROOT, + '{0}/index.html'.format(frontend_version))) as f: + offline_dump = f.read() + + offline_dump = offline_dump.replace('/*offline_data_placeholder*/', + offline_data_placeholder) + response = make_response(offline_dump) + content_disposition = "attachment; filename='Clipperz.html'" + response.headers['Content-Disposition'] = content_disposition + + return response + + +@app.route('/beta/') +def beta(path): + return send_from_directory('beta', path) + + +@app.route('/gamma/') +def gamma(path): + return send_from_directory('gamma', path) + + +@app.route('/delta/') +def delta(path): + return send_from_directory('delta', path) + + +@app.route('/pm', methods=['GET', 'OPTIONS', 'POST']) +def pm(): + method = request.form['method'] + if method not in globals(): + raise InvalidUsage('This method is not yet implemented', + status_code=501) + handler = globals()[method]() + app.logger.debug(method) + return handler.handle_request(request) diff --git a/backend/flask/src/config.py b/backend/flask/src/config.py new file mode 100644 index 0000000..a32c6f7 --- /dev/null +++ b/backend/flask/src/config.py @@ -0,0 +1,35 @@ +import datetime +import os +basedir = os.path.abspath(os.path.dirname(__file__)) + + +CSRF_ENABLED = True + + +if os.environ.get('DATABASE_URL') is None: + SQLALCHEMY_DATABASE_URI = ('sqlite:///' + os.path.join(basedir, 'app.db') + + '?check_same_thread=False') +else: + SQLALCHEMY_DATABASE_URI = os.environ['DATABASE_URL'] +SQLALCHEMY_MIGRATE_REPO = os.path.join(basedir, 'db_repository') +SQLALCHEMY_RECRD_QUERIES = True + +ADMINS = ['you@example.com'] + + +class Config(object): + DEBUG = False + TESTING = False + SQLALCHEMY_ECHO = False + WTF_CSRF_ENABLED = True + SECRET_KEY = 'you-will-never-guess' + sessionTimeout = datetime.timedelta(minutes=-2) + + +class DevelopmentConfig(Config): + DEBUG = True + SQLALCHEMY_ECHO = True + + +class TestingConfig(Config): + TESTING = True diff --git a/backend/flask/src/db_create.py b/backend/flask/src/db_create.py new file mode 100644 index 0000000..cd6ce69 --- /dev/null +++ b/backend/flask/src/db_create.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +from migrate.versioning import api +from config import SQLALCHEMY_DATABASE_URI +from config import SQLALCHEMY_MIGRATE_REPO +from app import db +import os.path +db.create_all() +if not os.path.exists(SQLALCHEMY_MIGRATE_REPO): + api.create(SQLALCHEMY_MIGRATE_REPO, 'database repository') + api.version_control(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +else: + api.version_control(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO, + api.version(SQLALCHEMY_MIGRATE_REPO)) diff --git a/backend/flask/src/db_downgrade.py b/backend/flask/src/db_downgrade.py new file mode 100644 index 0000000..c001e6c --- /dev/null +++ b/backend/flask/src/db_downgrade.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +from migrate.versioning import api +from config import SQLALCHEMY_DATABASE_URI +from config import SQLALCHEMY_MIGRATE_REPO +v = api.db_version(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +api.downgrade(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO, v - 1) +v = api.db_version(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +print('Current database version: ' + str(v)) diff --git a/backend/flask/src/db_migrate.py b/backend/flask/src/db_migrate.py new file mode 100644 index 0000000..03f65d1 --- /dev/null +++ b/backend/flask/src/db_migrate.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +import imp +from migrate.versioning import api +from app import db +from config import SQLALCHEMY_DATABASE_URI +from config import SQLALCHEMY_MIGRATE_REPO +v = api.db_version(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +migration = SQLALCHEMY_MIGRATE_REPO + ('/versions/%03d_migration.py' % (v+1)) +tmp_module = imp.new_module('old_model') +old_model = api.create_model(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +exec(old_model, tmp_module.__dict__) +script = api.make_update_script_for_model(SQLALCHEMY_DATABASE_URI, + SQLALCHEMY_MIGRATE_REPO, + tmp_module.meta, + db.metadata) +open(migration, "wt").write(script) +api.upgrade(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +v = api.db_version(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +print('New migration saved as ' + migration) +print('Current database version: ' + str(v)) diff --git a/backend/flask/src/db_repository/README b/backend/flask/src/db_repository/README new file mode 100644 index 0000000..6218f8c --- /dev/null +++ b/backend/flask/src/db_repository/README @@ -0,0 +1,4 @@ +This is a database migration repository. + +More information at +http://code.google.com/p/sqlalchemy-migrate/ diff --git a/backend/flask/src/db_repository/__init__.py b/backend/flask/src/db_repository/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/flask/src/db_repository/manage.py b/backend/flask/src/db_repository/manage.py new file mode 100644 index 0000000..554f89c --- /dev/null +++ b/backend/flask/src/db_repository/manage.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +from migrate.versioning.shell import main + +if __name__ == '__main__': + main() diff --git a/backend/flask/src/db_repository/migrate.cfg b/backend/flask/src/db_repository/migrate.cfg new file mode 100644 index 0000000..40bb1e3 --- /dev/null +++ b/backend/flask/src/db_repository/migrate.cfg @@ -0,0 +1,25 @@ +[db_settings] +# Used to identify which repository this database is versioned under. +# You can use the name of your project. +repository_id=clpperz + +# The name of the database table used to track the schema version. +# This name shouldn't already be used by your project. +# If this is changed once a database is under version control, you'll need to +# change the table name in each database too. +version_table=migrate_version + +# When committing a change script, Migrate will attempt to generate the +# sql for all supported databases; normally, if one of them fails - probably +# because you don't have that database installed - it is ignored and the +# commit continues, perhaps ending successfully. +# Databases in this list MUST compile successfully during a commit, or the +# entire commit will fail. List the databases your application will actually +# be using to ensure your updates to that database work properly. +# This must be a list; example: ['postgres','sqlite'] +required_dbs=[] + +# When creating new change scripts, Migrate will stamp the new script with +# a version number. By default this is latest_version + 1. You can set this +# to 'true' to tell Migrate to use the UTC timestamp instead. +use_timestamp_numbering=False diff --git a/backend/flask/src/db_repository/versions/__init__.py b/backend/flask/src/db_repository/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/flask/src/db_upgrade.py b/backend/flask/src/db_upgrade.py new file mode 100644 index 0000000..f5ae27b --- /dev/null +++ b/backend/flask/src/db_upgrade.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +from migrate.versioning import api +from config import SQLALCHEMY_DATABASE_URI +from config import SQLALCHEMY_MIGRATE_REPO +api.upgrade(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +v = api.db_version(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +print('Current database version: ' + str(v)) diff --git a/backend/flask/src/run.sh b/backend/flask/src/run.sh new file mode 100644 index 0000000..34f0381 --- /dev/null +++ b/backend/flask/src/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Sets up flask development environment and activates it + +virtualenv . + +bin/python setup.py develop +bin/python clipperz.py diff --git a/backend/flask/src/setup.py b/backend/flask/src/setup.py new file mode 100644 index 0000000..affb5e5 --- /dev/null +++ b/backend/flask/src/setup.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +from __future__ import print_function +from setuptools import setup +from setuptools.command.test import test as TestCommand +import io +import os +import sys + +here = os.path.abspath(os.path.dirname(__file__)) + + +def read(*filenames, **kwargs): + encoding = kwargs.get('encoding', 'utf-8') + sep = kwargs.get('sep', '\n') + buf = [] + try: + for filename in filenames: + with io.open(filename, encoding=encoding) as f: + buf.append(f.read()) + except IOError: + pass + return sep.join(buf) + +long_description = read('README.txt', 'CHANGES.txt') + + +class PyTest(TestCommand): + def finalize_options(self): + TestCommand.finalize_options(self) + self.test_args = [] + self.test_suite = True + + def run_tests(self): + import pytest + errcode = pytest.main(self.test_args) + sys.exit(errcode) + +setup( + name='clipperz', + version='0.1.0', + url='http://github.com/clipperz/password-manager/', + license='Apache Software License', + author='Jokajak', + tests_require=['pytest'], + install_requires=['Flask>=0.10.1', + 'Flask-SQLAlchemy>=1.0', + 'SQLAlchemy>=0.8.2', + 'Flask-Login', + 'Flask-KVSession', + ], + cmdclass={'test': PyTest}, + author_email='jokajak@gmail.com', + description='Clipperz password manager server', + long_description=long_description, + packages=['clipperz'], + include_package_data=True, + platforms='any', + test_suite='clipperz.test.test_clipperz', + classifiers=[ + 'Programming Language :: Python', + 'Development Status :: 4 - Beta', + 'Natural Language :: English', + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + ], + extras_require={ + 'testing': ['pytest'], + } +) diff --git a/scripts/builder/backends/flaskBuilder.py b/scripts/builder/backends/flaskBuilder.py new file mode 100755 index 0000000..613263a --- /dev/null +++ b/scripts/builder/backends/flaskBuilder.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +import os +import shutil +from scriptLanguageBuilder import ScriptLanguageBuilder + + +class FlaskBuilder(ScriptLanguageBuilder): + + def name(self): + return "Flask builder" + + def relativePath(self): + return 'flask' + + def createPackage(self): + super(FlaskBuilder, self).createPackage() diff --git a/scripts/builder/frontends/deltaBuilder.py b/scripts/builder/frontends/deltaBuilder.py index 24b523b..8f5223a 100644 --- a/scripts/builder/frontends/deltaBuilder.py +++ b/scripts/builder/frontends/deltaBuilder.py @@ -1,5 +1,5 @@ from frontendBuilder import FrontendBuilder -from scss import Scss +#from scss import Scss import os import shutil @@ -38,6 +38,7 @@ class DeltaBuilder(FrontendBuilder): return "" def preprocessCSS (self, targetFile): + from scss import Scss logging.basicConfig() scssVariables = {} scssCompiler = Scss(