# -*- test-case-name: mv3d.test.service.test_accountserver -*- # Copyright (C) 2006-2012 Mortal Coil Games # See LICENSE for details. """ Code for the AccountServer and its interface """ import logging import random from hashlib import md5 from time import time, timezone from base64 import b64encode, b64decode from Crypto.Cipher import AES from twisted.internet import defer from twisted.internet.defer import inlineCallbacks, returnValue from twisted.cred.error import UnauthorizedLogin from twisted.spread import pb from twisted.application.service import Service from zope.interface import implements from mv3d.net.security import requirePermissions from mv3d.net.client import ServiceLoc from mv3d.util.modifier import Modifiable from mv3d.util.classgen import ClassGenerator from mv3d.net.security import Securable, AccessDenied from mv3d.net.pb import Cacheable, withClientUpdate, Copyable from mv3d.net.client import padRandomText, genRandomText from mv3d.server.service import viewed, checkServicePermissions from mv3d.util.persist import Integer, Text, Reference, List, IDTuple, \ Stringable, Float, Boolean, Persistable from mv3d.server.iserver import IAccountService from mv3d.server.persist import SQLiteStore from twisted.python.log import deferr from mv3d.util.guide import ChangeNotifier, NotifierProperty import mv3d import os class Account(Persistable, Securable, Cacheable, Modifiable): """ Class for an account. """ username = Text(autoSave=True, partialSave=True) password = Text(autoSave=True, partialSave=True) email = Text(autoSave=True, partialSave=True) lastlogin = Integer(default=0, autoSave=True, partialSave=True) failedLogins = Integer(default=0, autoSave=True, partialSave=True) creationDate = Integer(default=0, autoSave=True, partialSave=True) pcs = List(IDTuple()) groups = List(Text()) serverInfo = List(Stringable(ServiceLoc)) status = "ok" classModifiers = dict(web=[ ClassGenerator("mv3d.server.editor.AccountEditor"), ClassGenerator("mv3d.server.editor.DeleteAccount"), ClassGenerator("mv3d.server.editor.EditPermissions"), ]) def __init__(self, store=None, username=None, password=None, email=None, pcs=None, groups=None): """ Initialize various aspects of the account to defaults """ Persistable.__init__(self, store) Cacheable.__init__(self) Securable.__init__(self) Modifiable.__init__(self) self.username = username self.password = password self.email = email self.pcs = pcs or [] self.groups = groups or [] self.tokens = {} self.serverInfo = [] self.sessionInfo = {} self.tempPasswords = {} self.creationDate = time() # Modify PCS = Permission to add / remove PCs from this account # Modify GRS = Permission to add / remove this account from groups self.addPermission("modify pcs") self.addPermission("modify grs") self.addPermission("authenticate") def onLoad(self): """ Initialize some variables after being loaded """ self.sessionInfo = {} self.tempPasswords = {} def deletedFromStore(self): """ Remove logs (not clear if we should remove charges) Really, we shouldn't be deleted evar. """ entries = self.getLogEntries() for e in entries: e.deleteFromStore() def getUsername(self): """ Returns the username associated with this account """ return self.username getName = getUsername @withClientUpdate def setUsername(self, name): """ Sets the username associated with this account """ self.username = name def getGroups(self): """ Gets the groups that this user is included in """ return self.groups @withClientUpdate def addGroup(self, g): """ Add a group to the groups this account id included in """ self.groups.append(g) self.save(partial=True, selectAttributes=["groups"]) @withClientUpdate def setGroups(self, g): """ Set the list of groups for this account """ self.groups = g self.save(partial=True, selectAttributes=["groups"]) @withClientUpdate def remGroup(self, g): """ Remove group g from the groups this account id is included in This function returns 1 if g in groups else 0 """ self.groups.remove(g) self.save(partial=True, selectAttributes=["groups"]) def getPassword(self): """ Returns the password associated with this account """ return self.password @withClientUpdate def setPassword(self, p): """ Sets the password associated with this account """ self.password = p def getStatus(self): """ Returns the status for the account """ return self.status @withClientUpdate def setStatus(self, status, reason=""): """ Sets a new status for the account """ if status == self.status: return self.status = status if reason != "": reason = " because of %s" % reason self.log(None, "status change", "Changed status to %s%s." % (status, reason)) def getPCs(self): """ Return the list of PCs this account is associated with The return value will be a list of object ids. """ return list(self.pcs) @withClientUpdate def addPC(self, oid): """ Associate a PC with this account. oid is meant to be an id, not an actual object. """ self.pcs.append(oid) if self.store is not None: self.save() @withClientUpdate def setPCs(self, pcs): """ Set the list of PCs for this account """ self.pcs = pcs self.save(partial=True, selectAttributes=["pcs"]) @withClientUpdate def remPC(self, oid): """ Remove a PC from this account. oid is the object id of the PC object """ self.pcs.remove(oid) self.save(partial=True, selectAttributes=["pcs"]) def countPCs(self): """ Return the number of PCs associated with this account """ return len(self.pcs) def getEmail(self): """ Return the email address """ return self.email @withClientUpdate def setEmail(self, em): """ Set the email address """ self.email = em @withClientUpdate def login(self, session, location=None): """ Updates the last login time stamp """ self.lastlogin = time() self.failedLogins = 0 self.log(session, "login", location) @withClientUpdate def logout(self, session=None): """ Updates the total time online """ self.log(session, "logout") @withClientUpdate def failLogin(self, location=""): """ Record a failed login """ self.failedLogins += 1 if location: location = " from %s" % location self.log(None, "Failed login", "A failed login attempt was detected%s." % location) @withClientUpdate def addToken(self, tok, value): """ Just addd an auth token. These are not persisted """ self.tokens[tok] = value @withClientUpdate def delToken(self, tok): """ Delete the token """ del self.tokens[tok] def getToken(self, key): """ Return the token if it exists (otherwise KeyError) Then delete it. """ t = self.tokens[key] self.delToken(key) return t @withClientUpdate def addServerInfo(self, sloc): """ Add a service location to our list """ self.serverInfo.append(ServiceLoc(sloc).stripToHost()) self.serverInfo[-1].creds = None self.save(partial=True, selectAttributes=["serverInfo"]) def checkServerInfo(self, sloc): """ Check a given service location to see if it matches us """ sl = ServiceLoc(sloc).stripToHost() sl.creds = None for s in self.serverInfo: if sl.sameHost(s): return True return False @withClientUpdate def setSessionInfo(self, session, key, value): """ Set session information """ sess = self.sessionInfo.get(session) if sess is None: self.sessionInfo[session] = sess = {} sess[key] = value def getSessionInfo(self, session, key): """ Retrieve session information """ return self.sessionInfo[session][key] @withClientUpdate def addTempPasswords(self, uid, pw1, pw2): """ Add a set of temporary passwords """ self.tempPasswords[uid] = [pw1, pw2] def getTempPasswords(self, uid): """ Returns a set of temporary passwords """ t = self.tempPasswords[uid] del self.tempPasswords[uid] return t def log(self, session, action, description=None): """ Create a new log entry """ AccountLog(user=self, date=time(), session=session, action=action, description=description).save(self.store) def getLogEntries(self, session=None, action=None, date=None, stopDate=None, sortDir=None, offset=0, limit= -1): """ Retrieve log entries for this user based on criteria. If stopDate is specified, then dates between date and stopDate will be returned. Otherwise, if only date is specified, then dates that match it are given. """ assert sortDir in [None, "asc", "desc"] if sortDir is None or sortDir == "asc": sortDir = AccountLog.date.asc else: sortDir = AccountLog.date.desc query = AccountLog.user == self if session is not None: query = query & (AccountLog.session == session) if action is not None: query = query & (AccountLog.action == action) if date is not None: if stopDate is None: query = query & (AccountLog.date == date) else: query = query & (AccountLog.date >= date) query = query & (AccountLog.date <= date) return AccountLog.query(self.store, query, order=sortDir, offset=offset, limit=limit) def makeCharge(self, quantity, perUnit, destUser, description): """ Add a charge to the user's account """ charge = AccountCharge(user=self, date=time(), quantity=quantity, perUnit=perUnit, destUser=destUser, description=description) charge.save(self.store) return charge def getCharges(self, destUser=None, paid=None, date=None, stopDate=None): """ Query for charges. If stopDate is specified, then dates between date and stopDate will be returned. """ query = AccountCharge.user == self if destUser is not None: query = query & (AccountCharge.destUser == destUser) if paid is not None: query = query & (AccountCharge.paid == paid) if date is not None: if stopDate is None: query = query & (AccountCharge.date == date) else: query = query & (AccountCharge.date >= date) query = query & (AccountCharge.date <= date) return AccountCharge.query(self.store, query, order=AccountCharge.date.asc) def getCredits(self, sourceUser=None, paid=None, date=None, stopDate=None): """ Query for credits. If stopDate is specified, then dates between date and stopDate will be returned. A credit is a charge with this account as the destUser. """ query = AccountCharge.destUser == self if sourceUser is not None: query = query & (AccountCharge.user == unicode(sourceUser)) if paid is not None: query = query & (AccountCharge.paid == paid) if date is not None: if stopDate is None: query = query & (AccountCharge.date == date) else: query = query & (AccountCharge.date >= date) query = query & (AccountCharge.date <= date) return AccountCharge.query(self.store, query, order=AccountCharge.date.asc) def getAccountBalance(self): """ Returns the total account balance """ charges = self.getCharges(paid=False) credits = self.getCredits(paid=True) balance = 0 for charge in charges: balance += charge.value() for credit in credits: balance -= credit.value() return balance class AccountCharge(Persistable, Copyable): """ A single charge that would show up on a users bill """ user = Reference(Account) date = Integer(autoSave=True, partialSave=True) quantity = Float(autoSave=True, partialSave=True) perUnit = Float(autoSave=True, partialSave=True) destUser = Reference(Account) description = Text(autoSave=True, partialSave=True) paid = Boolean(default=False, autoSave=True, partialSave=True) def value(self): """ Calculates the dollar value of this charge """ return self.quantity * self.perUnit class AccountLog(Persistable, Copyable): """ An entry in the account's log """ user = Reference(Account) date = Integer() session = Text() action = Text() description = Text() def toString(self): """ Output this as a friendly string. """ return "%s - %s %s %s" % (self.date, self.user, self.action, self.description or "") class AccountServiceView(pb.Viewable): """ This is a service that serves up account information """ def __init__(self, service): self.service = service def getProtocol(self): """ Return what protocol we implement """ return "pb" @checkServicePermissions("read") @viewed def view_getAccount(self, client, us): """ Called to remotely get an account """ @checkServicePermissions("modify") @viewed def view_addAccount(self, client, a): """ Called to remotely add an account """ @checkServicePermissions("modify") @viewed def view_remAccount(self, client, a): """ Called to remotely remove an account """ @checkServicePermissions("read") @viewed def view_getAccountServers(self, client): """ if someone is running low on account servers, they may use this """ @checkServicePermissions("read") @viewed def view_authenticate(self, client, creds): """ Try to authenticate this account. Raises UnauthorizedLogin on failure, returns accountInfo on success. """ @checkServicePermissions("read") @viewed def view_getAccountInfo(self, client, userName): """ Returns the account info for the given username """ @checkServicePermissions("modify") @viewed def view_addPCToAccount(self, client, userName, pcid): """ Add a new PC (object id) to an account. """ @checkServicePermissions("modify") @viewed def view_logoutUser(self, client, session, username): """ Log a user out """ @checkServicePermissions("modify") @viewed def view_makeLogEntry(self, client, username, session, action, description=None): """ Make a log entry in the user's account """ @checkServicePermissions("read") @viewed def view_getLogEntries(self, client, username, session, action=None, date=None, startDate=None): """ Get entries in the user's log related to the given session """ @checkServicePermissions("read") @viewed def view_getLoginInfo(self, serverUser, session, authUser, uid): """ Get the previously generated random passwords for the given authUser and uid """ @checkServicePermissions("read") @viewed def view_authenticateResponse(self, client, session, user, challenge, encData): """ Validate user's login by decrypting the data """ @checkServicePermissions("read") @viewed def view_createPasswords(self, client, user, session): """ Create random one use passwords for a user """ @checkServicePermissions("modify") @viewed def view_createAccount(self, client, un, pw, email, groups=None): """ Create a new account. """ @checkServicePermissions("modify") @viewed def view_changePassword(self, client, userName, newPassword): """ Changes the password on an account to newPassword """ @checkServicePermissions("read") @viewed def view_getStatistics(self, client): """ Retrieves the statistics from the realm server """ @checkServicePermissions("read") @viewed def view_getConfig(self, client): """ Returns the config for this account service. """ @checkServicePermissions("modify") @viewed def view_setStore(self, client, store): """ Sets the datastore this account service uses """ @checkServicePermissions("modify") @viewed def view_setAccountServices(self, client, services): """ Sets the list of account services """ class AccountService(Service, Securable, Modifiable): """ The account service handles storing accounts and authenticating logins. """ implements(IAccountService) lastcheck = 0 classModifiers = dict( web=[ClassGenerator("mv3d.server.editor.Stats"), ClassGenerator("mv3d.server.editor.ViewAccounts")], guide=[ClassGenerator("mv3d.server.account.AccountConfig")] ) def __init__(self): Securable.__init__(self) Modifiable.__init__(self) self.accountservers = [] self.interfaces = {} self.store = SQLiteStore() self.store.open() self.config = {} def configure(self, nm, cf): """ Configure this interface """ self.config = dict(cf.items(nm)) if cf.has_option(nm, "store"): self.store = SQLiteStore() fname = cf.get(nm, "store") if os.path.abspath(fname) != fname: fname = os.path.join(self.parent.dataRoot, fname) dirname = os.path.dirname(fname) if not os.path.exists(dirname): os.makedirs(dirname) self.store.open(fname) 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, "accountServers"): acts = eval(cf.get(nm, "accountServers")) for acs in acts: self.accountservers.append(ServiceLoc(acs)) if cf.has_option(nm, "interfaces"): ii = cf.get(nm, "interfaces") cc = ii.strip().split(".")[-1] mm = ".".join(ii.strip().split(".")[:-1]) i = getattr(__import__(mm, globals(), locals(), [cc]), cc)(self) self.interfaces[i.getProtocol()] = i def stopService(self): """ Shut down the server interface. """ self.store.close() return Service.stopService(self) def getAccountServer(self): """ This method will eventually return a connection to an account server as listed in the accountservers. It will return 0 if no accountservers are listed, a deferred if a connection is being established, and a connection if one is already connected. """ return self.parent.getOneService(self.accountservers) def getAccount(self, username): """ This function finds the account associated with the given account id. It returns a deferred that will eventually return the account or raise an error. Non local accounts are automatically cached locally """ try: res = Account.get(self.store, Account.username == username) return defer.succeed(res) except ValueError: pass d = self.getAccountServer() d.addCallback(lambda i: i.getAccount(username)) def gotAccount(a): self.addAccount(a) return a d.addCallback(gotAccount) return d def addAccount(self, acct): """ Add a to our list of accounts held locally """ res = Account.query(self.store, Account.username == acct.username) if len(res): raise ValueError("Accounts must have unique user names!") self.parent.log("Added new account: %s" % acct.getUsername(), logging.INFO, system=self.name) return acct.save(self.store) def remAccount(self, acct): """ If we have a local account a, remove it and return 1 otherwise, return 0 """ self.parent.log("Removed account: %s" % acct.getUsername(), logging.INFO, system=self.name) return acct.delete() def getAccountServers(self): """ Returns our account servers. """ return self.accountservers def createAccount(self, un, pw, email, groups=None): """ Create a new account. """ acct = Account(username=un, password=pw, groups=groups, email=email) self.addAccount(acct) return self.buildAccountInfo(acct) def buildAccountInfo(self, acct): """ Take non confidential stuff from the account and spit it back out as a dict """ return dict(username=acct.username, pcs=acct.pcs, groups=acct.groups) def getAccountInfo(self, username): """ Returns the account info for the given username """ d = self.getAccount(username) return d.addCallback(lambda a: self.buildAccountInfo(a)) def authenticate(self, username, hashedpass): """ Try to authenticate this account. Raises UnauthorizedLogin on failure, returns accountInfo on success. """ try: d = self.getAccount(username) except ValueError: deferr() self.parent.log("Invalid username %s." % username, logging.ERROR) raise UnauthorizedLogin("Invalid credentials.") except: self.parent.log("Unexpected error getting username %s." % username, logging.ERROR) deferr() raise UnauthorizedLogin("Invalid credentials.") def gotAcct(a): hpass = md5(a.username + a.password).hexdigest() if hashedpass != hpass: a.failLogin() self.parent.log("Bad password for user %s." % username, logging.ERROR) raise UnauthorizedLogin("Invalid credentials.") a.login(None) return self.buildAccountInfo(a) def error(failure): deferr(failure) raise UnauthorizedLogin("Invalid credentials.") return d.addCallback(gotAcct).addErrback(error) def authenticateResponse(self, session, user, challenge, encData): """ Validate user's login by decrypting the data """ try: d = self.getAccount(user) except: self.parent.log("Invalid username %s." % user, logging.ERROR) raise UnauthorizedLogin("Invalid credentials.") def gotAcct(a): m = md5() m.update(challenge) m.update(a.password) key = m.hexdigest() # print "using kwy", key try: cdata = b64decode(encData) # print AES.new(key).decrypt(cdata) tm, usr, rnd = AES.new(key).decrypt(cdata).split(",") tm = float(tm) except ValueError: # raise self.parent.log("Bad password for user %s." % user, logging.ERROR) a.failLogin() raise UnauthorizedLogin("Invalid credentials.") if usr != user: self.parent.log("Username didn't match up? (%s != %s)" % (usr, user), logging.ERROR) a.failLogin() raise UnauthorizedLogin("Invalid credentials.") if abs(tm - (time() + timezone)) > 120: self.parent.log("Auth token stale by %d seconds for user %s." % (abs(tm - (time() + timezone)), user), logging.WARNING) # create the reply m = md5() m.update(rnd) m.update(a.password) key = m.hexdigest() a.setSessionInfo(session, "key", key) data = "%f,%s," % (tm, user) data = padRandomText(data, 16) cdata = AES.new(key).encrypt(data) a.login(session) return b64encode(cdata) def error(failure): deferr(failure) raise UnauthorizedLogin("Invalid credentials.") return d.addCallback(gotAcct).addErrback(error) def createPasswords(self, user, session): """ Create random one use passwords for a user """ try: d = self.getAccount(user) except: import traceback self.parent.log("Error creating user passwords for %s: %s" % (user, traceback.format_exc()), logging.ERROR) raise UnauthorizedLogin("Invalid credentials") def gotAcct(a): pw1 = genRandomText(random.randrange(16, 48)) pw2 = genRandomText(random.randrange(16, 48)) uid = hash(pw1 + pw2 + user + session) a.addTempPasswords(uid, pw1, pw2) data = padRandomText(",".join([pw1, pw2, str(uid)]) + ",", 16) if session == "localSession": return data else: key = a.getSessionInfo(session, "key") cdata = AES.new(key).encrypt(data) return b64encode(cdata) def gotError(e): # error logging ftw self.parent.log("Error creating user passwords for %s: %s: %s" % (user, e.type, e.value), logging.ERROR) raise UnauthorizedLogin("Invalid credentials") d.addCallback(gotAcct).addErrback(gotError) return d def getLoginInfo(self, serverUser, session, authUser, uid): """ Get the previously generated random passwords for the given authUser and uid """ if serverUser == "localUser": dd = defer.succeed("localUser") else: dd = self.getAccount(serverUser) d = defer.gatherResults([dd, self.getAccount(authUser)]) def gotAccounts(accts): sa, aa = accts if session != "localSession" and not aa.checkPermissions( sa, "authenticate"): raise AccessDenied("No permissions to user %s" % authUser) try: pws = aa.getTempPasswords(uid) except KeyError: raise AccessDenied("Invalid UID for user %s" % authUser) data = padRandomText(",".join(pws) + ",", 16) ai = self.buildAccountInfo(aa) if session == "localSession": ai["passwords"] = pws else: key = sa.getSessionInfo(session, "key") ai["passwords"] = b64encode(AES.new(key).encrypt(data)) return ai return d.addCallback(gotAccounts) def addPCToAccount(self, userName, pcid): """ Add a PC to an account """ d = self.getAccount(userName) return d.addCallback(lambda acct: acct.addPC(pcid)) @inlineCallbacks def changePassword(self, userName, newPassword): """ Changest the password on an account to newPassword """ acct = yield self.getAccount(userName) acct.setPassword(newPassword) @inlineCallbacks def logoutUser(self, session, userName): """ Log a user out """ acct = yield self.getAccount(userName) acct.logout(session) @inlineCallbacks def makeLogEntry(self, username, session, action, description=None): """ Make a log entry in the user's account """ acct = yield self.getAccount(username) acct.log(session, action, description) @inlineCallbacks def getLogEntries(self, username, session, action=None, date=None, startDate=None): """ Get entries in the user's log related to the given session """ if session is None: raise AccessDenied("A session must be specified when retrieving" "log entries!") acct = yield self.getAccount(username) # must be a list for pb happiness returnValue(list(acct.getLogEntries(session, action, date, startDate))) @inlineCallbacks def getAccountBalance(self, username): """ Returns the account balance for a user """ acct = yield self.getAccount(username) returnValue(acct.getAccountBalance()) def getStatistics(self): """ Get some statistics about this service """ ret = {} ret["Accounts"] = len(Account.query(self.store)) alog = Account.first(self.store, order=AccountLog.date.desc) if alog is not None: ret["Last Account Log"] = alog.toString() return ret def getConfig(self): """ Returns a dict with the config of this account service. """ return self.config def setStore(self, store): """ Sets the datastore this realm service uses """ if store != self.store.filename: self.store.close() self.store = SQLiteStore() self.store.open(store) def setAccountServices(self, services): """ Sets the list of account services to use """ self.accountservers = [ServiceLoc(svc) for svc in services] @requirePermissions("reference") def getInterface(self, _client, protocol): """ Hand out public interfaces """ return self.interfaces[protocol] def isLocal(self): """ We are local so return true """ return True class AccountConfig(ChangeNotifier): """ Guide interface for changing sim properties """ store = NotifierProperty("_store", "store/account") accountServices = NotifierProperty("_accountServices}", "self/Dir") window = None def __init__(self, account): self.account = account self.getConfig() self.doneDeferred = defer.Deferred() @inlineCallbacks def getConfig(self): """ Called on startup to load everything """ config = yield self.account.getConfig() self.store = config["store"] self.accountServices = ", ".join([str(svc) for svc in config.get( "accountServices", [])]) try: from mv3d.tools.guidewx import WxParser parser = WxParser() fname = os.path.abspath(os.path.join(os.path.dirname(mv3d.__file__), "..", "templates", "guide", "accountconfig.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.account.setStore(self.store) dirsvcs = self.accountServices.split(",") yield self.account.setAccountServices(dirsvcs) self.doneDeferred.callback(True) self.doneDeferred = None self.window.destroy()