From b8477570692084da4f110d1405488bb12f70adb1 Mon Sep 17 00:00:00 2001 From: Max Goodman Date: Thu, 6 Oct 2011 07:21:04 -0700 Subject: [PATCH] Use cross domain https for slightly safer login. Login UI code has been simplified and moved into the client side. CORS is used for the cross-domain POST if available, otherwise an iframe and cookie polling technique is used. Start fleshing out the new JS tree. :) --- r2/Makefile | 2 +- r2/example.ini | 2 + r2/r2/controllers/api.py | 66 +++---- r2/r2/controllers/validator/validator.py | 54 ++---- r2/r2/lib/app_globals.py | 2 + r2/r2/lib/js.py | 2 + r2/r2/lib/jsonresponse.py | 5 +- r2/r2/lib/strings.py | 2 + r2/r2/public/static/compact/throbber.gif | 1 + r2/r2/public/static/css/compact.css | 6 + r2/r2/public/static/css/compact.scss | 15 ++ r2/r2/public/static/css/reddit.css | 100 ++++++++--- r2/r2/public/static/js/base.js | 6 +- r2/r2/public/static/js/compact.js | 1 - r2/r2/public/static/js/jquery.reddit.js | 11 +- r2/r2/public/static/js/login.js | 211 +++++++++++++++++++++++ r2/r2/public/static/js/reddit.js | 24 +-- r2/r2/public/static/js/ui.js | 104 +++++++++++ r2/r2/templates/captcha.html | 6 +- r2/r2/templates/login.html | 17 +- r2/r2/templates/loginformwide.html | 32 ++-- r2/r2/templates/printable.html | 2 +- r2/r2/templates/printablebuttons.html | 7 +- r2/r2/templates/profilebar.html | 2 +- r2/r2/templates/reddit.html | 2 +- r2/r2/templates/redditheader.html | 2 +- r2/r2/templates/sidebox.html | 17 +- r2/r2/templates/subreddit.html | 3 +- r2/r2/templates/utils.html | 5 +- 29 files changed, 513 insertions(+), 196 deletions(-) create mode 120000 r2/r2/public/static/compact/throbber.gif create mode 100644 r2/r2/public/static/js/login.js create mode 100644 r2/r2/public/static/js/ui.js diff --git a/r2/Makefile b/r2/Makefile index cad6aba05..93201e671 100644 --- a/r2/Makefile +++ b/r2/Makefile @@ -22,7 +22,7 @@ # Javascript files to be compressified js_libs = $(addprefix lib/,json2.js jquery.cookie.js jquery.url.js ui.core.js ui.datepicker.js jquery.flot.js jquery.lazyload.js) -js_sources = $(js_libs) jquery.reddit.js reddit.js base.js sponsored.js compact.js blogbutton.js flair.js analytics.js +js_sources = $(js_libs) jquery.reddit.js reddit.js login.js ui.js base.js sponsored.js compact.js blogbutton.js flair.js analytics.js js_targets = button.js jquery.flot.js sponsored.js localized_js_targets = reddit.js mobile.js localized_js_outputs = $(localized_js_targets:.js=.*.js) diff --git a/r2/example.ini b/r2/example.ini index 42a021741..4dbd12fbb 100755 --- a/r2/example.ini +++ b/r2/example.ini @@ -85,6 +85,8 @@ display_timezone = MST shutdown_secret = 12345 # list of servers that the service monitor will care about monitored_servers = reddit, localhost +# https api endpoint (must be g.domain or a subdomain of g.domain) +https_endpoint = # name of the cookie to drop with login information login_cookie = reddit_session diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index a8ea57c05..c99f0d74d 100644 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -20,7 +20,7 @@ # CondeNet, Inc. All Rights Reserved. ################################################################################ from reddit_base import RedditController, MinimalController, set_user_cookie -from reddit_base import paginated_listing +from reddit_base import cross_domain, paginated_listing from pylons.i18n import _ from pylons import c, request, response @@ -110,7 +110,7 @@ class ApiController(RedditController): @json_validate() - def GET_me(self): + def GET_me(self, responder): if c.user_is_loggedin: return Wrapped(c.user).render() else: @@ -353,60 +353,53 @@ class ApiController(RedditController): else: form.set_html(".title-status", _("no title found")) - def _login(self, form, user, dest='', rem = None): + def _login(self, responder, user, rem = None): """ AJAX login handler, used by both login and register to set the user cookie and send back a redirect. """ self.login(user, rem = rem) - form._send_data(modhash = user.modhash()) - form._send_data(cookie = user.make_cookie()) - dest = dest or request.referer or '/' - form.redirect(dest) + if request.params.get("hoist") != "cookie": + responder._send_data(modhash = user.modhash()) + responder._send_data(cookie = user.make_cookie()) + @cross_domain([g.origin, g.https_endpoint], allow_credentials=True) @validatedForm(VDelay("login"), user = VLogin(['user', 'passwd']), username = VLength('user', max_length = 100), - dest = VDestination(), - rem = VBoolean('rem'), - reason = VReason('reason')) - def POST_login(self, form, jquery, user, username, dest, rem, reason): - if form.has_errors('vdelay', errors.RATELIMIT): - jquery(".recover-password").addClass("attention") + rem = VBoolean('rem')) + def POST_login(self, form, responder, user, username, rem): + if responder.has_errors('vdelay', errors.RATELIMIT): return - if reason and reason[0] == 'redirect': - dest = reason[1] - - if login_throttle(username, wrong_password = form.has_errors("passwd", + if login_throttle(username, wrong_password = responder.has_errors("passwd", errors.WRONG_PASSWORD)): VDelay.record_violation("login", seconds=1, growfast=True) - jquery(".recover-password").addClass("attention") c.errors.add(errors.WRONG_PASSWORD, field = "passwd") - if not form.has_errors("passwd", errors.WRONG_PASSWORD): - self._login(form, user, dest, rem) + if not responder.has_errors("passwd", errors.WRONG_PASSWORD): + self._login(responder, user, rem) + @cross_domain([g.origin, g.https_endpoint], allow_credentials=True) @validatedForm(VCaptcha(), VRatelimit(rate_ip = True, prefix = "rate_register_"), name = VUname(['user']), email = ValidEmails("email", num = 1), password = VPassword(['passwd', 'passwd2']), - dest = VDestination(), - rem = VBoolean('rem'), - reason = VReason('reason')) - def POST_register(self, form, jquery, name, email, - password, dest, rem, reason): - if not (form.has_errors("user", errors.BAD_USERNAME, + rem = VBoolean('rem')) + def POST_register(self, form, responder, name, email, + password, rem): + bad_captcha = responder.has_errors('captcha', errors.BAD_CAPTCHA) + if not (responder.has_errors("user", errors.BAD_USERNAME, errors.USERNAME_TAKEN_DEL, errors.USERNAME_TAKEN) or - form.has_errors("email", errors.BAD_EMAILS) or - form.has_errors("passwd", errors.BAD_PASSWORD) or - form.has_errors("passwd2", errors.BAD_PASSWORD_MATCH) or - form.has_errors('ratelimit', errors.RATELIMIT) or - (not g.disable_captcha and form.has_errors('captcha', errors.BAD_CAPTCHA))): - + responder.has_errors("email", errors.BAD_EMAILS) or + responder.has_errors("passwd", errors.BAD_PASSWORD) or + responder.has_errors("passwd2", errors.BAD_PASSWORD_MATCH) or + responder.has_errors('ratelimit', errors.RATELIMIT) or + (not g.disable_captcha and bad_captcha)): + user = register(name, password) VRatelimit.ratelimit(rate_ip = True, prefix = "rate_register_") @@ -427,14 +420,7 @@ class ApiController(RedditController): user._commit() c.user = user - if reason: - if reason[0] == 'redirect': - dest = reason[1] - elif reason[0] == 'subscribe': - for sr, sub in reason[1].iteritems(): - self._subscribe(sr, sub) - - self._login(form, user, dest, rem) + self._login(responder, user, rem) @noresponse(VUser(), VModhash(), diff --git a/r2/r2/controllers/validator/validator.py b/r2/r2/controllers/validator/validator.py index 07b94054e..067c44ffc 100644 --- a/r2/r2/controllers/validator/validator.py +++ b/r2/r2/controllers/validator/validator.py @@ -19,7 +19,7 @@ # All portions of the code written by CondeNet are Copyright (c) 2006-2010 # CondeNet, Inc. All Rights Reserved. ################################################################################ -from pylons import c, request, g +from pylons import c, g, request, response from pylons.i18n import _ from pylons.controllers.util import abort from r2.lib import utils, captcha, promote @@ -154,14 +154,15 @@ def api_validate(response_type=None): def _api_validate(*simple_vals, **param_vals): def val(fn): def newfn(self, *a, **env): - c.render_style = api_type(request.params.get("renderstyle", - response_type)) - c.response_content_type = 'application/json; charset=UTF-8' + c.render_style = api_type(request.params.get("renderstyle", response_type)) # generate a response object - if response_type is None or request.params.get('api_type') == "json": - responder = JsonResponse() - else: + if response_type == "html" and not request.params.get('api_type') == "json": responder = JQueryResponse() + else: + responder = JsonResponse() + + c.response_content_type = responder.content_type + try: kw = _make_validated_kw(fn, simple_vals, param_vals, env) return response_function(self, fn, responder, @@ -191,8 +192,11 @@ def textresponse(self, self_method, responder, simple_vals, param_vals, *a, **kw def json_validate(self, self_method, responder, simple_vals, param_vals, *a, **kw): if c.extension != 'json': abort(404) - r = self_method(self, *a, **kw) - return self.api_wrapper(r) + + val = self_method(self, responder, *a, **kw) + if not val: + val = responder.make_response() + return self.api_wrapper(val) @api_validate("html") def validatedForm(self, self_method, responder, simple_vals, param_vals, @@ -1234,38 +1238,6 @@ class VOneOf(Validator): else: return val -class VReason(Validator): - def run(self, reason): - if not reason: - return - - if reason.startswith('redirect_'): - dest = reason[9:] - if (not dest.startswith(c.site.path) and - not dest.startswith("http:")): - dest = (c.site.path + dest).replace('//', '/') - return ('redirect', dest) - if reason.startswith('vote_'): - fullname = reason[5:] - t = Thing._by_fullname(fullname, data=True) - return ('redirect', t.make_permalink_slow()) - elif reason.startswith('share_'): - fullname = reason[6:] - t = Thing._by_fullname(fullname, data=True) - return ('redirect', t.make_permalink_slow()) - elif reason.startswith('reply_'): - fullname = reason[6:] - t = Thing._by_fullname(fullname, data=True) - return ('redirect', t.make_permalink_slow()) - elif reason.startswith('sr_change_'): - sr_list = reason[10:].split(',') - fullnames = dict(i.split(':') for i in sr_list) - srs = Subreddit._by_fullname(fullnames.keys(), data = True, - return_dict = False) - sr_onoff = dict((sr, fullnames[sr._fullname] == 1) for sr in srs) - return ('subscribe', sr_onoff) - - class ValidEmails(Validator): """Validates a list of email addresses passed in as a string and delineated by whitespace, ',' or ';'. Also validates quantity of diff --git a/r2/r2/lib/app_globals.py b/r2/r2/lib/app_globals.py index 0105ef3be..58e3a4f38 100755 --- a/r2/r2/lib/app_globals.py +++ b/r2/r2/lib/app_globals.py @@ -286,6 +286,8 @@ class Globals(object): self.origin = "http://" + self.domain self.secure_domains = set([urlparse(self.payment_domain).netloc]) + if self.https_endpoint: + self.secure_domains.add(urlparse(self.https_endpoint).netloc) # load the unique hashed names of files under static static_files = os.path.join(self.paths.get('static_files'), 'static') diff --git a/r2/r2/lib/js.py b/r2/r2/lib/js.py index 08870fd0c..188064100 100755 --- a/r2/r2/lib/js.py +++ b/r2/r2/lib/js.py @@ -187,6 +187,8 @@ module["reddit"] = LocalizedModule("reddit.js", "lib/jquery.url.js", "jquery.reddit.js", "base.js", + "ui.js", + "login.js", "analytics.js", "flair.js", "reddit.js", diff --git a/r2/r2/lib/jsonresponse.py b/r2/r2/lib/jsonresponse.py index e3c97acbd..f0034830f 100644 --- a/r2/r2/lib/jsonresponse.py +++ b/r2/r2/lib/jsonresponse.py @@ -44,6 +44,9 @@ class JsonResponse(object): in the api func's validators, as well as blobs of data set by the api func. """ + + content_type = 'application/json; charset=UTF-8' + def __init__(self): self._clear() @@ -67,7 +70,7 @@ class JsonResponse(object): res = {} if self._data: res['data'] = self._data - res['errors'] = [(e[0], c.errors[e].message) for e in self._errors] + res['errors'] = [(e[0], c.errors[e].message, e[1]) for e in self._errors] return {"json": res} def set_error(self, error_name, field_name): diff --git a/r2/r2/lib/strings.py b/r2/r2/lib/strings.py index 527bbf5d0..94ce55fe0 100644 --- a/r2/r2/lib/strings.py +++ b/r2/r2/lib/strings.py @@ -44,6 +44,8 @@ string_dict = dict( banned_by = "removed by %s", banned = "removed", reports = "reports: %d", + + submitting = _("submitting..."), # this accomodates asian languages which don't use spaces number_label = _("%(num)d %(thing)s"), diff --git a/r2/r2/public/static/compact/throbber.gif b/r2/r2/public/static/compact/throbber.gif new file mode 120000 index 000000000..6bec9b804 --- /dev/null +++ b/r2/r2/public/static/compact/throbber.gif @@ -0,0 +1 @@ +../throbber.gif \ No newline at end of file diff --git a/r2/r2/public/static/css/compact.css b/r2/r2/public/static/css/compact.css index 378e1002c..a3c266fcf 100644 --- a/r2/r2/public/static/css/compact.css +++ b/r2/r2/public/static/css/compact.css @@ -377,6 +377,10 @@ body[orient="landscape"] > #topbar > h1 { margin-left: -125px; width: 250px; } .loading img { -webkit-animation-name: rotateThis; -webkit-animation-duration: .5s; -webkit-animation-iteration-count: infinite; -webkit-animation-timing-function: linear; } +.throbber { display: none; margin: 0 2px; background: url("/static/compact/throbber.gif") no-repeat; width: 18px; height: 18px; } + +.working .throbber { display: inline-block; } + /* Login and Register */ #login_login, #login_reg { background: white; border: 1px solid #d9d9d9; margin: 10px; -webkit-border-bottom-left-radius: 8px; -webkit-border-bottom-right-radius: 8px; -moz-border-radius-bottomleft: 8px; -moz-border-radius-bottomright: 8px; max-width: 350px; margin-left: auto; margin-right: auto; } @@ -390,6 +394,8 @@ body[orient="landscape"] > #topbar > h1 { margin-left: -125px; width: 250px; } #login_login > div > ul li input[type="checkbox"] + label, #login_reg > div > ul li input[type="checkbox"] + label { display: inline; } +.user-form .submit * { vertical-align: middle; } + /* toolbar specific stuf here */ body.toolbar { margin: 0px; padding: 0px; overflow: hidden; } diff --git a/r2/r2/public/static/css/compact.scss b/r2/r2/public/static/css/compact.scss index 13a71bc33..752fa51ae 100644 --- a/r2/r2/public/static/css/compact.scss +++ b/r2/r2/public/static/css/compact.scss @@ -1097,6 +1097,16 @@ padding: 5px; -webkit-animation-iteration-count:infinite; -webkit-animation-timing-function:linear; } + +.throbber { + display: none; + margin: 0 2px; + background: url($static + 'throbber.gif') no-repeat; + width: 18px; + height: 18px; +} +.working .throbber { display: inline-block; } + /* Login and Register */ #login_login, #login_reg { background: white; @@ -1133,6 +1143,11 @@ padding: 5px; #login_login > div > ul li input[type="checkbox"] + label, #login_reg > div > ul li input[type="checkbox"] + label { display: inline; } + +.user-form .submit * { + vertical-align: middle; +} + /* toolbar specific stuf here */ body.toolbar { margin: 0px; diff --git a/r2/r2/public/static/css/reddit.css b/r2/r2/public/static/css/reddit.css index d54dea7c9..32212aa0b 100644 --- a/r2/r2/public/static/css/reddit.css +++ b/r2/r2/public/static/css/reddit.css @@ -1412,21 +1412,36 @@ textarea.gray { color: gray; } border: 1px solid gray; } -.login-form-side input { - border: 1px solid gray; - width: 138px; +.login-form-side input[type=text], +.login-form-side input[type=password] { + border: 1px solid #999; + width: 137px; height: 17px; margin: 5px 0px 0px 5px; top: 5px; padding: 1px; } -.login-form-side .error { - margin: 5px; +.login-form-side input[type=password] { + width: 138px; } -#remember-me { - margin: 5px; +.login-form-side #remember-me, +.login-form-side .submit { + margin: 4px; +} + +.login-form-side .submit input[type=button] { + margin:1px; +} + +.login-form-side #remember-me { + float: left; + line-height: 24px; +} + +.login-form-side #remember-me * { + vertical-align:middle; } /*the checkbox*/ @@ -1436,6 +1451,7 @@ textarea.gray { color: gray; } width: auto; border: none; margin-right: 5px; + margin-top: 0; } .login-form-side label { @@ -1444,13 +1460,31 @@ textarea.gray { color: gray; } white-space: nowrap; } -.login-form-side button { +.login-form-side .recover-password { + margin-left: 1em; +} + +.login-form-side .status { display:none; } + +.login-form-side .submit { float: right; } +.login-form-side .submit *, .user-form .submit * { + vertical-align: middle; +} -.status { margin-left: 5px; color: red; font-size: small;} -.error { color: red; font-size: small; margin: 5px; } +.throbber { + display: none; + margin: 0 2px; + background: url(/static/throbber.gif) no-repeat; + width: 18px; + height: 18px; +} +.working .throbber { display: inline-block; } + +.status { margin: 5px 0 0 5px; font-size: small;} +.error { color: red; font-size: small; } .red { color:red } .buygold { color: #9A7D2E; font-weight: bold; } .line-through { text-decoration: line-through } @@ -1572,16 +1606,16 @@ label + #moresearchinfo { .legal {color: #808080; font-family: serif; font-size: small; margin-top: 20px; } .legal a {text-decoration: underline} -.divide { border-right: 2px solid #D3D3D3; } +.divide { border-right: 2px solid #D3D3D3; margin-right: -2px; } -.loginform { +.login-form-section { float: left; - width: 45%; - padding-left: 15px; - padding-right: 15px; + width: 46%; + padding-left: 2%; + padding-right: 2%; } -.loginform h3 { +.login-form-section h3 { margin-bottom: 0; margin-top: 10px; font-size: large; @@ -1589,26 +1623,31 @@ label + #moresearchinfo { font-variant: small-caps; color: #404040; } -.loginform p { +.login-form-section p { text-align: left; margin-bottom: 10px; color: #606060; margin-bottom: 20px; } -.loginform label { + +.user-form label { display: block; font-weight: bold; color: #606060; } -.loginform .remember { display:inline; margin-left: 5px; } -.loginform ul { margin: 5px; } -.loginform li { margin-top: 5px; } -.loginform p .btn { margin-top: 5px } -.loginform input.logtxt { width: 125px; } +.user-form .error { + margin-left: 5px; +} -.loginform input[type=text], -.loginform input[type=password] { +.user-form .remember { display:inline; margin-left: 5px; } +.user-form ul { margin: 5px; } +.user-form li { margin-top: 5px; } +.user-form p .btn { margin-top: 5px } +.user-form input.logtxt { width: 125px; } + +.user-form input[type=text], +.user-form input[type=password] { width: 125px; border: 1px solid #A0A0A0; margin-top: 2px; @@ -1616,10 +1655,14 @@ label + #moresearchinfo { padding: 1px; } -.loginform #captcha { +.user-form #captcha { width: 250px; } +.user-form .submit { + margin-top: 10px; +} + #passform h1 {margin-bottom: 0px} #passform p {margin-bottom: 5px; font-size: small} @@ -1664,6 +1707,11 @@ label + #moresearchinfo { font-weight: normal; } +.popup .close-popup { + text-align: center; + margin-top: 10px; +} + .usertable { margin-left: 10px;} .usertable { font-size: larger } .usertable td, .usertable th { padding: 0 .7em } diff --git a/r2/r2/public/static/js/base.js b/r2/r2/public/static/js/base.js index e5886cfaa..5754abf1d 100644 --- a/r2/r2/public/static/js/base.js +++ b/r2/r2/public/static/js/base.js @@ -1 +1,5 @@ -r = {} +r = r || {} + +$(function() { + r.login.ui.init() +}) diff --git a/r2/r2/public/static/js/compact.js b/r2/r2/public/static/js/compact.js index c1cd5c8a4..afb3d4510 100644 --- a/r2/r2/public/static/js/compact.js +++ b/r2/r2/public/static/js/compact.js @@ -140,7 +140,6 @@ function showcover() { $(".login-popup:first").fadeIn() .find(".popup").css("top", $(window).scrollTop() + 75).end() .find(".cover").css("height", $(document).height()).end() - .find("form input[name=reason]").attr("value", (reason || "")); return false; } diff --git a/r2/r2/public/static/js/jquery.reddit.js b/r2/r2/public/static/js/jquery.reddit.js index d064ba39f..bc4dbae0d 100644 --- a/r2/r2/public/static/js/jquery.reddit.js +++ b/r2/r2/public/static/js/jquery.reddit.js @@ -259,10 +259,7 @@ rate_limit = function() { $.fn.vote = function(vh, callback, event, ui_only) { /* for vote to work, $(this) should be the clicked arrow */ - if (!reddit.logged) { - showcover(true, 'vote_' + $(this).thing_id()); - } - else if($(this).hasClass("arrow")) { + if (reddit.logged && $(this).hasClass("arrow")) { var dir = ( $(this).hasClass(up_cls) ? 1 : ( $(this).hasClass(down_cls) ? -1 : 0) ); var things = $(this).all_things_by_id(); @@ -303,9 +300,6 @@ $.fn.vote = function(vh, callback, event, ui_only) { if(callback) callback(things, dir); } - if(event) { - event.stopPropagation(); - } }; $.fn.show_unvotable_message = function() { @@ -584,8 +578,7 @@ $.fn.captcha = function(iden) { /* */ var c = this.find(".capimage"); if(iden) { - c.attr("src", "http://" + reddit.ajax_domain - + "/captcha/" + iden + ".png") + c.attr("src", "/captcha/" + iden + ".png") .parents("form").find('input[name="iden"]').val(iden); } return c; diff --git a/r2/r2/public/static/js/login.js b/r2/r2/public/static/js/login.js new file mode 100644 index 000000000..2c1201f0e --- /dev/null +++ b/r2/r2/public/static/js/login.js @@ -0,0 +1,211 @@ +r.login = { + post: function(form, action, callback) { + var username = $('input[name="user"]', form.$el).val(), + endpoint = r.config.https_endpoint || ('http://'+r.config.ajax_domain), + sameOrigin = location.protocol+'//'+location.host == endpoint, + apiTarget = endpoint+'/api/'+action+'/'+username + + if (sameOrigin || $.support.cors) { + var params = form.serialize() + params.push({name:'api_type', value:'json'}) + $.ajax({ + url: apiTarget, + type: 'POST', + dataType: 'json', + data: params, + success: callback, + error: function(xhr, err) { + callback(false, err, xhr) + }, + xhrFields: { + withCredentials: true + } + }) + } else { + var iframe = $('