From 46b63164a083e26b065d86cb45db4e31cb14392e Mon Sep 17 00:00:00 2001
From: jsa <jsa@edx.org>
Date: Mon, 9 Mar 2015 11:29:42 -0400
Subject: [PATCH] Implement profile_image upload and remove endpoints

TNL-1537

Co-Authored-By: Andy Armstrong <andya@edx.org>
Co-Authored-By: cahrens <christina@edx.org>
---
 common/djangoapps/enrollment/views.py         |   5 +-
 lms/djangoapps/commerce/views.py              |   2 +-
 lms/djangoapps/mobile_api/utils.py            |   8 +-
 lms/envs/common.py                            |  11 +-
 lms/envs/test.py                              |   2 +
 lms/urls.py                                   |   4 +
 .../djangoapps/profile_images/__init__.py     |   0
 .../core/djangoapps/profile_images/images.py  | 132 ++++++++
 .../profile_images/tests/__init__.py          |   0
 .../profile_images/tests/helpers.py           |  55 ++++
 .../profile_images/tests/test_images.py       | 182 +++++++++++
 .../profile_images/tests/test_views.py        | 304 ++++++++++++++++++
 .../core/djangoapps/profile_images/urls.py    |  22 ++
 .../core/djangoapps/profile_images/views.py   | 152 +++++++++
 .../djangoapps/user_api/accounts/helpers.py   |  57 ----
 .../user_api/accounts/image_helpers.py        | 115 +++++++
 .../user_api/accounts/serializers.py          |   9 +-
 .../user_api/accounts/tests/test_api.py       |   5 +-
 .../user_api/accounts/tests/test_helpers.py   |  69 ----
 .../accounts/tests/test_image_helpers.py      |  60 ++++
 .../user_api/accounts/tests/test_views.py     |   9 +-
 .../djangoapps/user_api/accounts/views.py     |  17 +-
 .../djangoapps/user_api/preferences/api.py    |   1 -
 .../user_api/preferences/tests/test_views.py  |  10 +-
 .../djangoapps/user_api/preferences/views.py  |  45 ++-
 .../djangoapps/user_api/tests/test_views.py   |   1 +
 openedx/core/djangoapps/user_api/views.py     |  11 +-
 .../core/lib/api}/authentication.py           |   0
 openedx/core/lib/api/permissions.py           |  21 +-
 openedx/core/lib/api/tests/__init__.py        |   0
 .../lib/api}/tests/test_authentication.py     |   0
 31 files changed, 1135 insertions(+), 174 deletions(-)
 create mode 100644 openedx/core/djangoapps/profile_images/__init__.py
 create mode 100644 openedx/core/djangoapps/profile_images/images.py
 create mode 100644 openedx/core/djangoapps/profile_images/tests/__init__.py
 create mode 100644 openedx/core/djangoapps/profile_images/tests/helpers.py
 create mode 100644 openedx/core/djangoapps/profile_images/tests/test_images.py
 create mode 100644 openedx/core/djangoapps/profile_images/tests/test_views.py
 create mode 100644 openedx/core/djangoapps/profile_images/urls.py
 create mode 100644 openedx/core/djangoapps/profile_images/views.py
 delete mode 100644 openedx/core/djangoapps/user_api/accounts/helpers.py
 create mode 100644 openedx/core/djangoapps/user_api/accounts/image_helpers.py
 delete mode 100644 openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py
 create mode 100644 openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py
 rename {common/djangoapps/util => openedx/core/lib/api}/authentication.py (100%)
 create mode 100644 openedx/core/lib/api/tests/__init__.py
 rename {common/djangoapps/util => openedx/core/lib/api}/tests/test_authentication.py (100%)

diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py
index 57301a9ec08..6ec535bae12 100644
--- a/common/djangoapps/enrollment/views.py
+++ b/common/djangoapps/enrollment/views.py
@@ -18,7 +18,10 @@ from opaque_keys.edx.keys import CourseKey
 from embargo import api as embargo_api
 from cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
 from cors_csrf.decorators import ensure_csrf_cookie_cross_domain
