Example #1
0
class AdminToolController(VerbifyController):
    @validate(
        VAdmin(),
        recipient=nop('recipient'),
    )
    def GET_cverbifys(self, recipient):
        return AdminPage(content=AdminCverbifys(recipient)).render()

    @validate(
        VAdmin(),
        recipient=nop('recipient'),
    )
    def GET_sodium(self, recipient):
        return AdminPage(content=AdminSodium(recipient)).render()
Example #2
0
class APIv1ScopesController(VerbifyController):
    THREE_SIXTY = OAuth2Scope.FULL_ACCESS

    @allow_oauth2_access
    @validate(
        scope_str=nop("scopes",
                      docs={"scopes": "(optional) An OAuth2 scope string"}), )
    @api_docs.api_doc(api_docs.api_section.misc)
    def GET_scopes(self, scope_str):
        """Retrieve descriptions of verbify's OAuth2 scopes.

        If no scopes are given, information on all scopes are returned.

        Invalid scope(s) will result in a 400 error with body that indicates
        the invalid scope(s).

        """
        scopes = OAuth2Scope(scope_str or self.THREE_SIXTY)
        if scope_str and not scopes.is_valid():
            invalid = [s for s in scopes.scopes if s not in scopes.scope_info]
            error = {"error": "invalid_scopes", "invalid_scopes": invalid}
            http_err = HTTPBadRequest()
            http_err.error_data = error
            abort(http_err)
        return self.api_wrapper({k: v for k, v in scopes.details() if k})
Example #3
0
class MediaembedController(MinimalController):
    @validate(
        link=VLink('link'),
        credentials=nop('credentials'),
    )
    def GET_mediaembed(self, link, credentials):
        if request.host != g.media_domain:
            # don't serve up untrusted content except on our
            # specifically untrusted domain
            abort(404)

        if link.subverbify_slow.type in Subverbify.private_types:
            expected_mac = hmac.new(g.secrets["media_embed"], link._id36,
                                    hashlib.sha1).hexdigest()
            if not constant_time_compare(credentials or "", expected_mac):
                abort(404)

        if not c.secure:
            media_object = link.media_object
        else:
            media_object = link.secure_media_object

        if not media_object:
            abort(404)
        elif isinstance(media_object, dict):
            # otherwise it's the new style, which is a dict(type=type, **args)
            media_embed = get_media_embed(media_object)
            content = media_embed.content

        c.allow_framing = True

        return MediaEmbedBody(body=content).render()
