Commits

Chris Miles committed 43fec36

Added user registration with email confirmation.

Comments (0)

Files changed (12)

 chronr.egg-info/
 build/
 dist/
+chronr/data/

chronr/chronr/config/environment.py

 from pylons import config
 from pylons.error import handle_mako_error
 from sqlalchemy import engine_from_config
+from turbomail.adapters import tm_pylons
 
 import chronr.lib.app_globals as app_globals
 import chronr.lib.helpers
     engine = engine_from_config(config, 'sqlalchemy.')
     init_model(engine, auto_schema_update=config.get('schemabot.update_database', False))
 
+    # Setup TurboMail
+    tm_pylons.start_extension()
+    
     # CONFIGURATION OPTIONS HERE (note: all config options will override
     # any Pylons config options)

chronr/chronr/controllers/account.py

+from datetime import datetime
+import hashlib
 import logging
+from urllib import quote
 
-from pylons import request, response, session, tmpl_context as c
+from pylons import request, response, session, tmpl_context as c, url
 from pylons.controllers.util import abort, redirect_to
 
+import tw.forms
+
 from chronr.lib.base import BaseController, render
+from chronr.model import User, UserActivation
+from chronr.model.meta import Session
+from chronr.model.forms.build import RegisterUserForm
+
+
+CAME_FROM_EXCLUDE = (
+    '/account/activation',
+)
 
 log = logging.getLogger(__name__)
 
+register_user_form = RegisterUserForm(
+    'register_account',
+    # action=url(),
+    # method="post",
+)
+
+class State(object):
+    """A container for passing state to validators.
+    """
+
+
 class AccountController(BaseController):
 
     def login(self):
         if identity is not None:
             came_from = request.params.get('came_from', None)
             if came_from:
-                redirect_to(str(came_from))
+                if came_from in CAME_FROM_EXCLUDE:
+                    redirect_to('/')
+                else:
+                    redirect_to(str(came_from))
         
         return render('/account/login.mako')
     
+    def register(self):
+        if request.method == 'POST':
+            state = State()
+            state.session = Session
+            try:
+                params = register_user_form.validate(request.params, state=state)
+            except tw.forms.core.Invalid, e:
+                c.form_error = e.error_dict or {}
+            else:
+                # Create the new account in database
+                user = User(
+                    user_name = params['user_name'],
+                    email_address = params['email_address'],
+                    display_name = params['display_name'],
+                    password = params['password'],
+                    activated = False,
+                )
+                Session.add(user)
+                
+                activation = UserActivation()
+                activation.user = user
+                key_seed = "%s%s%s" %(user.user_name, user.email_address, datetime.now().ctime())
+                activation.key = hashlib.sha512(key_seed).hexdigest()   # psuedo-random hashed key
+                Session.add(activation)
+                
+                activation_url = "%s%s?u=%s&key=%s" %(
+                    request.environ['HTTP_ORIGIN'],
+                    url(controller='account', action='activation'),
+                    quote(user.user_name),
+                    quote(activation.key)
+                )
+                
+                from turbomail import Message
+                message = Message("from@example.com", user.email_address, "Welcome to Chronr")
+                message.plain = "Your Chronr account is ready to use. Your username is '%s'.  Activate your account at %s" %(user.user_name, activation_url)
+                message.send()
+                
+                Session.commit()
+                
+                redirect_to(url('/'))
+        
+        c.register_user_form = register_user_form
+        
+        return render('/account/register.mako')
+    
+    def activation(self):
+        success = False
+        
+        user_name = request.params.get('u')
+        if user_name:
+            user = Session.query(User).filter_by(user_name=user_name).first()
+            if user is not None:
+                key = request.params.get('key')
+                if key and user.activation and user.activation[0]:
+                    activation = user.activation[0]
+                    if activation.key == key:
+                        Session.delete(activation)
+                        user.activated = True
+                        Session.commit()
+                        success = True
+        
+        if success:                    
+            c.message = u"Your account has been activated.  You may now login with username '%s'" %(user.user_name)
+        else:
+            c.message = u"Activation failed. The specified username or key may not be correct."
+        
+        # TODO: use a Flash message and redirect to "/" or login page
+        
+        return render('/account/activation.mako')
+    
+

chronr/chronr/model/__init__.py

 
 # ---- Constants ----
 