-from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
+from openedx.core.lib.api.authentication import (
+    SessionAuthenticationAllowInactiveUser,
+    OAuth2AuthenticationAllowInactiveUser,
+)
 from util.disable_rate_limit import can_disable_rate_limit
 from enrollment import api
 from enrollment.errors import (
diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py
index 46f0b925744..44b17efaa10 100644
--- a/lms/djangoapps/commerce/views.py
+++ b/lms/djangoapps/commerce/views.py
@@ -15,7 +15,7 @@ from course_modes.models import CourseMode
 from courseware import courses
 from enrollment.api import add_enrollment
 from student.models import CourseEnrollment
-from util.authentication import SessionAuthenticationAllowInactiveUser
+from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
 
 
 log = logging.getLogger(__name__)
diff --git a/lms/djangoapps/mobile_api/utils.py b/lms/djangoapps/mobile_api/utils.py
index 4d4eed8d266..57dc67c73c6 100644
--- a/lms/djangoapps/mobile_api/utils.py
+++ b/lms/djangoapps/mobile_api/utils.py
@@ -2,14 +2,16 @@
 Common utility methods and decorators for Mobile APIs.
 """
 
-
 import functools
 from rest_framework import permissions
-
-from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
 from opaque_keys.edx.keys import CourseKey
 from xmodule.modulestore.django import modulestore
+
 from courseware.courses import get_course_with_access
+from openedx.core.lib.api.authentication import (
+    SessionAuthenticationAllowInactiveUser,
+    OAuth2AuthenticationAllowInactiveUser,
+)
 from openedx.core.lib.api.permissions import IsUserInUrl
 
 
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 6a271030214..14fe067eaf0 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -2259,7 +2259,13 @@ FIELD_OVERRIDE_PROVIDERS = ()
 
 # PROFILE IMAGE CONFIG
 # TODO: add these settings to aws.py as well
-PROFILE_IMAGE_BACKEND = 'django.core.files.storage.FileSystemStorage'
+# WARNING: Certain django storage backends do not support atomic
+# file overwrites (including the default, specified below) - instead
+# there are separate calls to delete and then write a new file in the
+# storage backend.  This introduces the risk of a race condition
+# occurring when a user uploads a new profile image to replace an
+# earlier one (the file will temporarily be deleted).
+PROFILE_IMAGE_BACKEND = 'storages.backends.overwrite.OverwriteStorage'
 # PROFILE_IMAGE_DOMAIN points to the domain from which we serve image
 # files from.  When this is '/', it refers to the same domain as the
 # app server.  If serving from a different domain, specify that here
@@ -2272,4 +2278,5 @@ PROFILE_IMAGE_DEFAULT_FILENAME = 'default_profile_image'  # TODO: determine fina
 # platform unaware of current image URLs, resulting in reverting all
 # users' profile images to the default placeholder image.
 PROFILE_IMAGE_SECRET_KEY = 'placeholder secret key'
-
+PROFILE_IMAGE_MAX_BYTES = 1024 * 1024
+PROFILE_IMAGE_MIN_BYTES = 100
diff --git a/lms/envs/test.py b/lms/envs/test.py
index 58e1e7fef41..fdcf919dbe1 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -483,3 +483,5 @@ PROFILE_IMAGE_DOMAIN = 'http://example-storage.com/'
 PROFILE_IMAGE_URL_PATH = 'profile_images/'
 PROFILE_IMAGE_DEFAULT_FILENAME = 'default'
 PROFILE_IMAGE_SECRET_KEY = 'secret'
+PROFILE_IMAGE_MAX_BYTES = 1024 * 1024
+PROFILE_IMAGE_MIN_BYTES = 100
diff --git a/lms/urls.py b/lms/urls.py
index bb041f9876b..7822ea75132 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -91,6 +91,7 @@ urlpatterns = (
 if settings.FEATURES["ENABLE_USER_REST_API"]:
     urlpatterns += (
         url(r'^api/user/', include('openedx.core.djangoapps.user_api.urls')),
+        url(r'^api/profile_images/', include('openedx.core.djangoapps.profile_images.urls')),
     )
 
 if settings.FEATURES["ENABLE_COMBINED_LOGIN_REGISTRATION"]:
@@ -637,6 +638,9 @@ urlpatterns = patterns(*urlpatterns)
 
 if settings.DEBUG:
     urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+    urlpatterns += static(
+        settings.PROFILE_IMAGE_DOMAIN + settings.PROFILE_IMAGE_URL_PATH, document_root=settings.MEDIA_ROOT
+    )
 
     # in debug mode, allow any template to be rendered (most useful for UX reference templates)
     urlpatterns += url(r'^template/(?P<template>.+)$', 'debug.views.show_reference_template'),
diff --git a/openedx/core/djangoapps/profile_images/__init__.py b/openedx/core/djangoapps/profile_images/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/openedx/core/djangoapps/profile_images/images.py b/openedx/core/djangoapps/profile_images/images.py
new file mode 100644
index 00000000000..ab2e974cd03
--- /dev/null
+++ b/openedx/core/djangoapps/profile_images/images.py
@@ -0,0 +1,132 @@
+"""
+Image file manipulation functions related to profile images.
+"""
+from cStringIO import StringIO
+
+from django.conf import settings
+from django.core.files.base import ContentFile
+from django.utils.translation import ugettext as _, ugettext_noop as _noop
+from PIL import Image
+
+from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage
+
+
+FILE_UPLOAD_TOO_LARGE = _noop('Maximum file size exceeded.')
+FILE_UPLOAD_TOO_SMALL = _noop('Minimum file size not met.')
+FILE_UPLOAD_BAD_TYPE = _noop('Unsupported file type.')
+FILE_UPLOAD_BAD_EXT = _noop('File extension does not match data.')
+FILE_UPLOAD_BAD_MIMETYPE = _noop('Content-Type header does not match data.')
+
+
+class ImageValidationError(Exception):
+    """
+    Exception to use when the system rejects a user-supplied source image.
+    """
+    @property
+    def user_message(self):
+        """
+        Translate the developer-facing exception message for API clients.
+        """
+        # pylint: disable=translation-of-non-string
+        return _(self.message)
+
+
+def validate_uploaded_image(uploaded_file):
+    """
+    Raises ImageValidationError if the server should refuse to use this
+    uploaded file as the source image for a user's profile image.  Otherwise,
+    returns nothing.
+    """
+    # validation code by @pmitros,
+    # adapted from https://github.com/pmitros/ProfileXBlock
+    # see also: http://en.wikipedia.org/wiki/Magic_number_%28programming%29
+    image_types = {
+        'jpeg': {
+            'extension': [".jpeg", ".jpg"],
+            'mimetypes': ['image/jpeg', 'image/pjpeg'],
+            'magic': ["ffd8"]
+        },
+        'png': {
+            'extension': [".png"],
+            'mimetypes': ['image/png'],
+            'magic': ["89504e470d0a1a0a"]
+        },
+        'gif': {
+            'extension': [".gif"],
+            'mimetypes': ['image/gif'],
+            'magic': ["474946383961", "474946383761"]
+        }
+    }
+
+    if uploaded_file.size > settings.PROFILE_IMAGE_MAX_BYTES:
+        raise ImageValidationError(FILE_UPLOAD_TOO_LARGE)
+    elif uploaded_file.size < settings.PROFILE_IMAGE_MIN_BYTES:
+        raise ImageValidationError(FILE_UPLOAD_TOO_SMALL)
+
+    # check the file extension looks acceptable
+    filename = unicode(uploaded_file.name).lower()
+    filetype = [ft for ft in image_types if any(filename.endswith(ext) for ext in image_types[ft]['extension'])]
+    if not filetype:
+        raise ImageValidationError(FILE_UPLOAD_BAD_TYPE)
+    filetype = filetype[0]
+
+    # check mimetype matches expected file type
+    if uploaded_file.content_type not in image_types[filetype]['mimetypes']:
+        raise ImageValidationError(FILE_UPLOAD_BAD_MIMETYPE)
+
+    # check magic number matches expected file type
+    headers = image_types[filetype]['magic']
+    if uploaded_file.read(len(headers[0]) / 2).encode('hex') not in headers:
+        raise ImageValidationError(FILE_UPLOAD_BAD_EXT)
+    # avoid unexpected errors from subsequent modules expecting the fp to be at 0
+    uploaded_file.seek(0)
+
+
+def _get_scaled_image_file(image_obj, size):
+    """
+    Given a PIL.Image object, get a resized copy using `size` (square) and
+    return a file-like object containing the data saved as a JPEG.
+
+    Note that the file object returned is a django ContentFile which holds
+    data in memory (not on disk).
+    """
+    if image_obj.mode != "RGB":
+        image_obj = image_obj.convert("RGB")
+    scaled = image_obj.resize((size, size), Image.ANTIALIAS)
+    string_io = StringIO()
+    scaled.save(string_io, format='JPEG')
+    image_file = ContentFile(string_io.getvalue())
+    return image_file
+
+
+def create_profile_images(image_file, profile_image_names):
+    """
+    Generates a set of image files based on image_file and
+    stores them according to the sizes and filenames specified
+    in `profile_image_names`.
+    """
+    image_obj = Image.open(image_file)
+
+    # first center-crop the image if needed (but no scaling yet).
+    width, height = image_obj.size
+    if width != height:
+        side = width if width < height else height
+        image_obj = image_obj.crop(((width - side) / 2, (height - side) / 2, (width + side) / 2, (height + side) / 2))
+
+    storage = get_profile_image_storage()
+    for size, name in profile_image_names.items():
+        scaled_image_file = _get_scaled_image_file(image_obj, size)
+        # Store the file.
+        try:
+            storage.save(name, scaled_image_file)
+        finally:
+            scaled_image_file.close()
+
+
+def remove_profile_images(profile_image_names):
+    """
+    Physically remove the image files specified in `profile_image_names`
+    """
+    storage = get_profile_image_storage()
+    for name in profile_image_names.values():
+        storage.delete(name)
diff --git a/openedx/core/djangoapps/profile_images/tests/__init__.py b/openedx/core/djangoapps/profile_images/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/openedx/core/djangoapps/profile_images/tests/helpers.py b/openedx/core/djangoapps/profile_images/tests/helpers.py
new file mode 100644
index 00000000000..e1bf2cf8342
--- /dev/null
+++ b/openedx/core/djangoapps/profile_images/tests/helpers.py
@@ -0,0 +1,55 @@
+"""
+Helper methods for use in profile image tests.
+"""
+from contextlib import contextmanager
+import os
+from tempfile import NamedTemporaryFile
+
+from django.core.files.uploadedfile import UploadedFile
+from PIL import Image
+
+
+@contextmanager
+def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None):
+    """
+    Yields a named temporary file created with the specified image type and
+    options.
+
+    Note the default dimensions are unequal (not a square) ensuring that center-square
+    cropping logic will be exercised during tests.
+
+    The temporary file will be closed and deleted automatically upon exiting
+    the `with` block.
+    """
+    image = Image.new('RGB', dimensions, "green")
+    image_file = NamedTemporaryFile(suffix=extension)
+    try:
+        image.save(image_file)
+        if force_size is not None:
+            image_file.seek(0, os.SEEK_END)
+            bytes_to_pad = force_size - image_file.tell()
+            # write in hunks of 256 bytes
+            hunk, byte_ = bytearray([0] * 256), bytearray([0])
+            num_hunks, remainder = divmod(bytes_to_pad, 256)
+            for _ in xrange(num_hunks):
+                image_file.write(hunk)
+            for _ in xrange(remainder):
+                image_file.write(byte_)
+            image_file.flush()
+        image_file.seek(0)
+        yield image_file
+    finally:
+        image_file.close()
+
+
+@contextmanager
+def make_uploaded_file(content_type, *a, **kw):
+    """
+    Wrap the result of make_image_file in a django UploadedFile.
+    """
+    with make_image_file(*a, **kw) as image_file:
+        yield UploadedFile(
+            image_file,
+            content_type=content_type,
+            size=os.path.getsize(image_file.name),
+        )
diff --git a/openedx/core/djangoapps/profile_images/tests/test_images.py b/openedx/core/djangoapps/profile_images/tests/test_images.py
new file mode 100644
index 00000000000..3e65bd06d05
--- /dev/null
+++ b/openedx/core/djangoapps/profile_images/tests/test_images.py
@@ -0,0 +1,182 @@
+"""
+Test cases for image processing functions in the profile image package.
+"""
+from contextlib import closing
+from itertools import product
+import os
+from tempfile import NamedTemporaryFile
+import unittest
+
+from django.conf import settings
+from django.core.files.uploadedfile import UploadedFile
+from django.test import TestCase
+from django.test.utils import override_settings
+import ddt
+import mock
+from PIL import Image
+
+from ..images import (
+    FILE_UPLOAD_TOO_LARGE,
+    FILE_UPLOAD_TOO_SMALL,
+    FILE_UPLOAD_BAD_TYPE,
+    FILE_UPLOAD_BAD_EXT,
+    FILE_UPLOAD_BAD_MIMETYPE,
+    create_profile_images,
+    ImageValidationError,
+    remove_profile_images,
+    validate_uploaded_image,
+)
+from .helpers import make_image_file, make_uploaded_file
+
+
+@ddt.ddt
+@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS')
+class TestValidateUploadedImage(TestCase):
+    """
+    Test validate_uploaded_image
+    """
+    def check_validation_result(self, uploaded_file, expected_failure_message):
+        """
+        Internal DRY helper.
+        """
+        if expected_failure_message is not None:
+            with self.assertRaises(ImageValidationError) as ctx:
+                validate_uploaded_image(uploaded_file)
+            self.assertEqual(ctx.exception.message, expected_failure_message)
+        else:
+            validate_uploaded_image(uploaded_file)
+            self.assertEqual(uploaded_file.tell(), 0)
+
+    @ddt.data(
+        (99, FILE_UPLOAD_TOO_SMALL),
+        (100, ),
+        (1024, ),
+        (1025, FILE_UPLOAD_TOO_LARGE),
+    )
+    @ddt.unpack
+    @override_settings(PROFILE_IMAGE_MIN_BYTES=100, PROFILE_IMAGE_MAX_BYTES=1024)
+    def test_file_size(self, upload_size, expected_failure_message=None):
+        """
+        Ensure that files outside the accepted size range fail validation.
+        """
+        with make_uploaded_file(
+            dimensions=(1, 1), extension=".png", content_type="image/png", force_size=upload_size
+        ) as uploaded_file:
+            self.check_validation_result(uploaded_file, expected_failure_message)
+
+    @ddt.data(
+        (".gif", "image/gif"),
+        (".jpg", "image/jpeg"),
+        (".jpeg", "image/jpeg"),
+        (".png", "image/png"),
+        (".bmp", "image/bmp", FILE_UPLOAD_BAD_TYPE),
+        (".tif", "image/tiff", FILE_UPLOAD_BAD_TYPE),
+    )
+    @ddt.unpack
+    def test_extension(self, extension, content_type, expected_failure_message=None):
+        """
+        Ensure that files whose extension is not supported fail validation.
+        """
+        with make_uploaded_file(extension=extension, content_type=content_type) as uploaded_file:
+            self.check_validation_result(uploaded_file, expected_failure_message)
+
+    def test_extension_mismatch(self):
+        """
+        Ensure that validation fails when the file extension does not match the
+        file data.
+        """
+        # make a bmp, try to fool the function into thinking it's a jpeg
+        with make_image_file(extension=".bmp") as bmp_file:
+            with closing(NamedTemporaryFile(suffix=".jpeg")) as fake_jpeg_file:
+                fake_jpeg_file.write(bmp_file.read())
+                fake_jpeg_file.seek(0)
+                uploaded_file = UploadedFile(
+                    fake_jpeg_file,
+                    content_type="image/jpeg",
+                    size=os.path.getsize(fake_jpeg_file.name)
+                )
+                with self.assertRaises(ImageValidationError) as ctx:
+                    validate_uploaded_image(uploaded_file)
+                self.assertEqual(ctx.exception.message, FILE_UPLOAD_BAD_EXT)
+
+    def test_content_type(self):
+        """
+        Ensure that validation fails when the content_type header and file
+        extension do not match
+        """
+        with make_uploaded_file(extension=".jpeg", content_type="image/gif") as uploaded_file:
+            with self.assertRaises(ImageValidationError) as ctx:
+                validate_uploaded_image(uploaded_file)
+            self.assertEqual(ctx.exception.message, FILE_UPLOAD_BAD_MIMETYPE)
+
+
+@ddt.ddt
+@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS')
+class TestGenerateProfileImages(TestCase):
+    """
+    Test create_profile_images
+    """
+    @ddt.data(
+        *product(
+            ["gif", "jpg", "png"],
+            [(1, 1), (10, 10), (100, 100), (1000, 1000), (1, 10), (10, 100), (100, 1000), (1000, 999)],
+        )
+    )
+    @ddt.unpack
+    def test_generation(self, image_type, dimensions):
+        """
+        Ensure that regardless of the input format or dimensions, the outcome
+        of calling the function is square jpeg files with explicitly-requested
+        dimensions being saved to the profile image storage backend.
+        """
+        extension = "." + image_type
+        content_type = "image/" + image_type
+        requested_sizes = {
+            10: "ten.jpg",
+            100: "hundred.jpg",
+            1000: "thousand.jpg",
+        }
+        mock_storage = mock.Mock()
+        with make_uploaded_file(dimensions=dimensions, extension=extension, content_type=content_type) as uploaded_file:
+            with mock.patch(
+                "openedx.core.djangoapps.profile_images.images.get_profile_image_storage",
+                return_value=mock_storage,
+            ):
+                create_profile_images(uploaded_file, requested_sizes)
+                names_and_files = [v[0] for v in mock_storage.save.call_args_list]
+                actual_sizes = {}
+                for name, file_ in names_and_files:
+                    # get the size of the image file and ensure it's square jpeg
+                    with closing(Image.open(file_)) as image_obj:
+                        width, height = image_obj.size
+                        self.assertEqual(width, height)
+                        self.assertEqual(image_obj.format, 'JPEG')
+                        actual_sizes[width] = name
+                self.assertEqual(requested_sizes, actual_sizes)
+                mock_storage.save.reset_mock()
+
+
+@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS')
+class TestRemoveProfileImages(TestCase):
+    """
+    Test remove_profile_images
+    """
+    def test_remove(self):
+        """
+        Ensure that the outcome of calling the function is that the named images
+        are deleted from the profile image storage backend.
+        """
+        requested_sizes = {
+            10: "ten.jpg",
+            100: "hundred.jpg",
+            1000: "thousand.jpg",
+        }
+        mock_storage = mock.Mock()
+        with mock.patch(
+            "openedx.core.djangoapps.profile_images.images.get_profile_image_storage",
+            return_value=mock_storage,
+        ):
+            remove_profile_images(requested_sizes)
+            deleted_names = [v[0][0] for v in mock_storage.delete.call_args_list]
+            self.assertEqual(requested_sizes.values(), deleted_names)
+            mock_storage.save.reset_mock()
diff --git a/openedx/core/djangoapps/profile_images/tests/test_views.py b/openedx/core/djangoapps/profile_images/tests/test_views.py
new file mode 100644
index 00000000000..80c349d9c7d
--- /dev/null
+++ b/openedx/core/djangoapps/profile_images/tests/test_views.py
@@ -0,0 +1,304 @@
+"""
+Test cases for the HTTP endpoints of the profile image api.
+"""
+from contextlib import closing
+import unittest
+
+import ddt
+from django.conf import settings
+from django.core.urlresolvers import reverse
+import mock
+from mock import patch
+
+from PIL import Image
+from rest_framework.test import APITestCase, APIClient
+
+from student.tests.factories import UserFactory
+
+from ...user_api.accounts.image_helpers import (
+    set_has_profile_image,
+    get_profile_image_names,
+    get_profile_image_storage
+)
+from ..images import create_profile_images, ImageValidationError
+from .helpers import make_image_file
+
+TEST_PASSWORD = "test"
+
+
+class ProfileImageEndpointTestCase(APITestCase):
+    """
+    Base class / shared infrastructure for tests of profile_image "upload" and
+    "remove" endpoints.
+    """
+    # subclasses should override this with the name of the view under test, as
+    # per the urls.py configuration.
+    _view_name = None
+
+    def setUp(self):
+        super(ProfileImageEndpointTestCase, self).setUp()
+        self.user = UserFactory.create(password=TEST_PASSWORD)
+        # Ensure that parental controls don't apply to this user
+        self.user.profile.year_of_birth = 1980
+        self.user.profile.save()
+        self.url = reverse(self._view_name, kwargs={'username': self.user.username})
+        self.client.login(username=self.user.username, password=TEST_PASSWORD)
+        self.storage = get_profile_image_storage()
+        # this assertion is made here as a sanity check because all tests
+        # assume user.profile.has_profile_image is False by default
+        self.assertFalse(self.user.profile.has_profile_image)
+
+    def tearDown(self):
+        super(ProfileImageEndpointTestCase, self).tearDown()
+        for name in get_profile_image_names(self.user.username).values():
+            self.storage.delete(name)
+
+    def check_images(self, exist=True):
+        """
+        If exist is True, make sure the images physically exist in storage
+        with correct sizes and formats.
+
+        If exist is False, make sure none of the images exist.
+        """
+        for size, name in get_profile_image_names(self.user.username).items():
+            if exist:
+                self.assertTrue(self.storage.exists(name))
+                with closing(Image.open(self.storage.path(name))) as img:
+                    self.assertEqual(img.size, (size, size))
+                    self.assertEqual(img.format, 'JPEG')
+            else:
+                self.assertFalse(self.storage.exists(name))
+
+    def check_response(self, response, expected_code, expected_developer_message=None, expected_user_message=None):
+        """
+        Make sure the response has the expected code, and if that isn't 204,
+        optionally check the correctness of a developer-facing message.
+        """
+        self.assertEqual(expected_code, response.status_code)
+        if expected_code == 204:
+            self.assertIsNone(response.data)
+        else:
+            if expected_developer_message is not None:
+                self.assertEqual(response.data.get('developer_message'), expected_developer_message)
+            if expected_user_message is not None:
+                self.assertEqual(response.data.get('user_message'), expected_user_message)
+
+    def check_has_profile_image(self, has_profile_image=True):
+        """
+        Make sure the value of self.user.profile.has_profile_image is what we
+        expect.
+        """
+        # it's necessary to reload this model from the database since save()
+        # would have been called on another instance.
+        profile = self.user.profile.__class__.objects.get(user=self.user)
+        self.assertEqual(profile.has_profile_image, has_profile_image)
+
+
+@ddt.ddt
+@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS')
+class ProfileImageUploadTestCase(ProfileImageEndpointTestCase):
+    """
+    Tests for the profile_image upload endpoint.
+    """
+    _view_name = "profile_image_upload"
+
+    def test_unsupported_methods(self):
+        """
+        Test that GET, PUT, PATCH, and DELETE are not supported.
+        """
+        self.assertEqual(405, self.client.get(self.url).status_code)
+        self.assertEqual(405, self.client.put(self.url).status_code)
+        self.assertEqual(405, self.client.patch(self.url).status_code)
+        self.assertEqual(405, self.client.delete(self.url).status_code)
+
+    def test_anonymous_access(self):
+        """
+        Test that an anonymous client (not logged in) cannot POST.
+        """
+        anonymous_client = APIClient()
+        response = anonymous_client.post(self.url)
+        self.assertEqual(401, response.status_code)
+
+    def test_upload_self(self):
+        """
+        Test that an authenticated user can POST to their own upload endpoint.
+        """
+        with make_image_file() as image_file:
+            response = self.client.post(self.url, {'file': image_file}, format='multipart')
+            self.check_response(response, 204)
+            self.check_images()
+            self.check_has_profile_image()
+
+    def test_upload_other(self):
+        """
+        Test that an authenticated user cannot POST to another user's upload endpoint.
+        """
+        different_user = UserFactory.create(password=TEST_PASSWORD)
+        different_client = APIClient()
+        different_client.login(username=different_user.username, password=TEST_PASSWORD)
+        with make_image_file() as image_file:
+            response = different_client.post(self.url, {'file': image_file}, format='multipart')
+            self.check_response(response, 404)
+            self.check_images(False)
+            self.check_has_profile_image(False)
+
+    def test_upload_staff(self):
+        """
+        Test that an authenticated staff cannot POST to another user's upload endpoint.
+        """
+        staff_user = UserFactory(is_staff=True, password=TEST_PASSWORD)
+        staff_client = APIClient()
+        staff_client.login(username=staff_user.username, password=TEST_PASSWORD)
+        with make_image_file() as image_file:
+            response = staff_client.post(self.url, {'file': image_file}, format='multipart')
+            self.check_response(response, 403)
+            self.check_images(False)
+            self.check_has_profile_image(False)
+
+    def test_upload_missing_file(self):
+        """
+        Test that omitting the file entirely from the POST results in HTTP 400.
+        """
+        response = self.client.post(self.url, {}, format='multipart')
+        self.check_response(
+            response, 400,
+            expected_developer_message=u"No file provided for profile image",
+            expected_user_message=u"No file provided for profile image",
+        )
+        self.check_images(False)
+        self.check_has_profile_image(False)
+
+    def test_upload_not_a_file(self):
+        """
+        Test that sending unexpected data that isn't a file results in HTTP
+        400.
+        """
+        response = self.client.post(self.url, {'file': 'not a file'}, format='multipart')
+        self.check_response(
+            response, 400,
+            expected_developer_message=u"No file provided for profile image",
+            expected_user_message=u"No file provided for profile image",
+        )
+        self.check_images(False)
+        self.check_has_profile_image(False)
+
+    def test_upload_validation(self):
+        """
+        Test that when upload validation fails, the proper HTTP response and
+        messages are returned.
+        """
+        with make_image_file() as image_file:
+            with mock.patch(
+                'openedx.core.djangoapps.profile_images.views.validate_uploaded_image',
+                side_effect=ImageValidationError(u"test error message")
+            ):
+                response = self.client.post(self.url, {'file': image_file}, format='multipart')
+                self.check_response(
+                    response, 400,
+                    expected_developer_message=u"test error message",
+                    expected_user_message=u"test error message",
+                )
+                self.check_images(False)
+                self.check_has_profile_image(False)
+
+    @patch('PIL.Image.open')
+    def test_upload_failure(self, image_open):
+        """
+        Test that when upload validation fails, the proper HTTP response and
+        messages are returned.
+        """
+        image_open.side_effect = [Exception(u"whoops"), None]
+        with make_image_file() as image_file:
+            response = self.client.post(self.url, {'file': image_file}, format='multipart')
+            self.check_response(
+                response, 400,
+                expected_developer_message=u"Upload failed for profile image: whoops",
+                expected_user_message=u"Upload failed for profile image",
+            )
+            self.check_images(False)
+            self.check_has_profile_image(False)
+
+
+@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS')
+class ProfileImageRemoveTestCase(ProfileImageEndpointTestCase):
+    """
+    Tests for the profile_image remove endpoint.
+    """
+    _view_name = "profile_image_remove"
+
+    def setUp(self):
+        super(ProfileImageRemoveTestCase, self).setUp()
+        with make_image_file() as image_file:
+            create_profile_images(image_file, get_profile_image_names(self.user.username))
+            self.check_images()
+            set_has_profile_image(self.user.username, True)
+
+    def test_unsupported_methods(self):
+        """
+        Test that GET, PUT, PATCH, and DELETE are not supported.
+        """
+        self.assertEqual(405, self.client.get(self.url).status_code)
+        self.assertEqual(405, self.client.put(self.url).status_code)
+        self.assertEqual(405, self.client.patch(self.url).status_code)
+        self.assertEqual(405, self.client.delete(self.url).status_code)
+
+    def test_anonymous_access(self):
+        """
+        Test that an anonymous client (not logged in) cannot call GET or POST.
+        """
+        anonymous_client = APIClient()
+        for request in (anonymous_client.get, anonymous_client.post):
+            response = request(self.url)
+            self.assertEqual(401, response.status_code)
+
+    def test_remove_self(self):
+        """
+        Test that an authenticated user can POST to remove their own profile
+        images.
+        """
+        response = self.client.post(self.url)
+        self.check_response(response, 204)
+        self.check_images(False)
+        self.check_has_profile_image(False)
+
+    def test_remove_other(self):
+        """
+        Test that an authenticated user cannot POST to remove another user's
+        profile images.
+        """
+        different_user = UserFactory.create(password=TEST_PASSWORD)
+        different_client = APIClient()
+        different_client.login(username=different_user.username, password=TEST_PASSWORD)
+        response = different_client.post(self.url)
+        self.check_response(response, 404)
+        self.check_images(True)  # thumbnails should remain intact.
+        self.check_has_profile_image(True)
+
+    def test_remove_staff(self):
+        """
+        Test that an authenticated staff user can POST to remove another user's
+        profile images.
+        """
+        staff_user = UserFactory(is_staff=True, password=TEST_PASSWORD)
+        staff_client = APIClient()
+        staff_client.login(username=staff_user.username, password=TEST_PASSWORD)
+        response = self.client.post(self.url)
+        self.check_response(response, 204)
+        self.check_images(False)
+        self.check_has_profile_image(False)
+
+    @patch('student.models.UserProfile.save')
+    def test_remove_failure(self, user_profile_save):
+        """
+        Test that when upload validation fails, the proper HTTP response and
+        messages are returned.
+        """
+        user_profile_save.side_effect = [Exception(u"whoops"), None]
+        response = self.client.post(self.url)
+        self.check_response(
+            response, 400,
+            expected_developer_message=u"Delete failed for profile image: whoops",
+            expected_user_message=u"Delete failed for profile image",
+        )
+        self.check_images(True)  # thumbnails should remain intact.
+        self.check_has_profile_image(True)
diff --git a/openedx/core/djangoapps/profile_images/urls.py b/openedx/core/djangoapps/profile_images/urls.py
new file mode 100644
index 00000000000..a2e73c73ac8
--- /dev/null
+++ b/openedx/core/djangoapps/profile_images/urls.py
@@ -0,0 +1,22 @@
+"""
+Defines the URL routes for this app.
+"""
+from .views import ProfileImageUploadView, ProfileImageRemoveView
+
+from django.conf.urls import patterns, url
+
+USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
+
+urlpatterns = patterns(
+    '',
+    url(
+        r'^v0/' + USERNAME_PATTERN + '/upload$',
+        ProfileImageUploadView.as_view(),
+        name="profile_image_upload"
+    ),
+    url(
+        r'^v0/' + USERNAME_PATTERN + '/remove$',
+        ProfileImageRemoveView.as_view(),
+        name="profile_image_remove"
+    ),
+)
diff --git a/openedx/core/djangoapps/profile_images/views.py b/openedx/core/djangoapps/profile_images/views.py
new file mode 100644
index 00000000000..f2aaf748f79
--- /dev/null
+++ b/openedx/core/djangoapps/profile_images/views.py
@@ -0,0 +1,152 @@
+"""
+This module implements the upload and remove endpoints of the profile image api.
+"""
+from contextlib import closing
+
+from django.utils.translation import ugettext as _
+from rest_framework import permissions, status
+from rest_framework.parsers import MultiPartParser, FormParser
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from openedx.core.djangoapps.user_api.errors import UserNotFound
+from openedx.core.lib.api.authentication import (
+    OAuth2AuthenticationAllowInactiveUser,
+    SessionAuthenticationAllowInactiveUser,
+)
+from openedx.core.lib.api.permissions import IsUserInUrl, IsUserInUrlOrStaff
+from openedx.core.djangoapps.user_api.accounts.image_helpers import set_has_profile_image, get_profile_image_names
+from .images import validate_uploaded_image, create_profile_images, remove_profile_images, ImageValidationError
+
+
+class ProfileImageUploadView(APIView):
+    """
+    **Use Cases**
+
+        Uploads an image to be used for the user's profile.
+
+    **Example Requests**:
+
+        POST /api/profile_images/v0/{username}/upload
+
+    **Response for POST**
+
+        Users can only upload their own profile image. If the requesting user does not have username
+        "username", this method will return with a status of 403 for staff access but a 404 for ordinary
+        users to avoid leaking the existence of the account.
+
+        This method will also return a 404 if no user exists with username "username".
+
+        If the upload could not be performed then this method returns a 400 with specific errors
+        in the returned JSON.
+
+        If the update is successful, a 204 status is returned with no additional content.
+
+    """
+    parser_classes = (MultiPartParser, FormParser,)
+
+    authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser)
+    permission_classes = (permissions.IsAuthenticated, IsUserInUrl)
+
+    def post(self, request, username):
+        """
+        POST /api/profile_images/v0/{username}/upload
+        """
+        # validate request:
+        # verify that the user's
+        # ensure any file was sent
+        if 'file' not in request.FILES:
+            return Response(
+                {
+                    "developer_message": u"No file provided for profile image",
+                    "user_message": _(u"No file provided for profile image"),
+
+                },
+                status=status.HTTP_400_BAD_REQUEST
+            )
+
+        try:
+            # process the upload.
+            uploaded_file = request.FILES['file']
+
+            # no matter what happens, delete the temporary file when we're done
+            with closing(uploaded_file):
+
+                # image file validation.
+                try:
+                    validate_uploaded_image(uploaded_file)
+                except ImageValidationError as error:
+                    return Response(
+                        {"developer_message": error.message, "user_message": error.user_message},
+                        status=status.HTTP_400_BAD_REQUEST,
+                    )
+
+                # generate profile pic and thumbnails and store them
+                create_profile_images(uploaded_file, get_profile_image_names(username))
+
+                # update the user account to reflect that a profile image is available.
+                set_has_profile_image(username, True)
+        except Exception as error:
+            return Response(
+                {
+                    "developer_message": u"Upload failed for profile image: {error}".format(error=error),
+                    "user_message": _(u"Upload failed for profile image"),
+
+                },
+                status=status.HTTP_400_BAD_REQUEST
+            )
+
+        # send client response.
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class ProfileImageRemoveView(APIView):
+    """
+    **Use Cases**
+
+        Removes all of the profile images associated with the user's account.
+
+    **Example Requests**:
+
+        POST /api/profile_images/v0/{username}/remove
+
+    **Response for POST**
+
+        Users are authorized to delete their own profile images, while staff can delete images for
+        any account. All other users will receive a 404 to avoid leaking the existence of the account.
+
+        This method will also return a 404 if no user exists with username "username".
+
+        If the delete could not be performed then this method returns a 400 with specific errors
+        in the returned JSON.
+
+        If the delete is successful, a 204 status is returned with no additional content.
+
+    """
+    authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser)
+    permission_classes = (permissions.IsAuthenticated, IsUserInUrlOrStaff)
+
+    def post(self, request, username):  # pylint: disable=unused-argument
+        """
+        POST /api/profile_images/v0/{username}/remove
+        """
+        try:
+            # update the user account to reflect that the images were removed.
+            set_has_profile_image(username, False)
+
+            # remove physical files from storage.
+            remove_profile_images(get_profile_image_names(username))
+        except UserNotFound:
+            return Response(status=status.HTTP_404_NOT_FOUND)
+        except Exception as error:
+            return Response(
+                {
+                    "developer_message": u"Delete failed for profile image: {error}".format(error=error),
+                    "user_message": _(u"Delete failed for profile image"),
+
+                },
+                status=status.HTTP_400_BAD_REQUEST
+            )
+
+        # send client response.
+        return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/openedx/core/djangoapps/user_api/accounts/helpers.py b/openedx/core/djangoapps/user_api/accounts/helpers.py
deleted file mode 100644
index 836ace7d942..00000000000
--- a/openedx/core/djangoapps/user_api/accounts/helpers.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""
-Helper functions for the accounts API.
-"""
-import hashlib
-
-from django.conf import settings
-from django.core.files.storage import FileSystemStorage, get_storage_class
-
-PROFILE_IMAGE_SIZES_MAP = {
-    'full': 500,
-    'large': 120,
-    'medium': 50,
-    'small': 30
-}
-_PROFILE_IMAGE_SIZES = PROFILE_IMAGE_SIZES_MAP.values()
-PROFILE_IMAGE_FORMAT = 'jpg'
-
-
-def get_profile_image_url_for_user(user, size):
-    """Return the URL to a user's profile image for a given size.
-    Note that based on the value of
-    django.conf.settings.PROFILE_IMAGE_DOMAIN, the URL may be relative,
-    and in that case the caller is responsible for constructing the full
-    URL.
-
-    If the user has not yet uploaded a profile image, return the URL to
-    the default edX user profile image.
-
-    Arguments:
-        user (django.auth.User): The user for whom we're generating a
-        profile image URL.
-
-    Returns:
-        string: The URL for the user's profile image.
-
-    Raises:
-        ValueError: The caller asked for an unsupported image size.
-    """
-    if size not in _PROFILE_IMAGE_SIZES:
-        raise ValueError('Unsupported profile image size: {size}'.format(size=size))
-
-    if user.profile.has_profile_image:
-        name = hashlib.md5(settings.PROFILE_IMAGE_SECRET_KEY + user.username).hexdigest()
-    else:
-        name = settings.PROFILE_IMAGE_DEFAULT_FILENAME
-
-    filename = '{name}_{size}.{format}'.format(name=name, size=size, format=PROFILE_IMAGE_FORMAT)
-
-    # Note that, for now, the backend will be FileSystemStorage.  When
-    # we eventually support s3 storage, we'll need to pass a parameter
-    # to the storage class indicating the s3 bucket which we're using
-    # for profile picture uploads.
-    storage_class = get_storage_class(settings.PROFILE_IMAGE_BACKEND)
-    if storage_class == FileSystemStorage:
-        kwargs = {'base_url': (settings.PROFILE_IMAGE_DOMAIN + settings.PROFILE_IMAGE_URL_PATH)}
-    storage = storage_class(**kwargs)
-    return storage.url(filename)
diff --git a/openedx/core/djangoapps/user_api/accounts/image_helpers.py b/openedx/core/djangoapps/user_api/accounts/image_helpers.py
new file mode 100644
index 00000000000..64514db4c3b
--- /dev/null
+++ b/openedx/core/djangoapps/user_api/accounts/image_helpers.py
@@ -0,0 +1,115 @@
+"""
+Helper functions for the accounts API.
+"""
+import hashlib
+
+from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist
+from django.core.files.storage import get_storage_class
+
+from student.models import UserProfile
+from ..errors import UserNotFound
+
+
+PROFILE_IMAGE_SIZES_MAP = {
+    'full': 500,
+    'large': 120,
+    'medium': 50,
+    'small': 30
+}
+_PROFILE_IMAGE_SIZES = PROFILE_IMAGE_SIZES_MAP.values()
+
+
+def get_profile_image_storage():
+    """
+    Configures and returns a django Storage instance that can be used
+    to physically locate, read and write profile images.
+    """
+    storage_class = get_storage_class(settings.PROFILE_IMAGE_BACKEND)
+    return storage_class(base_url=(settings.PROFILE_IMAGE_DOMAIN + settings.PROFILE_IMAGE_URL_PATH))
+
+
+def _make_profile_image_name(username):
+    """
+    Returns the user-specific part of the image filename, based on a hash of
+    the username.
+    """
+    return hashlib.md5(settings.PROFILE_IMAGE_SECRET_KEY + username).hexdigest()
+
+
+def _get_profile_image_filename(name, size):
+    """
+    Returns the full filename for a profile image, given the name and size.
+    """
+    return '{name}_{size}.jpg'.format(name=name, size=size)
+
+
+def _get_profile_image_urls(name):
+    """
+    Returns a dict containing the urls for a complete set of profile images,
+    keyed by "friendly" name (e.g. "full", "large", "medium", "small").
+    """
+    storage = get_profile_image_storage()
+    return {
+        size_display_name: storage.url(_get_profile_image_filename(name, size))
+        for size_display_name, size in PROFILE_IMAGE_SIZES_MAP.items()
+    }
+
+
+def get_profile_image_names(username):
+    """
+    Returns a dict containing the filenames for a complete set of profile
+    images, keyed by pixel size.
+    """
+    name = _make_profile_image_name(username)
+    return {size: _get_profile_image_filename(name, size) for size in _PROFILE_IMAGE_SIZES}
+
+
+def get_profile_image_urls_for_user(user):
+    """
+    Return a dict {size:url} for each profile image for a given user.
+    Notes:
+      - this function does not determine whether the set of profile images
+    exists, only what the URLs will be if they do exist.  It is assumed that
+    callers will use `_get_default_profile_image_urls` instead to provide
+    a set of urls that point to placeholder images, when there are no user-
+    submitted images.
+      - based on the value of django.conf.settings.PROFILE_IMAGE_DOMAIN,
+    the URL may be relative, and in that case the caller is responsible for
+    constructing the full URL if needed.
+
+    Arguments:
+        user (django.contrib.auth.User): the user for whom we are getting urls.
+
+    Returns:
+        dictionary of {size_display_name: url} for each image.
+
+    """
+    if user.profile.has_profile_image:
+        return _get_profile_image_urls(_make_profile_image_name(user.username))
+    else:
+        return _get_default_profile_image_urls()
+
+
+def _get_default_profile_image_urls():
+    """
+    Returns a dict {size:url} for a complete set of default profile images,
+    used as a placeholder when there are no user-submitted images.
+
+    TODO The result of this function should be memoized, but not in tests.
+    """
+    return _get_profile_image_urls(settings.PROFILE_IMAGE_DEFAULT_FILENAME)
+
+
+def set_has_profile_image(username, has_profile_image=True):
+    """
+    System (not user-facing) API call used to store whether the user has
+    uploaded a profile image.  Used by profile_image API.
+    """
+    try:
+        profile = UserProfile.objects.get(user__username=username)
+    except ObjectDoesNotExist:
+        raise UserNotFound()
+
+    profile.has_profile_image = has_profile_image
+    profile.save()
diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py
index d8a187e3ee3..ceacd3e5e60 100644
--- a/openedx/core/djangoapps/user_api/accounts/serializers.py
+++ b/openedx/core/djangoapps/user_api/accounts/serializers.py
@@ -4,7 +4,8 @@ from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
 from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
 
 from student.models import UserProfile, LanguageProficiency
-from .helpers import get_profile_image_url_for_user, PROFILE_IMAGE_SIZES_MAP
+from .image_helpers import get_profile_image_urls_for_user
+
 
 PROFILE_IMAGE_KEY_PREFIX = 'image_url'
 
@@ -100,10 +101,10 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
     def get_profile_image(self, user_profile):
         """ Returns metadata about a user's profile image. """
         data = {'has_image': user_profile.has_profile_image}
+        urls = get_profile_image_urls_for_user(user_profile.user)
         data.update({
-            '{image_key_prefix}_{size}'.format(image_key_prefix=PROFILE_IMAGE_KEY_PREFIX, size=size_display_name):
-            get_profile_image_url_for_user(user_profile.user, size_value)
-            for size_display_name, size_value in PROFILE_IMAGE_SIZES_MAP.items()
+            '{image_key_prefix}_{size}'.format(image_key_prefix=PROFILE_IMAGE_KEY_PREFIX, size=size_display_name): url
+            for size_display_name, url in urls.items()
         })
         return data
 
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py
index 29ba9c03f72..729553706c9 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py
@@ -192,12 +192,13 @@ class TestAccountApi(TestCase):
         self.assertEqual(0, len(pending_change))
 
 
-@patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10])
+@patch('openedx.core.djangoapps.user_api.accounts.image_helpers._PROFILE_IMAGE_SIZES', [50, 10])
 @patch.dict(
-    'openedx.core.djangoapps.user_api.accounts.helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True
+    'openedx.core.djangoapps.user_api.accounts.image_helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True
 )
 @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
 class AccountSettingsOnCreationTest(TestCase):
