Skip to content

Commit

Permalink
Merge branch 'refactor-to-module-layout' into staging
Browse files Browse the repository at this point in the history
  • Loading branch information
campb303 committed Jun 21, 2021
2 parents 072397e + ef7104d commit 3762076
Show file tree
Hide file tree
Showing 19 changed files with 1,767 additions and 5 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ pip install -U pip
# Install the webqueue2-api package in editable mode with all extra packages.
pip install -e .[all]
```
4. Make changes and create a PR.
4. Make changes and create a PR.
10 changes: 6 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
"docs": [
"mkdocs",
"mkdocs-material",
"mkdocs-awesome-pages-plugin",
"mkdocs-macros-plugin"
"mkautodoc",
"mkdocs-awesome-pages-plugin"
],
}

Expand All @@ -21,11 +21,12 @@ def get_all_dependencies():
return dependencies

setup(
name="webqueue2_api",
name="webqueue2api",
version="0.9.1",
description="A library for managing Purdue ECN's queue system.",
python_requires='>=3.6',
packages=find_packages(),
packages=find_packages(where="src"),
package_dir={"": "src"},
install_requires = [
"gunicorn",
"python-dotenv",
Expand All @@ -37,6 +38,7 @@ def get_all_dependencies():
# Custom version of python-ldap without SASL requirements
"python-ldap @ git+https://github.itap.purdue.edu/ECN/python-ldap/@python-ldap-3.3.1",
"easyad",
"dataclasses"
],
extras_require={
"dev": conditional_dependencies["dev"],
Expand Down
2 changes: 2 additions & 0 deletions src/webqueue2api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from webqueue2api.parser import Item, Queue, load_queues
from .config import config
5 changes: 5 additions & 0 deletions src/webqueue2api/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# WSGI App
from .app import app

# Configuration
from .config import config
37 changes: 37 additions & 0 deletions src/webqueue2api/api/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from flask import Flask
from flask_restful import Api
from flask_jwt_extended import JWTManager
from .config import config
from .resources import Login, RefreshAccessToken, Item, Queue, QueueList

app = Flask(__name__)
api = Api(app)

# Set JWT secret key and create JWT manager
app.config["JWT_SECRET_KEY"] = config.jwt_secret_key
# The JWT RFC uses the "sub" key for identity claims. However,
# Flask-JWT-Extended uses "identity" by default for compatibility reasons so
# we ovverride the default claim key to comply with the RFC
app.config["JWT_IDENTITY_CLAIM"] = "sub"
# Set the key for error messages generated by Flask-JWT-Extended
app.config["JWT_ERROR_MESSAGE_KEY"] = "message"

# Look for JWTs in headers (for access) then cookies (for refresh)
app.config["JWT_TOKEN_LOCATION"] = ["headers", "cookies"]
# Restrict cookies to HTTPS in prod, allow HTTP in dev
app.config["JWT_COOKIE_SECURE"] = False if config.jwt_secret_key == "dev" else True
# Restrict cookies using SameSite=strict flag
app.config["JWT_COOKIE_SAMESITE"] = "strict"
# Restrict refresh tokens to /token/refresh endpoint
app.config["JWT_REFRESH_COOKIE_PATH"] = '/tokens/refresh'
# Set the cookie key for CRSF validation string
# This is the default value. Adding it for easy reference
app.config["JWT_REFRESH_CSRF_HEADER_NAME"] = "X-CSRF-TOKEN"

tokenManager = JWTManager(app)

api.add_resource(Login, "/api/login")
api.add_resource(RefreshAccessToken, "/api/tokens/refresh")
api.add_resource(Item, "/api/data/<string:queue>/<int:number>")
api.add_resource(Queue, "/api/data/<string:queues>")
api.add_resource(QueueList, "/api/data/get_queues")
55 changes: 55 additions & 0 deletions src/webqueue2api/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from easyad import EasyAD
from ldap.filter import escape_filter_chars
# pylint says this is an error but it works so ¯\_(ツ)_/¯
from ldap import INVALID_CREDENTIALS as LDAP_INVALID_CREDENTIALS



def user_is_valid(username: str, password: str) -> bool:
"""Checks if user is valid and in webqueue2 login group.
Args:
username (str): Career account username.
password (str): Career account passphrase.
Returns:
bool: True if user is valid, otherwise False.
"""

# Check for empty arguments
if (username == "" or password == ""):
return False

# Initialize EasyAD
config = {
"AD_SERVER": "boilerad.purdue.edu",
"AD_DOMAIN": "boilerad.purdue.edu"
}
ad = EasyAD(config)

# Prepare search critiera for Active Directory
credentials = {
"username": escape_filter_chars(username),
"password": password
}
attributes = [ 'cn', "memberOf" ]
filter_string = f'(&(objectClass=user)(|(sAMAccountName={username})))'

# Do user search
try:
user = ad.search(credentials=credentials, attributes=attributes, filter_string=filter_string)[0]
except LDAP_INVALID_CREDENTIALS:
return False

# Isolate group names
# Example:
# 'CN=00000227-ECNStuds,OU=BoilerADGroups,DC=BoilerAD,DC=Purdue,DC=edu' becomes
# `00000227-ECNStuds`
user_groups = [ group.split(',')[0].split('=')[1] for group in user["memberOf"] ]

# Check group membership
webqueue_login_group = "00000227-ECN-webqueue"
if webqueue_login_group not in user_groups:
return False

return True
39 changes: 39 additions & 0 deletions src/webqueue2api/api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Stores API configuartion data."""
from dataclasses import dataclass
import random, string



def generate_random_string(length=16) -> str:
"""Generate random string of letters and numbers of specified length.
Example:
generate_random_string() -> "aud04ki947rrje3k9"
Args:
length (int, optional): Number of characters to generate. Defaults to 16.
Returns:
str: Random string.
"""
possible_characters = string.ascii_letters + string.digits + "!@#$%^&*"
random_string = ''
for number in range(length):
random_string += random.choice(possible_characters)
return random_string

@dataclass
class Configuraton:
"""Stores API configuration.
Args:
jwt_secret_key (str): The key used to confirm JWT validity.
environment (str): The type of environment to run in. "prod" or "dev"
"""
jwt_secret_key: str
environment: str

config = Configuraton(
jwt_secret_key = generate_random_string(),
environment = "prod"
)
5 changes: 5 additions & 0 deletions src/webqueue2api/api/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .login import Login
from .refresh_access_token import RefreshAccessToken
from .item import Item
from .queue import Queue
from .queue_list import QueueList
48 changes: 48 additions & 0 deletions src/webqueue2api/api/resources/item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from flask import request
from flask_restful import Resource
from flask_jwt_extended import jwt_required
# To avoid naming conflicts
from webqueue2api.parser import Item as _Item
from webqueue2api.parser.errors import ItemDoesNotExistError

class Item(Resource):
@jwt_required
def get(self, queue: str, number: int) -> tuple:
"""Returns the JSON representation of the item requested.
Return Codes:
200 (OK): On success.
404 (Not Found): When an Item does not exist.
Example:
/api/ce/100 returns:
{
"lastUpdated": "07-23-20 10:11 PM",
"headers": [...],
"content": [...],
"isLocked": "ce 100 is locked by knewell using qvi",
"userEmail": "campb303@purdue.edu",
"userName": "Justin Campbell",
"userAlias": "campb303",
"assignedTo": "campb303",
"subject": "Beepboop",
"status": "Dont Delete",
"priority": "",
"deparment": "",
"building": "",
"dateReceived": "Tue, 23 Jun 2020 13:25:51 -0400"
}
Args:
queue (str): The queue of the item requested.
item (int): The number of the item requested.
Returns:
tuple: Item as JSON and HTTP response code.
"""
headers_only = True if request.args.get("headers_only") == "True" else False

try:
return (_Item(queue, number, headers_only=headers_only).to_json(), 200)
except ItemDoesNotExistError:
return ({"message": f"Item {queue}{number} not found."}, 404)
51 changes: 51 additions & 0 deletions src/webqueue2api/api/resources/login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from flask import request, after_this_request
from flask_restful import Resource
from flask_jwt_extended import create_access_token, create_refresh_token, set_refresh_cookies
from ..auth import user_is_valid



class Login(Resource):
def post(self) -> tuple:
"""Validates username/password, sets refresh token cookie and returns access token.
Return Codes:
200 (OK): On success.
401 (Unauthroized): When username or password are incorrect.
422 (Unprocessable Entitiy): When the username or password can't be parsed.
Example:
curl -X POST
-H "Content-Type: application/json"
-d '{"username": "bob", "password": "super_secret"}'
{ "access_token": fjr09hfp09h932jp9ruj3.3r8ihf8h0w8hr08ifhj804h8i.8h48ith08ity409hip0t4 }
Returns:
tuple: Response containing tokens and HTTP response code.
"""
if not request.is_json:
return ({ "message": "JSON missing from request body"}, 422)

data = request.json

fields_to_check = ["username", "password"]
for field in fields_to_check:
if field not in data.keys():
return ({ "message": f"{field} missing from request body"}, 422)

if not user_is_valid(data["username"], data["password"]):
return ({ "message": "Username or password is invalid"}, 401)

access_token = create_access_token(data["username"])
refresh_token = create_refresh_token(data["username"])

# This decorator is needed because Flask-RESTful's 'resourceful routing`
# doesn't allow for direct modification to the Flask response object.
# See: https://flask-restful.readthedocs.io/en/latest/quickstart.html#resourceful-routing
@after_this_request
def set_refresh_cookie_callback(response):
set_refresh_cookies(response, refresh_token)
return response

