Move verification email and password reset tokens into Cassandra.

In memcached there is a chance of the keys being evicted before their
time runs out. We can mitigate this by adding more memcaches, but that
has other downsides (such as increased risk of failure).
This commit is contained in:
Neil Williams
2012-07-14 17:23:43 -07:00
parent 7560c53464
commit cbb072c75b
5 changed files with 104 additions and 66 deletions

View File

@@ -1938,25 +1938,37 @@ class ApiController(RedditController):
form.set_html(".status", _("try again tomorrow"))
@validatedForm(cache_evt = VHardCacheKey('email-reset', ('key',)),
password = VPassword(['passwd', 'passwd2']))
def POST_resetpassword(self, form, jquery, cache_evt, password):
if form.has_errors('name', errors.EXPIRED):
cache_evt.clear()
@validatedForm(token=VOneTimeToken(PasswordResetToken, "key"),
password=VPassword(["passwd", "passwd2"]))
def POST_resetpassword(self, form, jquery, token, password):
# was the token invalid or has it expired?
if not token:
form.redirect("/password?expired=true")
return
# did they fill out the password form correctly?
form.has_errors("passwd", errors.BAD_PASSWORD)
form.has_errors("passwd2", errors.BAD_PASSWORD_MATCH)
if form.has_error():
return
# at this point, we should mark the token used since it's either
# valid now or will never be valid again.
token.consume()
# load up the user and check that things haven't changed
user = Account._by_fullname(token.user_id)
if not token.valid_for_user(user):
form.redirect('/password?expired=true')
elif form.has_errors('passwd', errors.BAD_PASSWORD):
pass
elif form.has_errors('passwd2', errors.BAD_PASSWORD_MATCH):
pass
elif cache_evt.user:
# successfully entered user name and valid new password
change_password(cache_evt.user, password)
g.hardcache.delete("%s_%s" % (cache_evt.cache_prefix, cache_evt.key))
print "%s did a password reset for %s via %s" % (
request.ip, cache_evt.user.name, cache_evt.key)
self._login(jquery, cache_evt.user)
jquery.redirect('/')
cache_evt.clear()
return
# successfully entered user name and valid new password
change_password(user, password)
g.log.warning("%s did a password reset for %s via %s",
request.ip, user.name, token._id)
self._login(jquery, user)
jquery.redirect('/')
@noresponse(VUser())

View File

@@ -49,6 +49,7 @@ from errors import errors
from listingcontroller import ListingController
from api_docs import api_doc, api_section
from pylons import c, request, request, Response
from r2.models.token import EmailVerificationToken
import string
import random as rand
@@ -990,34 +991,36 @@ class FormsController(RedditController):
return BoringPage(_("verify email"), content = content).render()
@validate(VUser(),
cache_evt = VCacheKey('email_verify', ('key',)),
key = nop('key'),
dest = VDestination(default = "/prefs/update"))
def GET_verify_email(self, cache_evt, key, dest):
if c.user_is_loggedin and c.user.email_verified:
cache_evt.clear()
return self.redirect(dest)
elif not (cache_evt.user and
key == passhash(cache_evt.user.name, cache_evt.user.email)):
content = PaneStack(
[InfoBar(message = strings.email_verify_failed),
PrefUpdate(email = True, verify = True,
password = False)])
return BoringPage(_("verify email"), content = content).render()
elif c.user != cache_evt.user:
# wrong user. Log them out and try again.
token=VOneTimeToken(EmailVerificationToken, "key"),
dest=VDestination(default="/prefs/update"))
def GET_verify_email(self, token, dest):
if token and token.user_id != c.user._fullname:
# wrong user. log them out and try again.
self.logout()
return self.redirect(request.fullpath)
else:
cache_evt.clear()
elif token and c.user.email_verified:
# they've already verified. consume and ignore this token.
token.consume()
return self.redirect(dest)
elif token and token.valid_for_user(c.user):
# successful verification!
token.consume()
c.user.email_verified = True
c.user._commit()
Award.give_if_needed("verified_email", c.user)
return self.redirect(dest)
else:
# failure. let 'em know.
content = PaneStack(
[InfoBar(message=strings.email_verify_failed),
PrefUpdate(email=True,
verify=True,
password=False)])
return BoringPage(_("verify email"), content=content).render()
@validate(cache_evt = VHardCacheKey('email-reset', ('key',)),
key = nop('key'))
def GET_resetpassword(self, cache_evt, key):
@validate(token=VOneTimeToken(PasswordResetToken, "key"),
key=nop("key"))
def GET_resetpassword(self, token, key):
"""page hit once a user has been sent a password reset email
to verify their identity before allowing them to update their
password."""
@@ -1031,7 +1034,7 @@ class FormsController(RedditController):
if not key and request.referer:
referer_path = request.referer.split(g.domain)[-1]
done = referer_path.startswith(request.fullpath)
elif not getattr(cache_evt, "user", None):
elif not token:
return self.redirect("/password?expired=true")
return BoringPage(_("reset password"),
content=ResetPassword(key=key, done=done)).render()

