handlers.py 8.2 KB
Newer Older
1 2
""" Handlers for OpenID Connect provider. """

3
from django.conf import settings
4
from django.core.cache import cache
5
from xmodule.modulestore.django import modulestore
6

7
from courseware.access import has_access
8
from openedx.core.djangoapps.user_api.models import UserPreference
9
from student.models import anonymous_id_for_user
10
from student.models import UserProfile
11
from lang_pref import LANGUAGE_KEY
12
from student.roles import GlobalStaff, CourseStaffRole, CourseInstructorRole
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36


class OpenIDHandler(object):
    """ Basic OpenID Connect scope handler. """

    def scope_openid(self, _data):
        """ Only override the sub (subject) claim. """
        return ['sub']

    def claim_sub(self, data):
        """
        Return the value of the sub (subject) claim. The value should be
        unique for each user.

        """

        # Use the anonymous ID without any course as unique identifier.
        # Note that this ID is derived using the value of the `SECRET_KEY`
        # setting, this means that users will have different sub
        # values for different deployments.
        value = anonymous_id_for_user(data['user'], None)
        return value


37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
class PermissionsHandler(object):
    """ Permissions scope handler """

    def scope_permissions(self, _data):
        return ['administrator']

    def claim_administrator(self, data):
        """
        Return boolean indicating user's administrator status.

        For our purposes an administrator is any user with is_staff set to True.
        """
        return data['user'].is_staff


52 53 54 55
class ProfileHandler(object):
    """ Basic OpenID Connect `profile` scope handler with `locale` claim. """

    def scope_profile(self, _data):
56 57 58 59 60 61 62 63
        """ Add specialized claims. """
        return ['name', 'locale']

    def claim_name(self, data):
        """ User displayable full name. """
        user = data['user']
        profile = UserProfile.objects.get(user=user)
        return profile.name
64 65 66 67 68 69 70

    def claim_locale(self, data):
        """
        Return the locale for the users based on their preferences.
        Does not return a value if the users have not set their locale preferences.
        """

71 72
        # Calling UserPreference directly because it is not clear which user made the request.
        language = UserPreference.get_value(data['user'], LANGUAGE_KEY)
73 74 75 76 77

        # If the user has no language specified, return the default one.
        if not language:
            language = getattr(settings, 'LANGUAGE_CODE')

78 79 80 81 82 83 84 85 86
        return language


class CourseAccessHandler(object):
    """
    Defines two new scopes: `course_instructor` and `course_staff`. Each one is
    valid only if the user is instructor or staff of at least one course.

    Each new scope has a corresponding claim: `instructor_courses` and
87
    `staff_courses` that lists the course_ids for which the user has instructor
88 89
    or staff privileges.

90 91 92 93 94
    The claims support claim request values: if there is no claim request, the
    value of the claim is the list all the courses for which the user has the
    corresponding privileges. If a claim request is used, then the value of the
    claim the list of courses from the requested values that have the
    corresponding privileges.
95 96

    For example, if the user is staff of course_a and course_b but not
97
    course_c, the claim corresponding to the scope request:
98 99 100

        scope = openid course_staff

101
    has the value:
102 103 104

        {staff_courses: [course_a, course_b] }

105
    For the claim request:
106

107
        claims = {userinfo: {staff_courses: {values=[course_b, course_d]}}}
108

109
    the corresponding claim will have the value:
110 111 112

        {staff_courses: [course_b] }.

113 114
    This is useful to quickly determine if a user has the right privileges for a
    given course.
115 116 117 118 119 120 121

    For a description of the function naming and arguments, see:

        `oauth2_provider/oidc/handlers.py`

    """

122 123 124 125 126
    COURSE_CACHE_TIMEOUT = getattr(settings, 'OIDC_COURSE_HANDLER_CACHE_TIMEOUT', 60)  # In seconds.

    def __init__(self, *_args, **_kwargs):
        self._course_cache = {}

127 128 129 130 131 132 133
    def scope_course_instructor(self, data):
        """
        Scope `course_instructor` valid only if the user is an instructor
        of at least one course.

        """