+USERNAME_SIZE = 16
 EVENT_TITLE_SIZE = 255
 
 
 
 user_table = sa.Table('user', meta.metadata,
     sa.Column('id', sa.Integer, primary_key=True),
-    sa.Column('user_name', sa.Unicode(16), unique=True),
-    sa.Column('email_address', sa.Unicode(255)),
+    sa.Column('user_name', sa.Unicode(USERNAME_SIZE), unique=True),
+    sa.Column('email_address', sa.Unicode(255), unique=True),
     sa.Column('display_name', sa.Unicode(255)),
     sa.Column('password', sa.String(128)),          # room for SHA-512 hashed passwords (hexdigest)
     sa.Column('created', sa.DateTime, default=datetime.now),
+    sa.Column('activated', sa.Boolean, nullable=False, default=False),
+)
+
+user_activation_table = sa.Table('user_activation', meta.metadata,
+    sa.Column('id', sa.Integer, primary_key=True),
+    sa.Column('user_id', sa.Integer, sa.ForeignKey("user.id")),
+    sa.Column('key', sa.Unicode(128), nullable=False),
+    sa.Column('created', sa.DateTime, default=datetime.now),
 )
 
 # Insert a default user "admin" with password "admin"
 admin_password_hash = hashlib.sha512('admin').hexdigest()
 insert_default_user = 'INSERT INTO "user" '                         \
-        "(id, user_name, display_name, password) VALUES "           \
-        "(0, 'admin', 'Admin User', '%s')"%admin_password_hash
-schema_version_1_upgrade = [user_table, insert_default_user]
-schema_version_1_downgrade = [user_table]
+        "(id, user_name, display_name, password, activated) VALUES "           \
+        "(0, 'admin', 'Admin User', '%s', true)"%admin_password_hash
+schema_version_1_upgrade = [user_table, user_activation_table, insert_default_user]
+schema_version_1_downgrade = [user_table, user_activation_table]
 
 
 # - Schema version 2 -
 # ---- ORM Mapped Classes ----
 
 class User(object):
+    def __init__(self, **kwargs):
+        for key,value in kwargs.items():
+            if key == 'password':
+                # Hash password
+                self.set_password(value)
+            else:
+                setattr(self, key, value)
+    
     @classmethod
     def by_email_address(cls, email):
         """
     
     def validate_password(self, password):
         """The given password is hashed and compared against the one
