From 34d27bddad3b663221cd573dd4c5b52cc3d8a399 Mon Sep 17 00:00:00 2001 From: krashanoff Date: Mon, 31 Jan 2022 13:24:54 -0800 Subject: [PATCH 01/13] Mock endpoints for backend. --- backend/main.py | 57 +++++++++++++++++++++++++++---- backend/schemas/3-assignments.sql | 2 +- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8136fe4..f58f147 100755 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,6 @@ app = Flask(__name__) - # Retrieve the global database connection object. # Pulled from https://flask.palletsprojects.com/en/2.0.x/appcontext/ def get_db() -> pg.Connection: @@ -28,6 +27,12 @@ def teardown_db(exception): conn.close() +# Log an user into the database, then return a valid JWT for their session. +@app.route("/login", methods=["POST"]) +def login(): + return {"token": "example"} + + # Create a user in the database, then return a valid JWT for their session. @app.route("/user", methods=["POST"]) def create(): @@ -68,18 +73,56 @@ def create(): return {}, 201 -# Lever Flask's automatic JSON response functionality: -# https://flask.palletsprojects.com/en/2.0.x/quickstart/#apis-with-json -@app.route("/login", methods=["POST"]) -def login(): - return {"token": "example"} +@app.route("//info", methods=["GET"]) +def view_class(class_id): + """ + Get all relevant information about a class, including its assignments, member list + (if allowed), and owner. + """ + return { + "name": "mycoolclass", + "owner": {"user": "thing"}, + "assignments": [], + "members": [{"name": "Svetly"}, {"name": "Preetha"}, {"name": "Leo"}], + }, 200 + + +@app.route("//", methods=["GET"]) +def get_assignment(class_id, assignment_id): + """ + Get information about an assignment for a specific user. + """ + return { + "name": "Cool assignment one", + "dueDate": "1647205512354", + "submissions": [{"date": "1643663222161", "pointsEarned": 100.0}], + }, 200 @app.route("/class", methods=["POST"]) def create_class(): + """ + Create a class in the database. + """ body = request.json print(body) - return {}, 200 + return {"id": "new_class_id"}, 200 + + +@app.route("//invite", methods=["POST"]) +def create_invite(class_id): + """ + Create an invite code for the class with ID `class_id`. + """ + return {"inviteCode": "my-new-invite-code"} + + +@app.route("//join", methods=["POST"]) +def join_class(class_id): + """ + Join the currently logged-in user to the class with ID `class-id`. + """ + return {}, 204 if __name__ == "__main__": diff --git a/backend/schemas/3-assignments.sql b/backend/schemas/3-assignments.sql index 952926a..d6c8840 100644 --- a/backend/schemas/3-assignments.sql +++ b/backend/schemas/3-assignments.sql @@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS Assignments ( due_date TIMESTAMPTZ NOT NULL, -- Points possible for the assignment. - points NUMERIC(11, 8), + points DOUBLE PRECISION, PRIMARY KEY (id), FOREIGN KEY (class) REFERENCES Classes (id) From ea6f67f156edf3a9fc1cc7f0cef40a2cc4f2abf0 Mon Sep 17 00:00:00 2001 From: krashanoff Date: Mon, 31 Jan 2022 12:46:22 -0800 Subject: [PATCH 02/13] Begin implementing basic JWT authorization. --- backend/README.md | 6 +++++- backend/__init__.py | 0 backend/auth.py | 22 ++++++++++++++++++++++ backend/main.py | 1 - 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 backend/__init__.py create mode 100644 backend/auth.py diff --git a/backend/README.md b/backend/README.md index 54928f0..3996f13 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,7 +2,11 @@ The backend for our grading solution supports operations on user-sensitive data. -## Get Started +## Get Started (as a user) + +Configure the server through `config.env`. + +## Get Started (as a dev) You will need * Docker diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..b858e27 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,22 @@ +from flask import request +from flask_jwt import JWT +from functools import wraps +import jwt + +def token_required(f): + @wraps(f) + def decorator(*args, **kwargs): + token = None + if 'x-access-tokens' in request.headers: + token = request.headers['x-access-tokens'] + + if not token: + return {'message': 'a valid token is missing'}, 400 + try: + data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + current_user = Users.query.filter_by(public_id=data['public_id']).first() + except: + return {'message': 'token is invalid'}, 400 + + return f(current_user, *args, **kwargs) + return decorator \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index f58f147..170d967 100755 --- a/backend/main.py +++ b/backend/main.py @@ -36,7 +36,6 @@ def login(): # Create a user in the database, then return a valid JWT for their session. @app.route("/user", methods=["POST"]) def create(): - return {"token": "example"} form = request.form account_type, username, password = form["type"], form["username"], form["password"] invite_key = None if account_type == "student" else form["inviteKey"] From 6d76efb5bfe69735e15d1a867994ab6c3742c149 Mon Sep 17 00:00:00 2001 From: krashanoff Date: Mon, 31 Jan 2022 14:11:20 -0800 Subject: [PATCH 03/13] Fix user creation queries. --- backend/main.py | 46 +++++++++++++++++++++++++++++++++--------- frontend/src/Login.tsx | 8 ++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/backend/main.py b/backend/main.py index 170d967..edb16c2 100755 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,12 @@ #!/bin/python3 from argparse import ArgumentParser +from dataclasses import dataclass +from datetime import datetime from flask import Flask, g, request import psycopg as pg +from psycopg.rows import class_row from argparse import ArgumentParser @@ -11,6 +14,14 @@ app = Flask(__name__) +@dataclass +class Account: + id: int + username: str + password: str + professor: bool + deleted: datetime + # Retrieve the global database connection object. # Pulled from https://flask.palletsprojects.com/en/2.0.x/appcontext/ def get_db() -> pg.Connection: @@ -48,23 +59,23 @@ def create(): # course. conn = get_db() with conn.cursor() as cur: + cur.row_factory = class_row(Account) cur.execute( - "INSERT INTO Accounts (username, password, professor) VALUES (%s, %s, %s)", - username, - password, - account_type == "professor", + "INSERT INTO Accounts (username, password, professor) VALUES (%s, %s, %s) RETURNING *", + [username, password, account_type == "professor"], ) + row = cur.fetchone() + print(row) # If the account is for a student, then join them to their class. if account_type == "student": cur.execute( """ - INSERT INTO ClassMembers (id, class_id) VALUES (id, class_id) \ - WHERE id = (SELECT id FROM Accounts WHERE username = %s) AND \ + INSERT INTO ClassMembers (id, class_id) VALUES (id, class_id) + HAVING id = (SELECT id FROM Accounts WHERE username = %s) AND class_id = (SELECT invites_to FROM Invites WHERE id = %s) """, - username, - invite_key, + [username, invite_key], ) conn.commit() @@ -86,6 +97,22 @@ def view_class(class_id): }, 200 +@app.route("//invite", methods=["POST"]) +def create_invite(class_id): + """ + Create an invite code for the class with ID `class_id`. + """ + return {"inviteCode": "my-new-invite-code"} + + +@app.route("//join", methods=["POST"]) +def join_class(class_id): + """ + Join the currently logged-in user to the class with ID `class_id`. + """ + return {}, 204 + + @app.route("//", methods=["GET"]) def get_assignment(class_id, assignment_id): """ @@ -136,9 +163,10 @@ def join_class(class_id): parser.add_argument( "--db-conn", type=str, - default="port=5432 user=dev password=dev", + default="host=localhost port=5432 dbname=gradebetter user=admin password=admin", help="connection string for a postgresql database", ) args = parser.parse_args() + CONN_STR = args.db_conn app.run(port=args.port) diff --git a/frontend/src/Login.tsx b/frontend/src/Login.tsx index 904cd92..ea18ef0 100644 --- a/frontend/src/Login.tsx +++ b/frontend/src/Login.tsx @@ -12,6 +12,14 @@ const Login = () => { const submit: React.FormEventHandler = (e: React.FormEvent) => { e.preventDefault(); + fetch('localhost:8080/class', { + method: 'post', + body: JSON.stringify({}), + }) + .then(r => r.json()) + .then(r => console.info(r)) + .catch(console.error); + setError(err => err ? "" : `Username is invalid.`); nav('/class'); }; From 73a24f5becea2940a2f0a302d048b171eb19a161 Mon Sep 17 00:00:00 2001 From: Svetoslav Todorov Date: Mon, 31 Jan 2022 15:24:46 -0800 Subject: [PATCH 04/13] Added mock endpoints for uploading grading script and submissions. --- backend/main.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/main.py b/backend/main.py index edb16c2..d2ea315 100755 --- a/backend/main.py +++ b/backend/main.py @@ -125,6 +125,22 @@ def get_assignment(class_id, assignment_id): }, 200 +@app.route("///script", methods=["GET", "POST"]) +def upload_grading_script(class_id, assignment_id): + """ + Upload the grading script for a specific assignment. + """ + return {}, 204 + + +@app.route("///upload", methods=["GET", "POST"]) +def upload_submission(class_id, assignment_id): + """ + Upload the a submission for a specific assignment. + """ + return {}, 204 + + @app.route("/class", methods=["POST"]) def create_class(): """ From e4e545bfb166dd62ff255d101d981581ecf2cf1e Mon Sep 17 00:00:00 2001 From: krashanoff Date: Tue, 1 Feb 2022 14:47:58 -0800 Subject: [PATCH 05/13] Add backend documentation. * Move class-related endpoints to /class. * Add docs to README.md. Co-authored-by: pkrish20 Co-authored-by: svetly-t --- backend/README.md | 207 +++++++++++++++++++++++++++++++++++++++++++++- backend/main.py | 39 +++------ 2 files changed, 217 insertions(+), 29 deletions(-) diff --git a/backend/README.md b/backend/README.md index 3996f13..d8a2771 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,11 +2,212 @@ The backend for our grading solution supports operations on user-sensitive data. -## Get Started (as a user) +## Exposed Endpoints -Configure the server through `config.env`. +### Conventions -## Get Started (as a dev) +Responses are formatted with JSON. Variable names passed in the request are documented +using `snake_case`, but responses will provide variable names that **are actually** in +`camelCase`. + +All endpoints except for `POST /login` and `POST /user` expect an `Authorization` header +in the HTTP request bearing a JWT returned by either of these endpoints. + +Any `GET` request does not require a body, only an `Authorization` header. + +### `POST /user` + +Login as an user, returning a JWT for future requests. Please please **please** only +send this on TLS. + +#### Request Body + +```json +{ + "username": "Smallberg", + "password": "MYSECRETPASSWORD DONT TELL ANYONE LOL", + "professor": "true", +} +``` + +#### Response Format + +```json +{ + "token": "SomeLongStringOfBase64" +} +``` + +Status Code | Semantic +:-|:- +201 | CREATED +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `POST /login` + +Login as an user, returning a JWT for future requests. Please please **please** only +send this on TLS. + +#### Request Body + +```json +{ + "username": "Smallberg", + "password": "MYSECRETPASSWORD DONT TELL ANYONE LOL" +} +``` + +#### Response Format + +```json +{ + "token": "SomeLongStringOfBase64" +} +``` + +Status Code | Semantic +:-|:- +200 | OK +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `POST /class` + +Creates a class in the database. + +#### Request Body + +```json +{ + "name": "My cool class" +} +``` + +#### Response Format + +```json +{ + "id": "new_class_id" +} +``` + +Status Code | Semantic +:-|:- +201 | CREATED +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `GET /class//` +Get the unique assignment ID for a given user + +### Response Format + +```json +{ + "name": "Cool assigment one", + "dueDate": "1647205512354", + "submissions": [{"date": "1643663222161", "pointsEarned": 100.0}] +} +``` +Status Code | Semantic +:-|:- +200 | OK +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `GET /class//info` + +Get all relevant information about a class, including its assignments, member list (if +allowed), and owner. + +#### Response Format + +```json +{ + "name": "My cool class", + "ownerName": "Prof. Eggert", + "assignments": [ + { + "id": "SomeID", + "name": "Homework 1", + "dueDate": "1643663222161", + "points": 100.0 + } + ], + + // Omitted if you are not logged in to a professor account. + "members": [ + {"name": "Svetly"}, + {"name": "Preetha"}, + {"name": "Leo"} + ] +} +``` + +Status Code | Semantic +:-|:- +200 | OK +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `POST /class//invite` + +Create an invite code for the class with ID `class_id`. + +#### Request Body + +```json +{ + "validUntil": "UNIX UTC TIMESTAMP" +} +``` + +#### Response Format + +```json +{ + "inviteCode": "my-new-invite-code" +} +``` + +Status Code | Semantic +:-|:- +201 | Created +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `POST /class/join` + +Join the logged-in user to the class associated with the given. If the user is already +in the class, then nothing happens. + +#### Request Body + +```json +{ + "inviteCode": "my-new-invite-code" +} +``` + +#### Response Format + +No data is returned with this endpoint. + +Status Code | Semantic +:-|:- +204 | OK. User joined successfully or is already in class +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +## Get Started Developing You will need * Docker diff --git a/backend/main.py b/backend/main.py index d2ea315..fb35cd9 100755 --- a/backend/main.py +++ b/backend/main.py @@ -83,37 +83,26 @@ def create(): return {}, 201 -@app.route("//info", methods=["GET"]) +@app.route("/class//info", methods=["GET"]) def view_class(class_id): """ Get all relevant information about a class, including its assignments, member list (if allowed), and owner. """ + + is_professor = True + return { "name": "mycoolclass", - "owner": {"user": "thing"}, + "ownerName": "Prof. Eggert", "assignments": [], - "members": [{"name": "Svetly"}, {"name": "Preetha"}, {"name": "Leo"}], + "members": [{"name": "Svetly"}, {"name": "Preetha"}, {"name": "Leo"}] + if is_professor + else None, }, 200 -@app.route("//invite", methods=["POST"]) -def create_invite(class_id): - """ - Create an invite code for the class with ID `class_id`. - """ - return {"inviteCode": "my-new-invite-code"} - - -@app.route("//join", methods=["POST"]) -def join_class(class_id): - """ - Join the currently logged-in user to the class with ID `class_id`. - """ - return {}, 204 - - -@app.route("//", methods=["GET"]) +@app.route("/class//", methods=["GET"]) def get_assignment(class_id, assignment_id): """ Get information about an assignment for a specific user. @@ -146,20 +135,18 @@ def create_class(): """ Create a class in the database. """ - body = request.json - print(body) - return {"id": "new_class_id"}, 200 + return {"id": "new_class_id"}, 201 -@app.route("//invite", methods=["POST"]) +@app.route("/class//invite", methods=["POST"]) def create_invite(class_id): """ Create an invite code for the class with ID `class_id`. """ - return {"inviteCode": "my-new-invite-code"} + return {"inviteCode": "my-new-invite-code"}, 201 -@app.route("//join", methods=["POST"]) +@app.route("/class/join", methods=["POST"]) def join_class(class_id): """ Join the currently logged-in user to the class with ID `class-id`. From 7d7796403cccc2172236d3f51bfec4605f1ba9e8 Mon Sep 17 00:00:00 2001 From: krashanoff Date: Tue, 1 Feb 2022 14:15:36 -0800 Subject: [PATCH 06/13] Begin implementing basic JWT authorization. --- backend/README.md | 207 +--------------------------------------------- 1 file changed, 3 insertions(+), 204 deletions(-) diff --git a/backend/README.md b/backend/README.md index d8a2771..3996f13 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,212 +2,11 @@ The backend for our grading solution supports operations on user-sensitive data. -## Exposed Endpoints +## Get Started (as a user) -### Conventions +Configure the server through `config.env`. -Responses are formatted with JSON. Variable names passed in the request are documented -using `snake_case`, but responses will provide variable names that **are actually** in -`camelCase`. - -All endpoints except for `POST /login` and `POST /user` expect an `Authorization` header -in the HTTP request bearing a JWT returned by either of these endpoints. - -Any `GET` request does not require a body, only an `Authorization` header. - -### `POST /user` - -Login as an user, returning a JWT for future requests. Please please **please** only -send this on TLS. - -#### Request Body - -```json -{ - "username": "Smallberg", - "password": "MYSECRETPASSWORD DONT TELL ANYONE LOL", - "professor": "true", -} -``` - -#### Response Format - -```json -{ - "token": "SomeLongStringOfBase64" -} -``` - -Status Code | Semantic -:-|:- -201 | CREATED -400 | Bad request (see format) -401 | Unauthorized -500 | Server error - -### `POST /login` - -Login as an user, returning a JWT for future requests. Please please **please** only -send this on TLS. - -#### Request Body - -```json -{ - "username": "Smallberg", - "password": "MYSECRETPASSWORD DONT TELL ANYONE LOL" -} -``` - -#### Response Format - -```json -{ - "token": "SomeLongStringOfBase64" -} -``` - -Status Code | Semantic -:-|:- -200 | OK -400 | Bad request (see format) -401 | Unauthorized -500 | Server error - -### `POST /class` - -Creates a class in the database. - -#### Request Body - -```json -{ - "name": "My cool class" -} -``` - -#### Response Format - -```json -{ - "id": "new_class_id" -} -``` - -Status Code | Semantic -:-|:- -201 | CREATED -400 | Bad request (see format) -401 | Unauthorized -500 | Server error - -### `GET /class//` -Get the unique assignment ID for a given user - -### Response Format - -```json -{ - "name": "Cool assigment one", - "dueDate": "1647205512354", - "submissions": [{"date": "1643663222161", "pointsEarned": 100.0}] -} -``` -Status Code | Semantic -:-|:- -200 | OK -400 | Bad request (see format) -401 | Unauthorized -500 | Server error - -### `GET /class//info` - -Get all relevant information about a class, including its assignments, member list (if -allowed), and owner. - -#### Response Format - -```json -{ - "name": "My cool class", - "ownerName": "Prof. Eggert", - "assignments": [ - { - "id": "SomeID", - "name": "Homework 1", - "dueDate": "1643663222161", - "points": 100.0 - } - ], - - // Omitted if you are not logged in to a professor account. - "members": [ - {"name": "Svetly"}, - {"name": "Preetha"}, - {"name": "Leo"} - ] -} -``` - -Status Code | Semantic -:-|:- -200 | OK -400 | Bad request (see format) -401 | Unauthorized -500 | Server error - -### `POST /class//invite` - -Create an invite code for the class with ID `class_id`. - -#### Request Body - -```json -{ - "validUntil": "UNIX UTC TIMESTAMP" -} -``` - -#### Response Format - -```json -{ - "inviteCode": "my-new-invite-code" -} -``` - -Status Code | Semantic -:-|:- -201 | Created -400 | Bad request (see format) -401 | Unauthorized -500 | Server error - -### `POST /class/join` - -Join the logged-in user to the class associated with the given. If the user is already -in the class, then nothing happens. - -#### Request Body - -```json -{ - "inviteCode": "my-new-invite-code" -} -``` - -#### Response Format - -No data is returned with this endpoint. - -Status Code | Semantic -:-|:- -204 | OK. User joined successfully or is already in class -400 | Bad request (see format) -401 | Unauthorized -500 | Server error - -## Get Started Developing +## Get Started (as a dev) You will need * Docker From 56c59e0f4b2824cc7fe6a63a570e239ef0bb2fd7 Mon Sep 17 00:00:00 2001 From: krashanoff Date: Tue, 1 Feb 2022 14:14:57 -0800 Subject: [PATCH 07/13] Add JWT helper functions. --- backend/main.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/backend/main.py b/backend/main.py index fb35cd9..468d2a7 100755 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from datetime import datetime +import jwt from flask import Flask, g, request import psycopg as pg from psycopg.rows import class_row @@ -11,9 +12,27 @@ from argparse import ArgumentParser CONN_STR = "" +SECRET = "" app = Flask(__name__) + +def get_auth() -> dict: + """ + Get the JSON map encoded in the request's "Authorization" header. + """ + return jwt.decode( + request.headers.get("Authorization"), SECRET, algorithms=["HS256"] + ) + + +def to_auth(map: dict): + """ + Convert the provided map into a Base64-encoded JWT. + """ + return jwt.encode(map, SECRET, algorithm="HS256") + + @dataclass class Account: id: int @@ -22,6 +41,7 @@ class Account: professor: bool deleted: datetime + # Retrieve the global database connection object. # Pulled from https://flask.palletsprojects.com/en/2.0.x/appcontext/ def get_db() -> pg.Connection: @@ -169,7 +189,15 @@ def join_class(class_id): default="host=localhost port=5432 dbname=gradebetter user=admin password=admin", help="connection string for a postgresql database", ) + parser.add_argument( + "-s", + "--secret", + type=str, + default="gradbetter", + help="secret key to use in JWT generation", + ) args = parser.parse_args() CONN_STR = args.db_conn + SECRET = args.secret app.run(port=args.port) From f8081d0f143595b8e8e3b542aba53320c974b7d1 Mon Sep 17 00:00:00 2001 From: krashanoff Date: Tue, 1 Feb 2022 14:46:09 -0800 Subject: [PATCH 08/13] Add JWT scaffolding. Implement POST /user. --- backend/README.md | 217 +++++++++++++++++++++++++++++++++++++++++++++- backend/main.py | 42 +++++---- 2 files changed, 234 insertions(+), 25 deletions(-) diff --git a/backend/README.md b/backend/README.md index 3996f13..036e6cd 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,11 +2,222 @@ The backend for our grading solution supports operations on user-sensitive data. -## Get Started (as a user) +## Exposed Endpoints -Configure the server through `config.env`. +### Conventions -## Get Started (as a dev) +Responses are formatted with JSON. Variable names passed in the request are documented +using `snake_case`, but responses will provide variable names that **are actually** in +`camelCase`. + +When `POST`ing to an endpoint, please specify `Content-Type: application/json` +in your headers. + +All endpoints except for `POST /login` and `POST /user` expect an `Authorization` header +in the HTTP request bearing a JWT returned by either of these endpoints. + +Any `GET` request does not require a body, only an `Authorization` header. + +### `POST /user` + +Login as an user, returning a JWT for future requests. Please please **please** only +send this on TLS. + +#### Request Body + +Field | Possible Values +:-|:- +type | professor, student +username | * +password | * + +```json +{ + "type": "professor", + "username": "Smallberg", + "password": "MYSECRETPASSWORD DONT TELL ANYONE LOL" +} +``` + +#### Response Format + +```json +{ + "token": "SomeLongStringOfBase64" +} +``` + +Status Code | Semantic +:-|:- +201 | CREATED +400 | Bad request (see format) +401 | Unauthorized +409 | Username already exists +500 | Server error + +### `POST /login` + +Login as an user, returning a JWT for future requests. Please please **please** only +send this on TLS. + +#### Request Body + +```json +{ + "username": "Smallberg", + "password": "MYSECRETPASSWORD DONT TELL ANYONE LOL" +} +``` + +#### Response Format + +```json +{ + "token": "SomeLongStringOfBase64" +} +``` + +Status Code | Semantic +:-|:- +200 | OK +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `POST /class` + +Creates a class in the database. + +#### Request Body + +```json +{ + "name": "My cool class" +} +``` + +#### Response Format + +```json +{ + "id": "new_class_id" +} +``` + +Status Code | Semantic +:-|:- +201 | CREATED +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `GET /class//` +Get the unique assignment ID for a given user + +### Response Format + +```json +{ + "name": "Cool assigment one", + "dueDate": "1647205512354", + "submissions": [{"date": "1643663222161", "pointsEarned": 100.0}] +} +``` +Status Code | Semantic +:-|:- +200 | OK +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `GET /class//info` + +Get all relevant information about a class, including its assignments, member list (if +allowed), and owner. + +#### Response Format + +```json +{ + "name": "My cool class", + "ownerName": "Prof. Eggert", + "assignments": [ + { + "id": "SomeID", + "name": "Homework 1", + "dueDate": "1643663222161", + "points": 100.0 + } + ], + + // Omitted if you are not logged in to a professor account. + "members": [ + {"name": "Svetly"}, + {"name": "Preetha"}, + {"name": "Leo"} + ] +} +``` + +Status Code | Semantic +:-|:- +200 | OK +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `POST /class//invite` + +Create an invite code for the class with ID `class_id`. + +#### Request Body + +```json +{ + "validUntil": "UNIX UTC TIMESTAMP" +} +``` + +#### Response Format + +```json +{ + "inviteCode": "my-new-invite-code" +} +``` + +Status Code | Semantic +:-|:- +201 | Created +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +### `POST /class/join` + +Join the logged-in user to the class associated with the given. If the user is already +in the class, then nothing happens. + +#### Request Body + +```json +{ + "inviteCode": "my-new-invite-code" +} +``` + +#### Response Format + +No data is returned with this endpoint. + +Status Code | Semantic +:-|:- +204 | OK. User joined successfully or is already in class +400 | Bad request (see format) +401 | Unauthorized +500 | Server error + +## Get Started Developing You will need * Docker diff --git a/backend/main.py b/backend/main.py index 468d2a7..6449988 100755 --- a/backend/main.py +++ b/backend/main.py @@ -7,6 +7,7 @@ import jwt from flask import Flask, g, request import psycopg as pg +import psycopg.errors as errors from psycopg.rows import class_row from argparse import ArgumentParser @@ -20,17 +21,21 @@ def get_auth() -> dict: """ Get the JSON map encoded in the request's "Authorization" header. + Expiration is automatically checked by PyJWT. If the signature is + expired, an ExpiredSignatureError is thrown. """ return jwt.decode( request.headers.get("Authorization"), SECRET, algorithms=["HS256"] ) -def to_auth(map: dict): +def to_auth(map: dict) -> str: """ Convert the provided map into a Base64-encoded JWT. + + TODO: expiry renewal """ - return jwt.encode(map, SECRET, algorithm="HS256") + return jwt.encode(map, SECRET, algorithm="HS256").decode("utf-8") @dataclass @@ -61,15 +66,17 @@ def teardown_db(exception): # Log an user into the database, then return a valid JWT for their session. @app.route("/login", methods=["POST"]) def login(): + form = request.form + return {"token": "example"} # Create a user in the database, then return a valid JWT for their session. @app.route("/user", methods=["POST"]) def create(): - form = request.form + form = request.json account_type, username, password = form["type"], form["username"], form["password"] - invite_key = None if account_type == "student" else form["inviteKey"] + # invite_key = None if account_type == "student" else form["inviteKey"] # Bad request if account_type not in ["student", "professor"]: @@ -80,27 +87,18 @@ def create(): conn = get_db() with conn.cursor() as cur: cur.row_factory = class_row(Account) - cur.execute( - "INSERT INTO Accounts (username, password, professor) VALUES (%s, %s, %s) RETURNING *", - [username, password, account_type == "professor"], - ) - row = cur.fetchone() - print(row) - - # If the account is for a student, then join them to their class. - if account_type == "student": + try: cur.execute( - """ - INSERT INTO ClassMembers (id, class_id) VALUES (id, class_id) - HAVING id = (SELECT id FROM Accounts WHERE username = %s) AND - class_id = (SELECT invites_to FROM Invites WHERE id = %s) - """, - [username, invite_key], + "INSERT INTO Accounts (username, password, professor) VALUES (%s, %s, %s)", + [username, password, account_type == "professor"], ) - conn.commit() + conn.commit() + except errors.UniqueViolation: + # User already exists. + return {}, 409 - # TODO: create and return a JWT for the new session - return {}, 201 + # Create and return a JWT for the new session. + return {"token": to_auth({"username": username})}, 201 @app.route("/class//info", methods=["GET"]) From 1273353deaccd7dc61f41f246cd6b42657555e13 Mon Sep 17 00:00:00 2001 From: krashanoff Date: Thu, 3 Feb 2022 14:44:28 -0800 Subject: [PATCH 09/13] Add expiry, NBF fields to JWT. Implement login endpoint. --- backend/main.py | 123 ++++++++++++++++++------------------------------ 1 file changed, 47 insertions(+), 76 deletions(-) diff --git a/backend/main.py b/backend/main.py index 9d5a6de..9e96dcb 100755 --- a/backend/main.py +++ b/backend/main.py @@ -2,7 +2,7 @@ from argparse import ArgumentParser from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta, timezone import jwt from flask import Flask, g, request @@ -23,19 +23,36 @@ def get_auth() -> dict: Get the JSON map encoded in the request's "Authorization" header. Expiration is automatically checked by PyJWT. If the signature is expired, an ExpiredSignatureError is thrown. + + The "nbf" and "exp" claims are required for each token. If they are + missing, then this function will throw an error. """ return jwt.decode( - request.headers.get("Authorization"), SECRET, algorithms=["HS256"] + request.headers.get("Authorization"), + SECRET, + algorithms=["HS256"], + options={"require": ["exp", "nbf"]}, ) -def to_auth(map: dict) -> str: +def to_auth(payload: dict) -> str: """ - Convert the provided map into a Base64-encoded JWT. + Convert the provided dictionary payload into a Base64-encoded JWT. - TODO: expiry renewal + Tokens expire in 31 days, and cannot be used before the time they + were issued at. """ - return jwt.encode(map, SECRET, algorithm="HS256").decode("utf-8") + return jwt.encode( + { + **payload, + # Tokens are valid for 31 days. + "exp": datetime.now(tz=timezone.utc) + timedelta(days=31), + # Tokens should not be accepted before the present day. + "nbf": datetime.now(tz=timezone.utc), + }, + SECRET, + algorithm="HS256", + ).decode("utf-8") @dataclass @@ -66,17 +83,35 @@ def teardown_db(exception): # Log an user into the database, then return a valid JWT for their session. @app.route("/login", methods=["POST"]) def login(): - form = request.form + json = request.json + username, password = json["username"], json["password"] - return {"token": "example"} + conn = get_db() + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM Accounts WHERE username = %s AND password = crypt(%s, password)", + [username, password], + ) + records = cur.fetchall() + if len(records) != 1: + return {}, 400 + account = records[0] + + return { + "token": to_auth( + # Right now, we just store the user's ID in the token. + { + "userid": account[0], + } + ), + }, 200 # Create a user in the database, then return a valid JWT for their session. @app.route("/user", methods=["POST"]) def create(): - form = request.json - account_type, username, password = form["type"], form["username"], form["password"] - # invite_key = None if account_type == "student" else form["inviteKey"] + json = request.json + account_type, username, password = json["type"], json["username"], json["password"] # Bad request if account_type not in ["student", "professor"]: @@ -85,8 +120,7 @@ def create(): # Create a database transaction to insert our accout into the associated # course. conn = get_db() - with conn.cursor() as cur: - cur.row_factory = class_row(Account) + with conn.cursor(row_factory=class_row(Account)) as cur: try: cur.execute( "INSERT INTO Accounts (username, password, professor) VALUES (%s, %s, %s)", @@ -132,53 +166,6 @@ def get_assignment(class_id, assignment_id): }, 200 -@app.route("///script", methods=["GET", "POST"]) -def upload_grading_script(class_id, assignment_id): - """ - Upload the grading script for a specific assignment. - """ - return {}, 204 - - -@app.route("///upload", methods=["GET", "POST"]) -def upload_submission(class_id, assignment_id): - """ - Upload the a submission for a specific assignment. - """ - return {}, 204 - - -@app.route("/class//info", methods=["GET"]) -def view_class(class_id): - """ - Get all relevant information about a class, including its assignments, member list - (if allowed), and owner. - """ - - is_professor = True - - return { - "name": "mycoolclass", - "ownerName": "Prof. Eggert", - "assignments": [], - "members": [{"name": "Svetly"}, {"name": "Preetha"}, {"name": "Leo"}] - if is_professor - else None, - }, 200 - - -@app.route("/class//", methods=["GET"]) -def get_assignment(class_id, assignment_id): - """ - Get information about an assignment for a specific user. - """ - return { - "name": "Cool assignment one", - "dueDate": "1647205512354", - "submissions": [{"date": "1643663222161", "pointsEarned": 100.0}], - }, 200 - - @app.route("///script", methods=["POST"]) def upload_grading_script(class_id, assignment_id): """ @@ -195,22 +182,6 @@ def upload_submission(class_id, assignment_id): return {}, 204 -@app.route("/class//invite", methods=["POST"]) -def create_invite(class_id): - """ - Create an invite code for the class with ID `class_id`. - """ - return {"inviteCode": "my-new-invite-code"}, 201 - - -@app.route("/class/join", methods=["POST"]) -def join_class(class_id): - """ - Join the currently logged-in user to the class with ID `class-id`. - """ - return {}, 204 - - @app.route("/class", methods=["POST"]) def create_class(): """ From 1a7745c7910c20ffa8c7c62d49bc262fa102abbc Mon Sep 17 00:00:00 2001 From: krashanoff Date: Thu, 3 Feb 2022 14:55:37 -0800 Subject: [PATCH 10/13] Delete auth.py --- backend/auth.py | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 backend/auth.py diff --git a/backend/auth.py b/backend/auth.py deleted file mode 100644 index b858e27..0000000 --- a/backend/auth.py +++ /dev/null @@ -1,22 +0,0 @@ -from flask import request -from flask_jwt import JWT -from functools import wraps -import jwt - -def token_required(f): - @wraps(f) - def decorator(*args, **kwargs): - token = None - if 'x-access-tokens' in request.headers: - token = request.headers['x-access-tokens'] - - if not token: - return {'message': 'a valid token is missing'}, 400 - try: - data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) - current_user = Users.query.filter_by(public_id=data['public_id']).first() - except: - return {'message': 'token is invalid'}, 400 - - return f(current_user, *args, **kwargs) - return decorator \ No newline at end of file From 7489104c1e51af56e6f088eaefcbfe6f8d77a149 Mon Sep 17 00:00:00 2001 From: krashanoff Date: Thu, 3 Feb 2022 15:52:52 -0800 Subject: [PATCH 11/13] Move JWT functionality to auth.py. * Add doc comments throughout. * Improve feedback for login failures, etc. * Reformat SQL queries for readability. --- backend/.gitignore | 1 + backend/auth.py | 69 +++++++++++++++++++++++++++++++++++++++ backend/main.py | 80 +++++++++++++++++----------------------------- 3 files changed, 100 insertions(+), 50 deletions(-) create mode 100644 backend/auth.py diff --git a/backend/.gitignore b/backend/.gitignore index bdaab25..06a47c5 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,2 @@ env/ +__pycache__/ diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..0ac24fd --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,69 @@ +from datetime import datetime, timedelta, timezone +from typing import Any +from typing_extensions import Self +from flask import request +import jwt + + +class Token: + """ + Python-representation of a JWT session token. + + Example: + + ```json + { "id": 0 } + ``` + """ + + def __init__(self, secret: str, user_id: int) -> None: + """ + Prepare a token for serialization with the given user ID. + """ + self.payload = {"id": user_id} + self._secret = secret + + def __repr__(self) -> str: + return str(self.payload) + + def to_jwt(self) -> str: + """ + Convert the provided dictionary payload into a Base64-encoded JWT. + + Tokens expire in 31 days, and cannot be used before the time they + were issued at. + """ + return jwt.encode( + { + **self.payload, + # Tokens are valid for 31 days. + "exp": datetime.now(tz=timezone.utc) + timedelta(days=31), + # Tokens should not be accepted before the present day. + "nbf": datetime.now(tz=timezone.utc), + }, + self._secret, + algorithm="HS256", + ).decode("utf-8") + + +def get_token(secret: str) -> Token: + """ + Get the JSON map encoded in the request's "Authorization" header. + Expiration is automatically checked by PyJWT. If the signature is + expired, an ExpiredSignatureError is thrown. + + The "nbf" and "exp" claims are required for each token. If they are + missing, then this function will throw an error. + + Similarly, should the JWT be valid but missing a required property + (see Token class doc), it will throw a KeyError. + """ + return Token( + secret, + jwt.decode( + request.headers.get("Authorization"), + secret, + algorithms=["HS256"], + options={"require": ["exp", "nbf"]}, + )["id"], + ) diff --git a/backend/main.py b/backend/main.py index 9e96dcb..241c398 100755 --- a/backend/main.py +++ b/backend/main.py @@ -2,9 +2,7 @@ from argparse import ArgumentParser from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -import jwt from flask import Flask, g, request import psycopg as pg import psycopg.errors as errors @@ -12,51 +10,20 @@ from argparse import ArgumentParser +from auth import * + CONN_STR = "" SECRET = "" app = Flask(__name__) -def get_auth() -> dict: - """ - Get the JSON map encoded in the request's "Authorization" header. - Expiration is automatically checked by PyJWT. If the signature is - expired, an ExpiredSignatureError is thrown. - - The "nbf" and "exp" claims are required for each token. If they are - missing, then this function will throw an error. - """ - return jwt.decode( - request.headers.get("Authorization"), - SECRET, - algorithms=["HS256"], - options={"require": ["exp", "nbf"]}, - ) - - -def to_auth(payload: dict) -> str: +@dataclass +class Account: """ - Convert the provided dictionary payload into a Base64-encoded JWT. - - Tokens expire in 31 days, and cannot be used before the time they - were issued at. + Helper dataclass for psycopg row factory use. """ - return jwt.encode( - { - **payload, - # Tokens are valid for 31 days. - "exp": datetime.now(tz=timezone.utc) + timedelta(days=31), - # Tokens should not be accepted before the present day. - "nbf": datetime.now(tz=timezone.utc), - }, - SECRET, - algorithm="HS256", - ).decode("utf-8") - -@dataclass -class Account: id: int username: str password: str @@ -87,29 +54,37 @@ def login(): username, password = json["username"], json["password"] conn = get_db() - with conn.cursor() as cur: + with conn.cursor(row_factory=class_row(Account)) as cur: cur.execute( - "SELECT * FROM Accounts WHERE username = %s AND password = crypt(%s, password)", + """ + SELECT * FROM Accounts + WHERE username = %s + AND password = crypt(%s, password) + """, [username, password], ) records = cur.fetchall() - if len(records) != 1: + + length = len(records) + if length == 0: + # The login failed. return {}, 400 + elif len(records) > 1: + # There is a problem with the database. + return {}, 500 account = records[0] return { - "token": to_auth( - # Right now, we just store the user's ID in the token. - { - "userid": account[0], - } + "token": Token( + SECRET, + account.id, ), }, 200 # Create a user in the database, then return a valid JWT for their session. @app.route("/user", methods=["POST"]) -def create(): +def create_user(): json = request.json account_type, username, password = json["type"], json["username"], json["password"] @@ -123,16 +98,21 @@ def create(): with conn.cursor(row_factory=class_row(Account)) as cur: try: cur.execute( - "INSERT INTO Accounts (username, password, professor) VALUES (%s, %s, %s)", + """ + INSERT INTO Accounts (username, password, professor) + VALUES (%s, %s, %s) + RETURNING * + """, [username, password, account_type == "professor"], ) conn.commit() + result = cur.fetchone() except errors.UniqueViolation: # User already exists. return {}, 409 - # Create and return a JWT for the new session. - return {"token": to_auth({"username": username})}, 201 + # Create and return a JWT for the new session containing the user's ID. + return {"token": Token(SECRET, result.id).to_jwt()}, 201 @app.route("/class//info", methods=["GET"]) From 82f53a6ca3e7a396da001b8c47fcb61b163edeac Mon Sep 17 00:00:00 2001 From: krashanoff Date: Sat, 5 Feb 2022 01:14:27 -0800 Subject: [PATCH 12/13] Format auth. --- backend/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/auth.py b/backend/auth.py index 0ac24fd..7b03b74 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -10,7 +10,7 @@ class Token: Python-representation of a JWT session token. Example: - + ```json { "id": 0 } ``` From 646c8ee2cf735ee6e63d3d6506c41b309abef449 Mon Sep 17 00:00:00 2001 From: krashanoff Date: Sun, 6 Feb 2022 16:28:06 -0800 Subject: [PATCH 13/13] Typo --- backend/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index 241c398..91c33f6 100755 --- a/backend/main.py +++ b/backend/main.py @@ -205,7 +205,7 @@ def join_class(class_id): "-s", "--secret", type=str, - default="gradbetter", + default="gradebetter", help="secret key to use in JWT generation", ) args = parser.parse_args()