+    # pylint: disable=missing-docstring
 
     USERNAME = u'frank-underwood'
     PASSWORD = u'ṕáśśẃőŕd'
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py b/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py
deleted file mode 100644
index 92abeaff01e..00000000000
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""
-Tests for helpers.py
-"""
-from ddt import ddt, data
-import hashlib
-from mock import patch
-from unittest import skipUnless
-
-from django.conf import settings
-from django.test import TestCase
-
-from student.tests.factories import UserFactory
-
-from ...models import UserProfile
-from ..helpers import get_profile_image_url_for_user
-
-
-@ddt
-@patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10])
-@patch.dict(
-    'openedx.core.djangoapps.user_api.accounts.helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True
-)
-@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
-class ProfileImageUrlTestCase(TestCase):
-    """
-    Tests for `get_profile_image_url_for_user`.
-    """
-    def setUp(self):
-        super(ProfileImageUrlTestCase, self).setUp()
-        self.user = UserFactory()
-
-        # Ensure that parental controls don't apply to this user
-        self.user.profile.year_of_birth = 1980
-        self.user.profile.save()
-
-    def verify_url(self, user, pixels, filename):
-        """
-        Helper method to verify that we're correctly generating profile
-        image URLs.
-        """
-        self.assertEqual(
-            get_profile_image_url_for_user(user, pixels),
-            'http://example-storage.com/profile_images/{filename}_{pixels}.jpg'.format(filename=filename, pixels=pixels)
-        )
-
-    @data(10, 50)
-    def test_profile_image_urls(self, pixels):
-        """
-        Verify we get the URL to the default image if the user does not
-        have a profile image.
-        """
-        # By default new users will have no profile image.
-        self.verify_url(self.user, pixels, 'default')
-        # A user can add an image, then remove it.  We should get the
-        # default image URL in that case.
-        self.user.profile.has_profile_image = True
-        self.user.profile.save()
-        self.verify_url(self.user, pixels, hashlib.md5('secret' + self.user.username).hexdigest())
-        self.user.profile.has_profile_image = False
-        self.user.profile.save()
-        self.verify_url(self.user, pixels, 'default')
-
-    @data(1, 5000)
-    def test_unsupported_sizes(self, image_size):
-        """
-        Verify that we cannot ask for image sizes which are unsupported.
-        """
-        with self.assertRaises(ValueError):
-            get_profile_image_url_for_user(self.user, image_size)
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py b/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py
new file mode 100644
index 00000000000..8fda6808a48
--- /dev/null
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py
@@ -0,0 +1,60 @@
+"""
+Tests for helpers.py
+"""
+import hashlib
+from mock import patch
+from unittest import skipUnless
+
+from django.conf import settings
+from django.test import TestCase
+
+from ..image_helpers import get_profile_image_urls_for_user
+from student.tests.factories import UserFactory
+
+TEST_SIZES = {'full': 50, 'small': 10}
+
+
+@patch.dict('openedx.core.djangoapps.user_api.accounts.image_helpers.PROFILE_IMAGE_SIZES_MAP', TEST_SIZES, clear=True)
+@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
+class ProfileImageUrlTestCase(TestCase):
+    """
+    Tests for profile image URL generation helpers.
+    """
+    def setUp(self):
+        super(ProfileImageUrlTestCase, self).setUp()
+        self.user = UserFactory()
+        # Ensure that parental controls don't apply to this user
+        self.user.profile.year_of_birth = 1980
+        self.user.profile.has_profile_image = False
+        self.user.profile.save()
+
+    def verify_url(self, actual_url, expected_name, expected_pixels):
+        """
+        Verify correct url structure.
+        """
+        self.assertEqual(
+            actual_url,
+            'http://example-storage.com/profile_images/{0}_{1}.jpg'.format(expected_name, expected_pixels),
+        )
+
+    def verify_urls(self, expected_name, actual_urls):
+        """
+        Verify correct url dictionary structure.
+        """
+        self.assertEqual(set(TEST_SIZES.keys()), set(actual_urls.keys()))
+        for size_display_name, url in actual_urls.items():
+            self.verify_url(url, expected_name, TEST_SIZES[size_display_name])
+
+    def test_get_profile_image_urls(self):
+        """
+        Tests `get_profile_image_urls_for_user`
+        """
+        self.user.profile.has_profile_image = True
+        self.user.profile.save()
+        expected_name = hashlib.md5('secret' + self.user.username).hexdigest()
+        actual_urls = get_profile_image_urls_for_user(self.user)
+        self.verify_urls(expected_name, actual_urls)
+
+        self.user.profile.has_profile_image = False
+        self.user.profile.save()
+        self.verify_urls('default', get_profile_image_urls_for_user(self.user))
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
index 6486e95df19..76ead6b47a9 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
@@ -97,9 +97,9 @@ class UserAPITestCase(APITestCase):
 
 @ddt.ddt
 @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
-@patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10])
+@patch('openedx.core.djangoapps.user_api.accounts.image_helpers._PROFILE_IMAGE_SIZES', [50, 10])
 @patch.dict(
-    'openedx.core.djangoapps.user_api.accounts.helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True
+    'openedx.core.djangoapps.user_api.accounts.image_helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True
 )
 class TestAccountAPI(UserAPITestCase):
     """