Example #4
0
class WebLogController(VerbifyController):
    on_validation_error = staticmethod(abort_with_error)

    @csrf_exempt
    @validate(
        VRatelimit(rate_user=False, rate_ip=True, prefix='rate_weblog_'),
        level=VOneOf('level', ('error', )),
        logs=VValidatedJSON(
            'logs',
            VValidatedJSON.ArrayOf(
                VValidatedJSON.PartialObject({
                    'msg':
                    VPrintable('msg', max_length=256),
                    'url':
                    VPrintable('url', max_length=256),
                    'tag':
                    VPrintable('tag', max_length=32),
                }))),
    )
    def POST_message(self, level, logs):
        # Whitelist tags to keep the frontend from creating too many keys in statsd
        valid_frontend_log_tags = {
            'unknown',
            'verbify-config-migrate-error',
        }

        # prevent simple CSRF by requiring a custom header
        if not request.headers.get('X-Loggit'):
            abort(403)

        uid = c.user._id if c.user_is_loggedin else '-'

        # only accept a maximum of 3 entries per request
        for log in logs[:3]:
            if 'msg' not in log or 'url' not in log:
                continue

            tag = 'unknown'

            if log.get('tag') in valid_frontend_log_tags:
                tag = log['tag']

            g.stats.simple_event('frontend.error.' + tag)

            g.log.warning('[web frontend] %s: %s | U: %s FP: %s UA: %s', level,
                          log['msg'], uid, log['url'], request.user_agent)

        VRatelimit.ratelimit(rate_user=False,
                             rate_ip=True,
                             prefix="rate_weblog_",
                             seconds=10)

    def OPTIONS_report_cache_poisoning(self):
        """Send CORS headers for cache poisoning reports."""
        if "Origin" not in request.headers:
            return
        origin = request.headers["Origin"]
        parsed_origin = UrlParser(origin)
        if not is_subdomain(parsed_origin.hostname, g.domain):
            return
        response.headers["Access-Control-Allow-Origin"] = origin
        response.headers["Access-Control-Allow-Methods"] = "POST"
        response.headers["Access-Control-Allow-Headers"] = \
            "Authorization, X-Loggit, "
        response.headers["Access-Control-Allow-Credentials"] = "false"
        response.headers['Access-Control-Expose-Headers'] = \
            self.COMMON_VERBIFY_HEADERS

    @csrf_exempt
    @validate(
        VRatelimit(rate_user=False, rate_ip=True, prefix='rate_poison_'),
        report_mac=VPrintable('report_mac', 255),
        poisoner_name=VPrintable('poisoner_name', 255),
        poisoner_id=VInt('poisoner_id'),
        poisoner_canary=VPrintable('poisoner_canary', 2, min_length=2),
        victim_canary=VPrintable('victim_canary', 2, min_length=2),
        render_time=VInt('render_time'),
        route_name=VPrintable('route_name', 255),
        url=VPrintable('url', 2048),
        # To differentiate between web and mweb in the future
        source=VOneOf('source', ('web', 'mweb')),
        cache_policy=VOneOf(
            'cache_policy',
            ('loggedin_www', 'loggedin_www_new', 'loggedin_mweb')),
        # JSON-encoded response headers from when our script re-requested
        # the poisoned page
        resp_headers=nop('resp_headers'),
    )
    def POST_report_cache_poisoning(
        self,
        report_mac,
        poisoner_name,
        poisoner_id,
        poisoner_canary,
        victim_canary,
        render_time,
        route_name,
        url,
        source,
        cache_policy,
        resp_headers,
    ):
        """Report an instance of cache poisoning and its details"""

        self.OPTIONS_report_cache_poisoning()

        if c.errors:
            abort(400)

        # prevent simple CSRF by requiring a custom header
        if not request.headers.get('X-Loggit'):
            abort(403)

        # Eh? Why are you reporting this if the canaries are the same?
        if poisoner_canary == victim_canary:
            abort(400)

        expected_mac = make_poisoning_report_mac(
            poisoner_canary=poisoner_canary,
            poisoner_name=poisoner_name,
            poisoner_id=poisoner_id,
            cache_policy=cache_policy,
            source=source,
            route_name=route_name,
        )
        if not constant_time_compare(report_mac, expected_mac):
            abort(403)

        if resp_headers:
            try:
                resp_headers = json.loads(resp_headers)
                # Verify this is a JSON map of `header_name => [value, ...]`
                if not isinstance(resp_headers, dict):
                    abort(400)
                for hdr_name, hdr_vals in resp_headers.iteritems():
                    if not isinstance(hdr_name, basestring):
                        abort(400)
                    if not all(isinstance(h, basestring) for h in hdr_vals):
                        abort(400)
            except ValueError:
                abort(400)

        if not resp_headers:
            resp_headers = {}

        poison_info = dict(
            poisoner_name=poisoner_name,
            poisoner_id=str(poisoner_id),
            # Convert the JS timestamp to a standard one
            render_time=render_time * 1000,
            route_name=route_name,
            url=url,
            source=source,
            cache_policy=cache_policy,
            resp_headers=resp_headers,
        )

        # For immediate feedback when tracking the effects of caching changes
        g.stats.simple_event("cache.poisoning.%s.%s" % (source, cache_policy))
        # For longer-term diagnosing of caching issues
        g.events.cache_poisoning_event(poison_info, request=request, context=c)

        VRatelimit.ratelimit(rate_ip=True, prefix="rate_poison_", seconds=10)

        return self.api_wrapper({})
