From 8dfd73b195080e1c32b93f3e0ec634c19a918fdd Mon Sep 17 00:00:00 2001 From: Neil Williams Date: Sun, 22 Jul 2012 13:57:45 -0700 Subject: [PATCH] Add framework for RFC-6238: Time-Based One Time Password Algorithm. This provides a system for two-factor authentication, using a compliant OTP-generator such as Google Authenticator. The framework includes a validator for use on API calls needing authentication as well as a UI for provisioning/resetting your secret key. A secure cookie may be generated to effectively turn the user's browser into a temporary authentication factor. This feature is currently limited to admins only until full-site SSL is available. --- r2/example.ini | 4 + r2/r2/controllers/api.py | 48 +++++++++++ r2/r2/controllers/errors.py | 1 + r2/r2/controllers/front.py | 2 + r2/r2/controllers/reddit_base.py | 22 ++++- r2/r2/controllers/validator/validator.py | 62 +++++++++++++- r2/r2/lib/app_globals.py | 1 + r2/r2/lib/js.py | 5 ++ r2/r2/lib/menus.py | 1 + r2/r2/lib/pages/pages.py | 7 ++ r2/r2/lib/totp.py | 76 +++++++++++++++++ r2/r2/models/account.py | 36 ++++++++ r2/r2/public/static/css/reddit.css | 26 +++++- .../public/static/js/lib/jquery.qrcode.min.js | 28 +++++++ r2/r2/public/static/js/qrcode.js | 23 +++++ r2/r2/templates/prefotp.html | 83 +++++++++++++++++++ 16 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 r2/r2/lib/totp.py create mode 100644 r2/r2/public/static/js/lib/jquery.qrcode.min.js create mode 100644 r2/r2/public/static/js/qrcode.js create mode 100644 r2/r2/templates/prefotp.html diff --git a/r2/example.ini b/r2/example.ini index 21fbb4c09..74e73e855 100644 --- a/r2/example.ini +++ b/r2/example.ini @@ -110,6 +110,8 @@ https_endpoint = login_cookie = reddit_session # name of the admin cookie admin_cookie = reddit_admin +# name of the otp cookie +otp_cookie = reddit_otp # the work factor for bcrypt, increment this every time computers double in # speed. don't worry, changing this won't break old passwords bcrypt_work_factor = 12 @@ -409,6 +411,8 @@ min_membership_create_community = 30 ADMIN_COOKIE_TTL = 32400 # the maximum amount of idle time for an admin cookie (seconds) ADMIN_COOKIE_MAX_IDLE = 900 +# the maximum life of an otp cookie +OTP_COOKIE_TTL = 604800 # min amount of karma to edit WIKI_KARMA = 100 diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index 94c84b4b2..75663f725 100755 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -2713,3 +2713,51 @@ class ApiController(RedditController): self.enable_admin_mode(c.user) form.redirect(dest) + @validatedForm(VUser("password", default=""), + VModhash()) + def POST_generate_otp_secret(self, form, jquery): + if form.has_errors("password", errors.WRONG_PASSWORD): + return + + secret = totp.generate_secret() + g.cache.set('otp_secret_' + c.user._id36, secret, time=300) + jquery("body").make_totp_qrcode(secret) + + @validatedForm(VUser(), + VModhash(), + otp=nop("otp")) + def POST_enable_otp(self, form, jquery, otp): + if form.has_errors("password", errors.WRONG_PASSWORD): + return + + secret = g.cache.get("otp_secret_" + c.user._id36) + if not secret: + c.errors.add(errors.EXPIRED, field="otp") + form.has_errors("otp", errors.EXPIRED) + return + + if not VOneTimePassword.validate_otp(secret, otp): + c.errors.add(errors.WRONG_PASSWORD, field="otp") + form.has_errors("otp", errors.WRONG_PASSWORD) + return + + c.user.otp_secret = secret + c.user._commit() + + form.redirect("/prefs/otp") + + @validatedForm(VUser("password", default=""), + VOneTimePassword("otp", required=True), + VModhash()) + def POST_disable_otp(self, form, jquery): + if form.has_errors("password", errors.WRONG_PASSWORD): + return + + if form.has_errors("otp", errors.WRONG_PASSWORD, + errors.NO_OTP_SECRET, + errors.RATELIMIT): + return + + c.user.otp_secret = "" + c.user._commit() + form.redirect("/prefs/otp") diff --git a/r2/r2/controllers/errors.py b/r2/r2/controllers/errors.py index 0f41473c5..f84a3f3f5 100644 --- a/r2/r2/controllers/errors.py +++ b/r2/r2/controllers/errors.py @@ -97,6 +97,7 @@ error_list = dict(( ('CONFIRM', _("please confirm the form")), ('NO_API', _('cannot perform this action via the API')), ('DOMAIN_BANNED', _('%(domain)s is not allowed on reddit: %(reason)s')), + ('NO_OTP_SECRET', _('you must enable two-factor authentication')), )) errors = Storage([(e, e) for e in error_list.keys()]) diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index bea5c56da..b081212c5 100755 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -1104,6 +1104,8 @@ class FormsController(RedditController): content = PrefFeeds() elif location == 'delete': content = PrefDelete() + elif location == 'otp': + content = PrefOTP() else: return self.abort404() diff --git a/r2/r2/controllers/reddit_base.py b/r2/r2/controllers/reddit_base.py index e08c3f9d3..2caf654c1 100644 --- a/r2/r2/controllers/reddit_base.py +++ b/r2/r2/controllers/reddit_base.py @@ -150,9 +150,10 @@ def read_user_cookie(name): else: return '' -def set_user_cookie(name, val): +def set_user_cookie(name, val, **kwargs): uname = c.user.name if c.user_is_loggedin else "" - c.cookies[uname + '_' + name] = Cookie(value = val) + c.cookies[uname + '_' + name] = Cookie(value=val, + **kwargs) valid_click_cookie = fullname_regex(Link, True).match @@ -782,6 +783,17 @@ class RedditController(MinimalController): # no expiration time so the cookie dies with the browser session c.cookies[g.admin_cookie] = Cookie(value=user.make_admin_cookie(first_login=first_login)) + @staticmethod + def remember_otp(user): + cookie = user.make_otp_cookie() + expiration = datetime.utcnow() + timedelta(seconds=g.OTP_COOKIE_TTL) + expiration = expiration.strftime("%a, %d %b %Y %H:%M:%S GMT") + set_user_cookie(g.otp_cookie, + cookie, + secure=True, + httponly=True, + expires=expiration) + @staticmethod def disable_admin_mode(user): c.cookies[g.admin_cookie] = Cookie(value='', expires=DELETE) @@ -809,6 +821,7 @@ class RedditController(MinimalController): # the user could have been logged in via one of the feeds maybe_admin = False + is_otpcookie_valid = False # no logins for RSS feed unless valid_feed has already been called if not c.user: @@ -828,6 +841,10 @@ class RedditController(MinimalController): else: self.disable_admin_mode(c.user) + otp_cookie = read_user_cookie(g.otp_cookie) + if c.user_is_loggedin and otp_cookie: + is_otpcookie_valid = valid_otp_cookie(otp_cookie) + if not c.user: c.user = UnloggedUser(get_browser_langs()) # patch for fixing mangled language preferences @@ -850,6 +867,7 @@ class RedditController(MinimalController): c.user_is_admin = maybe_admin and c.user.name in g.admins c.user_special_distinguish = c.user.special_distinguish() c.user_is_sponsor = c.user_is_admin or c.user.name in g.sponsors + c.otp_cached = is_otpcookie_valid if request.path != '/validuser' and not g.disallow_db_writes: c.user.update_last_visit(c.start_time) diff --git a/r2/r2/controllers/validator/validator.py b/r2/r2/controllers/validator/validator.py index 854d45575..8fc299cc8 100644 --- a/r2/r2/controllers/validator/validator.py +++ b/r2/r2/controllers/validator/validator.py @@ -24,7 +24,7 @@ from pylons import c, g, request, response from pylons.i18n import _ from pylons.controllers.util import abort from r2.config.extensions import api_type -from r2.lib import utils, captcha, promote +from r2.lib import utils, captcha, promote, totp from r2.lib.filters import unkeep_space, websafe, _force_unicode from r2.lib.filters import markdown_souptest from r2.lib.db import tdb_cassandra @@ -1767,3 +1767,63 @@ class VFlairTemplateByID(VRequired): c.site._id, flair_template_id) except tdb_cassandra.NotFound: return None + +class VOneTimePassword(Validator): + max_skew = 2 # check two periods to allow for some clock skew + ratelimit = 3 # maximum number of tries per period + + def __init__(self, param, required): + self.required = required + Validator.__init__(self, param) + + @classmethod + def validate_otp(cls, secret, password): + # is the password a valid format and has it been used? + try: + key = "otp-%s-%d" % (c.user._id36, int(password)) + except (TypeError, ValueError): + valid_and_unused = False + else: + # leave this key around for one more time period than the maximum + # number of time periods we'll check for valid passwords + key_ttl = totp.PERIOD * (cls.max_skew + 1) + valid_and_unused = g.cache.add(key, True, time=key_ttl) + + # check the password (allowing for some clock-skew as 2FA-users + # frequently travel at relativistic velocities) + if valid_and_unused: + for skew in range(cls.max_skew): + expected_otp = totp.make_totp(secret, skew=skew) + if constant_time_compare(password, expected_otp): + return True + + return False + + def run(self, password): + # does the user have 2FA configured? + secret = c.user.otp_secret + if not secret: + if self.required: + self.set_error(errors.NO_OTP_SECRET) + return + + # do they have the otp cookie instead? + if c.otp_cached: + return + + # make sure they're not trying this too much + if not g.disable_ratelimit: + current_password = totp.make_totp(secret) + key = "otp-tries-" + current_password + g.cache.add(key, 0) + recent_attempts = g.cache.incr(key) + if recent_attempts > self.ratelimit: + self.set_error(errors.RATELIMIT, dict(time="30 seconds")) + return + + # check the password + if self.validate_otp(secret, password): + return + + # if we got this far, their password was wrong, invalid or already used + self.set_error(errors.WRONG_PASSWORD) diff --git a/r2/r2/lib/app_globals.py b/r2/r2/lib/app_globals.py index b89128ba3..401fd2fb9 100755 --- a/r2/r2/lib/app_globals.py +++ b/r2/r2/lib/app_globals.py @@ -63,6 +63,7 @@ class Globals(object): 'QUOTA_THRESHOLD', 'ADMIN_COOKIE_TTL', 'ADMIN_COOKIE_MAX_IDLE', + 'OTP_COOKIE_TTL', 'num_comments', 'max_comments', 'max_comments_gold', diff --git a/r2/r2/lib/js.py b/r2/r2/lib/js.py index 34b062ff4..dc227ce9f 100755 --- a/r2/r2/lib/js.py +++ b/r2/r2/lib/js.py @@ -310,6 +310,11 @@ module["traffic"] = LocalizedModule("traffic.js", "traffic.js", ) +module["qrcode"] = Module("qrcode.js", + "lib/jquery.qrcode.min.js", + "qrcode.js", +) + def use(*names): return "\n".join(module[name].use() for name in names) diff --git a/r2/r2/lib/menus.py b/r2/r2/lib/menus.py index 3853d3287..cd68457f9 100644 --- a/r2/r2/lib/menus.py +++ b/r2/r2/lib/menus.py @@ -109,6 +109,7 @@ menu = MenuHandler(hot = _('hot'), friends = _("friends"), update = _("password/email"), delete = _("delete"), + otp = _("two-factor authentication"), # messages compose = _("compose"), diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 08c7b6e20..6c3e11358 100755 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -625,6 +625,10 @@ class PrefsPage(Reddit): buttons.extend([NamedButton('friends'), NamedButton('update')]) + + if c.user_is_loggedin and c.user.name in g.admins: + buttons += [NamedButton('otp')] + #if CustomerID.get_id(user): # buttons += [NamedButton('payment')] buttons += [NamedButton('delete')] @@ -639,6 +643,9 @@ class PrefOptions(Templated): class PrefFeeds(Templated): pass +class PrefOTP(Templated): + pass + class PrefUpdate(Templated): """Preference form for updating email address and passwords""" def __init__(self, email = True, password = True, verify = False): diff --git a/r2/r2/lib/totp.py b/r2/r2/lib/totp.py new file mode 100644 index 000000000..d64a5744c --- /dev/null +++ b/r2/r2/lib/totp.py @@ -0,0 +1,76 @@ +# The contents of this file are subject to the Common Public Attribution +# License Version 1.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://code.reddit.com/LICENSE. The License is based on the Mozilla Public +# License Version 1.1, but Sections 14 and 15 have been added to cover use of +# software over a computer network and provide for limited attribution for the +# Original Developer. In addition, Exhibit A has been modified to be consistent +# with Exhibit B. +# +# Software distributed under the License is distributed on an "AS IS" basis, +# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +# the specific language governing rights and limitations under the License. +# +# The Original Code is reddit. +# +# The Original Developer is the Initial Developer. The Initial Developer of +# the Original Code is reddit Inc. +# +# All portions of the code written by reddit are Copyright (c) 2006-2012 reddit +# Inc. All Rights Reserved. +############################################################################### + +"""An implementation of the RFC-6238 Time-Based One Time Password algorithm.""" + +import time +import hmac +import base64 +import struct +import hashlib + + +PERIOD = 30 + + +def make_hotp(secret, counter): + """Generate an RFC-4226 HMAC-Based One Time Password.""" + key = base64.b32decode(secret) + + # compute the HMAC digest of the counter with the secret key + counter_encoded = struct.pack(">q", counter) + hmac_result = hmac.HMAC(key, counter_encoded, hashlib.sha1).digest() + + # do HOTP dynamic truncation (see RFC4226 5.3) + offset = ord(hmac_result[-1]) & 0x0f + truncated_hash = hmac_result[offset:offset + 4] + code_bits, = struct.unpack(">L", truncated_hash) + htop = (code_bits & 0x7fffffff) % 1000000 + + # pad it out as necessary + return "%06d" % htop + + +def make_totp(secret, skew=0, timestamp=None): + """Generate an RFC-6238 Time-Based One Time Password.""" + timestamp = timestamp or time.time() + counter = timestamp // PERIOD + return make_hotp(secret, counter - skew) + + +def generate_secret(): + """Make a secret key suitable for use in TOTP.""" + from Crypto.Random import get_random_bytes + bytes = get_random_bytes(20) + encoded = base64.b32encode(bytes) + return encoded + + +if __name__ == "__main__": + # based on RFC-6238 Appendix B (trimmed to six-digit OTPs) + secret = base64.b32encode("12345678901234567890") + assert make_totp(secret, timestamp=59) == "287082" + assert make_totp(secret, timestamp=1111111109) == "081804" + assert make_totp(secret, timestamp=1111111111) == "050471" + assert make_totp(secret, timestamp=1234567890) == "005924" + assert make_totp(secret, timestamp=2000000000) == "279037" + assert make_totp(secret, timestamp=20000000000) == "353130" diff --git a/r2/r2/models/account.py b/r2/r2/models/account.py index e15e60354..44df9f52e 100644 --- a/r2/r2/models/account.py +++ b/r2/r2/models/account.py @@ -108,6 +108,7 @@ class Account(Thing): gold_charter = False, gold_creddits = 0, gold_creddit_escrow = 0, + otp_secret=None, ) def has_interacted_with(self, sr): @@ -241,6 +242,16 @@ class Account(Thing): mac = hmac.new(g.SECRET, hashable, hashlib.sha1).hexdigest() return ','.join((first_login, last_request, mac)) + def make_otp_cookie(self, timestamp=None): + if not self._loaded: + self._load() + + timestamp = timestamp or datetime.utcnow().strftime(COOKIE_TIMESTAMP_FORMAT) + secrets = [request.user_agent, self.otp_secret, self.password] + signature = hmac.new(g.SECRET, ','.join([timestamp] + secrets), hashlib.sha1).hexdigest() + + return ",".join((timestamp, signature)) + def needs_captcha(self): return not g.disable_captcha and self.link_karma < 1 @@ -631,6 +642,31 @@ def valid_admin_cookie(cookie): first_login) +def valid_otp_cookie(cookie): + if g.read_only_mode: + return False + + # parse the cookie + try: + remembered_at, signature = cookie.split(",") + except ValueError: + return False + + # make sure it hasn't expired + try: + remembered_at_time = datetime.strptime(remembered_at, COOKIE_TIMESTAMP_FORMAT) + except ValueError: + return False + + age = datetime.utcnow() - remembered_at_time + if age.total_seconds() > g.OTP_COOKIE_TTL: + return False + + # validate + expected_cookie = c.user.make_otp_cookie(remembered_at) + return constant_time_compare(cookie, expected_cookie) + + def valid_feed(name, feedhash, path): if name and feedhash and path: from r2.lib.template_helpers import add_sr diff --git a/r2/r2/public/static/css/reddit.css b/r2/r2/public/static/css/reddit.css index c49bdb9f3..5f97533ef 100755 --- a/r2/r2/public/static/css/reddit.css +++ b/r2/r2/public/static/css/reddit.css @@ -3600,7 +3600,8 @@ ul.tabmenu.formtab { .roundfield textarea, .roundfield input[type=text], -.roundfield input[type=password] { +.roundfield input[type=password], +.roundfield input[type=number] { font-size: 100%; width: 492px; padding: 3px; @@ -5622,3 +5623,26 @@ tr.gold-accent + tr > td { .sr-description p { margin: .75em 0; } + +/** one-time password stuff **/ +#pref-otp .roundfield { + margin: 1em 0; +} + +#pref-otp-qr { + display: none; +} + +#otp-secret-info { + margin: 2em; + width: 512px; + font-size: small; +} + +#otp-secret-info div { + margin: 1em 0; +} + +#otp-secret-info .secret { + font-weight: bold; +} diff --git a/r2/r2/public/static/js/lib/jquery.qrcode.min.js b/r2/r2/public/static/js/lib/jquery.qrcode.min.js new file mode 100644 index 000000000..fe9680e6c --- /dev/null +++ b/r2/r2/public/static/js/lib/jquery.qrcode.min.js @@ -0,0 +1,28 @@ +(function(r){r.fn.qrcode=function(h){var s;function u(a){this.mode=s;this.data=a}function o(a,c){this.typeNumber=a;this.errorCorrectLevel=c;this.modules=null;this.moduleCount=0;this.dataCache=null;this.dataList=[]}function q(a,c){if(void 0==a.length)throw Error(a.length+"/"+c);for(var d=0;da||this.moduleCount<=a||0>c||this.moduleCount<=c)throw Error(a+","+c);return this.modules[a][c]},getModuleCount:function(){return this.moduleCount},make:function(){if(1>this.typeNumber){for(var a=1,a=1;40>a;a++){for(var c=p.getRSBlocks(a,this.errorCorrectLevel),d=new t,b=0,e=0;e=d;d++)if(!(-1>=a+d||this.moduleCount<=a+d))for(var b=-1;7>=b;b++)-1>=c+b||this.moduleCount<=c+b||(this.modules[a+d][c+b]= +0<=d&&6>=d&&(0==b||6==b)||0<=b&&6>=b&&(0==d||6==d)||2<=d&&4>=d&&2<=b&&4>=b?!0:!1)},getBestMaskPattern:function(){for(var a=0,c=0,d=0;8>d;d++){this.makeImpl(!0,d);var b=j.getLostPoint(this);if(0==d||a>b)a=b,c=d}return c},createMovieClip:function(a,c,d){a=a.createEmptyMovieClip(c,d);this.make();for(c=0;c=f;f++)for(var i=-2;2>=i;i++)this.modules[b+f][e+i]=-2==f||2==f||-2==i||2==i||0==f&&0==i?!0:!1}},setupTypeNumber:function(a){for(var c= +j.getBCHTypeNumber(this.typeNumber),d=0;18>d;d++){var b=!a&&1==(c>>d&1);this.modules[Math.floor(d/3)][d%3+this.moduleCount-8-3]=b}for(d=0;18>d;d++)b=!a&&1==(c>>d&1),this.modules[d%3+this.moduleCount-8-3][Math.floor(d/3)]=b},setupTypeInfo:function(a,c){for(var d=j.getBCHTypeInfo(this.errorCorrectLevel<<3|c),b=0;15>b;b++){var e=!a&&1==(d>>b&1);6>b?this.modules[b][8]=e:8>b?this.modules[b+1][8]=e:this.modules[this.moduleCount-15+b][8]=e}for(b=0;15>b;b++)e=!a&&1==(d>>b&1),8>b?this.modules[8][this.moduleCount- +b-1]=e:9>b?this.modules[8][15-b-1+1]=e:this.modules[8][15-b-1]=e;this.modules[this.moduleCount-8][8]=!a},mapData:function(a,c){for(var d=-1,b=this.moduleCount-1,e=7,f=0,i=this.moduleCount-1;0g;g++)if(null==this.modules[b][i-g]){var n=!1;f>>e&1));j.getMask(c,b,i-g)&&(n=!n);this.modules[b][i-g]=n;e--; -1==e&&(f++,e=7)}b+=d;if(0>b||this.moduleCount<=b){b-=d;d=-d;break}}}};o.PAD0=236;o.PAD1=17;o.createData=function(a,c,d){for(var c=p.getRSBlocks(a, +c),b=new t,e=0;e8*a)throw Error("code length overflow. ("+b.getLengthInBits()+">"+8*a+")");for(b.getLengthInBits()+4<=8*a&&b.put(0,4);0!=b.getLengthInBits()%8;)b.putBit(!1);for(;!(b.getLengthInBits()>=8*a);){b.put(o.PAD0,8);if(b.getLengthInBits()>=8*a)break;b.put(o.PAD1,8)}return o.createBytes(b,c)};o.createBytes=function(a,c){for(var d= +0,b=0,e=0,f=Array(c.length),i=Array(c.length),g=0;g>>=1;return c},getPatternPosition:function(a){return j.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,c,d){switch(a){case 0:return 0==(c+d)%2;case 1:return 0==c%2;case 2:return 0==d%3;case 3:return 0==(c+d)%3;case 4:return 0==(Math.floor(c/2)+Math.floor(d/3))%2;case 5:return 0==c*d%2+c*d%3;case 6:return 0==(c*d%2+c*d%3)%2;case 7:return 0==(c*d%3+(c+d)%2)%2;default:throw Error("bad maskPattern:"+ +a);}},getErrorCorrectPolynomial:function(a){for(var c=new q([1],0),d=0;dc)switch(a){case 1:return 10;case 2:return 9;case s:return 8;case 8:return 8;default:throw Error("mode:"+a);}else if(27>c)switch(a){case 1:return 12;case 2:return 11;case s:return 16;case 8:return 10;default:throw Error("mode:"+a);}else if(41>c)switch(a){case 1:return 14;case 2:return 13;case s:return 16;case 8:return 12;default:throw Error("mode:"+ +a);}else throw Error("type:"+c);},getLostPoint:function(a){for(var c=a.getModuleCount(),d=0,b=0;b=g;g++)if(!(0>b+g||c<=b+g))for(var h=-1;1>=h;h++)0>e+h||c<=e+h||0==g&&0==h||i==a.isDark(b+g,e+h)&&f++;5a)throw Error("glog("+a+")");return l.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;256<=a;)a-=255;return l.EXP_TABLE[a]},EXP_TABLE:Array(256), +LOG_TABLE:Array(256)},m=0;8>m;m++)l.EXP_TABLE[m]=1<m;m++)l.EXP_TABLE[m]=l.EXP_TABLE[m-4]^l.EXP_TABLE[m-5]^l.EXP_TABLE[m-6]^l.EXP_TABLE[m-8];for(m=0;255>m;m++)l.LOG_TABLE[l.EXP_TABLE[m]]=m;q.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var c=Array(this.getLength()+a.getLength()-1),d=0;d +this.getLength()-a.getLength())return this;for(var c=l.glog(this.get(0))-l.glog(a.get(0)),d=Array(this.getLength()),b=0;b>>7-a%8&1)},put:function(a,c){for(var d=0;d>>c-d-1&1))},getLengthInBits:function(){return this.length},putBit:function(a){var c=Math.floor(this.length/8);this.buffer.length<=c&&this.buffer.push(0);a&&(this.buffer[c]|=128>>>this.length%8);this.length++}};"string"===typeof h&&(h={text:h});h=r.extend({},{render:"canvas",width:256,height:256,typeNumber:-1, +correctLevel:2,background:"#ffffff",foreground:"#000000"},h);return this.each(function(){var a;if("canvas"==h.render){a=new o(h.typeNumber,h.correctLevel);a.addData(h.text);a.make();var c=document.createElement("canvas");c.width=h.width;c.height=h.height;for(var d=c.getContext("2d"),b=h.width/a.getModuleCount(),e=h.height/a.getModuleCount(),f=0;f").css("width",h.width+"px").css("height",h.height+"px").css("border","0px").css("border-collapse","collapse").css("background-color",h.background);d=h.width/a.getModuleCount();b=h.height/a.getModuleCount();for(e=0;e").css("height",b+"px").appendTo(c);for(i=0;i").css("width", +d+"px").css("background-color",a.isDark(e,i)?h.foreground:h.background).appendTo(f)}}a=c;jQuery(a).appendTo(this)})}})(jQuery); diff --git a/r2/r2/public/static/js/qrcode.js b/r2/r2/public/static/js/qrcode.js new file mode 100644 index 000000000..5b758a71c --- /dev/null +++ b/r2/r2/public/static/js/qrcode.js @@ -0,0 +1,23 @@ +(function($) { + $.fn.make_totp_qrcode = function (secret) { + var form = $('#pref-otp'), + newform = $('#pref-otp-qr'), + placeholder = $('
'), + uri = ('otpauth://totp/' + r.config.logged + '@' + + r.config.cur_domain + '?secret=' + secret) + + newform.find('#otp-secret-info').append( + placeholder, + $('