@@ -200,7 +200,7 @@ class TestAccountAPI(UserAPITestCase):
         """
         client = self.login_client(api_client, user)
         response = client.get(reverse("accounts_api", kwargs={'username': "does_not_exist"}))
-        self.assertEqual(404, response.status_code)
+        self.assertEqual(403 if user == "staff_user" else 404, response.status_code)
 
     # Note: using getattr so that the patching works even if there is no configuration.
     # This is needed when testing CMS as the patching is still executed even though the
@@ -279,7 +279,6 @@ class TestAccountAPI(UserAPITestCase):
             for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"):
                 self.assertIsNone(data[empty_field])
             self.assertIsNone(data["country"])
-            # TODO: what should the format of this be?
             self.assertEqual("m", data["gender"])
             self.assertEqual("Learn a lot", data["goals"])
             self.assertEqual(self.user.email, data["email"])
@@ -323,7 +322,7 @@ class TestAccountAPI(UserAPITestCase):
         is_staff access).
         """
         client = self.login_client(api_client, user)
-        self.send_patch(client, {}, expected_status=404)
+        self.send_patch(client, {}, expected_status=403 if user == "staff_user" else 404)
 
     @ddt.data(
         ("client", "user"),
diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py
index 1bd0696f54f..19c86490305 100644
--- a/openedx/core/djangoapps/user_api/accounts/views.py
+++ b/openedx/core/djangoapps/user_api/accounts/views.py
@@ -8,9 +8,12 @@ from django.db import transaction
 from rest_framework.views import APIView
 from rest_framework.response import Response
 from rest_framework import status
-from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
 from rest_framework import permissions
 
+from openedx.core.lib.api.authentication import (
+    SessionAuthenticationAllowInactiveUser,
+    OAuth2AuthenticationAllowInactiveUser,
+)
 from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
 from openedx.core.lib.api.parsers import MergePatchParser
 from .api import get_account_settings, update_account_settings
@@ -123,8 +126,10 @@ class AccountView(APIView):
 
         **Response Values for PATCH**
 
-            Users can modify only their own account information. If the user
-            attempts to modify another user's account, a 404 error is returned.
+            Users can only modify their own account information. If the requesting
+            user does not have username "username", this method will return with
+            a status of 403 for staff access but a 404 for ordinary users
+            to avoid leaking the existence of the account.
 
             If no user exists with the specified username, a 404 error is
             returned.
@@ -158,7 +163,7 @@ class AccountView(APIView):
                 if key.startswith(PROFILE_IMAGE_KEY_PREFIX):
                     account_settings['profile_image'][key] = request.build_absolute_uri(value)
         except UserNotFound:
-            return Response(status=status.HTTP_404_NOT_FOUND)
+            return Response(status=status.HTTP_403_FORBIDDEN if request.user.is_staff else status.HTTP_404_NOT_FOUND)
 
         return Response(account_settings)
 
@@ -173,7 +178,9 @@ class AccountView(APIView):
         try:
             with transaction.commit_on_success():
                 update_account_settings(request.user, request.DATA, username=username)
-        except (UserNotFound, UserNotAuthorized):
+        except UserNotAuthorized:
+            return Response(status=status.HTTP_403_FORBIDDEN if request.user.is_staff else status.HTTP_404_NOT_FOUND)
+        except UserNotFound:
             return Response(status=status.HTTP_404_NOT_FOUND)
         except AccountValidationError as err:
             return Response({"field_errors": err.field_errors}, status=status.HTTP_400_BAD_REQUEST)
diff --git a/openedx/core/djangoapps/user_api/preferences/api.py b/openedx/core/djangoapps/user_api/preferences/api.py
index 2c40f8515e7..8c2a3860ac2 100644
--- a/openedx/core/djangoapps/user_api/preferences/api.py
+++ b/openedx/core/djangoapps/user_api/preferences/api.py
@@ -256,7 +256,6 @@ def update_email_opt_in(user, org, opt_in):
         log.warn(u"Could not update organization wide preference due to IntegrityError: {}".format(err.message))
 
 
-
 def _track_update_email_opt_in(user_id, organization, opt_in):
     """Track an email opt-in preference change.
 
diff --git a/openedx/core/djangoapps/user_api/preferences/tests/test_views.py b/openedx/core/djangoapps/user_api/preferences/tests/test_views.py
index d3f4830edc7..8ebbf282e91 100644
--- a/openedx/core/djangoapps/user_api/preferences/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/preferences/tests/test_views.py
@@ -160,7 +160,7 @@ class TestPreferencesAPI(UserAPITestCase):
                 "dict_pref": {"int_key": 10},
                 "string_pref": "value",
             },
-            expected_status=404,
+            expected_status=403 if user == "staff_user" else 404,
         )
 
     def test_update_preferences(self):
@@ -288,7 +288,7 @@ class TestPreferencesAPI(UserAPITestCase):
                 "new_pref": "new_value",
                 "extra_pref": None,
             },
-            expected_status=404
+            expected_status=403 if user == "staff_user" else 404
         )
 
 
@@ -503,7 +503,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
         self._set_url("new_key")
         client = self.login_client(api_client, user)
         new_value = "new value"
-        self.send_put(client, new_value, expected_status=404)
+        self.send_put(client, new_value, expected_status=403 if user == "staff_user" else 404)
 
     @ddt.data(
         (u"new value",),
@@ -531,7 +531,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
         """
         client = self.login_client(api_client, user)
         new_value = "new value"
-        self.send_put(client, new_value, expected_status=404)
+        self.send_put(client, new_value, expected_status=403 if user == "staff_user" else 404)
 
     @ddt.data(
         (None,),
@@ -578,4 +578,4 @@ class TestPreferencesDetailAPI(UserAPITestCase):
         Test that a client (logged in) cannot delete a preference for another user.
         """
         client = self.login_client(api_client, user)
-        self.send_delete(client, expected_status=404)
+        self.send_delete(client, expected_status=403 if user == "staff_user" else 404)
diff --git a/openedx/core/djangoapps/user_api/preferences/views.py b/openedx/core/djangoapps/user_api/preferences/views.py
index 1974ff0ccad..695d811f4d3 100644
--- a/openedx/core/djangoapps/user_api/preferences/views.py
+++ b/openedx/core/djangoapps/user_api/preferences/views.py
@@ -7,13 +7,17 @@ https://openedx.atlassian.net/wiki/display/TNL/User+API
 from rest_framework.views import APIView
 from rest_framework.response import Response
 from rest_framework import status
-from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
 from rest_framework import permissions
 
 from django.db import transaction
 from django.utils.translation import ugettext as _
 
+from openedx.core.lib.api.authentication import (
+    SessionAuthenticationAllowInactiveUser,
+    OAuth2AuthenticationAllowInactiveUser,
+)
 from openedx.core.lib.api.parsers import MergePatchParser
+from openedx.core.lib.api.permissions import IsUserInUrlOrStaff
 from ..errors import UserNotFound, UserNotAuthorized, PreferenceValidationError, PreferenceUpdateError
 from .api import (
     get_user_preference, get_user_preferences, set_user_preference, update_user_preferences, delete_user_preference
@@ -45,7 +49,8 @@ class PreferencesView(APIView):
         **Response for PATCH**
 
             Users can only modify their own preferences. If the requesting user does not have username
-            "username", this method will return with a status of 404.
+            "username", this method will return with a status of 403 for staff access but a 404 for ordinary
+            users to avoid leaking the existence of the account.
 
             This method will also return a 404 if no user exists with username "username".
 
@@ -61,7 +66,7 @@ class PreferencesView(APIView):
 
     """
     authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser)
-    permission_classes = (permissions.IsAuthenticated,)
+    permission_classes = (permissions.IsAuthenticated, IsUserInUrlOrStaff)
     parser_classes = (MergePatchParser,)
 
     def get(self, request, username):
@@ -70,7 +75,9 @@ class PreferencesView(APIView):
         """
         try:
             user_preferences = get_user_preferences(request.user, username=username)
-        except (UserNotFound, UserNotAuthorized):
+        except UserNotAuthorized:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+        except UserNotFound:
             return Response(status=status.HTTP_404_NOT_FOUND)
 
         return Response(user_preferences)
@@ -91,7 +98,9 @@ class PreferencesView(APIView):
         try:
             with transaction.commit_on_success():
                 update_user_preferences(request.user, request.DATA, username=username)
-        except (UserNotFound, UserNotAuthorized):
+        except UserNotAuthorized:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+        except UserNotFound:
             return Response(status=status.HTTP_404_NOT_FOUND)
         except PreferenceValidationError as error:
             return Response(
@@ -136,17 +145,25 @@ class PreferencesDetailView(APIView):
 
             A successful put returns a 204 and no content.
 
-            If the specified username or preference does not exist, this method returns a 404.
+            Users can only update their own preferences. If the requesting user does not have username
+            "username", this method will return with a status of 403 for staff access but a 404 for ordinary
+            users to avoid leaking the existence of the account.
+
+            If the specified preference does not exist, this method returns a 404.
 
         **Response for DELETE**
 
             A successful delete returns a 204 and no content.
 
-            If the specified username or preference does not exist, this method returns a 404.
+            Users can only delete their own preferences. If the requesting user does not have username
+            "username", this method will return with a status of 403 for staff access but a 404 for ordinary
+            users to avoid leaking the existence of the account.
+
+            If the specified preference does not exist, this method returns a 404.
 
     """
     authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser)