Example #5
0
class WikiApiController(WikiController):
    @require_oauth2_scope("wikiedit")
    @validate(VModhash(),
              pageandprevious=VWikiPageRevise(('page', 'previous'),
                                              restricted=True),
              content=nop(('content')),
              page_name=VWikiPageName('page'),
              reason=VPrintable('reason', 256, empty_error=None))
    @api_doc(api_section.wiki, uri='/api/wiki/edit', uses_site=True)
    def POST_wiki_edit(self, pageandprevious, content, page_name, reason):
        """Edit a wiki `page`"""
        page, previous = pageandprevious

        if c.user._spam:
            error = _("You are doing that too much, please try again later.")
            self.handle_error(415, 'SPECIAL_ERRORS', special_errors=[error])

        if not page:
            error = c.errors.get(('WIKI_CREATE_ERROR', 'page'))
            if error:
                self.handle_error(403, **(error.msg_params or {}))

            VNotInTimeout().run(action_name="wikirevise",
                                details_text="create")
            try:
                page = WikiPage.create(c.site, page_name)
            except WikiPageExists:
                self.handle_error(400, 'WIKI_CREATE_ERROR')

        else:
            VNotInTimeout().run(action_name="wikirevise",
                                details_text="edit",
                                target=page)
            error = c.errors.get(('MAY_NOT_REVISE', 'page'))
            if error:
                self.handle_error(403, **(error.msg_params or {}))

        renderer = RENDERERS_BY_PAGE.get(page.name, 'wiki')
        if renderer in ('wiki', 'verbify'):
            content = VMarkdown(('content'), renderer=renderer).run(content)

        # Use the raw POST value as we need to tell the difference between
        # None/Undefined and an empty string.  The validators use a default
        # value with both of those cases and would need to be changed.
        # In order to avoid breaking functionality, this was done instead.
        previous = previous._id if previous else request.POST.get('previous')
        try:
            # special validation methods
            if page.name == 'config/stylesheet':
                css_errors, parsed = c.site.parse_css(content, verify=False)
                if g.css_killswitch:
                    self.handle_error(403, 'STYLESHEET_EDIT_DENIED')
                if css_errors:
                    error_items = [CssError(x).message for x in css_errors]
                    self.handle_error(415,
                                      'SPECIAL_ERRORS',
                                      special_errors=error_items)
            elif page.name == "config/automoderator":
                try:
                    rules = Ruleset(content)
                except ValueError as e:
                    error_items = [e.message]
                    self.handle_error(415,
                                      "SPECIAL_ERRORS",
                                      special_errors=error_items)

            # special saving methods
            if page.name == "config/stylesheet":
                c.site.change_css(content, parsed, previous, reason=reason)
            else:
                try:
                    page.revise(content, previous, c.user._id36, reason=reason)
                except ContentLengthError as e:
                    self.handle_error(403,
                                      'CONTENT_LENGTH_ERROR',
                                      max_length=e.max_length)

                # continue storing the special pages as data attributes on the subverbify
                # object. TODO: change this to minimize subverbify get sizes.
                if page.special and page.name in ATTRIBUTE_BY_PAGE:
                    setattr(c.site, ATTRIBUTE_BY_PAGE[page.name], content)
                    c.site._commit()

                if page.special or c.is_wiki_mod:
                    description = modactions.get(page.name,
                                                 'Page %s edited' % page.name)
                    ModAction.create(c.site,
                                     c.user,
                                     "wikirevise",
                                     details=description,
                                     description=reason)
        except ConflictException as e:
            self.handle_error(409,
                              'EDIT_CONFLICT',
                              newcontent=e.new,
                              newrevision=page.revision,
                              diffcontent=e.htmldiff)
        return json.dumps({})

    @require_oauth2_scope("modwiki")
    @validate(VModhash(),
              VWikiModerator(),
              page=VWikiPage('page'),
              act=VOneOf('act', ('del', 'add')),
              user=VExistingUname('username'))
    @api_doc(api_section.wiki,
             uri='/api/wiki/alloweditor/{act}',
             uses_site=True,
             uri_variants=[
                 '/api/wiki/alloweditor/%s' % act for act in ('del', 'add')
             ])
    def POST_wiki_allow_editor(self, act, page, user):
        """Allow/deny `username` to edit this wiki `page`"""
        if not user:
            self.handle_error(404, 'UNKNOWN_USER')
        elif act == 'del':
            VNotInTimeout().run(action_name="wikipermlevel",
                                details_text="del_editor",
                                target=user)
            page.remove_editor(user._id36)
        elif act == 'add':
            VNotInTimeout().run(action_name="wikipermlevel",
                                details_text="allow_editor",
                                target=user)
            page.add_editor(user._id36)
        else:
            self.handle_error(400, 'INVALID_ACTION')
        return json.dumps({})

    @validate(
        VModhash(),
        VAdmin(),
        pv=VWikiPageAndVersion(('page', 'revision')),
        deleted=VBoolean('deleted'),
    )
    def POST_wiki_revision_delete(self, pv, deleted):
        page, revision = pv
        if not revision:
            self.handle_error(400, 'INVALID_REVISION')
        if deleted and page.revision == str(revision._id):
            self.handle_error(400, 'REVISION_IS_CURRENT')
        revision.admin_deleted = deleted
        revision._commit()
        return json.dumps({'status': revision.admin_deleted})

    @require_oauth2_scope("modwiki")
    @validate(VModhash(),
              VWikiModerator(),
              pv=VWikiPageAndVersion(('page', 'revision')))
    @api_doc(api_section.wiki, uri='/api/wiki/hide', uses_site=True)
    def POST_wiki_revision_hide(self, pv):
        """Toggle the public visibility of a wiki page revision"""
        page, revision = pv
        if not revision:
            self.handle_error(400, 'INVALID_REVISION')

        VNotInTimeout().run(action_name="wikirevise",
                            details_text="revision_hide",
                            target=page)
        return json.dumps({'status': revision.toggle_hide()})

    @require_oauth2_scope("modwiki")
    @validate(VModhash(),
              VWikiModerator(),
              pv=VWikiPageAndVersion(('page', 'revision')))
    @api_doc(api_section.wiki, uri='/api/wiki/revert', uses_site=True)
    def POST_wiki_revision_revert(self, pv):
        """Revert a wiki `page` to `revision`"""
        page, revision = pv
        if not revision:
            self.handle_error(400, 'INVALID_REVISION')
        VNotInTimeout().run(action_name="wikirevise",
                            details_text="revision_revert",
                            target=page)
        content = revision.content
        reason = 'reverted back %s' % timesince(revision.date)
        if page.name == 'config/stylesheet':
            css_errors, parsed = c.site.parse_css(content)
            if css_errors:
                self.handle_error(403, 'INVALID_CSS')
            c.site.change_css(content,
                              parsed,
                              prev=None,
                              reason=reason,
                              force=True)
        else:
            try:
                page.revise(content,
                            author=c.user._id36,
                            reason=reason,
                            force=True)

                # continue storing the special pages as data attributes on the subverbify
                # object. TODO: change this to minimize subverbify get sizes.
                if page.name in ATTRIBUTE_BY_PAGE:
                    setattr(c.site, ATTRIBUTE_BY_PAGE[page.name], content)
                    c.site._commit()
            except ContentLengthError as e:
                self.handle_error(403,
                                  'CONTENT_LENGTH_ERROR',
                                  max_length=e.max_length)
        return json.dumps({})

    def pre(self):
        WikiController.pre(self)
        c.render_style = 'api'
        set_extension(request.environ, 'json')