134 135 136
        # TODO: unfortunately there is not a faster and still correct way to
        # check if a user is instructor of at least one course other than
        # checking the access type against all known courses.
137
        course_ids = self.find_courses(data['user'], CourseInstructorRole.ROLE)
138 139 140 141 142 143 144 145
        return ['instructor_courses'] if course_ids else None

    def scope_course_staff(self, data):
        """
        Scope `course_staff` valid only if the user is an instructor of at
        least one course.

        """
146
        # TODO: see :method:CourseAccessHandler.scope_course_instructor
147
        course_ids = self.find_courses(data['user'], CourseStaffRole.ROLE)
148 149 150 151 152 153 154 155 156

        return ['staff_courses'] if course_ids else None

    def claim_instructor_courses(self, data):
        """
        Claim `instructor_courses` with list of course_ids for which the
        user has instructor privileges.

        """
157

158
        return self.find_courses(data['user'], CourseInstructorRole.ROLE, data.get('values'))
159 160 161 162 163 164 165 166

    def claim_staff_courses(self, data):
        """
        Claim `staff_courses` with list of course_ids for which the user
        has staff privileges.

        """

167
        return self.find_courses(data['user'], CourseStaffRole.ROLE, data.get('values'))
168 169

    def find_courses(self, user, access_type, values=None):
170
        """
171 172
        Find all courses for which the user has the specified access type. If
        `values` is specified, check only the courses from `values`.
173

174
        """
175

176 177 178
        # Check the instance cache and update if not present.  The instance
        # cache is useful since there are multiple scope and claims calls in the
        # same request.
179

180 181 182 183 184 185
        key = (user.id, access_type)
        if key in self._course_cache:
            course_ids = self._course_cache[key]
        else:
            course_ids = self._get_courses_with_access_type(user, access_type)
            self._course_cache[key] = course_ids
186

187 188 189
        # If values was specified, filter out other courses.
        if values is not None:
            course_ids = list(set(course_ids) & set(values))
190

191
        return course_ids
192

193 194 195 196
    # pylint: disable=missing-docstring
    def _get_courses_with_access_type(self, user, access_type):
        # Check the application cache and update if not present. The application
        # cache is useful since there are calls to different endpoints in close
197
        # succession, for example the id_token and user_info endpoints.
198

199 200
        key = '-'.join([str(self.__class__), str(user.id), access_type])
        course_ids = cache.get(key)
201

202 203 204 205 206 207 208 209 210
        if not course_ids:
            courses = _get_all_courses()

            # Global staff have access to all courses. Filter courses for non-global staff.
            if not GlobalStaff().has_user(user):
                courses = [course for course in courses if has_access(user, access_type, course)]

            course_ids = [unicode(course.id) for course in courses]

211
            cache.set(key, course_ids, self.COURSE_CACHE_TIMEOUT)
212

213
        return course_ids
214

215

216
class IDTokenHandler(OpenIDHandler, ProfileHandler, CourseAccessHandler, PermissionsHandler):
217
    """ Configure the ID Token handler for the LMS. """
218

219
    def claim_instructor_courses(self, data):
220 221 222 223 224
        # Don't return list of courses unless they are requested as essential.
        if data.get('essential'):
            return super(IDTokenHandler, self).claim_instructor_courses(data)
        else:
            return None
225 226

    def claim_staff_courses(self, data):
227 228 229 230 231
        # Don't return list of courses unless they are requested as essential.
        if data.get('essential'):
            return super(IDTokenHandler, self).claim_staff_courses(data)
        else:
            return None
232 233


234
class UserInfoHandler(OpenIDHandler, ProfileHandler, CourseAccessHandler, PermissionsHandler):
235 236
    """ Configure the UserInfo handler for the LMS. """
    pass
237 238 239


def _get_all_courses():
240
    """ Utility function to list all available courses. """
241

242
    ms_courses = modulestore().get_courses()
243
    courses = [course for course in ms_courses if course.scope_ids.block_type == 'course']
244

245
    return courses