diff --git a/README.md b/README.md index 2adcbea..dc91ec9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A simple API to authenticate PESU credentials using PESU Academy The API is secure and protects user privacy by not storing any user credentials. It only validates credentials and -returns the user's profile information. +returns the user's profile information. No personal data is stored or logged. ### PESUAuth LIVE Deployment @@ -58,16 +58,19 @@ your system. # How to use pesu-auth -You can send a request to the `/authenticate` endpoint with the user's credentials and the API will return a JSON object, +You can send a request to the `/authenticate` endpoint with the user's credentials and the API will return a JSON +object, with the user's profile information if requested. ### Request Parameters -| **Parameter** | **Optional** | **Type** | **Default** | **Description** | -|---------------|--------------|-----------|-------------|--------------------------------------| -| `username` | No | `str` | | The user's SRN or PRN | -| `password` | No | `str` | | The user's password | -| `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | +| **Parameter** | **Optional** | **Type** | **Default** | **Description** | +|-------------------------------|--------------|-------------|-------------|-------------------------------------------------------------------------------------------------| +| `username` | No | `str` | | The user's SRN or PRN | +| `password` | No | `str` | | The user's password | +| `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | +| `know_your_class_and_section` | Yes | `boolean` | `False` | Whether to fetch Know Your Class and Section information | +| `fields` | Yes | `list[str]` | `None` | Which fields to fetch from the profile information. If not provided, all fields will be fetched | ### Response Object @@ -100,6 +103,7 @@ profile data was requested, the response's `profile` key will store a dictionary | `phone` | Phone number of the user registered with PESU | | `campus_code` | The integer code of the campus (1 for RR and 2 for EC) | | `campus` | Abbreviation of the user's campus name | +| `error` | The error name and stack trace, if an error occurs | #### KnowYourClassAndSectionObject @@ -114,8 +118,13 @@ profile data was requested, the response's `profile` key will store a dictionary | `department` | Abbreviation of the branch along with the campus of the user | | `branch` | Abbreviation of the branch that the user is pursuing | | `institute_name` | The name of the campus that the user is studying in | +| `error` | The error name and stack trace, if an error occurs | -
Here is an example using Python +## Integrating your application with pesu-auth + +Here are some examples of how you can integrate your application with the PESUAuth API using Python and cURL. + +### Python #### Request @@ -125,8 +134,9 @@ import requests data = { 'username': 'your SRN or PRN here', 'password': 'your password here', - 'profile': True # Optional, defaults to False - # Set to True if you want to retrieve the user's profile information + 'profile': True, # Optional, defaults to False + 'know_your_class_and_section': True, # Optional, defaults to False + 'fields': None, # Optional, defaults to None to represent all fields } response = requests.post("http://localhost:5000/authenticate", json=data) @@ -168,5 +178,25 @@ print(response.json()) } ``` -
+### cURL + +#### Request + +```bash +curl -X POST http://localhost:5000/authenticate \ +-H "Content-Type: application/json" \ +-d '{ + "username": "your SRN or PRN here", + "password": "your password here" +}' +``` + +#### Response +```json +{ + "status": true, + "message": "Login successful.", + "timestamp": "2024-07-28 22:30:10.103368+05:30" +} +``` \ No newline at end of file diff --git a/app/app.py b/app/app.py index 153908d..1fd2f0c 100644 --- a/app/app.py +++ b/app/app.py @@ -35,6 +35,39 @@ def convert_readme_to_html(): f.write(html) +def validate_input( + username: str, + password: str, + profile: bool, + know_your_class_and_section: bool, + fields: list[str], +): + """ + Validate the input provided by the user. + :param username: str: The username of the user. + :param password: str: The password of the user. + :param profile: bool: Whether to fetch the profile details of the user. + :param know_your_class_and_section: bool: Whether to fetch the class and section details of the user. + :param fields: dict: The fields to fetch from the user's profile. + """ + assert username is not None, "Username not provided." + assert isinstance(username, str), "Username should be a string." + assert password is not None, "Password not provided." + assert isinstance(password, str), "Password should be a string." + assert isinstance(profile, bool), "Profile should be a boolean." + assert isinstance( + know_your_class_and_section, bool + ), "know_your_class_and_section should be a boolean." + assert fields is None or ( + isinstance(fields, list) and fields + ), "Fields should be a non-empty list or None." + if fields is not None: + for field in fields: + assert ( + isinstance(field, str) and field in pesu_academy.DEFAULT_FIELDS + ), f"Invalid field: '{field}'. Valid fields are: {pesu_academy.DEFAULT_FIELDS}." + + @app.route("/") def index(): """ @@ -57,24 +90,45 @@ def authenticate(): """ Authenticate the user with the provided username and password. """ + # Extract the input provided by the user + current_time = datetime.datetime.now(IST) username = request.json.get("username") password = request.json.get("password") profile = request.json.get("profile", False) - current_time = datetime.datetime.now(IST) + know_your_class_and_section = request.json.get("know_your_class_and_section", False) + fields = request.json.get("fields") + + # Validate the input provided by the user + try: + validate_input(username, password, profile, know_your_class_and_section, fields) + except Exception as e: + stacktrace = traceback.format_exc() + logging.error(f"Could not validate request data: {e}: {stacktrace}") + return ( + json.dumps( + { + "status": False, + "message": f"Could not validate request data: {e}", + "timestamp": str(current_time), + } + ), + 400, + ) - # try to log in only if both username and password are provided - if username and password: - username = username.strip() - password = password.strip() - authentication_result = pesu_academy.authenticate(username, password, profile) + # Authenticate the user + try: + authentication_result = pesu_academy.authenticate( + username, password, profile, know_your_class_and_section, fields + ) authentication_result["timestamp"] = str(current_time) return json.dumps(authentication_result), 200 - - # if either username or password is not provided, we return an error - return ( - json.dumps({"status": False, "message": "Username or password not provided."}), - 400, - ) + except Exception as e: + stacktrace = traceback.format_exc() + logging.error(f"Error authenticating user: {e}: {stacktrace}") + return ( + json.dumps({"status": False, "message": f"Error authenticating user: {e}"}), + 500, + ) if __name__ == "__main__": diff --git a/app/pesu.py b/app/pesu.py index e14266a..878a26f 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -13,6 +13,26 @@ class PESUAcademy: Class to interact with the PESU Academy website. """ + # Default fields to fetch from the profile and know your class and section data + DEFAULT_FIELDS = [ + "name", + "prn", + "srn", + "program", + "branch_short_code", + "branch", + "semester", + "section", + "email", + "phone", + "campus_code", + "campus", + "class", + "cycle", + "department", + "institute_name", + ] + @staticmethod def map_branch_to_short_code(branch: str) -> Optional[str]: """ @@ -31,7 +51,7 @@ def map_branch_to_short_code(branch: str) -> Optional[str]: return branch_short_code_map.get(branch) def get_profile_information( - self, session: requests_html.HTMLSession, username: Optional[str] = None + self, session: requests_html.HTMLSession, username: Optional[str] = None ) -> dict[str, Any]: """ Get the profile information of the user. @@ -40,6 +60,7 @@ def get_profile_information( :return: The profile information """ try: + # Fetch the profile data from the student profile page profile_url = ( "https://www.pesuacademy.com/Academy/s/studentProfilePESUAdmin" ) @@ -53,17 +74,17 @@ def get_profile_information( "_": str(int(datetime.now().timestamp() * 1000)), } response = session.get(profile_url, allow_redirects=False, params=query) + # If the status code is not 200, raise an exception because the profile page is not accessible if response.status_code != 200: - raise Exception("Unable to fetch profile data.") + raise Exception( + "Unable to fetch profile data. Profile page not accessible." + ) + # Parse the response text soup = BeautifulSoup(response.text, "lxml") except Exception as e: logging.error(f"Unable to fetch profile data: {traceback.format_exc()}") - return { - "status": False, - "message": "Unable to fetch profile data.", - "error": str(e), - } + return {"error": f"Unable to fetch profile data: {traceback.format_exc()}"} profile = dict() for element in soup.find_all("div", attrs={"class": "form-group"})[:7]: @@ -84,7 +105,7 @@ def get_profile_information( "section", ]: if key == "branch" and ( - branch_short_code := self.map_branch_to_short_code(value) + branch_short_code := self.map_branch_to_short_code(value) ): profile["branch_short_code"] = branch_short_code key = "prn" if key == "pesu_id" else key @@ -102,13 +123,13 @@ def get_profile_information( profile["campus_code"] = int(campus_code) profile["campus"] = "RR" if campus_code == "1" else "EC" - return {"status": True, "profile": profile, "message": "Login successful."} + return profile @staticmethod def get_know_your_class_and_section( - username: str, - session: Optional[requests_html.HTMLSession] = None, - csrf_token: Optional[str] = None, + username: str, + session: Optional[requests_html.HTMLSession] = None, + csrf_token: Optional[str] = None, ) -> dict[str, Any]: """ Get the class and section information from the public Know Your Class and Section page. @@ -119,15 +140,18 @@ def get_know_your_class_and_section( """ if not session: + # If a session is not provided, create a new session session = requests_html.HTMLSession() if not csrf_token: + # If csrf token is not provided, fetch the default csrf token assigned to the user session home_url = "https://www.pesuacademy.com/Academy/" response = session.get(home_url) soup = BeautifulSoup(response.text, "lxml") csrf_token = soup.find("meta", attrs={"name": "csrf-token"})["content"] try: + # Make a post request to get the class and section information response = session.post( "https://www.pesuacademy.com/Academy/getStudentClassInfo", headers={ @@ -149,10 +173,13 @@ def get_know_your_class_and_section( data={"loginId": username}, ) except Exception: + # Log the error and return an empty dictionary logging.error( f"Unable to get profile from Know Your Class and Section: {traceback.format_exc()}" ) - return {} + return { + "error": f"Unable to get profile from Know Your Class and Section: {traceback.format_exc()}" + } soup = BeautifulSoup(response.text, "html.parser") profile = dict() @@ -162,27 +189,42 @@ def get_know_your_class_and_section( value = td.text.strip() profile[key] = value + # Return the class and section information return profile def authenticate( - self, username: str, password: str, profile: bool = False + self, + username: str, + password: str, + profile: bool = False, + know_your_class_and_section: bool = False, + fields: Optional[list[str]] = None, ) -> dict[str, Any]: """ Authenticate the user with the provided username and password. :param username: Username of the user, usually their PRN/email/phone number :param password: Password of the user :param profile: Whether to fetch the profile information or not + :param know_your_class_and_section : Whether to fetch the class and section information or not + :param fields: The fields to fetch from the profile and know your class and section data. Defaults to all fields if not provided. :return: The authentication result """ + # Create a new session session = requests_html.HTMLSession() + # Default fields to fetch if fields is not provided + fields = self.DEFAULT_FIELDS if fields is None else fields + # check if fields is not the default fields and enable field filtering + field_filtering = fields != self.DEFAULT_FIELDS try: - # Get the initial csrf token + # Get the initial csrf token assigned to the user session when the home page is loaded home_url = "https://www.pesuacademy.com/Academy/" response = session.get(home_url) soup = BeautifulSoup(response.text, "lxml") + # extract the csrf token from the meta tag csrf_token = soup.find("meta", attrs={"name": "csrf-token"})["content"] except Exception as e: + # Log the error and return the error message logging.error(f"Unable to fetch csrf token: {traceback.format_exc()}") session.close() return { @@ -199,10 +241,12 @@ def authenticate( } try: + # Make a post request to authenticate the user auth_url = "https://www.pesuacademy.com/Academy/j_spring_security_check" response = session.post(auth_url, data=data) soup = BeautifulSoup(response.text, "lxml") except Exception as e: + # Log the error and return the error message logging.error(f"Unable to authenticate: {traceback.format_exc()}") session.close() return { @@ -211,8 +255,9 @@ def authenticate( "error": str(e), } - # if class login-form is present, login failed + # If class login-form is present, login failed if soup.find("div", attrs={"class": "login-form"}): + # Log the error and return the error message logging.error("Login unsuccessful") session.close() return { @@ -220,17 +265,37 @@ def authenticate( "message": "Invalid username or password, or the user does not exist.", } + # If the user is successfully authenticated logging.info("Login successful") status = True + # Get the newly authenticated csrf token csrf_token = soup.find("meta", attrs={"name": "csrf-token"})["content"] + result = {"status": status, "message": "Login successful."} if profile: - result = self.get_profile_information(session, username) - know_your_class_and_section_data = self.get_know_your_class_and_section( - username, session, csrf_token + # Fetch the profile information + result["profile"] = self.get_profile_information(session, username) + # Filter the fields if field filtering is enabled + if field_filtering: + result["profile"] = { + key: value + for key, value in result["profile"].items() + if key in fields + } + + if know_your_class_and_section: + # Fetch the class and section information + result["know_your_class_and_section"] = ( + self.get_know_your_class_and_section(username, session, csrf_token) ) - result["know_your_class_and_section"] = know_your_class_and_section_data - return result - else: - session.close() - return {"status": status, "message": "Login successful."} + # Filter the fields if field filtering is enabled + if field_filtering: + result["know_your_class_and_section"] = { + key: value + for key, value in result["know_your_class_and_section"].items() + if key in fields + } + + # Close the session and return the result + session.close() + return result diff --git a/requirements.txt b/requirements.txt index 42480c3..b3b6971 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,46 +1,46 @@ appdirs==1.4.4 beautifulsoup4==4.12.3 -black==24.4.2 -blinker==1.8.2 +black==24.10.0 +blinker==1.9.0 bs4==0.0.2 -certifi==2024.7.4 -charset-normalizer==3.3.2 +certifi==2024.12.14 +charset-normalizer==3.4.0 click==8.1.7 cssselect==1.2.0 -emoji==2.12.1 -fake-useragent==1.5.1 -Flask==3.0.3 +emoji==2.14.0 +fake-useragent==2.0.3 +Flask==3.1.0 gh-md-to-html==1.21.3 -idna==3.7 -importlib_metadata==8.2.0 +idna==3.10 +importlib_metadata==8.5.0 itsdangerous==2.2.0 Jinja2==3.1.4 -lxml==5.2.2 -lxml_html_clean==0.1.1 -MarkupSafe==2.1.5 +lxml==5.3.0 +lxml_html_clean==0.4.1 +MarkupSafe==3.0.2 mypy-extensions==1.0.0 -packaging==24.1 +packaging==24.2 parse==1.20.2 pathspec==0.12.1 -pillow==10.4.0 -pip==24.0 -platformdirs==4.2.2 -pyee==11.1.0 +pillow==11.0.0 +pip==24.2 +platformdirs==4.3.6 +pyee==11.1.1 pyppeteer==2.0.0 -pyquery==2.0.0 -pytz==2024.1 +pyquery==2.0.1 +pytz==2024.2 requests==2.32.3 requests-html==0.10.0 -setuptools==69.5.1 +setuptools==75.1.0 shellescape==3.8.1 -soupsieve==2.5 -tomli==2.0.1 -tqdm==4.66.4 +soupsieve==2.6 +tomli==2.2.1 +tqdm==4.67.1 typing_extensions==4.12.2 -urllib3==1.26.19 +urllib3==1.26.20 w3lib==2.2.1 -webcolors==24.6.0 +webcolors==24.11.1 websockets==10.4 -Werkzeug==3.0.3 -wheel==0.43.0 -zipp==3.19.2 +Werkzeug==3.1.3 +wheel==0.44.0 +zipp==3.21.0