-        stored in the database.  Returns True if they are equal, else
-        False.
+        stored in the database.  Returns True if they are equal AND the
+        account is flagged as activated, else False.
         
         This method is called by repoze.who.plugins.sa.SQLAlchemyAuthenticatorPlugin
         """
-        hashed_password = hashlib.sha512(password).hexdigest()
-        return self.password == hashed_password
+        # hashed_password = hashlib.sha512(password).hexdigest()
+        hashed_password = self._hash_password(password)
+        return self.password == hashed_password and self.activated
     
+    def set_password(self, raw_pass):
+        """Set a new password for the account.  The raw password
+        will be stored in hashed form and will not be reversible.
+        """
+        self.password = self._hash_password(raw_pass)
+    
+    def _hash_password(self, raw_pass):
+        return hashlib.sha512(raw_pass).hexdigest()
+    
+
+
+class UserActivation(object):
+    pass
 
 
 class Event(object):
 
 
 
-orm.mapper(User, user_table)
+
+orm.mapper(User, user_table,
+    properties = dict(    
+        activation = sa.orm.relation(UserActivation, backref='user', cascade="all, delete, delete-orphan"),
+    )
+)
+
+orm.mapper(UserActivation, user_activation_table)
 
 orm.mapper(Event, events_table,
     properties = dict(
Add a comment to this file

chronr/chronr/model/forms/__init__.py

Empty file added.

chronr/chronr/model/forms/build.py

+# encoding: utf-8
+
+'''build
+'''
+
+__author__ = 'Chris Miles'
+__copyright__ = '(c) Chris Miles 2009. All rights reserved.'
+__id__ = '$Id$'
+__url__ = '$URL$'
+
+
+# ---- Imports ----
+
+# - ToscaWidgets & tw.forms modules -
+import tw.forms
+from tw.api import WidgetsList, CSSSource, JSSource, js_function
+
+# - Chronr modules -
+from chronr.model.forms.validators import UserValidators
+
+
+# ---- Form Definitions ----
+
+#
+# Uses ToscaWidgets Forms (tw.forms)
+#
+
+# External css files can be wrapped with CSSLink
+css = CSSSource("""
+.fielderror {
+    font-weight: bold;
+    color: red;
+};
+label.required {
+    font-weight: bold;
+};
+"""
+)
+
+
+# We define the source for some JS functions we're going to interface
+# External js files can be wrapped with JSLink
+functions = JSSource("""
+var focus_element = function (elem) {
+    var elem = document.getElementById(elem);
+    elem.focus(); elem.select();
+    }; 
+    """,
+)
+
+alert = js_function('alert')
+focus_element = js_function('focus_element')
+
+
+# Automatically filter out any unexpected parameters
+class FilteringSchema(tw.forms.validators.Schema):
+    filter_extra_fields = True
+    allow_extra_fields = True
+
+
+#
+# - Form for new user registration -
+#
+
+class RegisterUserForm(tw.forms.TableForm):
+    template = "chronr.templates.forms.register-user"
+    # hover_help = True
+    
+    class fields(WidgetsList):
+        user_name = tw.forms.TextField(
+            label_text = 'User Name',
+            # help_text = '',
+            required = True,
+            css_classes = ['form-text'],
+            attrs = {
+                'maxlength': "16",
+                'size': "30",
+            },
+            validator = UserValidators.user_name
+        )
+        
+        display_name = tw.forms.TextField(
+            label_text = 'Display Name',
+            # help_text = 'The name you want displayed throughout the site',
+            required = True,
+            css_classes = ['form-text'],
+            attrs = {
+                'maxlength': "255",
+                'size': "30",
+            },
+            validator = UserValidators.display_name
+        )
+        
+        email_address = tw.forms.TextField(
+            label_text = 'Email Address',
+            # help_text = 'Your email address',
+            required = True,
+            css_classes = ['form-text'],
+            attrs = {
+                'maxlength': "255",
+                'size': "30",
+            },
+            validator = UserValidators.email_address
+        )
+        
+        email_address2 = tw.forms.TextField(
+            label_text = 'Retype Email Address',
+            # help_text = 'Retype your email address',
+            required = True,
+            css_classes = ['form-text'],
+            attrs = {
+                'maxlength': "255",
+                'size': "30",
+            },
+            validator = tw.forms.validators.String(
+                not_empty=True,
+            )
+        )
+        
+        password = tw.forms.PasswordField(
+            label_text = 'Password',
+            # help_text = 'The password you will use to login to the site',
+            required = True,
+            css_classes = ['form-text'],
+            attrs = {
+                'maxlength': "75",
+                'size': "30",
+            },
+            validator = UserValidators.password
+        )
+        
+        password2 = tw.forms.PasswordField(
+            label_text = 'Retype Password',
+            # help_text = 'Retype your password to confirm we have it correct',
+            required = True,
+            css_classes = ['form-text'],
+            attrs = {
+                'maxlength': "75",
+                'size': "30",
+            },
+            validator = tw.forms.validators.String(
+                not_empty = True,
+            )
+        )
+    
+    # allow adding js calls dynamically for a request
+    include_dynamic_js_calls = True
+    
+    css = [css]
+    javascript = [functions]
+    validator = FilteringSchema(
+        chained_validators = [
+            tw.forms.validators.FieldsMatch(
+                'email_address',
+                'email_address2',
+                messages={
+                    'match': "Email addresses don't match."
+                }
+            ),
+            tw.forms.validators.FieldsMatch(
+                'password',
+                'password2',
+                messages={
+                    'match': "Passwords don't match."
+                }
+            ),
+        ]
+    )
+    
+    def update_params(self, d):
+        super(RegisterUserForm, self).update_params(d)
+        # Focus and select the 'name' field on the form
+        # The adapter we just wrote lets us pass formfields as parameters and
+        # the right thing will be done.
+        if not d.error:
+            self.add_call(focus_element(d.c.user_name))
+        ### This would show a JS alert containing details of all the validation
+        ###     errors.  This is too intrusive so we don't use it.
+        # else:
+        #     self.add_call(
+        #         alert('The form contains invalid data\n%s'% unicode(d.error))
+        #     )
+    
+

chronr/chronr/model/forms/validators.py

+# encoding: utf-8
+
+'''validators
+'''
+
+__author__ = 'Chris Miles'
+__copyright__ = '(c) Chris Miles 2009. All rights reserved.'
+__id__ = '$Id$'
+__url__ = '$URL$'
+
+
+# ---- Imports ----
+
+# - Python modules -
+import re
+
+import formencode
+from formencode.compound import All, Any
+
+# note: avoid tw.forms.validators -> use formencode directly so that
+#   these validators can be used outside of a ToscaWidgets environment.
+# import tw.forms
+
+from chronr.model import User, USERNAME_SIZE
+
+
+USERNAME_MIN_LENGTH=4
+
+
+# ---- Custom Validators ----
+
+# - Unique username validator -
+class UniqueUsername(formencode.FancyValidator):
+    """Validate that the value is a unique username (i.e. the username
+    does not already exist in the database).
+    
+    Requires an object to be passed in as ``state`` that contains a
+    ``session`` attribute pointing to a SQLAlchemy Session object.
+    The validator uses the Session object to access the database.
+    """
+    
+    messages = {
+        'username_taken': 'Username already taken',
+    }
+    
+    def validate_python(self, value, state):
+        """``state`` should be an object containing a ``session``
+        attribute, referencing an SQLAlchemy Session object
+        that can be used to access the database.
+        """
+        if state is not None and hasattr(state, 'session'):
+            acct = state.session.query(User).filter_by(user_name=value).first()
+            if acct is not None:
+                raise formencode.Invalid(self.message("username_taken", state), value, state)
+        
+        else:
+            # This means a Session object wasn't passed in as the "state".
+            raise ValueError("state object needs session attribute", value, state)
+
+
+# - Unique email validator -
+class UniqueEmail(formencode.FancyValidator):
+    """Validate that the value is a unique email (i.e. the email
+    address does not already exist in the database).
+    
+    Requires an object to be passed in as ``state`` that contains a
+    ``session`` attribute pointing to a SQLAlchemy Session object.
+    The validator uses the Session object to access the database.
+    """
+    
+    messages = {
+        'email_taken': 'Email address already taken',
+    }
+    
+    def validate_python(self, value, state):
+        """``state`` should be an object containing a ``session``
+        attribute, referencing an SQLAlchemy Session object
+        that can be used to access the database.
+        """
+        if state is not None and hasattr(state, 'session'):
+            acct = state.session.query(User).filter_by(email_address=value).first()
+            if acct is not None:
+                raise formencode.Invalid(self.message("email_taken", state), value, state)
+        
+        else:
+            # This means a Session object wasn't passed in as the "state".
+            raise ValueError("state object needs session attribute", value, state)
+
+
+# - Valid username validator -
+class ValidUsername(formencode.FancyValidator):
+    """Validate that the value meets the requirements for a username.
+    
+    The attributes ``reserved_usernames`` and ``username_re`` can be
+    overridden by passing new values as keyword arguments to the
+    constructor.
+    """
+    reserved_usernames = (
+        'admin',
+        'root',
+        'administrator',
+        'functest',
+        'functest1',
+        'functest2',
+        'functest3',
+        'functest4',
+        'functest5',
+        'functest6',
+        'functest7',
+        'functest8',
+        'functest9',
+    )
+    username_re = re.compile(r'^[a-z][a-z0-9.-]+$')
+    
+    messages = {
+        'bad_length': 'Username must be %d-%d characters long' %(USERNAME_MIN_LENGTH, USERNAME_SIZE),
+        'invalid_username': 'Invalid username',
+    }
+    
+    def validate_python(self, value, state):
+        if len(value) < USERNAME_MIN_LENGTH or len(value) > USERNAME_SIZE:
+            raise formencode.Invalid(self.message('bad_length', state), value, state)
+        inx = self.username_re.match(value)
+        if inx is None:
+            raise formencode.Invalid(self.message('invalid_username', state), value, state)
+        if '..' in value or '--' in value:
+            raise formencode.Invalid(self.message('invalid_username', state), value, state)
+        if value in self.reserved_usernames:
+            raise formencode.Invalid(self.message('invalid_username', state), value, state)
+    
+
+
+# - Good password validator -
+class SecurePassword(formencode.FancyValidator):
+    """Validate that value meets certain requirements that help make it
+    a more "secure" password.
+    
+    The attribute ``password_re`` can be overridden by passing new values
+    as keyword arguments to the constructor.
+    """
+    password_re = re.compile(r'[0-9,./;:\'"\\|\[\]{}!@#$%^&*()_=+-]')
+    
+    messages = {
+        'too_short': 'Password must be at least 4 characters long',
+        'insecure': 'Password must contain at least one number or punctuation character',
+    }
+    
+    def _to_python(self, value, state):
+        # _to_python gets run before validate_python.  Here we
+        # strip whitespace off the password, because leading and
+        # trailing whitespace in a password is too elite.
+        return value.strip()
+    
+    def validate_python(self, value, state):
+        if len(value) < 4:
+            raise formencode.Invalid(self.message('too_short', state), value, state)
+        # Ensure password contains at least one digit or non a-z character
+        inx = self.password_re.search(value)
+        if inx is None:
+            raise formencode.Invalid(self.message('insecure', state), value, state)
+    
+
+
+# ---- Validator Containers ----
+
+class UserValidators(object):
+    user_name = All(
+        formencode.validators.String(not_empty=True),
+        ValidUsername(),
+        UniqueUsername(),
+    )
+    
+    display_name = formencode.validators.String(
+        not_empty=True,
+        max=255,
+        # messages={
+        #     'empty':'Please enter your name.'
+        # }
+    )
+    
+    email_address = All(
+        # formencode.validators.Email(resolve_domain=True),
+        formencode.validators.Email(not_empty=True),
+        formencode.validators.String(max=255),
+        UniqueEmail(),
+    )
+    
+    password = All(
+        formencode.validators.String(not_empty=True),
+        SecurePassword()
+    )
+    
+    activated = formencode.validators.StringBool()
+

chronr/chronr/templates/account/activation.mako

+<%inherit file="/base/base-index.mako"/>
+
+<%def name="title()">Account Activation</%def>
+
+<p>
+  ${c.message}
+</p>
+
+<%def name="rightcontent()">
+</%def>

chronr/chronr/templates/account/register.mako

+<%inherit file="/base/base-index.mako"/>
+
+<%def name="title()">Register</%def>
+
+<h3>Register for an account</h3>
+
+${c.register_user_form()|n}
+
+<%def name="rightcontent()">
+</%def>

chronr/chronr/templates/forms/register-user.mak

+<%namespace name="tw" module="tw.core.mako_util"/>\
+<form ${tw.attrs(
+    [('id', context.get('id')),
+     ('name', name),
+     ('action', action),
+     ('method', method),
+     ('class', css_class)],
+    attrs=attrs
+)}>
+    % if hidden_fields:
+    <div>
+        % for field in hidden_fields:
+            <%
+                error = error_for(field)
+            %>
+            ${field.display(value_for(field), **args_for(field))}
+            % if show_children_errors and error and not field.show_error:
+            <span class="fielderror">${tw.content(error)}</span>
+            % endif
+        % endfor
+    </div>
+    % endif
+    <table border="0" cellspacing="0" cellpadding="2" ${tw.attrs(attrs=table_attrs)}>
+        % for i, field in enumerate(fields):
+        <%
+            required = ['',' required'][int(field.is_required)]
+            error = error_for(field)
+            label_text = field.label_text
+            help_text = field.help_text
+        %>
+        <tr id="${field.id}.container" \
+                title="${hover_help and help_text or ''}" \
+                ${tw.attrs(args_for(field).get('container_attrs') or field.container_attrs)}>\
+            % if show_labels and not field.suppress_label:
+                <td class="labelcol">
+                    <label ${tw.attrs(
+                        [('id', '%s.label' % field.id),
+                         ('for', field.id),
+                         ('class', 'fieldlabel%s' % required)]
+                    )}>${tw.content(label_text)}</label>
+                </td>
+            % endif
+            <td class="fieldcol" ${tw.attrs(show_labels and field.suppress_label and dict(colspan=2))}>
+                ${field.display(value_for(field), **args_for(field))}
+                % if required:
+                (*)
+                % endif
+                % if help_text and not hover_help:
+                <span class="fieldhelp">${tw.content(help_text)}</span>
+                % endif
+                % if show_children_errors and error and not field.show_error:
+                <span class="fielderror">${tw.content(error)}</span>
+                % endif
+            </td>
+        </tr>
+        % endfor
+    </table>
+    % if error and not error.error_dict:
+    <span class="fielderror">${tw.content(error)}</span>
+    % endif
+</form>\

chronr/development.ini

 who.log_level = debug
 who.log_file = stdout
 
+# TurboMail
+mail.on = true
+mail.debug = true
+mail.transport = smtp
+mail.smtp.server = localhost
+# mail.manager = immediate
+
 # WARNING: *THE LINE BELOW MUST BE UNCOMMENTED ON A PRODUCTION ENVIRONMENT*
 # Debug mode will enable the interactive debugging tool, allowing ANYONE to
 # execute malicious code after an exception is raised.
         "Pylons >= 0.9.7",
         "SQLAlchemy >= 0.5",
         "SchemaBot >= 0.1a2",
-        "ColossusModel",
         "repoze.who",
         "repoze.who.plugins.sa",
         "repoze.who-friendlyform",
         "ToscaWidgets",
         "tw.forms",
+        "TurboMail >= 3.0b2",
         "pytz",
     ],
     setup_requires=["PasteScript>=1.6.3"],
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.