-    permission_classes = (permissions.IsAuthenticated,)
+    permission_classes = (permissions.IsAuthenticated, IsUserInUrlOrStaff)
 
     def get(self, request, username, preference_key):
         """
@@ -157,7 +174,9 @@ class PreferencesDetailView(APIView):
             # There was no preference with that key, raise a 404.
             if value is None:
                 return Response(status=status.HTTP_404_NOT_FOUND)
-        except (UserNotFound, UserNotAuthorized):
+        except UserNotAuthorized:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+        except UserNotFound:
             return Response(status=status.HTTP_404_NOT_FOUND)
         return Response(value)
 
@@ -167,7 +186,9 @@ class PreferencesDetailView(APIView):
         """
         try:
             set_user_preference(request.user, preference_key, request.DATA, username=username)
-        except (UserNotFound, UserNotAuthorized):
+        except UserNotAuthorized:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+        except UserNotFound:
             return Response(status=status.HTTP_404_NOT_FOUND)
         except PreferenceValidationError as error:
             return Response(
@@ -193,7 +214,9 @@ class PreferencesDetailView(APIView):
         """
         try:
             preference_existed = delete_user_preference(request.user, preference_key, username=username)
-        except (UserNotFound, UserNotAuthorized):
+        except UserNotAuthorized:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+        except UserNotFound:
             return Response(status=status.HTTP_404_NOT_FOUND)
         except PreferenceUpdateError as error:
             return Response(
diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py
index 464b5c9ed7b..0337b2cbcf0 100644
--- a/openedx/core/djangoapps/user_api/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/tests/test_views.py
@@ -1693,6 +1693,7 @@ class TestGoogleRegistrationView(
     """Tests the User API registration endpoint with Google authentication."""
     pass
 
+
 @ddt.ddt
 class UpdateEmailOptInTestCase(ApiTestCase, ModuleStoreTestCase):
     """Tests the UpdateEmailOptInPreference view. """
diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py
index d1844b119fd..dc9ad7c5c15 100644
--- a/openedx/core/djangoapps/user_api/views.py
+++ b/openedx/core/djangoapps/user_api/views.py
@@ -1,9 +1,6 @@
 """HTTP end-points for the User API. """
 import copy
-from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
 from opaque_keys import InvalidKeyError
-import third_party_auth
-
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.http import HttpResponse
@@ -21,12 +18,14 @@ from rest_framework import viewsets
 from rest_framework.views import APIView
 from rest_framework.exceptions import ParseError
 from django_countries import countries
-from django_comment_common.models import Role
 from opaque_keys.edx.locations import SlashSeparatedCourseKey
-from edxmako.shortcuts import marketing_link
 
+from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
+import third_party_auth
+from django_comment_common.models import Role
+from edxmako.shortcuts import marketing_link
 from student.views import create_account_with_params, set_marketing_cookie
-from util.authentication import SessionAuthenticationAllowInactiveUser
+from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
 from util.json_request import JsonResponse
 from .preferences.api import update_email_opt_in
 from .helpers import FormDescription, shim_student_view, require_post_params
diff --git a/common/djangoapps/util/authentication.py b/openedx/core/lib/api/authentication.py
similarity index 100%
rename from common/djangoapps/util/authentication.py
rename to openedx/core/lib/api/authentication.py
diff --git a/openedx/core/lib/api/permissions.py b/openedx/core/lib/api/permissions.py
index e02b190a5cd..83734fc0646 100644
--- a/openedx/core/lib/api/permissions.py
+++ b/openedx/core/lib/api/permissions.py
@@ -52,9 +52,26 @@ class IsUserInUrl(permissions.BasePermission):
     Permission that checks to see if the request user matches the user in the URL.
     """
     def has_permission(self, request, view):
-        # Return a 404 instead of a 403 (Unauthorized). If one user is looking up
-        # other users, do not let them deduce the existence of an account.
+        """
+        Returns true if the current request is by the user themselves.
+
+        Note: a 404 is returned for non-staff instead of a 403. This is to prevent
+        users from being able to detect the existence of accounts.
+        """
         url_username = request.parser_context.get('kwargs', {}).get('username', '')
         if request.user.username.lower() != url_username.lower():
+            if request.user.is_staff:
+                return False  # staff gets 403
             raise Http404()
         return True
+
+
+class IsUserInUrlOrStaff(IsUserInUrl):
+    """
+    Permission that checks to see if the request user matches the user in the URL or has is_staff access.
+    """
+    def has_permission(self, request, view):
+        if request.user.is_staff:
+            return True
+
+        return super(IsUserInUrlOrStaff, self).has_permission(request, view)
diff --git a/openedx/core/lib/api/tests/__init__.py b/openedx/core/lib/api/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/common/djangoapps/util/tests/test_authentication.py b/openedx/core/lib/api/tests/test_authentication.py
similarity index 100%
rename from common/djangoapps/util/tests/test_authentication.py
rename to openedx/core/lib/api/tests/test_authentication.py
-- 
GitLab