#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
#
# Copyright 2012 ShopWiki
#
# Licensed 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.
#
import bcrypt
import random
import string
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column
from sqlalchemy.types import Integer, Unicode, String, Boolean
[docs]class ActivationError(Exception):
pass
Base = declarative_base()
[docs]class UserBase(Base):
'''
The :class:`UserBase` class subclasses sqlalchemy's default
declarative base to provide a schema for basic user authentication.
:class:`UserBase` provides fields and methods for setting and
checking a password, generating and checking an activation code, and
disabling the user entirely. The :attr:`email` field is unique.
This class **cannot be used on its own**. In order to use this
class in your application, subclass it and define the
:attr:`__tablename__` attribute.
'''
__abstract__ = True
id = Column(Integer, primary_key=True)
'''
The primary key and id which will always be used to reference a user.
'''
email = Column(Unicode(255), unique=True)
'''
The user's email. The uniqueness is enforced at the database level.
'''
password_hash = Column(String(80))
'''
The hash of the user's password, with salt, as generated by bcrypt.
'''
password_is_set = Column(Boolean())
'''
:attr:`password_hash` might be empty, so we explicitly record
whether or not the user has a password set.
'''
disabled = Column(Boolean())
'''
Prevent check_password from returning ``True``, regardless of the
password supplied.
'''
activated = Column(Boolean())
'''
:attr:`activated` is ``True`` if the user has completed the e-mail
verification process using :attr:`activation_code_hash`.
'''
activation_code_hash = Column(String(80))
'''
The :attr:`activation_code_hash` is set to a hash of the activation
code that is generated by :meth:`generate_activation_code` and
subsequently e-mailed to the user. This code is immediately
forgotten by the application; only the bcrypt hashed version of it
is stored in :attr:`activation_code_hash`. The activation code can
be checked using :meth:`.activate`.
'''
[docs] def set_password(self, password_plaintext):
'''
:param password_plaintext: The plaintext of the password to set.
Given a plaintext password, generate a salted hash using
bcrypt. Set :attr:`password_is_set` to ``True`` and
:attr:`password_hash` to the generated hash. The user can now
be authenticated with the plain text password and
:meth:`check_password`.
'''
hashed = bcrypt.hashpw(password_plaintext, bcrypt.gensalt())
self.password_is_set = True
self.password_hash = hashed
[docs] def check_password(self, password_plaintext):
'''
:param password_plaintext: The plaintext of the password to check.
If :attr:`password_is_set` is ``False`` or :attr:`disabled` is
``True``, return ``False``. Otherwise, take a plaintext password
string and hash it using bcrypt along with the salt for this user.
If the resulting hash matches the hash in :attr:`password_hash`,
return ``True``. The session can now be considered authenticated
for this user.
'''
return (self.password_is_set
and not self.disabled
and bcrypt.hashpw(password_plaintext, self.password_hash)
== self.password_hash)
[docs] def activate(self, activation_code_plaintext):
'''
:param activation_code_plaintext: The activation code to check.
If its hash matches :attr:`activation_code_hash`, the user
will be activated.
Take the plaintext activation code that was sent to the user,
generally via e-mail. Hash the plaintext and compare it against
:attr:`activation_code_hash`. If they are the same, set
:attr:`activated` to ``True``. Finally, call
:meth:`generate_activation_code` in order to prevent
reactivation of the account using the same code. Since we don't
save the new reactivation code, it is useless. If the account
becomes inactive in the future for any reason,
:meth:`generate_activation_code` must be called again in order
to reactivate.
If activation fails, an exception is raised and no change is made
to the model.
'''
hashed = bcrypt.hashpw(activation_code_plaintext,
self.activation_code_hash)
if hashed == self.activation_code_hash:
self.activated = True
self.generate_activation_code()
else:
raise ActivationError('Activation code failed')
[docs] def generate_activation_code(self):
'''
Generate a 20 character random code from letters and digits.
This is the activation code that will be sent to the user
(presumably via e-mail) and allow them to activate their account.
The actual code is not stored locally; like a password, it is hashed
and stored in :attr:`activation_code_hash`. The plaintext
activation code is returned to the caller. In order to activate
the user with the plaintext activation code, call :meth:`activate`.
'''
alphabet = string.ascii_letters + string.digits
sr = random.SystemRandom()
activation_code = ''.join(sr.choice(alphabet) for x in range(20))
self.activation_code_hash = bcrypt.hashpw(activation_code,
bcrypt.gensalt())
return activation_code
[docs] def __init__(self, email):
'''
:param email: **Required**. The user's email address. Validity
as an email address is not enforced; any string will be
allowed.
Create a new :class:`UserBase` object. By default,
:attr:`password_is_set`, :attr:`disabled`, and
:attr:`activated` are all initialized to ``False``.
'''
self.email = email
self.password_is_set = False
self.disabled = False
self.activated = False
def __repr__(self):
return "<User('{email}')>".format(**dict(email=self.email))