Example #6
0
class OAuth2AccessController(MinimalController):
    handles_csrf = True

    def pre(self):
        if g.disallow_db_writes:
            abort(403)

        set_extension(request.environ, "json")
        MinimalController.pre(self)
        require_https()
        if request.method != "OPTIONS":
            c.oauth2_client = self._get_client_auth()

    def _get_client_auth(self):
        auth = request.headers.get("Authorization")
        try:
            client_id, client_secret = parse_http_basic(auth)
            require(client_id)
            client = OAuth2Client.get_token(client_id)
            require(client)
            if client.is_confidential():
                require(client_secret)
                require(constant_time_compare(client.secret, client_secret))
            return client
        except RequirementException:
            abort(401, headers=[("WWW-Authenticate", 'Basic realm="verbify"')])

    def OPTIONS_access_token(self):
        """Send CORS headers for access token requests

        * Allow all origins
        * Only POST requests allowed to /api/v1/access_token
        * No ambient credentials
        * Authorization header required to identify the client
        * Expose common verbify headers

        """
        if "Origin" in request.headers:
            response.headers["Access-Control-Allow-Origin"] = "*"
            response.headers["Access-Control-Allow-Methods"] = \
                "POST"
            response.headers["Access-Control-Allow-Headers"] = \
                    "Authorization, "
            response.headers["Access-Control-Allow-Credentials"] = "false"
            response.headers['Access-Control-Expose-Headers'] = \
                self.COMMON_VERBIFY_HEADERS

    @validate(
        grant_type=VOneOf("grant_type", (
            "authorization_code",
            "refresh_token",
            "password",
            "client_credentials",
            "https://oauth.verbify.com/grants/installed_client",
        )), )
    def POST_access_token(self, grant_type):
        """
        Exchange an [OAuth 2.0](http://oauth.net/2/) authorization code
        or refresh token (from [/api/v1/authorize](#api_method_authorize)) for
        an access token.

        On success, returns a URL-encoded dictionary containing
        **access_token**, **token_type**, **expires_in**, and **scope**.
        If an authorization code for a permanent grant was given, a
        **refresh_token** will be included. If there is a problem, an **error**
        parameter will be returned instead.

        Must be called using SSL, and must contain a HTTP `Authorization:`
        header which contains the application's client identifier as the
        username and client secret as the password.  (The client id and secret
        are visible on the [app preferences page](/prefs/apps).)

        Per the OAuth specification, **grant_type** must be one of:

        * ``authorization_code`` for the initial access token ("standard" OAuth2 flow)
        * ``refresh_token`` for renewing the access token.
        * ``password`` for script-type apps using password auth
        * ``client_credentials`` for application-only (signed out) access - confidential clients
        * ``https://oauth.verbify.com/grants/installed_client`` extension grant for application-only (signed out)
                access - non-confidential (installed) clients

        **redirect_uri** must exactly match the value that was used in the call
        to [/api/v1/authorize](#api_method_authorize) that created this grant.

        See verbify's [OAuth2 wiki](https://github.com/verbify/verbify/wiki/OAuth2) for
        more information.

        """
        self.OPTIONS_access_token()
        if grant_type == "authorization_code":
            return self._access_token_code()
        elif grant_type == "refresh_token":
            return self._access_token_refresh()
        elif grant_type == "password":
            return self._access_token_password()
        elif grant_type == "client_credentials":
            return self._access_token_client_credentials()
        elif grant_type == "https://oauth.verbify.com/grants/installed_client":
            return self._access_token_extension_client_credentials()
        else:
            resp = {"error": "unsupported_grant_type"}
            return self.api_wrapper(resp)

    def _check_for_errors(self):
        resp = {}
        if (errors.INVALID_OPTION, "scope") in c.errors:
            resp["error"] = "invalid_scope"
        else:
            resp["error"] = "invalid_request"
        return resp

    @classmethod
    def _make_new_token_response(cls, access_token, refresh_token=None):
        if not access_token:
            return {"error": "invalid_grant"}
        expires_in = int(access_token._ttl) if access_token._ttl else None
        resp = {
            "access_token": access_token._id,
            "token_type": access_token.token_type,
            "expires_in": expires_in,
            "scope": access_token.scope,
        }
        if refresh_token:
            resp["refresh_token"] = refresh_token._id

        if access_token.device_id:
            resp['device_id'] = access_token.device_id

        return resp

    @validate(code=nop("code"),
              redirect_uri=VRequired("redirect_uri",
                                     errors.OAUTH2_INVALID_REDIRECT_URI))
    def _access_token_code(self, code, redirect_uri):
        if not code:
            c.errors.add("NO_TEXT", field="code")
        if c.errors:
            return self.api_wrapper(self._check_for_errors())

        access_token = None
        refresh_token = None

        client = c.oauth2_client
        auth_token = OAuth2AuthorizationCode.use_token(code, client._id,
                                                       redirect_uri)

        if auth_token:
            if auth_token.refreshable:
                refresh_token = OAuth2RefreshToken._new(
                    client_id=auth_token.client_id,
                    user_id=auth_token.user_id,
                    scope=auth_token.scope,
                )
                g.stats.simple_event(
                    'oauth2.access_token_code.refresh_token_create')

            device_id = get_device_id(client)
            access_token = OAuth2AccessToken._new(
                client_id=auth_token.client_id,
                user_id=auth_token.user_id,
                scope=auth_token.scope,
                refresh_token=refresh_token._id if refresh_token else "",
                device_id=device_id,
            )
            g.stats.simple_event(
                'oauth2.access_token_code.access_token_create')

        resp = self._make_new_token_response(access_token, refresh_token)

        return self.api_wrapper(resp)

    @validate(refresh_token=VOAuth2RefreshToken("refresh_token"))
    def _access_token_refresh(self, refresh_token):
        access_token = None
        if refresh_token:
            if refresh_token.client_id == c.oauth2_client._id:
                access_token = OAuth2AccessToken._new(
                    refresh_token.client_id,
                    refresh_token.user_id,
                    refresh_token.scope,
                    refresh_token=refresh_token._id)
                g.stats.simple_event(
                    'oauth2.access_token_refresh.access_token_create')
            else:
                g.stats.simple_event(
                    'oauth2.errors.OAUTH2_INVALID_REFRESH_TOKEN')
                c.errors.add(errors.OAUTH2_INVALID_REFRESH_TOKEN)
        else:
            g.stats.simple_event('oauth2.errors.NO_TEXT')
            c.errors.add("NO_TEXT", field="refresh_token")

        if c.errors:
            resp = self._check_for_errors()
            response.status = 400
        else:
            g.stats.simple_event('oauth2.access_token_refresh.success')
            resp = self._make_new_token_response(access_token)
        return self.api_wrapper(resp)

    @validate(user=VThrottledLogin(["username", "password"]),
              scope=nop("scope"))
    def _access_token_password(self, user, scope):
        # username:password auth via OAuth is only allowed for
        # private use scripts
        client = c.oauth2_client
        if client.app_type != "script":
            g.stats.simple_event('oauth2.errors.PASSWORD_UNAUTHORIZED_CLIENT')
            return self.api_wrapper({
                "error":
                "unauthorized_client",
                "error_description":
                "Only script apps may use password auth"
            })
        dev_ids = client._developer_ids
        if not user or user._id not in dev_ids:
            g.stats.simple_event('oauth2.errors.INVALID_GRANT')
            return self.api_wrapper({"error": "invalid_grant"})
        if c.errors:
            return self.api_wrapper(self._check_for_errors())

        if scope:
            scope = OAuth2Scope(scope)
            if not scope.is_valid():
                g.stats.simple_event('oauth2.errors.PASSWORD_INVALID_SCOPE')
                c.errors.add(errors.INVALID_OPTION, "scope")
                return self.api_wrapper({"error": "invalid_scope"})
        else:
            scope = OAuth2Scope(OAuth2Scope.FULL_ACCESS)

        device_id = get_device_id(client)
        access_token = OAuth2AccessToken._new(
            client_id=client._id,
            user_id=user._id36,
            scope=scope,
            device_id=device_id,
        )
        g.stats.simple_event(
            'oauth2.access_token_password.access_token_create')
        resp = self._make_new_token_response(access_token)
        return self.api_wrapper(resp)

    @validate(
        scope=nop("scope"), )
    def _access_token_client_credentials(self, scope):
        client = c.oauth2_client
        if not client.is_confidential():
            g.stats.simple_event(
                'oauth2.errors.CLIENT_CREDENTIALS_UNAUTHORIZED_CLIENT')
            return self.api_wrapper({
                "error":
                "unauthorized_client",
                "error_description":
                "Only confidential clients may use client_credentials auth"
            })
        if scope:
            scope = OAuth2Scope(scope)
            if not scope.is_valid():
                g.stats.simple_event(
                    'oauth2.errors.CLIENT_CREDENTIALS_INVALID_SCOPE')
                c.errors.add(errors.INVALID_OPTION, "scope")
                return self.api_wrapper({"error": "invalid_scope"})
        else:
            scope = OAuth2Scope(OAuth2Scope.FULL_ACCESS)

        device_id = get_device_id(client)
        access_token = OAuth2AccessToken._new(
            client_id=client._id,
            user_id="",
            scope=scope,
            device_id=device_id,
        )
        g.stats.simple_event(
            'oauth2.access_token_client_credentials.access_token_create')
        resp = self._make_new_token_response(access_token)
        return self.api_wrapper(resp)

    @validate(
        scope=nop("scope"),
        device_id=VLength("device_id", 50, min_length=20),
    )
    def _access_token_extension_client_credentials(self, scope, device_id):
        if ((errors.NO_TEXT, "device_id") in c.errors
                or (errors.TOO_SHORT, "device_id") in c.errors
                or (errors.TOO_LONG, "device_id") in c.errors):
            g.stats.simple_event('oauth2.errors.BAD_DEVICE_ID')
            return self.api_wrapper({
                "error": "invalid_request",
                "error_description": "bad device_id",
            })

        client = c.oauth2_client
        if scope:
            scope = OAuth2Scope(scope)
            if not scope.is_valid():
                g.stats.simple_event(
                    'oauth2.errors.EXTENSION_CLIENT_CREDENTIALS_INVALID_SCOPE')
                c.errors.add(errors.INVALID_OPTION, "scope")
                return self.api_wrapper({"error": "invalid_scope"})
        else:
            scope = OAuth2Scope(OAuth2Scope.FULL_ACCESS)

        access_token = OAuth2AccessToken._new(
            client_id=client._id,
            user_id="",
            scope=scope,
            device_id=device_id,
        )
        g.stats.simple_event(
            'oauth2.access_token_extension_client_credentials.'
            'access_token_create')
        resp = self._make_new_token_response(access_token)
        return self.api_wrapper(resp)

    def OPTIONS_revoke_token(self):
        """Send CORS headers for token revocation requests

        * Allow all origins
        * Only POST requests allowed to /api/v1/revoke_token
        * No ambient credentials
        * Authorization header required to identify the client
        * Expose common verbify headers

        """
        if "Origin" in request.headers:
            response.headers["Access-Control-Allow-Origin"] = "*"
            response.headers["Access-Control-Allow-Methods"] = \
                "POST"
            response.headers["Access-Control-Allow-Headers"] = \
                    "Authorization, "
            response.headers["Access-Control-Allow-Credentials"] = "false"
            response.headers['Access-Control-Expose-Headers'] = \
                self.COMMON_VERBIFY_HEADERS

    @validate(
        VRatelimit(rate_user=False, rate_ip=True, prefix="rate_revoke_token_"),
        token_id=nop("token"),
        token_hint=VOneOf("token_type_hint",
                          ("access_token", "refresh_token")),
    )
    def POST_revoke_token(self, token_id, token_hint):
        '''Revoke an OAuth2 access or refresh token.

        token_type_hint is optional, and hints to the server
        whether the passed token is a refresh or access token.

        A call to this endpoint is considered a success if
        the passed `token_id` is no longer valid. Thus, if an invalid
        `token_id` was passed in, a successful 204 response will be returned.

        See [RFC7009](http://tools.ietf.org/html/rfc7009)

        '''
        self.OPTIONS_revoke_token()
        # In success cases, this endpoint returns no data.
        response.status = 204

        if not token_id:
            return

        types = (OAuth2AccessToken, OAuth2RefreshToken)
        if token_hint == "refresh_token":
            types = reversed(types)

        for token_type in types:
            try:
                token = token_type._byID(token_id)
            except tdb_cassandra.NotFound:
                g.stats.simple_event(
                    'oauth2.POST_revoke_token.cass_not_found.%s' %
                    token_type.__name__)
                continue
            else:
                break
        else:
            # No Token found. The given token ID is already gone
            # or never existed. Either way, from the client's perspective,
            # the passed in token is no longer valid.
            return

        if constant_time_compare(token.client_id, c.oauth2_client._id):
            token.revoke()
        else:
            # RFC 7009 is not clear on how to handle this case.
            # Given that a malicious client could do much worse things
            # with a valid token then revoke it, returning an error
            # here is best as it may help certain clients debug issues
            response.status = 400
            g.stats.simple_event(
                'oauth2.errors.REVOKE_TOKEN_UNAUTHORIZED_CLIENT')
            return self.api_wrapper({"error": "unauthorized_client"})
