Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions openedx/core/djangoapps/user_authn/views/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
231 changes: 159 additions & 72 deletions openedx/features/fbr_features/paypeople_auth_backend.py
Original file line number Diff line number Diff line change
@@ -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": "<auth_code>"}
"""
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)

Expand All @@ -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

Expand Down
Loading