return ({ "access_token": access_token }, 200)
33 changes: 33 additions & 0 deletions src/webqueue2api/api/resources/queue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from flask import request
from flask_restful import Resource
from flask_jwt_extended import jwt_required
# To avoid naming conflicts
from webqueue2api.parser import Queue as _Queue
from webqueue2api.parser.errors import QueueDoesNotExistError



class Queue(Resource):
@jwt_required
def get(self, queues: str) -> tuple:
"""Returns the JSON representation of the queue requested.
Return Codes:
200 (OK): On success.
404 (Not Found): When a Queue does not exist.
Args:
queues (str): Plus (+) deliminited list of queues.
Returns:
tuple: Queues as JSON and HTTP response code.
"""
queues_requested = queues.split("+")
headers_only = False if request.args.get("headers_only") == "False" else True

try:
queue_list = []
for queue in queues_requested:
queue_list.append(_Queue(queue, headers_only=headers_only).to_json())
return (queue_list, 200)
except QueueDoesNotExistError:
return ({"message": f"Queue {queue} not found."}, 404)
29 changes: 29 additions & 0 deletions src/webqueue2api/api/resources/queue_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from flask_restful import Resource
from flask_jwt_extended import jwt_required
from webqueue2api.parser import get_queue_counts


class QueueList(Resource):
@jwt_required
def get(self) -> tuple:
"""Returns a list of dictionaries with the number of items in each queue.
Return Codes:
200 (OK): On success.
Example:
[
{
name: "me",
number_of_items: 42
},
{
name: "bidc",
number_of_items: 3
}
]
Returns:
tuple: Queues and item counts as JSON and HTTP response code.
"""
return (get_queue_counts(), 200)
9 changes: 9 additions & 0 deletions src/webqueue2api/api/resources/refresh_access_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from flask_restful import Resource
from flask_jwt_extended import jwt_refresh_token_required, get_jwt_identity, create_access_token

class RefreshAccessToken(Resource):
@jwt_refresh_token_required
def post(self):
username = get_jwt_identity()
access_token = create_access_token(username)
return ({"access_token": access_token}, 200)
13 changes: 13 additions & 0 deletions src/webqueue2api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from dataclasses import dataclass
import webqueue2api.parser.config
import webqueue2api.api.config

@dataclass
class Configuration:
parser: dataclass
api: dataclass

config = Configuration(
parser = webqueue2api.parser.config,
api = webqueue2api.api.config
)
Loading

0 comments on commit 3762076

Please sign in to comment.