diff --git a/.gitignore b/.gitignore index 60d24de..5e6003b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,4 @@ __pycache__ # testing infrastrucutre /.tox -/resets.db -/captcha.db -/key +/var diff --git a/docs/deploy.rst b/docs/deploy.rst index 35c6ba0..a5f1216 100644 --- a/docs/deploy.rst +++ b/docs/deploy.rst @@ -35,7 +35,7 @@ The CAPTCHA functionality relies on the Pillow library:: dnf install python-pillow These components are the core application. CherryPy as the web framework, -Jinja2 provides templating, and SQLAlchemy is used for the databases:: +Jinja2 provides templating, and SQLAlchemy is used for the database:: dnf install python-cherrypy python-jinja2 python-sqlalchemy @@ -83,16 +83,16 @@ Next, the installer copies the apache config from the conf directory to installation of the portal, you probably will not need this file, because you probably know what you're doing. -Then, the installer creates the directory where the portal keeps its databases:: +Then, the installer creates the directory where the portal keeps its database:: - mkdir -p /var/lib/freeipa_community_portal + mkdir -p -m 750 /var/lib/freeipa_community_portal chown apache:apache /var/lib/freeipa_community_portal/ If Apache doesn't own this folder, it will vomit when attempting to put -databases in it. Next, the installer generates a random key and stores it in a -file called "key" the above directory. The portal uses this key to secure the -captcha. It would be mostly harmless if this key gets compromised, so there's -no need to take any special precautions to secure it. +database in it. Next, the installer generates a random key and stores it in a +file called "captcha.key" the above directory. The portal uses this key to +secure the captcha. It would be mostly harmless if this key gets compromised, +so there's no need to take any special precautions to secure it. After this, the installer does:: diff --git a/docs/development.rst b/docs/development.rst index 726b7da..d1df7eb 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -10,30 +10,21 @@ and then do:: in the root of the tree. This should install a local, editable copy of the app, and put all of the configuration files and assets where they are expected. -You will also have to create a key file for the captcha. Because this is -development, you can probably just do:: - - touch key - -and the empty file will work. There just needs to be a key file available to -read. - You can configure exactly where the application spews its files by editing the -freeipa_community_portal_dev.ini file and plugging in values that make you -happy. +freeipa_community_portal_dev.ini file in freeipa_community_portal/conf and +plugging in values that make you happy. By default the development server uses +var/ in your current working directory to store its database and captcha key +file. The directory, sqlite database and key files are created automatically. Before you run the app, even in tree, you should kinit as a user with -sufficient permissions as outlined in the deployment doc. +sufficient permissions as outlined in the deployment doc. You can also drop +a client keytab in your var/ directory. To run the application in-tree, do:: - python freeipa_community_portal/app.py - -If you're running an IPA server on the host you're doing development on, one of -the IPA apps already uses port 8080 (the default CherryPy port). You may need -to add:: - - cherrypy.config.update({"server.socket_port": 8099}) + python -m freeipa_community_portal -to app.py between lines 182 and 183. You can use any port that isn't being -used be default, not just 8099. +On an IPA server Dogtag PKI is already occupying port 8080. For that reason +the development server listens on port 10080 on localhost. You can change +the port in freeipa_community_port/__main__.py if the port is already used +on your machine. diff --git a/freeipa_community_portal/__main__.py b/freeipa_community_portal/__main__.py new file mode 100644 index 0000000..0865a5f --- /dev/null +++ b/freeipa_community_portal/__main__.py @@ -0,0 +1,43 @@ +# Authors: +# Christian Heimes +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Entry point for development server + +Copy your keytab to ./var/portal.keytab and run:: + + python2.7 -m freeipa_community_portal + +""" +import cherrypy + +from freeipa_community_portal import app +from freeipa_community_portal.config import config + +config.load(config.development_config) + +# 8080 is occupied by Dogtag +cherrypy.config.update({ + 'server.socket_host': '127.0.0.1', + 'server.socket_port': 10080, +}) + +cherrypy.quickstart( + app.app(), + '/', + app.conf +) diff --git a/freeipa_community_portal/app.py b/freeipa_community_portal/app.py index 3d184f6..17f44a6 100644 --- a/freeipa_community_portal/app.py +++ b/freeipa_community_portal/app.py @@ -33,6 +33,7 @@ from freeipa_community_portal.model.password_reset import PasswordReset # TODO: move over to a "from" import import freeipa_community_portal.model.captcha_wrapper as captcha_helper +from freeipa_community_portal.config import config TEMPLATE_ENV = jinja2.Environment(loader=jinja2.PackageLoader('freeipa_community_portal','templates')) @@ -149,6 +150,8 @@ def check_captcha(args): return "Incorrect Captcha response" else: return None + + conf = { '/assets': { 'tools.staticdir.on': True, @@ -169,17 +172,16 @@ def check_captcha(args): } } + def app(): """Main entry point for the web application. If you run this library as a standalone application, you can just use this function """ + if not config: + raise ValueError('Run config.load(configfile) first!') webapp = SelfServicePortal() - webapp.user = SelfServiceUserRegistration() # pylint: disable=attribute-defined-outside-init + webapp.user = SelfServiceUserRegistration() # pylint: disable=attribute-defined-outside-init webapp.request_reset = RequestSelfServicePasswordReset() webapp.reset_password = SelfServicePasswordReset() return webapp - -if __name__ == "__main__": - webapp = app() - cherrypy.quickstart(webapp, '/', conf) diff --git a/freeipa_community_portal/conf/freeipa_community_portal.ini b/freeipa_community_portal/conf/freeipa_community_portal.ini index ac8cb86..9e8974b 100644 --- a/freeipa_community_portal/conf/freeipa_community_portal.ini +++ b/freeipa_community_portal/conf/freeipa_community_portal.ini @@ -20,10 +20,6 @@ default_from_email=CHANGEME@example.com # the address to send admin mail to default_admin_email=CHANGEME@example.com -[Captcha] -# the location of the key -key_location=/var/lib/freeipa_community_portal/key - [Database] # the directory where we store our databases db_directory=/var/lib/freeipa_community_portal/ diff --git a/freeipa_community_portal/conf/freeipa_community_portal_dev.ini b/freeipa_community_portal/conf/freeipa_community_portal_dev.ini index 09bcde6..ffc669b 100644 --- a/freeipa_community_portal/conf/freeipa_community_portal_dev.ini +++ b/freeipa_community_portal/conf/freeipa_community_portal_dev.ini @@ -18,18 +18,12 @@ default_from_email=user@example.com # the address to send admin mail to default_admin_email=user@example.com -[Captcha] - -# the location of the key -key_location=key - [Database] - # the directory where we store our databases -db_directory= +db_directory=var/ [KRB5] # set KRB5_CLIENT_KTNAME if non-empty -client_keytab=portal.keytab +client_keytab=var/portal.keytab # set KRB5CCNAME if non-empty ccache_name= diff --git a/freeipa_community_portal/config.py b/freeipa_community_portal/config.py index 91a04cf..037abe8 100644 --- a/freeipa_community_portal/config.py +++ b/freeipa_community_portal/config.py @@ -18,24 +18,74 @@ # along with this program. If not, see . import ConfigParser +import errno import os +from sqlalchemy import MetaData, create_engine + class Config(object): - default_configs = [ - '/etc/freeipa_community_portal.ini', - 'conf/freeipa_community_portal_dev.ini' - ] + development_config = 'freeipa_community_portal/conf/freeipa_community_portal_dev.ini' + deployment_config = '/etc/freeipa_community_portal.ini' captcha_length = 4 + umask = 0o027 + + metadata = MetaData() - def __init__(self, *configs): - if not configs: - configs = self.default_configs - self._cfg = ConfigParser.SafeConfigParser() - print configs - self._cfg.read(configs) + def __init__(self): + self._cfg = None + self.configfile = None self._captcha_key = None + self._engine = None + + def __nonzero__(self): + return self._cfg is not None + + def load(self, configfile): + cfg = ConfigParser.SafeConfigParser() + with open(configfile) as f: + cfg.readfp(f, configfile) + self._cfg = cfg + self.configfile = configfile + # set secure umask + os.umask(self.umask) + self._init_vardir() + self._init_captcha_key() + self._init_engine() + + def _init_vardir(self): + """Create our var directory with secure mode + """ + if not os.path.isdir(self.db_directory): + os.makedirs(self.db_directory, mode=0o750) + + def _init_captcha_key(self): + """Read or create captcha key file + """ + try: + with open(self.captcha_key_location, 'rb') as f: + self._captcha_key = f.read() + except IOError as e: + if e.errno != errno.ENOENT: + raise + new_key = os.urandom(8) + # write key with secure mode + with open(self.captcha_key_location, 'wb') as f: + os.fchmod(f.fileno(), 0o600) + f.write(new_key) + os.fdatasync(f.fileno()) + # re-read key from file system in case somebody else wrote to it. + with open(self.captcha_key_location, 'rb') as f: + self._captcha_key = f.read() + + def _init_engine(self): + """Create engine and tables + """ + if self._engine is not None: + self._engine.close() + self._engine = create_engine('sqlite:///' + self.communityportal_db) + self.metadata.create_all(self._engine) def _get_default(self, section, option, raw=False, vars=None, default=None): @@ -45,26 +95,25 @@ def _get_default(self, section, option, raw=False, vars=None, return default @property - def captcha_db(self): - return os.path.join(self._cfg.get('Database', 'db_directory'), - 'captcha.db') + def engine(self): + return self._engine + + @property + def db_directory(self): + return self._cfg.get('Database', 'db_directory') + + @property + def communityportal_db(self): + return os.path.join(self.db_directory, 'communityportal.db') @property def captcha_key_location(self): - return self._cfg.get('Captcha', 'key_location') + return os.path.join(self.db_directory, 'captcha.key') @property def captcha_key(self): - if self._captcha_key is None: - with open(self.captcha_key_location, 'rb') as f: - self._captcha_key = f.read() return self._captcha_key - @property - def reset_db(self): - return os.path.join(self._cfg.get('Database', 'db_directory'), - 'resets.db') - @property def smtp_server(self): return self._cfg.get('Mailers', 'smtp_server') diff --git a/freeipa_community_portal/freeipa_community_portal.wsgi b/freeipa_community_portal/freeipa_community_portal.wsgi index 397a33d..e5057ff 100755 --- a/freeipa_community_portal/freeipa_community_portal.wsgi +++ b/freeipa_community_portal/freeipa_community_portal.wsgi @@ -2,5 +2,12 @@ import cherrypy from freeipa_community_portal import app +from freeipa_community_portal.config import config -application = cherrypy.Application(app.app(), script_name=None, config=app.conf) +config.load(config.deployment_config) + +application = cherrypy.Application( + app.app(), + script_name=None, + config=app.conf +) diff --git a/freeipa_community_portal/model/captcha_wrapper.py b/freeipa_community_portal/model/captcha_wrapper.py index a85dcea..92c08ea 100644 --- a/freeipa_community_portal/model/captcha_wrapper.py +++ b/freeipa_community_portal/model/captcha_wrapper.py @@ -27,23 +27,18 @@ import base64 import hmac -from sqlalchemy import Table, Column, MetaData, String, DateTime, create_engine +from sqlalchemy import Table, Column, String, DateTime from sqlalchemy.sql import select, insert, delete from ..config import config # retrieve the captcha key from the key file # trust me, i know cryptography -LENGTH = config.captcha_length -KEY = config.captcha_key -_engine = create_engine('sqlite:///' + config.captcha_db) -_metadata = MetaData() -_captcha = Table('captcha', _metadata, +_captcha = Table('captcha', config.metadata, Column('hmac', String, primary_key=True), Column('timestamp', DateTime) ) -_metadata.create_all(_engine) class CaptchaHelper(object): @@ -54,12 +49,12 @@ def __init__(self): """create a new captcha """ # generate a captcha solution, which consists of 4 letter and digits self.solution = u''.join(random.SystemRandom().choice( - (string.ascii_uppercase + string.digits).translate(None, '0OQ')) for _ in range(LENGTH) + (string.ascii_uppercase + string.digits).translate(None, '0OQ')) for _ in range(config.captcha_length) ) # generate the captcha image, hold it as bytes self.image = self.image_generator.generate(self.solution, format='jpeg').getvalue() - conn = _engine.connect() + conn = config.engine.connect() conn.execute( _captcha.insert().values( hmac=self.solution_hash(), @@ -78,14 +73,14 @@ def datauri(self): def solution_hash(self): """combines the captcha solution and a secret key into a hash that can be used to prove that a correct answer has been found""" - return hmac.new(KEY, self.solution).hexdigest() + return hmac.new(config.captcha_key, self.solution).hexdigest() def checkResponse(response, solution): """Compares a given solution hash with the response provided""" valid = False - digest = hmac.new(KEY, response.upper()).hexdigest() + digest = hmac.new(config.captcha_key, response.upper()).hexdigest() if hmac.compare_digest(digest, solution.encode('ascii','ignore')): - conn = _engine.connect() + conn = config.engine.connect() result = conn.execute( select([_captcha]).where(_captcha.c.hmac == digest) ) diff --git a/freeipa_community_portal/model/password_reset.py b/freeipa_community_portal/model/password_reset.py index db654f3..8595c6c 100644 --- a/freeipa_community_portal/model/password_reset.py +++ b/freeipa_community_portal/model/password_reset.py @@ -21,7 +21,7 @@ import os import base64 -from sqlalchemy import Table, Column, MetaData, String, DateTime, create_engine +from sqlalchemy import Table, Column, String, DateTime from sqlalchemy.sql import select, insert, delete from ipalib import api, errors @@ -29,18 +29,12 @@ from . import api_connect from ..config import config - -_engine = create_engine('sqlite:///' + config.reset_db) - -_metadata = MetaData() -_password_reset = Table('password_reset', _metadata, +_password_reset = Table('password_reset', config.metadata, Column('username', String, primary_key=True), Column('token', String), Column('timestamp', DateTime) ) -_metadata.create_all(_engine) - USE_BY = timedelta(days=3) class PasswordReset(object): @@ -63,7 +57,7 @@ def load(username): None otherwise """ # connect to the database - conn = _engine.connect() + conn = config.engine.connect() # and grab a record cooresponding to this username result = conn.execute( select([_password_reset]).where(_password_reset.c.username == username) @@ -89,7 +83,7 @@ def load(username): def save(self): if self.check_valid(): - conn = _engine.connect() + conn = config.engine.connect() self.expire(self.username) conn.execute( _password_reset.insert().values( @@ -134,7 +128,7 @@ def reset_password(self): @staticmethod def expire(username): - conn = _engine.connect() + conn = config.engine.connect() conn.execute( delete(_password_reset).where(_password_reset.c.username == username) ) diff --git a/install/freeipa-portal-install b/install/freeipa-portal-install index 56db989..9e7da16 100644 --- a/install/freeipa-portal-install +++ b/install/freeipa-portal-install @@ -5,11 +5,9 @@ import os import shutil import sys import argparse -import socket -import time import subprocess - -from ipalib import api +import time +import pwd from freeipa_community_portal import PACKAGE_DATA_DIR @@ -19,11 +17,11 @@ HTTPD_CONF = '/etc/httpd/conf.d/freeipa_community_portal.conf' VAR_DIR = '/var/lib/freeipa_community_portal' WSGI_DIR = '/var/www/wsgi' EXECUTABLE = 'freeipa_community_portal.wsgi' +WEBSERVER_USER = pwd.getpwnam('apache') logger = logging.getLogger() - # borrowing a bunch of code from the ipsilon installer def openlogs(): # if the logfile is actual a file @@ -98,14 +96,16 @@ def install(opts): # create a /var/lib/freeipa-community-portal, if it does not exist # this is where our databases live - if not os.path.exists(VAR_DIR): - os.makedirs(VAR_DIR) + if not os.path.isdir(VAR_DIR): + os.makedirs(VAR_DIR, mode=0o750) # give apache this directory, so apache can write to it. - subprocess.call(["chown", "apache:apache", VAR_DIR]) + os.chown(VAR_DIR, WEBSERVER_USER.pw_uid, WEBSERVER_USER.pw_gid) # create a key for the captcha - with open(os.path.join(VAR_DIR, 'key'), 'w') as fp: + with open(os.path.join(VAR_DIR, 'captcha.key'), 'wb') as fp: logger.info('writing captcha key file') + os.fchmod(fp.fileno(), 0o600) + os.fchown(fp.fileno(), WEBSERVER_USER.pw_uid, WEBSERVER_USER.pw_gid) fp.write(os.urandom(8)) # set httpd_can_sendmail so that we send mail instead of crashing @@ -115,7 +115,7 @@ def install(opts): # create a directory to store our public scripts. if not os.path.exists(WSGI_DIR): logger.info('creating directory for WSGI script') - os.makedirs(WSGI_DIR) + os.makedirs(WSGI_DIR, mode=0o755) # remove a file that already exists, if there is one if os.path.lexists(os.path.join(WSGI_DIR, EXECUTABLE)): logger.warning('executable already exists, overwriting!')