| #!/usr/bin/env python |
| # |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| from __future__ import division, absolute_import, print_function, unicode_literals |
| |
| import datetime |
| import json |
| import os |
| import sys |
| import time |
| import uuid |
| from urlparse import urlparse |
| |
| import tornado.autoreload |
| import tornado.escape |
| import tornado.gen |
| import tornado.httpclient |
| import tornado.httpserver |
| import tornado.ioloop |
| import tornado.options |
| import tornado.web |
| from tornado.log import app_log as log |
| from tornado.options import define, options |
| |
| import mailer |
| from mpin_utils.common import detectProxy, getLogLevel, Time |
| from storage import get_storage_cls |
| |
| if os.name == "posix": |
| from mpDaemon import Daemon |
| elif os.name == "nt": |
| from mpWinService import Service as Daemon |
| else: |
| raise Exception("Unsupported platform: {0}".format(os.name)) |
| |
| BASE_DIR = os.path.dirname(__file__) |
| CONFIG_FILE = os.path.join(BASE_DIR, "config.py") |
| |
| |
| # OPTIONS |
| |
| # general options |
| define("configFile", default=os.path.join(BASE_DIR, "config.py"), type=unicode) |
| define("address", default="127.0.0.1", type=unicode) |
| define("port", default=8005, type=int) |
| define("cookieSecret", type=unicode) |
| define("resourcesBasePath", default=BASE_DIR, type=unicode) |
| define("mpinJSURL", default="", type=unicode) |
| |
| # debugging options |
| define("autoReload", default=False, type=bool) |
| define("logLevel", default="ERROR", type=unicode) |
| define("forceActivate", default=False, type=bool) |
| |
| # RPS service discovery options |
| define("RPSURL", default="", type=unicode) |
| define("rpsPrefix", default="rps", type=unicode) |
| define("clientSettingsURL", default="", type=unicode) |
| define("verifyIdentityURL", default="", type=unicode) |
| |
| # authentication options |
| define("requestOTP", default=False, type=bool) |
| |
| # email options |
| define("emailSubject", type=unicode) |
| define("emailSender", type=unicode) |
| define("smtpServer", type=unicode) |
| define("smtpPort", default=25, type=int) |
| define("smtpUser", type=unicode) |
| define("smtpPassword", type=unicode) |
| define("smtpUseTLS", default=False, type=bool) |
| |
| # mobile |
| define("mobileOnly", default=False, type=bool) |
| define("mobileSupport", default=True, type=bool) |
| define("mobileAppPath", default="", type=unicode) |
| define("mobileAppFullURL", default="", type=unicode) |
| |
| define("mobileUseNative", default=False, type=bool) |
| define("mobileConfigURL", default=None, type=unicode) |
| |
| |
| # UTILITIES |
| class MobileLoginHandler(object): |
| def __init__(self): |
| self.waiters = dict() |
| |
| def waitForLogin(self, callback, sessionId, logout=False): |
| sid = logout and ("logout;%s" % sessionId) or sessionId |
| log.debug("Adding callback for session {0}".format(sid)) |
| self.waiters[sid] = callback |
| |
| def cancelWait(self, sessionId, logout=False): |
| sid = logout and ("logout;%s" % sessionId) or sessionId |
| if self.waiters.get(sid): |
| del self.waiters[sid] |
| |
| def userLogged(self, sessionId, logout=False): |
| sid = logout and ("logout;%s" % sessionId) or sessionId |
| log.debug("WAITING CALLBACK FOR: {0}".format(sid)) |
| |
| callback = self.waiters.get(sid) |
| if callback: |
| callback() |
| if self.waiters.get(sid): |
| del self.waiters[sid] |
| return True |
| else: |
| log.debug("No Callback!") |
| |
| return False |
| mobileLoginHandler = MobileLoginHandler() |
| |
| |
| def generateSessionID(): |
| return uuid.uuid4().hex + uuid.uuid1().hex |
| |
| |
| # BASE HANDLERS |
| class BaseHandler(tornado.web.RequestHandler): |
| def set_default_headers(self): |
| self.set_header("Access-Control-Allow-Origin", "*") |
| self.set_header("Access-Control-Allow-Credentials", "true") |
| self.set_header("Access-Control-Allow-Methods", "GET,POST,HEAD,OPTIONS") |
| self.set_header("Access-Control-Allow-Headers", "Content-Type, Depth, User-Agent, X-File-Size, X-Requested-With, X-Requested-By, If-Modified-Since, X-File-Name, Cache-Control, Pragma, Expires, WWW-Authenticate") |
| # self.set_header("Connection", "keep-alive") |
| |
| self.set_header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate") |
| self.set_header("Pragma", "no-cache") |
| self.set_header("Expires", "Sat, 26 Jul 1997 05:00:00 GMT") |
| |
| def prepare(self): |
| self.sessionId = self.get_secure_cookie("mpindemo_session") |
| |
| if not self.sessionId: |
| self.sessionId = generateSessionID() |
| log.debug("Generated new sessionId: {0}".format(self.sessionId)) |
| self.set_secure_cookie("mpindemo_session", self.sessionId) |
| self.loggedUser = "" |
| else: |
| item = self.storage.find(key="s;{0}".format(self.sessionId)) |
| self.loggedUser = item.value if item else None |
| |
| def get_flash(self): |
| flash = self.get_secure_cookie("flash") |
| if flash: |
| self.clear_cookie("flash") |
| return flash |
| |
| def set_flash(self, message): |
| self.set_secure_cookie("flash", message) |
| |
| @property |
| def storage(self): |
| return self.application.storage |
| |
| def finish(self, *args, **kwargs): |
| if self._status_code == 401: |
| log.error('asdasdasd') |
| self.set_header("WWW-Authenticate", "Authenticate") |
| super(BaseHandler, self).finish(*args, **kwargs) |
| |
| |
| # APPLICATION HANDLERS |
| class RPSRedirectHandler(BaseHandler): |
| SUPPORTED_METHODS = ['GET', 'POST', 'PUT', 'OPTIONS'] |
| |
| @tornado.web.asynchronous |
| @tornado.gen.engine |
| def _processRequest(self, path): |
| |
| url = "{0}/{1}".format(options.RPSURL.rstrip("/"), self.request.uri.strip("/")) |
| method = self.request.method |
| headers = self.request.headers |
| data = self.request.body |
| |
| if (not data) and (method in ["GET", "OPTIONS"]): |
| data = None |
| |
| client = tornado.httpclient.AsyncHTTPClient() |
| |
| response = yield tornado.gen.Task(client.fetch, url, method=method, headers=headers, body=data) |
| |
| for h in response.headers: |
| hV = response.headers.get(h) |
| if hV: |
| self.set_header(h, hV) |
| |
| if (response.error): |
| if response.code > 500: |
| errorCode = 500 |
| else: |
| errorCode = response.code |
| self.set_status(errorCode) |
| else: |
| self.set_status(response.code) |
| |
| if response.body: |
| self.write(response.body) |
| self.finish() |
| |
| def get(self, path): |
| return self._processRequest(path) |
| |
| def post(self, path): |
| return self._processRequest(path) |
| |
| def put(self, path): |
| return self._processRequest(path) |
| |
| def options(self, path): |
| return self._processRequest(path) |
| |
| |
| class IndexHandler(BaseHandler): |
| |
| def get(self): |
| mobileAppURL = options.mobileAppFullURL |
| |
| if not mobileAppURL.startswith("http"): |
| pr = urlparse(self.request.full_url()) |
| mobileAppURL = "{0}://{1}{2}".format(pr.scheme, pr.netloc, mobileAppURL or "/m") |
| |
| params = { |
| "clientSettingsURL": options.clientSettingsURL, |
| "mobileAppFullURL": mobileAppURL, |
| "mpinJSURL": options.mpinJSURL, |
| "logoutWaitURL": "/logoutWait", |
| "user": self.loggedUser, |
| "mobileOnly": options.mobileOnly and "true" or "false", |
| "mobileSupport": options.mobileSupport and "true" or "false", |
| "mobileUseNative": options.mobileUseNative, |
| "mobileConfigURL": options.mobileConfigURL |
| # "emailCheckRegex": "[0-9a-zA-Z]+" |
| } |
| |
| templateName = self.request.path == "/login" and "login.html" or "index.html" |
| |
| self.render(templateName, flash=self.get_flash(), **params) |
| |
| |
| class VerifyUserHandler(BaseHandler): |
| |
| def _generateValidationURL(self, base_url, identity, signature, expires): |
| return "{0}?i={1}&e={2}&s={3}".format(base_url, identity, expires, signature) |
| |
| def post(self): |
| self.content_type = 'application/json' |
| |
| try: |
| data = json.loads(self.request.body) |
| identity = data["mpinId"] |
| userid = data["userId"] |
| expireTime = data["expireTime"] |
| activateKey = data["activateKey"] |
| mobile = data["mobile"] |
| except ValueError: |
| log.error("Cannot decode body as JSON.") |
| log.debug(self.request.body) |
| self.set_status(400, reason="BAD REQUEST. INVALID JSON") |
| self.finish() |
| return |
| |
| userId = data.get("userId") |
| if not userId: |
| log.error("Missing userId") |
| log.debug(self.request.body) |
| self.set_status(400, reason="BAD REQUEST. INVALID USER ID") |
| self.finish() |
| return |
| |
| if options.verifyIdentityURL.startswith("/"): # relative path |
| base_url = "{0}/{1}".format( |
| self.request.headers.get("RPS-BASE-URL").rstrip("/"), |
| options.verifyIdentityURL.lstrip("/") |
| ) |
| else: |
| base_url = options.verifyIdentityURL |
| |
| validateURL = self._generateValidationURL(base_url, identity, activateKey, expireTime) |
| log.info("Sending activation email for user {0}: {1}".format(userid.encode("utf-8"), validateURL)) |
| |
| deviceName = mobile and "Mobile" or "PC" |
| |
| if options.forceActivate: |
| log.warning("forceActivate option set! User activated without verification!") |
| else: |
| mailer.sendActivationEmail(userid.encode("utf-8"), options.emailSubject, deviceName, validateURL, options.smtpUser, options.smtpPassword) |
| log.warning("Sending Mail!") |
| |
| responseData = { |
| "forceActivate": options.forceActivate |
| } |
| self.write(json.dumps(responseData)) |
| |
| self.set_status(200) |
| self.finish() |
| |
| |
| class mpinPermitUserHandler(BaseHandler): |
| |
| def get(self): |
| |
| # The revocation handler |
| # When the RPS option RPAPermitUserURL is set |
| # It will make a request for validating the identity |
| # Before giving the time permit share to the client |
| |
| self.content_type = 'application/json' |
| |
| # If you return 403 it will show Unauthoirized message inside the PinPad |
| # self.set_status(403) |
| |
| self.set_status(200) # We give permissions to everyone |
| self.finish() |
| |
| |
| class mpinActivateHandler(BaseHandler): |
| def _verifySignature(self): |
| identity = self.get_argument("i", default="") |
| expires = self.get_argument("e", default="") |
| signature = self.get_argument("s", default="") |
| |
| log.debug("/mpinActivate request for identity: {0}".format(identity)) |
| |
| try: |
| data = json.loads(identity.decode("hex")) |
| userid = data["userID"] |
| issued = data["issued"] |
| sIssued = Time.DateTimetoHuman(Time.ISOtoDateTime(issued)) |
| |
| mobile = int(data.get("mobile") or 0) |
| |
| except Exception as E: |
| log.error("Error parsing the verification email: {0}".format(E)) |
| userid, issued, sIssued = None, None, None |
| |
| if userid: |
| if expires < datetime.datetime.utcnow().isoformat(b"T").split(".")[0] + "Z": |
| isValid = False |
| info = "Link expired" |
| else: |
| isValid = True |
| info = "" |
| |
| deviceName = mobile and "Mobile" or "PC" |
| |
| else: |
| log.error("/mpinActivate: Invalid IDENTITY: {0}".format(identity)) |
| isValid, info = False, "Invalid identity" |
| deviceName, issued = "", "" |
| |
| params = { |
| "isValid": isValid, |
| "identity": identity, |
| "errorMessage": info, |
| "userid": userid, |
| "issued": issued, |
| "humanIssued": sIssued, |
| "activated": False, |
| "deviceName": deviceName, |
| "activateKey": signature |
| } |
| |
| return params |
| |
| def get(self): |
| params = self._verifySignature() |
| self.render("activate.html", **params) |
| |
| @tornado.web.asynchronous |
| @tornado.gen.engine |
| def post(self): |
| params = self._verifySignature() |
| |
| if params['isValid']: |
| mpinId = params["identity"] |
| |
| url = "{0}/user/{1}".format(options.RPSURL.rstrip("/"), mpinId) |
| |
| data = json.dumps({"activateKey": params["activateKey"]}) |
| client = tornado.httpclient.AsyncHTTPClient() |
| response = yield tornado.gen.Task(client.fetch, url, method="POST", body=data) |
| |
| if (response.error): |
| log.error("URL: {0}: Error: {1}".format(url, response.error)) |
| |
| params["isValid"] = False |
| params["errorMessage"] = "This verification link has already been used or has expired!" |
| else: |
| params["activated"] = True |
| |
| self.render("activate.html", **params) |
| |
| |
| class AuthenticateUserHandler(BaseHandler): |
| |
| @tornado.web.asynchronous |
| @tornado.gen.engine |
| def post(self): |
| self.content_type = 'application/json' |
| |
| try: |
| data = json.loads(self.request.body) |
| authOTT = data["mpinResponse"]["authOTT"] |
| except ValueError: |
| log.error("Cannot decode body as JSON.") |
| log.debug(self.request.body) |
| self.set_status(400, reason="BAD REQUEST. INVALID JSON") |
| self.finish() |
| return |
| except KeyError: |
| log.error("Invalid JSON structure.") |
| log.debug(self.request.body) |
| self.set_status(400, reason="BAD REQUEST. INVALID JSON") |
| self.finish() |
| return |
| |
| url = "{0}/authenticate".format(options.RPSURL.rstrip("/")) |
| client = tornado.httpclient.AsyncHTTPClient() |
| reqData = { |
| "authOTT": authOTT, |
| |
| "logoutData": {"sessionToken": self.sessionId} |
| } |
| response = yield tornado.gen.Task(client.fetch, url, method="POST", body=json.dumps(reqData)) |
| |
| try: |
| data = json.loads(response.body) |
| status = data["status"] |
| userId = data["userId"] |
| message = data["message"] |
| except: |
| log.error("Invalid data from RPS: {1}: {0}".format(url, response.body)) |
| message = "Server error" |
| status = 500 |
| |
| if (status == 200): |
| # The Revocation check based on userId or mpinId can be performed here |
| # The new status can be |
| # 200 - Login successfull |
| # 401 - Invalid PIN |
| # 403 - User not authorized. Login denied without deleting the client's token. |
| # 408 - The authentication has been expired. |
| # 410 - Login denied permanently. Will delete the client's token. |
| |
| # If the RPS waitLoginResult option is set, /loginResult request must be made |
| # It can contain logoutData and logoutURL for mobile Logout functionality |
| |
| url = "{0}/loginResult".format(options.RPSURL.rstrip("/")) |
| client = tornado.httpclient.AsyncHTTPClient() |
| reqData = { |
| "authOTT": authOTT, |
| "status": status, |
| "message": message, |
| "logoutData": {"sessionToken": self.sessionId, "userId": userId} |
| } |
| response = yield tornado.gen.Task(client.fetch, url, method="POST", body=json.dumps(reqData)) |
| |
| if status == 200: |
| # Login user. Update session information |
| session = self.sessionId |
| if session: |
| key = "s;{0}".format(session) |
| item = self.storage.find(key=key) |
| if item: |
| item.update(value=userId) |
| else: |
| self.storage.add( |
| key=key, |
| value=userId, |
| expire_time=datetime.datetime.now() + datetime.timedelta(seconds=3600) |
| ) |
| |
| self.set_status(status, message) |
| # Optional data can be send to the client's javascript handler. |
| if options.requestOTP: |
| ttlSeconds = 60 |
| nowtm = int(time.mktime(datetime.datetime.utcnow().timetuple()) * 1000) |
| returnData = { |
| "expireTime": nowtm + ttlSeconds * 1000, |
| "ttlSeconds": ttlSeconds, |
| "nowTime": nowtm |
| } |
| else: |
| returnData = {"someUserData": "This will be handled by onSuccessLogin handler."} |
| |
| self.write(returnData) |
| self.finish() |
| |
| |
| class AboutHandler(BaseHandler): |
| def get(self): |
| self.redirect("http://www.miracl.com/miracl-product-m-pin-core") |
| |
| |
| class ProtectedHandler(BaseHandler): |
| def get(self, protected_page=None): |
| if not self.loggedUser: |
| self.set_flash("protected") |
| self.redirect("/") |
| else: |
| template_name = "protected_{}.html".format(protected_page) if protected_page else "protected.html" |
| self.render(template_name, welcome=(self.get_flash() == "login"), user=self.loggedUser, logoutWaitURL="/logoutWait") |
| |
| |
| class LogoutHandler(BaseHandler): |
| def get(self): |
| item = self.storage.find(key="s;{0}".format(self.sessionId)) |
| if item: |
| item.delete() |
| self.clear_cookie("mpindemo_session") |
| self.redirect("/") |
| |
| def post(self): |
| data = tornado.escape.json_decode(self.request.body) |
| sessionId = data.get("sessionToken") |
| |
| log.debug("Logout request. Session token: {0}".format(sessionId)) |
| |
| item = self.storage.find(key="s;{0}".format(sessionId)) |
| loggedUser = item.value if item else None |
| |
| if (data.get("userId") != loggedUser): |
| log.debug(" The logged user {0} does not match the requested user {1}".format(loggedUser, data.get("userId"))) |
| self.set_status(400, "Logout failed") |
| return |
| |
| mobileLoginHandler.userLogged(sessionId, True) |
| item = self.storage.find(key="s;{0}".format(sessionId)) |
| if item: |
| item.delete() |
| |
| def options(self): |
| self.set_status(200) |
| self.finish() |
| |
| |
| class LogoutWaitHandler(BaseHandler): |
| @tornado.web.asynchronous |
| def get(self): |
| '''Wait for user logout''' |
| mobileLoginHandler.waitForLogin(self.onUserLoggedOut, self.sessionId, True) |
| |
| def onUserLoggedOut(self): |
| if self.request.connection.stream.closed(): |
| return |
| self.set_flash("forced_logout") |
| self.write("OK") |
| self.finish() |
| |
| def on_connection_close(self): |
| mobileLoginHandler.cancelWait(self.sessionId, True) |
| |
| |
| class ServeMobileFileHandler(tornado.web.StaticFileHandler): |
| def set_extra_headers(self, path): |
| if path == "mpin.appcache": |
| self.set_header("Content-Type", "text/cache-manifest") |
| |
| |
| class NotFoundHandler(BaseHandler): |
| def get(self, *args): |
| self.render("404.html") |
| |
| |
| # MAIN |
| class Application(tornado.web.Application): |
| def __init__(self): |
| staticPath = os.path.join(options.resourcesBasePath, "public") |
| templatesPath = os.path.join(options.resourcesBasePath, "templates") |
| mobilePath = options.mobileAppPath |
| |
| if mobilePath: |
| handlers = [ |
| (r'/m', tornado.web.RedirectHandler, {"url": "/m/index.html"}), |
| (r'/m/', tornado.web.RedirectHandler, {"url": "/m/index.html"}), |
| (r'/m/(.*)', ServeMobileFileHandler, {'path': mobilePath}), |
| ] |
| else: |
| handlers = [] |
| |
| handlers.extend([ |
| (r"/", IndexHandler), |
| (r"/login", IndexHandler), |
| |
| # M-PIN handlers |
| (r"/{0}/(.*)".format(options.rpsPrefix), RPSRedirectHandler), |
| (r"/mpinVerify", VerifyUserHandler), |
| (r"/mpinAuthenticate", AuthenticateUserHandler), |
| (r"/mpinActivate", mpinActivateHandler), |
| (r"/mpinPermitUser", mpinPermitUserHandler), |
| |
| # Application handlers |
| (r"/protected/(.*)", ProtectedHandler), |
| (r"/protected", ProtectedHandler), |
| (r"/about", AboutHandler), |
| (r"/logout", LogoutHandler), |
| (r"/logoutWait", LogoutWaitHandler), |
| |
| ]) |
| |
| if os.path.exists(os.path.join(templatesPath, "404.html")): |
| handlers.extend([(r"/(.*)", NotFoundHandler)]) |
| |
| settings = { |
| "template_path": templatesPath, |
| "static_path": staticPath, |
| "static_url_prefix": "/public/", |
| "cookie_secret": options.cookieSecret, |
| "xsrf_cookies": False |
| } |
| |
| super(Application, self).__init__(handlers, **settings) |
| |
| storage_cls = get_storage_cls() |
| self.storage = storage_cls(tornado.ioloop.IOLoop.instance(), 'key') |
| |
| |
| def main(): |
| options.parse_command_line() |
| |
| if os.path.exists(options.configFile): |
| try: |
| options.parse_config_file(options.configFile) |
| options.parse_command_line() |
| except Exception, E: |
| print("Invalid config file {0}".format(options.configFile)) |
| print(E) |
| sys.exit(1) |
| |
| # Set Log level |
| log.setLevel(getLogLevel(options.logLevel)) |
| |
| if not options.cookieSecret: |
| log.error("cookieSecret option required") |
| sys.exit(1) |
| |
| detectProxy() |
| mailer.setup(options.smtpServer, options.smtpPort, options.emailSender, options.smtpUseTLS) |
| |
| log.info("Server starting on {0}:{1}...".format(options.address, options.port)) |
| |
| http_server = Application() |
| http_server.listen(options.port, options.address, xheaders=True) |
| io_loop = tornado.ioloop.IOLoop.instance() |
| |
| if options.autoReload: |
| log.debug("Starting autoreloader") |
| |
| tornado.autoreload.watch(CONFIG_FILE) |
| for f in os.listdir(http_server.settings["template_path"]): |
| fn = os.path.join(http_server.settings["template_path"], f) |
| if os.path.isfile(fn): |
| tornado.autoreload.watch(fn) |
| tornado.autoreload.start(io_loop) |
| |
| log.info("Server started. Listening on {0}:{1}".format(options.address, options.port)) |
| io_loop.start() |
| |
| |
| class ServiceDaemon(Daemon): |
| def run(self): |
| main() |
| |
| |
| if __name__ == "__main__": |
| if len(sys.argv) > 1 and sys.argv[1].lower() in ("start", "stop"): |
| action = sys.argv.pop(1) |
| logFile = os.path.join(BASE_DIR, "mpinDemo.log") |
| pidFile = os.path.join(BASE_DIR, "mpinDemo.pid") |
| |
| daemon = ServiceDaemon(pidfile=pidFile, stdout=logFile, stderr=logFile) |
| if action == "start": |
| log.info("Starting as daemon. Log file: {0}".format(logFile)) |
| daemon.start() |
| elif action == "stop": |
| log.info("Stopping daemon...") |
| daemon.stop() |
| sys.exit() |
| else: |
| try: |
| main() |
| except Exception as e: |
| log.error(e) |
| sys.exit(1) |