Skip to content

Commit

Permalink
Merge pull request #20 from p4kl0nc4t/faizj-add-password-enc
Browse files Browse the repository at this point in the history
feat: Add password encryption
  • Loading branch information
lc-at authored Mar 11, 2023
2 parents a4b7c44 + 4fb15db commit dfbf14a
Show file tree
Hide file tree
Showing 10 changed files with 318 additions and 111 deletions.
2 changes: 2 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SICS_ENABLE_PWD_ENC=0
SICS_PRIVATE_KEY=YOUR_VALID_PRIVATE_KEY_SEE_README_MD
67 changes: 40 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,62 @@
# `simaster.ics`

Simple Python-based web app to generate an iCalendar file from SIMASTER courses
schedule.
Simple Python-based web app to generate an iCalendar file from SIMASTER
courses schedule.

## Quick Usage

1. Open the [demo](https://simaster-ics.onrender.com) or your server URL if
you self-hosted it.
1. Open the [demo](https://simaster-ics.onrender.com) or your server
URL if you self-hosted it.
2. Fill in all the fields
3. Copy the iCalendar URL
4. Subscribe to it using your preferred calendar app (e.g. Google Calendar)
4. Subscribe to it using your preferred calendar app (e.g. Google
Calendar)

## API

Once you have deployed simaster.ics (or, just use the
[demo](https://simaster-ics.onrender.com), you can directly utilize its API to
make an iCalendar file.

- Method: `GET`
- URI: `/ics`
- Parameters:
- `username`: your SIMASTER account username
- `password`: your SIMASTER account password
- `period`: calendar period (e.g. `20212`, `20211`)
- `type`: optional, calendar event type (possible values: `exam`, `class`)
- `reuse_session`: optional, cache and reuse session (possible values:
`0`, `1`)
[demo](https://simaster-ics.onrender.com), you can directly utilize its
API to make an iCalendar file.

- Method: `GET`
- URI: `/ics`
- Parameters:
- `username`: your SIMASTER account username
- `password`: your SIMASTER account password
- `period`: calendar period (e.g. `20212`, `20211`)
- `type`: optional, calendar event type (possible values: `exam`,
`class`)
- `reuse_session`: optional, cache and reuse session (possible
values: `0`, `1`)

## Deployment

Before deploying, make sure that you have already installed the requirements
(e.g. by using `pip install -r requirements.txt`).
Before deploying, make sure that you have already installed the
requirements (e.g. by using `pip install -r requirements.txt`).

- Gunicorn: `gunicorn wsgi:app`
- Flask Development Server: `python wsgi.py`
- Heroku: use the provided (or your own) `Procfile`
- Docker: use the `Dockerfile`
- `docker build . -t simasterics`
- `docker run -p 8000:8000 simasterics`
- Gunicorn: `gunicorn wsgi:app`
- Flask Development Server: `python wsgi.py`
- Heroku: use the provided (or your own) `Procfile`
- Docker: use the `Dockerfile`
- `docker build . -t simasterics`
- `docker run -p 8000:8000 simasterics`

### Enabling Password Encryption

Password encryption is feature that allows user password to be encrypted
(to hide plaintext password from the public calendar URL). To enable
this feature, you have to provide the following environment variables
(dotenv or `.env` file is also supported).

- `SICS_ENABLE_PWD_ENC` set to `1`
- `SICS_PRIVATE_KEY` set to the Base64-encoded private key in PEM
encoding and PKCS\#8 format (you may want to use `generate_key.py`)

## License

Distributed under the MIT License.

## Contribution

Any form of contribution is highly appreciated. Feel free to contribute (or
maybe even [buying me a cofffee](https://github.com/p4kl0nc4t)).
Any form of contribution is highly appreciated. Feel free to contribute
(or maybe even [buying me a cofffee](https://github.com/p4kl0nc4t)).
20 changes: 20 additions & 0 deletions generate_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
Use this script to generate an RSA private key and prints it
to the standard output. The generated private key is in the
PKCS#8 format with no encryption.
"""

from base64 import urlsafe_b64encode

from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
serialized_private_key = private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)

# set the printed value in .env
print(urlsafe_b64encode(serialized_private_key).decode())
5 changes: 5 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ arrow==0.14.7
beautifulsoup4==4.11.1
cachelib==0.9.0
certifi==2021.10.8
cffi==1.15.1
charset-normalizer==2.0.12
click==8.0.4
cryptography==39.0.2
cryptograpy==0.0.0
Flask==2.0.3
gunicorn==20.1.0
html2text==2020.1.16
Expand All @@ -13,7 +16,9 @@ itsdangerous==2.1.0
Jinja2==3.0.3
lxml==4.9.1
MarkupSafe==2.1.0
pycparser==2.21
python-dateutil==2.8.2
python-dotenv==1.0.0
requests==2.27.1
six==1.16.0
soupsieve==2.3.2.post1
Expand Down
19 changes: 18 additions & 1 deletion web/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import os

import dotenv
from flask import Flask

from .cryptutils import read_private_bytes_from_b64, derive_serialized_public_key


def create_app():
dotenv.load_dotenv()
app = Flask(__name__)

private_key_b64 = os.getenv("SICS_PRIVATE_KEY")
if os.getenv("SICS_ENABLE_PWD_ENC") == "1" and private_key_b64 is not None:
private_key = read_private_bytes_from_b64(private_key_b64)
app.config["ENABLE_PWD_ENC"] = True
app.config["PRIVATE_KEY"] = private_key
app.config["PUBLIC_KEY_PEM"] = derive_serialized_public_key(private_key)
else:
app.config["ENABLE_PWD_ENC"] = False
app.config["PRIVATE_KEY"] = None
app.config["PUBLIC_KEY_PEM"] = None

from . import views

app.register_blueprint(views.bp)

return app

48 changes: 48 additions & 0 deletions web/cryptutils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""
This file contains the high-level cryptographic utilities needed by
simaster.ics. The functions defined here are used internally by the views to
decrypt user-supplied encrypted password or to generate a cache key for reusing
session.
"""

import base64
import hashlib

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import types, padding


def read_private_bytes_from_b64(private_bytes: str) -> types.PRIVATE_KEY_TYPES:
"""Load private key in PEM format from a Base64-encoded (urlsafe) string"""
decoded_pem = base64.urlsafe_b64decode(private_bytes)
private_key = serialization.load_pem_private_key(decoded_pem, password=None)
return private_key


def derive_serialized_public_key(private_key: types.PRIVATE_KEY_TYPES) -> str:
"""Generates a serialized public key"""
public_key = private_key.public_key()
serialized = public_key.public_bytes(
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
)
return serialized.decode()


def decrypt_b64_password(
private_key: types.PRIVATE_KEY_TYPES, b64_password: str
) -> str:
"""Decrypts a Base64-encoded (urlsafe) encrypted password (from JSEncrypt,
with PKCS1v15 padding)"""
encrypted_password = base64.urlsafe_b64decode(b64_password)
plaintext_password = private_key.decrypt(
encrypted_password,
padding.PKCS1v15(),
)
return plaintext_password


def get_cache_key(username: str, password: str) -> str:
"""Generate a cache key for safer session caching"""
data = f"{username}{password}"
password_hash = hashlib.sha256(data.encode()).hexdigest()
return password_hash
27 changes: 18 additions & 9 deletions web/evdata_processor.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""This file contains the components required to process events data"""

from textwrap import dedent

import arrow
Expand All @@ -13,11 +15,12 @@
class NamedCalendar(Calendar):
def __init__(self, calendar_name: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.extra.append(ContentLine(
name="X-WR-CALNAME", value=calendar_name))
self.extra.append(ContentLine(name="X-WR-CALNAME", value=calendar_name))


def process_class_evdata(events: list, calendar_name: str) -> str:
"""Process events data for classes, returns concretized iCalendar string"""

def preprocess_time(s: str):
return arrow.get(s, "YYYY-M-D HH:mm:ss", tzinfo=TIMEZONE)

Expand All @@ -35,6 +38,8 @@ def preprocess_time(s: str):


def process_exam_evdata(exam_tables: list, calendar_name: str) -> str:
"""Process events data for classes, returns concretized iCalendar string"""

def preprocess_str(s: str):
if not isinstance(s, str) and not s:
return None
Expand All @@ -52,20 +57,24 @@ def preprocess_str(s: str):

if not row[5] or not row[6]:
continue
time_span = row[6].split('-')
time_span = row[6].split("-")
dt_format = "D MMMM YYYY HH:mm"
e.begin = arrow.get(f"{row[5]} {time_span[0]}",
dt_format, locale=LOCALE, tzinfo=TIMEZONE)
e.end = arrow.get(f"{row[5]} {time_span[1]}",
dt_format, locale=LOCALE, tzinfo=TIMEZONE)
e.begin = arrow.get(
f"{row[5]} {time_span[0]}", dt_format, locale=LOCALE, tzinfo=TIMEZONE
)
e.end = arrow.get(
f"{row[5]} {time_span[1]}", dt_format, locale=LOCALE, tzinfo=TIMEZONE
)

e.name = f"[{exam_type}] {row[2]} ({row[4]})"
e.description = dedent(f"""\
e.description = dedent(
f"""\
Kode: {row[1]}
SKS: {row[3]}
Ruangan: {row[7]}
No. kursi: {row[8]}
""")
"""
)
calendar.events.add(e)

return str(calendar)
35 changes: 22 additions & 13 deletions web/simaster.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
import hashlib
"""This file contains the functions required to interact with SIMASTER. The
interactions include logging in and collecting calendar events data."""

import re
import random
import string

import cachelib
import requests
from lxml.html.soupparser import fromstring

from .cryptutils import get_cache_key

BASE_URL = "https://simaster.ugm.ac.id"
HOME_URL = f"{BASE_URL}/beranda"
LOGIN_URL = f"{BASE_URL}/services/simaster/service_login"
HEADERS = {"UGMFWSERVICE": "1", "User-Agent": "SimasterICS/1.0.0"}

cache = cachelib.SimpleCache()

def get_cache_key(username, password):
password_hash = hashlib.sha256(password.encode()).hexdigest()
return f'{username}:{password_hash}'

def get_simaster_session(username, password, reuse_session=False):
def get_simaster_session(
username: str, password: str, reuse_session: bool = False
) -> requests.Session:
"""Log in to SIMASTER, then return a `Session` object if success. Returns
`None` if failed."""

# get session from cache, then return it if valid
key = get_cache_key(username, password)
ses = cache.get(key)
if ses and reuse_session:
req = ses.get(HOME_URL)
if 'simasterUGM_token' in req.text:
if "simasterUGM_token" in req.text:
return ses

# create new session
Expand All @@ -46,30 +50,35 @@ def get_simaster_session(username, password, reuse_session=False):
return ses


def get_class_evdata(ses, period):
evdata = ses.get(f"{BASE_URL}/akademik/mhs_jadwal_kuliah/content_harian",
params={"sesi": period}).json()["events"]
def get_class_evdata(ses: requests.Session, period: str) -> list:
"""Collect events data for classes"""
evdata = ses.get(
f"{BASE_URL}/akademik/mhs_jadwal_kuliah/content_harian", params={"sesi": period}
).json()["events"]
return evdata


def get_exam_evdata(ses, period):
def get_exam_evdata(ses: requests.Session, period: str) -> list:
"""Collect events data for exams"""
if len(period) != 5 or not period.isdigit():
raise ValueError("invalid period")

# build a corresponding period key
odd = period[4] == "1"
year = int(period[:4])

period_key = "Gasal" if odd else "Genap"
period_key += f" {year}/{year + 1}"

resp = ses.get(f"{BASE_URL}/akademik/mhs_jadwal_ujian/view")

# find and send request to the corresponding sesid in the response
cal_sesid = re.findall(r"value='(.+?)'.*?" + period_key, resp.text)
if not cal_sesid:
return []
cal_sesid = cal_sesid[0]
resp = ses.get(f"{BASE_URL}/akademik/mhs_jadwal_ujian/content/{cal_sesid}")

# build evdata by parsing response
evdata = []
tree = fromstring(resp.text)

Expand Down
Loading

0 comments on commit dfbf14a

Please sign in to comment.