View File

@@ -1369,29 +1369,19 @@ class CachedUser(object):
if self.key and self.cache_prefix:
g.cache.delete(str(self.cache_prefix + "_" + self.key))
class VCacheKey(Validator):
def __init__(self, cache_prefix, param, *a, **kw):
self.cache = g.cache
self.cache_prefix = cache_prefix
Validator.__init__(self, param, *a, **kw)
class VOneTimeToken(Validator):
def __init__(self, model, param, *args, **kwargs):
self.model = model
Validator.__init__(self, param, *args, **kwargs)
def run(self, key):
c_user = CachedUser(self.cache_prefix, None, key)
if key:
uid = self.cache.get(str(self.cache_prefix + "_" + key))
if uid:
try:
c_user.user = Account._byID(uid, data = True)
except NotFound:
return
return c_user
self.set_error(errors.EXPIRED)
token = self.model.get_token(key)
class VHardCacheKey(VCacheKey):
def __init__(self, cache_prefix, param, *a, **kw):
VCacheKey.__init__(self, cache_prefix, param, *a, **kw)
self.cache = g.hardcache
if token:
return token
else:
self.set_error(errors.EXPIRED)
return None
class VOneOf(Validator):
def __init__(self, param, options = (), *a, **kw):

View File

@@ -27,6 +27,8 @@ from r2.lib.utils import timeago, query_string, randstr
from r2.models import passhash, Email, DefaultSR, has_opted_out, Account, Award
import os, random, datetime
import traceback, sys, smtplib
from r2.models.token import EmailVerificationToken, PasswordResetToken
def _feedback_email(email, body, kind, name='', reply_to = ''):
"""Function for handling feedback and ad_inq emails. Adds an
@@ -64,14 +66,14 @@ def verify_email(user, dest):
For verifying an email address
"""
from r2.lib.pages import VerifyEmail
key = passhash(user.name, user.email)
user.email_verified = False
user._commit()
Award.take_away("verified_email", user)
emaillink = ('http://' + g.domain + '/verification/' + key
token = EmailVerificationToken._new(user)
emaillink = ('http://' + g.domain + '/verification/' + token._id
+ query_string(dict(dest=dest)))
g.log.debug("Generated email verification link: " + emaillink)
g.cache.set("email_verify_%s" %key, user._id, time=1800)
_system_email(user.email,
VerifyEmail(user=user,
@@ -94,10 +96,9 @@ def password_email(user):
if g.cache.incr(reset_count_global) > 1000:
raise ValueError("Somebody's beating the hell out of the password reset box")
key = passhash(randstr(64, reallyrandom = True), user.email)
passlink = 'http://' + g.domain + '/resetpassword/' + key
token = PasswordResetToken._new(user)
passlink = 'http://' + g.domain + '/resetpassword/' + token._id
g.log.info("Generated password reset link: " + passlink)
g.hardcache.set("email-reset_%s" % key, user._id, time=3600 * 12)
_system_email(user.email,
PasswordReset(user=user,
passlink=passlink).render(style='email'),

View File

@@ -161,3 +161,35 @@ class OAuth2AccessToken(Token):
return super(OAuth2AccessToken, cls)._new(
user_id=user_id,
scope=scope)
class EmailVerificationToken(ConsumableToken):
_use_db = True
_connection_pool = "main"
_ttl = 60 * 60 * 12
token_size = 20
@classmethod
def _new(cls, user):
return super(EmailVerificationToken, cls)._new(user_id=user._fullname,
email=user.email)
def valid_for_user(self, user):
return self.email == user.email
class PasswordResetToken(ConsumableToken):
_use_db = True
_connection_pool = "main"
_ttl = 60 * 60 * 12
token_size = 20
@classmethod
def _new(cls, user):
return super(PasswordResetToken, cls)._new(user_id=user._fullname,
email_address=user.email,
password=user.password)
def valid_for_user(self, user):
return (self.email_address == user.email and
self.password == user.password)