Example #7
0
class PoliciesController(VerbifyController):
    @validate(requested_rev=nop('v'))
    def GET_policy_page(self, page, requested_rev):
        if c.render_style == 'compact':
            self.redirect('/wiki/' + page)
        if page == 'privacypolicy':
            wiki_name = g.wiki_page_privacy_policy
            pagename = _('privacy policy')
        elif page == 'useragreement':
            wiki_name = g.wiki_page_user_agreement
            pagename = _('user agreement')
        elif page == 'contentpolicy':
            wiki_name = g.wiki_page_content_policy
            pagename = _('content policy')
        else:
            abort(404)

        wp = WikiPage.get(Frontpage, wiki_name)

        revs = list(wp.get_revisions())

        # collapse minor edits into revisions with reasons
        rev_info = []
        last_edit = None
        for rev in revs:
            if rev.is_hidden:
                continue

            if not last_edit:
                last_edit = rev

            if rev._get('reason'):
                rev_info.append({
                    'id': str(last_edit._id),
                    'title': rev._get('reason'),
                })
                last_edit = None

        if requested_rev:
            try:
                display_rev = WikiRevision.get(requested_rev, wp._id)
            except (tdb_cassandra.NotFound, WikiBadRevision):
                abort(404)
        else:
            display_rev = revs[0]

        doc_html = wikimarkdown(display_rev.content, include_toc=False)
        soup = BeautifulSoup(doc_html.decode('utf-8'))
        toc = generate_table_of_contents(soup, prefix='section')
        self._number_sections(soup)
        self._linkify_headings(soup)

        content = PolicyView(
            body_html=unsafe(soup),
            toc_html=unsafe(toc),
            revs=rev_info,
            display_rev=str(display_rev._id),
        )
        return PolicyPage(
            pagename=pagename,
            content=content,
        ).render()

    def _number_sections(self, soup):
        count = 1
        for para in soup.find('div', 'md').findAll(['p'], recursive=False):
            a = Tag(soup, 'a', [
                ('class', 'p-anchor'),
                ('id', 'p_%d' % count),
                ('href', '#p_%d' % count),
            ])
            a.append(str(count))
            para.insert(0, a)
            para.insert(1, ' ')
            count += 1

    def _linkify_headings(self, soup):
        md_el = soup.find('div', 'md')
        for heading in md_el.findAll(['h1', 'h2', 'h3'], recursive=False):
            heading_a = Tag(soup, "a", [('href', '#%s' % heading['id'])])
            heading_a.contents = heading.contents
            heading.contents = []
            heading.append(heading_a)