').text(secret) + ) + + placeholder.qrcode({ + width: 256, + height: 256, + text: uri + }) + + newform.show() + form.hide() + } +})(jQuery) diff --git a/r2/r2/templates/prefotp.html b/r2/r2/templates/prefotp.html new file mode 100644 index 000000000..e9897891e --- /dev/null +++ b/r2/r2/templates/prefotp.html @@ -0,0 +1,83 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.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://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be +## consistent with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is reddit Inc. +## +## All portions of the code written by reddit are Copyright (c) 2006-2012 +## reddit Inc. All Rights Reserved. +############################################################################### + +<%! + from r2.lib import js + from r2.lib.strings import strings +%> + +<%namespace file="utils.html" import="error_field, _md"/> +<%namespace name="utils" file="utils.html"/> + +

${_("two-factor authentication")}

+ +% if c.user.otp_secret: +
+ +${_md("two-factor authentication is currently **enabled**. fill out the form below if you would like to disable it.", wrap=True)} + +<%utils:round_field title="${_('password')}" description="${_('(required)')}"> + + ${error_field("WRONG_PASSWORD", "password")} + + +<%utils:round_field title="${_('one-time password')}" description="${_('(required)')}"> + + ${error_field("WRONG_PASSWORD", "otp")} + ${error_field("NO_OTP_SECRET", "otp")} + ${error_field("RATELIMIT", "otp")} + + + +
+% else: +
+ +${_md("enter your current password below to start the activation process for two-factor authentication.", wrap=True)} + +<%utils:round_field title="${_('password')}" description="${_('(required)')}"> + + ${error_field("WRONG_PASSWORD", "password")} + + + + +
+ +
+ +
+ ${_md("below is your two-factor authentication secret. you can scan the QR code with Google Authenticator or enter the key below manually. you WILL NOT have another chance to see this secret.")} +
+ +<%utils:round_field title="${_('one-time password')}" description="${_('(required)')}"> + + ${error_field("WRONG_PASSWORD", "otp")} + ${error_field("EXPIRED", "otp")} + + + + +
+% endif + +${unsafe(js.use("qrcode"))}