# -*- test-case-name: mv3d.test.service.test_loginservice -*- # Copyright (C) 2008-2012 Mortal Coil Games # See LICENSE for details. """ """ import os import random from hashlib import md5 from zope.interface import implements #@UnresolvedImport from twisted.application.service import Service from twisted.internet.defer import inlineCallbacks, returnValue from twisted.spread import pb from twisted.internet import defer from twisted.python.log import deferr from nevow import loaders, rend, inevow, tags as T import mv3d from mv3d.net.client import ServiceLoc from mv3d.net.security import Securable, AccessDenied from mv3d.server.network import JSONRPCPage, WebClient from mv3d.server.service import checkServicePermissions, viewed from mv3d.util.conductor import parseInterfaceConfig from mv3d.util.guide import ChangeNotifier, NotifierProperty from mv3d.util.modifier import Modifiable from mv3d.util.classgen import ClassGenerator from mv3d.server.iserver import ILoginService class LoginServiceError(Exception): """ Raised when there are problems with the login service """ class LoginServiceClient: """ Defines a client to the login service """ session = None def __init__(self, accountInfo): self.accountInfo = accountInfo def __getitem__(self, key): return self.accountInfo[key] def getName(self): return self.accountInfo["username"] def getGroups(self): return self.accountInfo["groups"] class LoginJSONInterface(JSONRPCPage): """ A JSON interface to the login service """ def json_challenge(self, client): """ Proxy over to our accountServers to get this info """ return self.service.challenge(client) def json_authenticateSession(self, client, username, cryptData): """ Proxy over to our accountServers to do this """ return self.service.authenticateSession(client, username, cryptData) def json_createPasswords(self, client): """ Create passwords for this session by proxying to the account server """ return self.service.createPasswords(client) def json_getLoginInfo(self, client, username, uid): """ Get the accountInfo including passwords generated under uid """ return self.service.getLoginInfo(client, username, uid) def json_createAccount(self, client, username, password, email): """ Create a new account if allowed. """ return self.service.createAccount(client, username, password, email) class CreateAccount(rend.Page): """ Form to create an account """ capatchaAnswer = None def __init__(self, parent): self.parent = parent self.realm = parent file = os.path.join(self.parent.templateDir, "create_account.xml") self.docFactory = loaders.xmlfile(file) self.capatchas = { "What color is the sky?":["blue"], "What is two plus two?":["four", "4"] } def render_capatcha(self, _ctx, _data): """ Render our simple capatcha """ c = random.choice(self.capatchas.keys()) self.capatchaAnswer = self.capatchas[c] return c def render_checkArgs(self, ctx, _data): """ See if someone posted us stuff """ req = inevow.IRequest(ctx) if req.args.has_key("create") and self.capatchaAnswer is not None: # we have a winner user = req.args["username"][0] pw = req.args["password"][0] cpw = req.args["confPassword"][0] email = req.args["email"][0] cap = req.args["capatcha"][0] if pw != cpw: return T.font(color="#FF0000")["Passwords don't match"] #@UndefinedVariable if len(pw) < 4: return T.font(color="#FF0000")["Password is too short"] #@UndefinedVariable if len(user) < 3: return T.font(color="#FF0000")["Username is too short"] #@UndefinedVariable if not "@" in email: return T.font(color="#FF0000")["Invalid email address"] #@UndefinedVariable if cap.lower() not in self.capatchaAnswer: return T.font(color="#ff0000")["I think you may be a computer."] #@UndefinedVariable d = self.parent.createAccount(LoginServiceClient(dict( username="None", groups=[])), user, pw, email) ctx.fillSlots("user", user) ctx.fillSlots("password", pw) return d.addCallback(lambda _: inevow.IQ(ctx).patternGenerator('created')(user=user, password=pw, data="george")) return inevow.IQ(ctx).patternGenerator('newAccount')() class AnonymousAccessRoot(rend.Page): """ What you get if you aren't logged in to the web server """ addSlash = True def __init__(self, _client, parent): self.parent = parent file = os.path.join(self.parent.templateDir, "index.xml") self.docFactory = loaders.xmlfile(file) self.putChild("createAccount", CreateAccount(parent)) self.putChild("service", LoginJSONInterface(parent)) def render_loginFailure(self, ctx, _data): """ If the last login attempt failed, give a message... """ req = inevow.IRequest(ctx) if req.args.has_key("login-failure"): return T.td(colspan=2)[T.font(color="#FF0000")[ #@UndefinedVariable "Login failed! %s" % ", ".join(req.args["login-failure"])]] return "" def render_createAccount(self, _ctx, _data): """ If the login service supports it, render the create new account part of the page """ if not self.parent.allowNewAccounts: return "" return [ T.td[ "Not registered?"], #@UndefinedVariable T.td[T.a(href="createAccount")["Sign up for a free account"]] #@UndefinedVariable ] def childFactory(self, ctx, name): """ Make sure everyone is required to log in """ if name == "__login__": return rend.Page.childFactory(self, ctx, name) return self class RootPage(rend.Page): """ The root page """ addSlash = True docFactory = None client = None def __init__(self, parent, client): self.parent = parent self.client = client file = os.path.join(self.parent.templateDir, "account_details.xml") self.docFactory = loaders.xmlfile(file) def render_checkArgs(self, ctx, _data): """ Check if we have a change password request here """ request = inevow.IRequest(ctx) if not self.client.accountInfo.has_key("session"): self.client.accountInfo["session"] = request.session.uid if request.method == "POST": if "oldPassword" in request.args: user = self.client["username"] oldPassword = request.args["oldPassword"][0] newPassword = request.args["newPassword"][0] confPassword = request.args["confPassword"][0] if confPassword != newPassword: return "Passwords do not match" d = self.parent.changePassword(self.client, user, oldPassword, newPassword) def done(_): return "Password successfully changed!" def error(e): return "Error changing password: %s" % e.value d.addCallback(done) return d.addErrback(error) return "" def render_userName(self, _ctx, _data): """ Simply return the user name """ return self.client["username"] def render_checkAdmin(self, _ctx, _data): """ Check if the user is an admin """ if not self.parent.checkPermissions( self.client, "admin"): return "" return T.a(href="/webedit/")["Editor"] #@UndefinedVariable def render_groups(self, _ctx, _data): """ Just return the list of groups """ return ", ".join(self.client["groups"]) def render_pcs(self, _ctx, _data): """ Just return the list of groups """ return ", ".join([str(pc) for pc in self.client["pcs"]]) def logout(self): """ Called when the user logs out """ self.parent.logoutUser(self.client.session, self.client.getName()) class LoginServiceView(pb.Viewable): """ This is the login service. It proxies over to an account server to do a lot of the dirty work, but also serves up a http(s) page to allow users to view/change their logins or optionally create accounts. """ def __init__(self, service): self.service = service def getProtocol(self): """ Return what protocol we implement """ return "pb" @checkServicePermissions("read") @viewed def view_getStatistics(self, client): """ Gets a dict of stats """ @checkServicePermissions("read") @viewed def view_getConfig(self, client): """ Returns a dict with the config of this sim. """ @checkServicePermissions("modify") @viewed def view_setSite(self, client, site): """ Sets the datastore this realm service uses """ @checkServicePermissions("modify") @viewed def view_setAccountServices(self, client, services): """ Sets the list of account services to use """ @checkServicePermissions("modify") @viewed def view_setDocRoot(self, client, docRoot): """ Sets the doc root """ @checkServicePermissions("modify") @viewed def view_setTemplateDir(self, client, templateDir): """ Sets the template directory """ @checkServicePermissions("modify") @viewed def view_setAllowNewAccounts(self, client, allowNewAccounts): """ Sets whether we allow new accounts """ class LoginService(Service, Securable, Modifiable): """ This is the login service. It proxies over to an account server to do a lot of the dirty work, but also serves up a http(s) page to allow users to view/change their logins or optionally create accounts. """ lastcheck = 0 allowNewAccounts = False port = None docRoot = None sslContext = None listener = None site = None implements(ILoginService) classModifiers = dict( guide=[ClassGenerator("mv3d.server.login.LoginConfig")]) def __init__(self): Securable.__init__(self) Modifiable.__init__(self) self.accountServers = [] self.defaultGroups = [] self.interfaces = {} self.challenges = {} self.addPermission("admin") self.config = {} def configure(self, nm, cf): """ Configure this interface """ self.config = dict(cf.items(nm)) if cf.has_option(nm, "accountServices"): acts = cf.get(nm, "accountServices").split(",") for act in acts: self.accountServers.append(ServiceLoc(act.strip())) if cf.has_option(nm, "site"): self.site = cf.get(nm, "site") if cf.has_option(nm, "docRoot"): self.docRoot = cf.get(nm, "docRoot") if cf.has_option(nm, "templateDir"): self.templateDir = cf.get(nm, "templateDir") if cf.has_option(nm, "allowNewAccounts"): self.allowNewAccounts = cf.getboolean(nm, "allowNewAccounts") if cf.has_option(nm, "grantPermissions"): perms = eval(cf.get(nm, "grantPermissions")) for p, u in perms: self.grantPermission(p, u) if cf.has_option(nm, "denyPermissions"): perms = eval(cf.get(nm, "denyPermissions")) for p, u in perms: self.denyPermission(p, u) if cf.has_option(nm, "interfaces"): parseInterfaceConfig(cf.get(nm, "interfaces"), self) def startService(self): """ Start up the service """ if self.site is not None: self.site = self.parent.getServiceNamed(self.site) self.site.setPage(self.docRoot, AnonymousAccessRoot, True, self) self.site.setPage(self.docRoot, lambda ai: RootPage(self, LoginServiceClient(ai))) def stopService(self): """ Stop the service """ def createAccount(self, client, user, passwd, email, groups=None): """ Create a new account """ d = self.parent.getOneService(self.accountServers) if self.checkPermissions(client, "admin"): d.addCallback(lambda i: i.createAccount(user, passwd, email, groups)) elif self.allowNewAccounts: d.addCallback(lambda i: i.createAccount(user, passwd, email, self.defaultGroups)) else: raise LoginServiceError("New accounts are not allowed" " at this time.") return d def changePassword(self, client, user, oldPassword, newPassword): """ Change passwords. If you are an admin, you can change people's passwords without entering the oldPassword. """ if not len(newPassword): raise LoginServiceError("New password can not be empty") d = self.parent.getOneService(self.accountServers) if self.checkPermissions(client, "admin"): d.addCallback(lambda i: i.changePassword(user, newPassword)) else: #check the old password if client.accountInfo["username"] != user: raise LoginServiceError("Usernames don't match!") def gotInt(i): d = i.authenticate(client.accountInfo["username"], md5(client.accountInfo["username"] + oldPassword).hexdigest()) d.addCallback(lambda _: i.changePassword(client.accountInfo[ "username"], newPassword)) return d d.addCallback(gotInt) return d def authorize(self, user, password): """ Simply check the password of the user """ d = self.parent.getOneService(self.accountServers) return d.addCallback(lambda i: i.authorize(user, password)) def getAccountInfo(self, user): """ Get the account info for this user """ d = self.parent.getOneService(self.accountServers) return d.addCallback(lambda i: i.getAccountInfo(user)) def challenge(self, session): """ Return a random string of characters to be used as a challenge to the user authenticating on session """ c = "".join([chr(random.randint(65, 90)) for _ in range( random.randrange(20, 30))]) session.challenge = c return c def authenticateSession(self, session, username, cryptData): """ Proxy over to the account server to do the authentication """ d = self.parent.getOneService(self.accountServers) d.addCallback(lambda i: i.authenticateResponse(session.getSessionID(), username, session.challenge, cryptData)) def done(r): session.authenticated = True session.authenticatedUser = username return r return d.addCallback(done) def createPasswords(self, session): """ Create passwords for this session by proxying to the account server """ if not session.authenticated: raise AccessDenied("You must log in first") d = self.parent.getOneService(self.accountServers) return d.addCallback(lambda i: i.createPasswords( session.authenticatedUser, session.getSessionID())) @inlineCallbacks def getLoginInfo(self, session, username, uid): """ Get the accountInfo including passwords generated under uid """ if not session.authenticated: raise AccessDenied("You must log in first") acct = yield self.parent.getOneService(self.accountServers) info = yield acct.getLoginInfo( session.authenticatedUser, session.getSessionID(), username, uid) returnValue(info) @inlineCallbacks def logoutUser(self, session, username): """ Log the user out of the session """ svc = yield self.parent.getOneService(self.accountServers) yield svc.logoutUser(session, username) def getStatistics(self): """ Gets a dict of stats """ return dict() def getConfig(self): """ Returns a dict with the config of this sim. """ return self.config def setSite(self, site): """ Sets the datastore this realm service uses """ self.site = self.parent.getNamedService(site) def setAccountServices(self, services): """ Sets the list of account services to use """ self.accountServers = [ServiceLoc(svc) for svc in services] def setDocRoot(self, docRoot): """ Sets the doc root """ self.docRoot = docRoot def setTemplateDir(self, templateDir): """ Sets the template directory """ self.templateDir = templateDir def setAllowNewAccounts(self, allowNewAccounts): """ Sets whether we allow new accounts """ self.allowNewAccounts = allowNewAccounts def isLocal(self): """ We are local so return true """ return True def getInterface(self, client, protocol): """ Returns an interface for the given type """ if protocol.startswith("http"): if isinstance(client, dict): client = WebClient(client) return LoginJSONInterface(self, client) return self.interfaces[protocol] class LoginConfig(ChangeNotifier): """ Guide interface for changing sim properties """ site = NotifierProperty("_site", "HttpsServer") accountServices = NotifierProperty("_accountServices}", "self/Account") templateDir = NotifierProperty("_templateDir", "templates/loginservice") docRoot = NotifierProperty("_docRoot", "/") allowNewAccounts = NotifierProperty("_allowNewAccounts", True) window = None def __init__(self, login): self.login = login self.getConfig() self.doneDeferred = defer.Deferred() @inlineCallbacks def getConfig(self): """ Called on startup to load everything """ config = yield self.login.getConfig() self.site = config["site"] self.accountServices = ", ".join([str(svc) for svc in config.get( "accountServices", [])]) self.templateDir = config.get("templateDir") self.docRoot = config.get("docRoot") self.allowNewAccounts = config.get("allowNewAccounts") try: from mv3d.tools.guidewx import WxParser parser = WxParser() fname = os.path.abspath(os.path.join(os.path.dirname(mv3d.__file__), "..", "templates", "guide", "loginconfig.xml")) self.window = parser.load(fname, self) except: deferr() self.window.show() def cancel(self, _obj, _property): """ Called when the user hits the cancel button """ if self.doneDeferred is not None: self.doneDeferred.callback(False) self.doneDeferred = None self.window.destroy() @inlineCallbacks def submit(self, _obj, _property): """ Called when the user hits the submit button. """ if self.doneDeferred is not None: yield self.login.setSite(self.site) dirsvcs = self.accountServices.split(",") yield self.login.setAccountServices(dirsvcs) yield self.login.setDocRoot(self.docRoot) yield self.login.setTemplateDir(self.templateDir) yield self.login.setAllowNewAccounts(self.allowNewAccounts) self.doneDeferred.callback(True) self.doneDeferred = None self.window.destroy()