diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py index 16d1bd70254e..979f555d29a6 100644 --- a/openedx/core/djangoapps/user_authn/views/login.py +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -625,6 +625,9 @@ def login_user(request, api_version="v1"): # pylint: disable=too-many-statement if possibly_authenticated_user is None or not ( possibly_authenticated_user.is_active or settings.MARKETING_EMAILS_OPT_IN ): + fbr_error = getattr(request, "_fbr_login_error", None) + if fbr_error: + raise AuthFailedError(fbr_error, error_code="fbr-no-profile") _handle_failed_authentication(user, possibly_authenticated_user) pwned_properties = ( diff --git a/openedx/features/fbr_features/paypeople_auth_backend.py b/openedx/features/fbr_features/paypeople_auth_backend.py index bbea6255c859..3e9f302dd572 100644 --- a/openedx/features/fbr_features/paypeople_auth_backend.py +++ b/openedx/features/fbr_features/paypeople_auth_backend.py @@ -1,89 +1,146 @@ import logging import json +import requests from django.conf import settings from social_core.backends.oauth import BaseOAuth2 log = logging.getLogger(__name__) +class PayPeopleClient: + """ + Reusable PayPeople API client. + First tries with SSO token, refreshes on 401 and retries once. + """ + + def __init__(self, token, company_id, user_id): + self.token = token + self.company_id = company_id + self.user_id = user_id + self.base_url = getattr( + settings, + 'PAYPEOPLE_BASE_URL', + 'https://dev.paypeople.app/ServiceApi/api' + ) + self.session = requests.Session() + self.session.headers.update({"Content-Type": "application/json"}) + + def _auth_headers(self): + return { + "Authorization": f"Bearer {self.token}", + "CompanyID": self.company_id, + "UserID": self.user_id, + } + + def _request(self, method, endpoint, retried=False, **kwargs): + """ + Central request handler. + Step 1 — Try with current SSO token. + Step 2 — On 401, refresh token and retry once. + Step 3 — On second 401, raise exception. + """ + url = f"{self.base_url}/{endpoint}" + kwargs.setdefault("headers", {}).update(self._auth_headers()) + + log.info("PayPeople REQUEST [%s] %s (retried=%s)", method, url, retried) + + response = self.session.request(method, url, **kwargs) + + log.info("PayPeople RESPONSE status: %s", response.status_code) + log.info("PayPeople RESPONSE reason: %s", response.reason) + + if response.status_code == 401: + if retried: + # Already retried — give up + raise Exception("PayPeople token rejected even after refresh.") + + log.warning("PayPeople 401 on first attempt. Refreshing token...") + + if self._refresh_token(): + log.info("Token refreshed. Retrying request...") + return self._request(method, endpoint, retried=True, **kwargs) + else: + raise Exception("PayPeople token refresh failed.") + + response.raise_for_status() + return response.json() + + def _refresh_token(self): + """Refresh access token and update instance credentials.""" + try: + url = f"{self.base_url}/accounts/RefreshToken" + response = self.session.post( + url, + data=json.dumps({"Token": self.token}) + ) + response.raise_for_status() + data = response.json() + + if not data.get("IsSuccess"): + log.error("PayPeople refresh failed: %s", data.get("ErrorMessage")) + return False + + result = data.get("ResultSet", {}) + self.token = result.get("Token") + self.company_id = result.get("CompanyID", self.company_id) + self.user_id = result.get("UserID", self.user_id) + + log.info("PayPeople token refreshed. Expires: %s", result.get("ExpDate")) + return True + + except Exception as e: + log.error("PayPeople token refresh error: %s", e) + return False + + def get_employees(self): + """Fetch all employees for the authenticated company.""" + try: + data = self._request("GET", "employee/GetEmployees") + employees = data.get("ResultSet", []) + log.info("PayPeople fetched %d employees.", len(employees)) + return employees + except Exception as e: + log.error("PayPeople failed to fetch employees: %s", e) + return [] + + class PayPeopleOAuth2(BaseOAuth2): name = 'paypeople-oauth2' AUTHORIZATION_URL = getattr(settings, 'PAYPEOPLE_AUTHORIZATION_URL', None) - ACCESS_TOKEN_URL = getattr(settings, 'PAYPEOPLE_ACCESS_TOKEN_URL', None) - USER_INFO_URL = getattr(settings, 'PAYPEOPLE_USER_INFO_URL', None) + ACCESS_TOKEN_URL = getattr(settings, 'PAYPEOPLE_ACCESS_TOKEN_URL', None) + USER_INFO_URL = getattr(settings, 'PAYPEOPLE_USER_INFO_URL', None) ACCESS_TOKEN_METHOD = 'POST' - DEFAULT_SCOPE = ['openid'] - REDIRECT_STATE = False - STATE_PARAMETER = True + DEFAULT_SCOPE = ['openid'] + REDIRECT_STATE = False + STATE_PARAMETER = True def auth_params(self, state=None, *args, **kwargs): - """Add custom required parameters to authorization request""" params = super().auth_params(state=state, *args, **kwargs) - params['scope'] = 'openid' + params['scope'] = 'openid' params['origin'] = 'paypeople.app' if state: params['state'] = state return params def auth_complete_params(self, state=None): - """ - PayPeople expects ONLY Token in the body. - Docs: POST /RefreshToken with {"Token": ""} - """ code = self.data.get('code') log.info("=== PayPeople Auth Code received: %s ===", code) - return { - 'Token': code - } - - def request_access_token(self, *args, **kwargs): - """ - Override to send JSON body instead of form data - since PayPeople expects a JSON payload. - """ - log.info("=== PayPeople TOKEN REQUEST ===") - log.info("URL: %s", self.ACCESS_TOKEN_URL) - log.info("Payload: %s", kwargs.get('data')) - - # PayPeople expects JSON not form-encoded - response = self.get_json( - self.ACCESS_TOKEN_URL, - method='POST', - json=kwargs.get('data'), # send as JSON body - headers={'Content-Type': 'application/json'} - ) - - log.info("=== PayPeople TOKEN RESPONSE ===") - log.info("Response: %s", response) - - return response + return {'Token': code} def process_error(self, data): - """Handle errors in token response""" if data.get('IsSuccess') is False: error = data.get('ErrorMessage') or data.get('Message') or 'Unknown error' log.error("=== PayPeople error: %s ===", error) raise Exception(f"PayPeople SSO error: {error}") - def get_access_token(self, response): - """ - PayPeople returns 'Token' instead of standard 'access_token'. - Extract it here. - """ - token = response.get('Token') or response.get('AuthToken') - log.info("=== PayPeople extracted access_token: %s ===", token) - return token - def auth_complete(self, *args, **kwargs): - import json - self.process_error(self.data) - # Fix + signs decoded as spaces + # Fix: + signs in code decoded as spaces raw_code = self.data.get('code', '') - code = raw_code.replace(' ', '+') + code = raw_code.replace(' ', '+') log.info("=== PayPeople Fixed code: %s ===", code) @@ -96,54 +153,84 @@ def auth_complete(self, *args, **kwargs): log.info("=== PayPeople Token Response: %s ===", token_response) - # Token is nested inside ResultSet - result_set = token_response.get('ResultSet', {}) + result_set = token_response.get('ResultSet', {}) access_token = result_set.get('Token') - log.info("=== PayPeople Extracted access_token: %s ===", access_token) - if not access_token: log.error("=== PayPeople: No access token in ResultSet: %s ===", result_set) raise Exception("PayPeople: No access token in response") - # Pass result_set as response so get_user_details gets the right fields + log.info("=== PayPeople Extracted access_token: %s ===", access_token) + + # Fetch employees using the token from SSO + self._fetch_and_log_employees( + token = access_token, + company_id = result_set.get('CompanyID', ''), + user_id = result_set.get('UserID', '') + ) + kwargs.update({ - 'response': result_set, + 'response': result_set, 'access_token': access_token }) return self.strategy.authenticate(self, *args, **kwargs) + def _fetch_and_log_employees(self, token, company_id, user_id): + """ + Fetch employees from PayPeople after successful SSO login. + Uses the token obtained during authentication. + """ + log.info("=== PayPeople Fetching employees after SSO login ===") + + try: + client = PayPeopleClient(token, company_id, user_id) + employees = client.get_employees() + + if employees: + log.info("=== PayPeople Employees fetched: %d ===", len(employees)) + for emp in employees: + log.info( + "Employee — ID: %s | Code: %s | Name: %s %s | Email: %s", + emp.get('EmployeeID'), + emp.get('EmployeeCode'), + emp.get('FirstName'), + emp.get('LastName'), + emp.get('EmailAddress'), + ) + else: + log.warning("=== PayPeople No employees returned ===") + + except Exception as e: + # Don't break SSO login if employee fetch fails + log.error("=== PayPeople Employee fetch failed: %s ===", e) + def get_user_details(self, response): log.info("=== PayPeople GET USER DETAILS: %s ===", response) return { - 'username': str(int(response.get('EmployeeID', 0))), # remove .0 from float - 'email': response.get('Email', ''), - 'fullname': response.get('CompanyName', ''), + 'username': str(int(response.get('EmployeeID', 0))), + 'email': response.get('Email', ''), + 'fullname': response.get('CompanyName', ''), 'first_name': response.get('UserID', ''), - 'last_name': '', + 'last_name': '', } def user_data(self, access_token, *args, **kwargs): - """ - Fetch additional user info if needed. - PayPeople may already return user info in token response. - """ log.info("=== PayPeople USER DATA REQUEST, token: %s ===", access_token) - # If token response already had user data, return it from kwargs response = kwargs.get('response', {}) if response.get('EmployeeID') or response.get('UserID'): log.info("=== PayPeople Using user data from token response ===") return response - # Otherwise call userinfo endpoint - url = self.USER_INFO_URL - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json' - } - data = self.get_json(url, headers=headers) + url = self.USER_INFO_URL + data = self.get_json( + url, + headers={ + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + ) log.info("=== PayPeople USER INFO RESPONSE: %s ===", data) return data