Permalink
Browse files

Preliminary OAuth2 for new API call authentication.

  • Loading branch information...
1 parent 3d55cea commit 5df8723c19e4470dce27749bf65fe09112eb398c @chromakode chromakode committed Oct 24, 2011
@@ -224,6 +224,10 @@ def make_map(global_conf={}, app_conf={}):
requirements=dict(action="new_captcha"))
mc('/api/:action', controller='api')
+ mc("/api/v1/:action", controller="oauth2frontend", requirements=dict(action="authorize"))
+ mc("/api/v1/:action", controller="oauth2access", requirements=dict(action="access_token"))
+ mc("/api/v1/:action", controller="apiv1")
+
mc("/button_info", controller="api", action="info", limit = 1)
mc('/captcha/:iden', controller='captcha', action='captchaimg')
@@ -61,6 +61,9 @@
from api import ApiController
from api import ApiminimalController
+from apiv1 import APIv1Controller as Apiv1Controller
+from oauth2 import OAuth2FrontendController as Oauth2frontendController
+from oauth2 import OAuth2AccessController as Oauth2accessController
from admin import AdminController
from redirect import RedirectController
from ipn import IpnController
@@ -0,0 +1,33 @@
+# 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 CondeNet, Inc.
+#
+# All portions of the code written by CondeNet are Copyright (c) 2006-2010
+# CondeNet, Inc. All Rights Reserved.
+################################################################################
+from pylons import c
+from r2.controllers.oauth2 import OAuth2ResourceController, require_oauth2_scope
+from r2.lib.jsontemplates import IdentityJsonTemplate
+
+class APIv1Controller(OAuth2ResourceController):
+ def try_pagecache(self):
+ pass
+
+ @require_oauth2_scope("identity")
+ def GET_me(self):
+ resp = IdentityJsonTemplate().data(c.oauth_user)
+ return self.api_wrapper(resp)
@@ -85,6 +85,8 @@
('TOO_OLD', _("that's a piece of history now; it's too late to reply to it")),
('BAD_CSS_NAME', _('invalid css name')),
('TOO_MUCH_FLAIR_CSS', _('too many flair css classes')),
+ ('OAUTH2_INVALID_CLIENT', _('invalid client id')),
+ ('OAUTH2_ACCESS_DENIED', _('access denied by the user')),
))
errors = Storage([(e, e) for e in error_list.keys()])
@@ -0,0 +1,215 @@
+# 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 CondeNet, Inc.
+#
+# All portions of the code written by CondeNet are Copyright (c) 2006-2010
+# CondeNet, Inc. All Rights Reserved.
+################################################################################
+from urllib import urlencode
+import base64
+import simplejson
+
+from pylons import c, g, request
+from pylons.controllers.util import abort
+from pylons.i18n import _
+from r2.config.extensions import set_extension
+from reddit_base import RedditController, MinimalController, require_https
+from r2.lib.db.thing import NotFound
+from r2.models import Account
+from r2.models.oauth2 import OAuth2Client, OAuth2AuthorizationCode, OAuth2AccessToken
+from r2.controllers.errors import errors
+from validator import validate, VRequired, VOneOf, VUrl, VUser, VModhash
+from r2.lib.pages import OAuth2AuthorizationPage
+from r2.lib.require import RequirementException, require, require_split
+
+scope_info = {
+ "identity": {
+ "id": "identity",
+ "name": _("My Identity"),
+ "description": _("Access my reddit username and signup date.")
+ }
+}
+
+class VClientID(VRequired):
+ default_param = "client_id"
+ def __init__(self, param=None, *a, **kw):
+ VRequired.__init__(self, param, errors.OAUTH2_INVALID_CLIENT, *a, **kw)
+
+ def run(self, client_id):
+ if not client_id:
+ return self.error()
+
+ client = OAuth2Client.get_token(client_id)
+ if client:
+ return client
+ else:
+ return self.error()
+
+class OAuth2FrontendController(RedditController):
+ def pre(self):
+ RedditController.pre(self)
+ require_https()
+
+ def _check_redirect_uri(self, client, redirect_uri):
+ if not redirect_uri or not client or redirect_uri != client.redirect_uri:
+ abort(403)
+
+ def _error_response(self, resp):
+ if (errors.OAUTH2_INVALID_CLIENT, "client_id") in c.errors:
+ resp["error"] = "unauthorized_client"
+ elif (errors.OAUTH2_ACCESS_DENIED, "authorize") in c.errors:
+ resp["error"] = "access_denied"
+ elif (errors.BAD_HASH, None) in c.errors:
+ resp["error"] = "access_denied"
+ elif (errors.INVALID_OPTION, "response_type") in c.errors:
+ resp["error"] = "unsupported_response_type"
+ elif (errors.INVALID_OPTION, "scope") in c.errors:
+ resp["error"] = "invalid_scope"
+ else:
+ resp["error"] = "invalid_request"
+
+ @validate(VUser(),
+ response_type = VOneOf("response_type", ("code",)),
+ client = VClientID(),
+ redirect_uri = VUrl("redirect_uri", allow_self=False, lookup=False),
+ scope = VOneOf("scope", scope_info.keys()),
+ state = VRequired("state", errors.NO_TEXT))
+ def GET_authorize(self, response_type, client, redirect_uri, scope, state):
+ self._check_redirect_uri(client, redirect_uri)
+
+ resp = {}
+ if not c.errors:
+ c.deny_frames = True
+ return OAuth2AuthorizationPage(client, redirect_uri, scope_info[scope], state).render()
+ else:
+ self._error_response(resp)
+ return self.redirect(redirect_uri+"?"+urlencode(resp), code=302)
+
+ @validate(VUser(),
+ VModhash(fatal=False),
+ client = VClientID(),
+ redirect_uri = VUrl("redirect_uri", allow_self=False, lookup=False),
+ scope = VOneOf("scope", scope_info.keys()),
+ state = VRequired("state", errors.NO_TEXT),
+ authorize = VRequired("authorize", errors.OAUTH2_ACCESS_DENIED))
+ def POST_authorize(self, authorize, client, redirect_uri, scope, state):
+ self._check_redirect_uri(client, redirect_uri)
+
+ resp = {}
+ if state:
+ resp["state"] = state
+
+ if not c.errors:
+ code = OAuth2AuthorizationCode._new(client._id, redirect_uri, c.user._id, scope)
+ resp["code"] = code._id
+ else:
+ self._error_response(resp)
+
+ return self.redirect(redirect_uri+"?"+urlencode(resp), code=302)
+
+class OAuth2AccessController(MinimalController):
+ def pre(self):
+ set_extension(request.environ, "json")
+ MinimalController.pre(self)
+ require_https()
+ c.oauth2_client = self._get_client_auth()
+
+ def _get_client_auth(self):
+ auth = request.headers.get("Authorization")
+ try:
+ auth_scheme, auth_token = require_split(auth, 2)
+ require(auth_scheme.lower() == "basic")
+ try:
+ auth_data = base64.b64decode(auth_token)
+ except TypeError:
+ raise RequirementException
+ client_id, client_secret = require_split(auth_data, 2, ":")
+ client = OAuth2Client.get_token(client_id)
+ require(client)
+ require(client.secret == client_secret)
+ return client
+ except RequirementException:
+ abort(401, headers=[("WWW-Authenticate", 'Basic realm="reddit"')])
+
+ @validate(grant_type = VOneOf("grant_type", ("authorization_code",)),
+ code = VRequired("code", errors.NO_TEXT),
+ redirect_uri = VUrl("redirect_uri", allow_self=False, lookup=False))
+ def POST_access_token(self, grant_type, code, redirect_uri):
+ resp = {}
+ if not c.errors:
+ auth_token = OAuth2AuthorizationCode.use_token(code, c.oauth2_client._id, redirect_uri)
+ if auth_token:
+ access_token = OAuth2AccessToken._new(auth_token.user_id, auth_token.scope)
+ resp["access_token"] = access_token._id
+ resp["token_type"] = access_token.token_type
+ resp["expires_in"] = access_token._ttl
+ resp["scope"] = auth_token.scope
+ else:
+ resp["error"] = "invalid_grant"
+ else:
+ if (errors.INVALID_OPTION, "grant_type") in c.errors:
+ resp["error"] = "unsupported_grant_type"
+ elif (errors.INVALID_OPTION, "scope") in c.errors:
+ resp["error"] = "invalid_scope"
+ else:
+ resp["error"] = "invalid_request"
+
+ return self.api_wrapper(resp)
+
+class OAuth2ResourceController(MinimalController):
+ def pre(self):
+ set_extension(request.environ, "json")
+ MinimalController.pre(self)
+ require_https()
+
+ try:
+ access_token = self._get_bearer_token()
+ require(access_token)
+ c.oauth2_access_token = access_token
+ account = Account._byID(access_token.user_id, data=True)
+ require(account)
+ require(not account._deleted)
+ c.oauth_user = account
+ except RequirementException:
+ self._auth_error(401, "invalid_token")
+
+ handler = self._get_action_handler()
+ if handler:
+ oauth2_perms = getattr(handler, "oauth2_perms", None)
+ if oauth2_perms:
+ if access_token.scope not in oauth2_perms["allowed_scopes"]:
+ self._auth_error(403, "insufficient_scope")
+ else:
+ self._auth_error(400, "invalid_request")
+
+ def _auth_error(self, code, error):
+ abort(code, headers=[("WWW-Authenticate", 'Bearer realm="reddit", error="%s"' % error)])
+
+ def _get_bearer_token(self):
+ auth = request.headers.get("Authorization")
+ try:
+ auth_scheme, bearer_token = require_split(auth, 2)
+ require(auth_scheme.lower() == "bearer")
+ return OAuth2AccessToken.get_token(bearer_token)
+ except RequirementException:
+ self._auth_error(400, "invalid_request")
+
+def require_oauth2_scope(*scopes):
+ def oauth2_scope_wrap(fn):
+ fn.oauth2_perms = {"allowed_scopes": scopes}
+ return fn
+ return oauth2_scope_wrap
@@ -504,6 +504,10 @@ def cross_domain_handler(self, *args, **kwargs):
return cross_domain_handler
return cross_domain_wrap
+def require_https():
+ if not c.secure:
+ abort(403)
+
class MinimalController(BaseController):
allow_stylesheets = False
@@ -539,6 +543,8 @@ def pre(self):
c.domain_prefix = request.environ.get("reddit-domain-prefix",
g.domain_prefix)
+ c.secure = request.host in g.secure_domains
+
#check if user-agent needs a dose of rate-limiting
if not c.error_page:
ratelimit_agents()
@@ -697,7 +703,6 @@ def api_wrapper(self, kw):
return c.response
-
class RedditController(MinimalController):
@staticmethod
@@ -728,7 +733,6 @@ def pre(self):
#can't handle broken cookies
request.environ['HTTP_COOKIE'] = ''
- c.secure = request.host in g.secure_domains
c.firsttime = firsttime()
# the user could have been logged in via one of the feeds
@@ -201,6 +201,7 @@ class ThingBase(object):
# updated columns will be present.) This is an expected convention
# and is not enforced.
_ttl = None
+ _warn_on_partial_ttl = True
# A per-class dictionary of default TTLs that new columns of this
# class should have
@@ -463,7 +464,7 @@ def _commit(self, write_consistency_level = None):
if self._id is None:
raise TdbException("Can't commit %r without an ID" % (self,))
- if self._committed and self._ttl:
+ if self._committed and self._ttl and self._warn_on_partial_ttl:
log.warning("Using a full-TTL object %r in a mutable fashion"
% (self,))
@@ -211,16 +211,19 @@ def thing_attr(self, thing, attr):
else:
return ThingJsonTemplate.thing_attr(self, thing, attr)
-class AccountJsonTemplate(ThingJsonTemplate):
+class IdentityJsonTemplate(ThingJsonTemplate):
_data_attrs_ = ThingJsonTemplate.data_attrs(name = "name",
link_karma = "safe_karma",
comment_karma = "comment_karma",
- has_mail = "has_mail",
- has_mod_mail = "has_mod_mail",
- is_mod = "is_mod",
is_gold = "gold"
)
+class AccountJsonTemplate(IdentityJsonTemplate):
+ _data_attrs_ = IdentityJsonTemplate.data_attrs(has_mail = "has_mail",
+ has_mod_mail = "has_mod_mail",
+ is_mod = "is_mod",
+ )
+
def thing_attr(self, thing, attr):
from r2.models import Subreddit
if attr == "has_mail":
Oops, something went wrong. Retry.

0 comments on commit 5df8723

Please sign in to comment.