diff --git a/docs/docs_settings.py b/docs/docs_settings.py
index f12848876e8a..f791b2faafb9 100644
--- a/docs/docs_settings.py
+++ b/docs/docs_settings.py
@@ -4,7 +4,7 @@
import all the Studio code.
"""
-
+from textwrap import dedent
import os
from openedx.core.lib.derived import derive_settings
@@ -27,18 +27,71 @@
FEATURES[key] = True
# Settings that will fail if we enable them, and we don't need them for docs anyway.
-FEATURES['RUN_AS_ANALYTICS_SERVER_ENABLED'] = False
-FEATURES['ENABLE_SOFTWARE_SECURE_FAKE'] = False
-FEATURES['ENABLE_MKTG_SITE'] = False
+FEATURES["RUN_AS_ANALYTICS_SERVER_ENABLED"] = False
+FEATURES["ENABLE_SOFTWARE_SECURE_FAKE"] = False
+FEATURES["ENABLE_MKTG_SITE"] = False
+
+INSTALLED_APPS.extend(
+ [
+ "cms.djangoapps.contentstore.apps.ContentstoreConfig",
+ "cms.djangoapps.course_creators",
+ "cms.djangoapps.xblock_config.apps.XBlockConfig",
+ "lms.djangoapps.lti_provider",
+ ]
+)
+
+# Swagger generation details
+openapi_security_info_basic = (
+ "Obtain with a `POST` request to `/user/v1/account/login_session/`. "
+ "If needed, copy the cookies from the response to your new call."
+)
+openapi_security_info_jwt = dedent(
+ """
+ Obtain by making a `POST` request to `/oauth2/v1/access_token`.
+
+ You will need to be logged in and have a client ID and secret already created.
-INSTALLED_APPS.extend([
- 'cms.djangoapps.contentstore.apps.ContentstoreConfig',
- 'cms.djangoapps.course_creators',
- 'cms.djangoapps.xblock_config.apps.XBlockConfig',
- 'lms.djangoapps.lti_provider',
-])
+ Your request should have the headers
+
+ ```
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ ```
+
+ Your request should have the data payload
+
+ ```
+ 'grant_type': 'client_credentials'
+ 'client_id': [your client ID]
+ 'client_secret': [your client secret]
+ 'token_type': 'jwt'
+ ```
+
+ Your JWT will be returned in the response as `access_token`. Prefix with `JWT ` in your header.
+ """
+)
+openapi_security_info_csrf = (
+ "Obtain by making a `GET` request to `/csrf/api/v1/token`. The token will be in the response cookie `csrftoken`."
+)
+SWAGGER_SETTINGS["SECURITY_DEFINITIONS"] = {
+ "Basic": {
+ "type": "basic",
+ "description": openapi_security_info_basic,
+ },
+ "jwt": {
+ "type": "apiKey",
+ "name": "Authorization",
+ "in": "header",
+ "description": openapi_security_info_jwt,
+ },
+ "csrf": {
+ "type": "apiKey",
+ "name": "X-CSRFToken",
+ "in": "header",
+ "description": openapi_security_info_csrf,
+ },
+}
-COMMON_TEST_DATA_ROOT = ''
+COMMON_TEST_DATA_ROOT = ""
derive_settings(__name__)
diff --git a/docs/lms-openapi.yaml b/docs/lms-openapi.yaml
index 5e9afcc6d370..8011f84b4573 100644
--- a/docs/lms-openapi.yaml
+++ b/docs/lms-openapi.yaml
@@ -13,8 +13,44 @@ produces:
securityDefinitions:
Basic:
type: basic
+ description: Obtain with a `POST` request to `/user/v1/account/login_session/`. If
+ needed, copy the cookies from the response to your new call.
+ jwt:
+ type: apiKey
+ name: Authorization
+ in: header
+ description: |2
+
+ Obtain by making a `POST` request to `/oauth2/v1/access_token`.
+
+ You will need to be logged in and have a client ID and secret already created.
+
+ Your request should have the headers
+
+ ```
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ ```
+
+ Your request should have the data payload
+
+ ```
+ 'grant_type': 'client_credentials'
+ 'client_id': [your client ID]
+ 'client_secret': [your client secret]
+ 'token_type': 'jwt'
+ ```
+
+ Your JWT will be returned in the response as `access_token`. Prefix with `JWT ` in your header.
+ csrf:
+ type: apiKey
+ name: X-CSRFToken
+ in: header
+ description: Obtain by making a `GET` request to `/csrf/api/v1/token`. The token
+ will be in the response cookie `csrftoken`.
security:
- Basic: []
+- csrf: []
+- jwt: []
paths:
/agreements/v1/integrity_signature/{course_id}:
get:
@@ -3975,6 +4011,7 @@ paths:
"profile_name": "Jon Doe"
"verification_attempt_id": (Optional)
"proctored_exam_attempt_id": (Optional)
+ "platform_verification_attempt_id": (Optional)
"status": (Optional)
}
parameters:
@@ -4130,6 +4167,7 @@ paths:
"profile_name": "Jon Doe"
"verification_attempt_id": (Optional)
"proctored_exam_attempt_id": (Optional)
+ "platform_verification_attempt_id": (Optional)
"status": (Optional)
}
parameters:
@@ -6788,6 +6826,59 @@ paths:
in: path
required: true
type: string
+ /mobile/{api_version}/notifications/create-token/:
+ post:
+ operationId: mobile_notifications_create-token_create
+ summary: |-
+ **Use Case**
+ This endpoint allows clients to register a device for push notifications.
+ description: |-
+ If the device is already registered, the existing registration will be updated.
+ If setting PUSH_NOTIFICATIONS_SETTINGS is not configured, the endpoint will return a 501 error.
+
+ **Example Request**
+ POST /api/mobile/{version}/notifications/create-token/
+ **POST Parameters**
+ The body of the POST request can include the following parameters.
+ * name (optional) - A name of the device.
+ * registration_id (required) - The device token of the device.
+ * device_id (optional) - ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)
+ * active (optional) - Whether the device is active, default is True.
+ If False, the device will not receive notifications.
+ * cloud_message_type (required) - You should choose FCM or GCM. Currently, only FCM is supported.
+ * application_id (optional) - Opaque application identity, should be filled in for multiple
+ key/certificate access. Should be equal settings.FCM_APP_NAME.
+ **Example Response**
+ ```json
+ {
+ "id": 1,
+ "name": "My Device",
+ "registration_id": "fj3j4",
+ "device_id": 1234,
+ "active": true,
+ "date_created": "2024-04-18T07:39:37.132787Z",
+ "cloud_message_type": "FCM",
+ "application_id": "my_app_id"
+ }
+ ```
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ $ref: '#/definitions/GCMDevice'
+ responses:
+ '201':
+ description: ''
+ schema:
+ $ref: '#/definitions/GCMDevice'
+ tags:
+ - mobile
+ parameters:
+ - name: api_version
+ in: path
+ required: true
+ type: string
/mobile/{api_version}/users/{username}:
get:
operationId: mobile_users_read
@@ -8849,22 +8940,6 @@ paths:
tags:
- user
parameters: []
- /user/v1/accounts/verifications/{attempt_id}/:
- get:
- operationId: user_v1_accounts_verifications_read
- description: Get IDV attempt details by attempt_id. Only accessible by global
- staff.
- parameters: []
- responses:
- '200':
- description: ''
- tags:
- - user
- parameters:
- - name: attempt_id
- in: path
- required: true
- type: string
/user/v1/accounts/{username}:
get:
operationId: user_v1_accounts_read
@@ -9423,22 +9498,57 @@ paths:
- user
post:
operationId: user_account_login_session_create
- summary: Log in a user.
- description: |-
- See `login_user` for details.
-
- Example Usage:
-
- POST /api/user/v1/login_session
- with POST params `email`, `password`.
-
- 200 {'success': true}
- parameters: []
+ summary: POST /user/{api_version}/account/login_session/
+ description: Returns 200 on success, and a detailed error message otherwise.
+ parameters:
+ - name: data
+ in: body
+ required: true
+ schema:
+ type: object
+ properties:
+ email:
+ type: string
+ password:
+ type: string
responses:
- '201':
+ '200':
+ description: ''
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ value:
+ type: string
+ error_code:
+ type: string
+ '400':
+ description: ''
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ value:
+ type: string
+ error_code:
+ type: string
+ '403':
description: ''
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ value:
+ type: string
+ error_code:
+ type: string
tags:
- user
+ security:
+ - csrf: []
parameters:
- name: api_version
in: path
@@ -10047,6 +10157,7 @@ definitions:
required:
- celebrations
- course_access
+ - studio_access
- course_id
- is_enrolled
- is_self_paced
@@ -10084,6 +10195,9 @@ definitions:
additionalProperties:
type: string
x-nullable: true
+ studio_access:
+ title: Studio access
+ type: boolean
course_id:
title: Course id
type: string
@@ -11237,10 +11351,24 @@ definitions:
title: Verification attempt id
type: integer
x-nullable: true
+ verification_attempt_status:
+ title: Verification attempt status
+ type: string
+ minLength: 1
+ x-nullable: true
proctored_exam_attempt_id:
title: Proctored exam attempt id
type: integer
x-nullable: true
+ platform_verification_attempt_id:
+ title: Platform verification attempt id
+ type: integer
+ x-nullable: true
+ platform_verification_attempt_status:
+ title: Platform verification attempt status
+ type: string
+ minLength: 1
+ x-nullable: true
status:
title: Status
type: string
@@ -11277,10 +11405,24 @@ definitions:
title: Verification attempt id
type: integer
x-nullable: true
+ verification_attempt_status:
+ title: Verification attempt status
+ type: string
+ minLength: 1
+ x-nullable: true
proctored_exam_attempt_id:
title: Proctored exam attempt id
type: integer
x-nullable: true
+ platform_verification_attempt_id:
+ title: Platform verification attempt id
+ type: integer
+ x-nullable: true
+ platform_verification_attempt_status:
+ title: Platform verification attempt status
+ type: string
+ minLength: 1
+ x-nullable: true
status:
title: Status
type: string
@@ -11710,6 +11852,52 @@ definitions:
title: Enddatetime
type: string
format: date-time
+ GCMDevice:
+ required:
+ - registration_id
+ type: object
+ properties:
+ id:
+ title: ID
+ type: integer
+ name:
+ title: Name
+ type: string
+ maxLength: 255
+ x-nullable: true
+ registration_id:
+ title: Registration ID
+ type: string
+ minLength: 1
+ device_id:
+ title: Device id
+ description: 'ANDROID_ID / TelephonyManager.getDeviceId() (e.g: 0x01)'
+ type: integer
+ x-nullable: true
+ active:
+ title: Is active
+ description: Inactive devices will not be sent notifications
+ type: boolean
+ date_created:
+ title: Creation date
+ type: string
+ format: date-time
+ readOnly: true
+ x-nullable: true
+ cloud_message_type:
+ title: Cloud Message Type
+ description: You should choose FCM, GCM is deprecated
+ type: string
+ enum:
+ - FCM
+ - GCM
+ application_id:
+ title: Application ID
+ description: Opaque application identity, should be filled in for multiple
+ key/certificate access
+ type: string
+ maxLength: 64
+ x-nullable: true
mobile_api.User:
required:
- username
diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py
index 2ad3c0ff18a0..6c7390406be1 100644
--- a/openedx/core/djangoapps/user_authn/views/login.py
+++ b/openedx/core/djangoapps/user_authn/views/login.py
@@ -23,11 +23,14 @@
from django.views.decorators.debug import sensitive_post_parameters
from django.views.decorators.http import require_http_methods
from django_ratelimit.decorators import ratelimit
+from drf_yasg import openapi
+from drf_yasg.utils import swagger_auto_schema
from edx_django_utils.monitoring import set_custom_attribute
from eventtracking import tracker
from openedx_events.learning.data import UserData, UserPersonalData
from openedx_events.learning.signals import SESSION_LOGIN_COMPLETED
from openedx_filters.learning.filters import StudentLoginRequested
+from rest_framework import status
from rest_framework.views import APIView
from common.djangoapps import third_party_auth
@@ -49,7 +52,7 @@
from openedx.core.djangoapps.user_authn.tasks import check_pwned_password_and_send_track_event
from openedx.core.djangoapps.user_authn.toggles import (
is_require_third_party_auth_enabled,
- should_redirect_to_authn_microfrontend
+ should_redirect_to_authn_microfrontend,
)
from openedx.core.djangoapps.user_authn.views.login_form import get_login_session_form
from openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user
@@ -62,7 +65,7 @@
log = logging.getLogger("edx.student")
AUDIT_LOG = logging.getLogger("audit")
USER_MODEL = get_user_model()
-PASSWORD_RESET_INITIATED = 'edx.user.passwordreset.initiated'
+PASSWORD_RESET_INITIATED = "edx.user.passwordreset.initiated"
def _do_third_party_auth(request):
@@ -70,9 +73,9 @@ def _do_third_party_auth(request):
User is already authenticated via 3rd party, now try to find and return their associated Django user.
"""
running_pipeline = pipeline.get(request)
- username = running_pipeline['kwargs'].get('username')
- backend_name = running_pipeline['backend']
- third_party_uid = running_pipeline['kwargs']['uid']
+ username = running_pipeline["kwargs"].get("username")
+ backend_name = running_pipeline["backend"]
+ third_party_uid = running_pipeline["kwargs"]["uid"]
requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
@@ -81,26 +84,25 @@ def _do_third_party_auth(request):
except USER_MODEL.DoesNotExist:
AUDIT_LOG.info(
"Login failed - user with username {username} has no social auth "
- "with backend_name {backend_name}".format(
- username=username, backend_name=backend_name)
+ "with backend_name {backend_name}".format(username=username, backend_name=backend_name)
)
- message = Text(_(
- "You've successfully signed in to your {provider_name} account, "
- "but this account isn't linked with your {platform_name} account yet. {blank_lines}"
- "Use your {platform_name} username and password to sign in to {platform_name} below, "
- "and then link your {platform_name} account with {provider_name} from your dashboard. {blank_lines}"
- "If you don't have an account on {platform_name} yet, "
- "click {register_label_strong} at the top of the page."
- )).format(
- blank_lines=HTML('
'),
+ message = Text(
+ _(
+ "You've successfully signed in to your {provider_name} account, "
+ "but this account isn't linked with your {platform_name} account yet. {blank_lines}"
+ "Use your {platform_name} username and password to sign in to {platform_name} below, "
+ "and then link your {platform_name} account with {provider_name} from your dashboard. {blank_lines}"
+ "If you don't have an account on {platform_name} yet, "
+ "click {register_label_strong} at the top of the page."
+ )
+ ).format(
+ blank_lines=HTML("
"),
platform_name=platform_name,
provider_name=requested_provider.name,
- register_label_strong=HTML('{register_text}').format(
- register_text=_('Register')
- )
+ register_label_strong=HTML("{register_text}").format(register_text=_("Register")),
)
- raise AuthFailedError(message, error_code='third-party-auth-with-no-linked-account') # lint-amnesty, pylint: disable=raise-missing-from
+ raise AuthFailedError(message, error_code="third-party-auth-with-no-linked-account") # lint-amnesty, pylint: disable=raise-missing-from
def _get_user_by_email(email):
@@ -128,14 +130,14 @@ def _get_user_by_email_or_username(request, api_version):
Finds a user object in the database based on the given request, ignores all fields except for email and username.
"""
is_api_v2 = api_version != API_V1
- login_fields = ['email', 'password']
+ login_fields = ["email", "password"]
if is_api_v2:
- login_fields = ['email_or_username', 'password']
+ login_fields = ["email_or_username", "password"]
if any(f not in request.POST.keys() for f in login_fields):
- raise AuthFailedError(_('There was an error receiving your login information. Please email us.'))
+ raise AuthFailedError(_("There was an error receiving your login information. Please email us."))
- email_or_username = request.POST.get('email', None) or request.POST.get('email_or_username', None)
+ email_or_username = request.POST.get("email", None) or request.POST.get("email_or_username", None)
user = _get_user_by_email(email_or_username)
if not user and is_api_v2:
@@ -143,7 +145,7 @@ def _get_user_by_email_or_username(request, api_version):
user = _get_user_by_username(email_or_username)
if not user:
- digest = hashlib.shake_128(email_or_username.encode('utf-8')).hexdigest(16)
+ digest = hashlib.shake_128(email_or_username.encode("utf-8")).hexdigest(16)
AUDIT_LOG.warning(f"Login failed - Unknown user email or username {digest}")
return user
@@ -165,27 +167,30 @@ def _generate_locked_out_error_message():
"""
locked_out_period_in_sec = settings.MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS
- error_message = Text(_('To protect your account, it’s been temporarily '
- 'locked. Try again in {locked_out_period} minutes.'
- '{li_start}To be on the safe side, you can reset your '
- 'password {link_start}here{link_end} before you try again.')).format(
- link_start=HTML(''),
- link_end=HTML(''),
- li_start=HTML('