diff --git a/Dockerfile b/Dockerfile index 77837fc..fe3e970 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,8 @@ RUN apt-get install -y apache2 RUN pip install -U pip RUN pip install -U flask RUN pip install -U flask-cors +RUN pip install -U pytz +RUN pip install -U passlib RUN echo "ServerName localhost " >> /etc/apache2/apache2.conf RUN echo "$user hard nproc 20" >> /etc/security/limits.conf ADD ./src/service /service diff --git a/README.md b/README.md index 9a814e3..b726a7d 100644 --- a/README.md +++ b/README.md @@ -96,48 +96,56 @@ Please fill out this section with details relevant to your team. ### Team Members -1. Member 1 Name -2. Member 2 Name -3. Member 3 Name -4. Member 4 Name +1. Eldric Lim +2. Joshua Che +3. Chan Jian Hui +4. Derek Kok ### Short Answer Questions #### Question 1: Briefly describe the web technology stack used in your implementation. -Answer: Please replace this sentence with your answer. +Answer: Python Flask for the web application, sqlite3 for the backend-database. We chose sqlite3 as it is light-weight and easy to use with Python Flask. #### Question 2: Are there any security considerations your team thought about? -Answer: Please replace this sentence with your answer. +Answer: Yes, we initially made a user's token "" (empty string) when expired. This allowed requests to be authenticated by simply passing an empty string in a http request. We proceeded to change it to NULL instead. #### Question 3: Are there any improvements you would make to the API specification to improve the security of the web application? -Answer: Please replace this sentence with your answer. +Answer: Make it https-compliant, have more parameters like nonces or timeouts for things like requests or tokens, etc. #### Question 4: Are there any additional features you would like to highlight? -Answer: Please replace this sentence with your answer. +Answer: Our front-end code is incomplete, features such as deletion and changing of permission on diary entries are not implemented. #### Question 5: Is your web application vulnerable? If yes, how and why? If not, what measures did you take to secure it? -Answer: Please replace this sentence with your answer. +Answer: +Yes. To facilitate user access via tokens, a user's token is stored within the webpage as a hidden field. An attacker may be able to output the value of the field by launching an XSS attack, outputting it via an alert message. + +No steps were taken to prevent SQL injection vulnerabilities either. + +As stated in question 2, our application initially allowed blank tokens, which could authenticate a request when a blank token is used, but we fixed this by using NULL instead. #### Feedback: Is there any other feedback you would like to give? -Answer: Please replace this sentence with your answer. +Answer: None. ### Declaration #### Please declare your individual contributions to the assignment: -1. Member 1 Name - - Integrated feature x into component y - - Implemented z -2. Member 2 Name - - Wrote the front-end code -3. Member 3 Name - - Designed the database schema -4. Member 4 Name - - Implemented x +1. Eldric Lim + - API + - Front-end +2. Joshua Che + - API + - Front-end +3. Chan Jian Hui + - API + - Front-end +4. Derek Kok + - API + - Front-end diff --git a/Sample cURL requests b/Sample cURL requests new file mode 100644 index 0000000..ee457be --- /dev/null +++ b/Sample cURL requests @@ -0,0 +1,77 @@ +==VERY IMPORTANT POINTS== + +Each GET/POST request is sent with correct parameters as specified in the assignment API. Wrong request parameters may cause KeyErrors or other errors. + +Queries/operations done on non-existent records --but with a valid token-- will return HTTP 200 OK. (e.g. /diary/delete with valid token but non-existent diary id) + +Database file is in src/service/database.db, and it contains 2 tables "users" and "diaries". +Use DB Browser for SQLite to see how the tables look like, I've included 3 users by default in the "users" table. +The "diaries" table is empty, and requires us to populate it by using sample queries that I've included. + +Since this web server is run from Docker, I've taken the liberty to make the database volatile; you can only use DB Browser to look and edit the table layouts, but won't be able to see real-time data inside the tables. To do that, observe the output when you submit curl requests, or enter the URL in firefox to see if the output is correct. + + + +==INSTRUCTIONS== + +Run these POST curl requests in a terminal, or submit your own http GET/POST request with any tool. GET requests can be done by simply opening the respective page in Firefox. (e.g. http://localhost:8080/meta/members) + + + +==REGISTER A USER== + +curl -i -X POST -H 'Content-Type: application/json' -d '{"username": "testuser", "password": "testpass", "fullname": "test full name", "age": 2009}' http://127.0.0.1:8080/users/register + + + +==AUTHENTICATE A USER== + +curl -i -X POST -H 'Content-Type: application/json' -d '{"username": "testuser", "password": "testpass"}' http://127.0.0.1:8080/users/authenticate + + + +==RETURNS WHICHEVER USER REPRESENTATED BY THIS TOKEN== + +curl -i -X POST -H 'Content-Type: application/json' -d '{"token":"111111111111111111111111111111111111"}' http://127.0.0.1:8080/users + + + +==CREATES A --PUBLIC-- DIARY FOR AN AUTHENTICATED USER== + +curl -i -X POST -H 'Content-Type: application/json' -d '{"token":"111111111111111111111111111111111111", "title": "No One Can See This Post", "public": true, "text": "It is very secret!"}' http://127.0.0.1:8080/diary/create + + + +==CREATES A --PRIVATE-- DIARY FOR AN AUTHENTICATED USER== + +curl -i -X POST -H 'Content-Type: application/json' -d '{"token":"111111111111111111111111111111111111", "title": "No One Can See This Post", "public": true, "text": "It is very secret!"}' http://127.0.0.1:8080/diary/create + + + +==LISTS ALL DIARY ENTRIES THAT AN AUTHENTICATED USER HAS== + +curl -i -X POST -H 'Content-Type: application/json' -d '{"token":"111111111111111111111111111111111111"}' http://127.0.0.1:8080/diary + + + +==SETS AN AUTHENTICATED USER'S DIARY ENTRY TO PRIVATE== + +curl -i -X POST -H 'Content-Type: application/json' -d '{"token":"111111111111111111111111111111111111", "id":1, "public":false}' http://127.0.0.1:8080/diary/permission + + + +==SETS AN AUTHENTICATED USER'S DIARY ENTRY TO PUBLIC== + +curl -i -X POST -H 'Content-Type: application/json' -d '{"token":"111111111111111111111111111111111111", "id":1, "public":false}' http://127.0.0.1:8080/diary/permission + + + +==DELETE AN AUTHENTICATED USER'S DIARY ENTRY== + +curl -i -X POST -H 'Content-Type: application/json' -d '{"token":"111111111111111111111111111111111111", "id":1}' http://127.0.0.1:8080/diary/delete + + + +==EXPIRES AN AUTHENTICATED USER'S TOKEN== + +curl -i -X POST -H 'Content-Type: application/json' -d '{"token":"111111111111111111111111111111111111"}' http://127.0.0.1:8080/users/expire diff --git a/img/samplescreenshot.png b/img/samplescreenshot.png old mode 100644 new mode 100755 index 4b69df0..70b200d Binary files a/img/samplescreenshot.png and b/img/samplescreenshot.png differ diff --git a/src/html/demo.js b/src/html/demo.js index ada1b22..e952d3b 100644 --- a/src/html/demo.js +++ b/src/html/demo.js @@ -21,6 +21,26 @@ function ajax_get(url, callback) { xmlhttp.send(); } +function ajax_post(url, jsondata, callback) { + var xmlhttp = new XMLHttpRequest(); + xmlhttp.onreadystatechange = function() { + if (xmlhttp.readyState == 4 && xmlhttp.status == 200) { + console.log('responseText:' + xmlhttp.responseText); + try { + var data = JSON.parse(xmlhttp.responseText); + } catch(err) { + console.log(err.message + " in " + xmlhttp.responseText); + return; + } + callback(data); + } + }; + + xmlhttp.open("POST", url, true); + xmlhttp.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + xmlhttp.send(jsondata); +} + ajax_get(API_ENDPOINT + '/meta/heartbeat', function(data) { if (data.status) { document.getElementById("demo_heartbeat").innerHTML = "Heartbeat success"; @@ -45,3 +65,180 @@ ajax_get(API_ENDPOINT + '/meta/members', function(data) { } }); +ajax_get(API_ENDPOINT + '/diary', function(data) { + if (data.status) { + var diaries = data.result; + var output = "
"; + document.getElementById("demo_diary").innerHTML = output; + } + else { + document.getElementById("demo_diary").innerHTML = "Diary output failed"; + } +}); + +const isValidElement = element => { + return element.name && element.value; +}; + +const formToJSON = elements => [].reduce.call(elements, (data, element) => { + + if (isValidElement(element)){ + if (element.name == "public"){ + if (element.checked){ + data[element.name] = true; + } + else { + data[element.name] = false; + } + } + else { + data[element.name] = element.value; + } + } + return data; + +}, {}); + +const handleLoginFormSubmit = event => { + url = API_ENDPOINT + "/users/authenticate"; + event.preventDefault(); + const data = formToJSON(loginform.elements); + var loggeduser = data["username"]; + jsondata = JSON.stringify(data, null, " "); + + ajax_post(url, jsondata, function(data) { + var result = data.result + if (data.status) { + var output = ""; + document.getElementById("login-feedback").innerHTML = "Logged in as " + loggeduser + ". " + output; + document.getElementById("token").value = data.result.token; + document.getElementById("create-token").value = data.result.token; + } + else { + document.getElementById("login-feedback").innerHTML = "Login failed"; + document.getElementById("token").value = ""; + document.getElementById("create-token").value = ""; + } + }); + + loginform.reset(); +}; + +const handleRegisterFormSubmit = event => { + url = API_ENDPOINT + "/users/register"; + event.preventDefault(); + const data = formToJSON(registerform.elements); + jsondata = JSON.stringify(data, null, " "); + + ajax_post(url, jsondata, function(data) { + if (data.status) { + document.getElementById("register-feedback").innerHTML = "Successfully registered! Please login to continue."; + } + else { + document.getElementById("register-feedback").innerHTML = "Registration failed."; + } + }); + + registerform.reset() +}; + +const retrieveDiarySubmit = event => { + url = API_ENDPOINT + '/diary' + event.preventDefault(); + const data = formToJSON(retrievediary.elements); + jsondata = JSON.stringify(data, null, " "); + document.getElementById("my-diaries").innerHTML = "Please log in to view your diary."; + + ajax_post(API_ENDPOINT + '/diary', jsondata, function(data) { + var diaries = data.result; + var output = ""; + if (data.status) { + document.getElementById("my-diaries").innerHTML = output; + } + else { + document.getElementById("my-diaries").innerHTML = "Please log in to view your diaries."; + } + }); +}; + +const deleteDiarySubmit = event => { + url = API_ENDPOINT + "/diary/delete"; + event.preventDefault(); + const data = formToJSON(deletediary.elements); + jsondata = JSON.stringify(data, null, " "); + + ajax_post(url, jsondata, function(data) { + if (data.status) { + document.getElementById("diary-feedback").innerHTML = "Entry deleted"; + } + else { + document.getElementById("diary-feedback").innerHTML = "Invalid authentication"; + } + }); +}; + +const loginform = document.getElementById('login_form'); +const registerform = document.getElementById('register_form'); +const retrievediary = document.getElementById('retrieve-diary'); +const deletediary = document.getElementById('delete-diary'); + +loginform.addEventListener('submit', handleLoginFormSubmit); +registerform.addEventListener('submit', handleRegisterFormSubmit); +retrievediary.addEventListener('submit', retrieveDiarySubmit); +deletediary.addEventListener('submit', deleteDiarySubmit); + +function userLogOut(){ + location.reload(); + //document.getElementById("token").value = ""; + + //document.getElementById("my-diaries").innerHTML = ""; + //document.getElementById("login-feedback").innerHTML = "Logged out!"; +} + +function createDiary(){ + const creatediary = document.getElementById('create_diary'); + + url = API_ENDPOINT + "/diary/create"; + event.preventDefault(); + const data = formToJSON(creatediary.elements); + jsondata = JSON.stringify(data, null, " "); + + ajax_post(url, jsondata, function(data) { + if (data.status) { + document.getElementById("create-feedback").innerHTML = "Entry created"; + } + else { + document.getElementById("create-feedback").innerHTML = "Invalid authentication"; + } + }); + + creatediary.reset(); +} diff --git a/src/html/index.css b/src/html/index.css new file mode 100644 index 0000000..86cd875 --- /dev/null +++ b/src/html/index.css @@ -0,0 +1,41 @@ +li { + margin: 20px 0; +} + +fieldset.login_form { + width: 300px; +} + +fieldset.register_form { + width: 300px; +} + +fieldset.diaries { + width: 700px; +} + +legend { + font-size: 20px; +} + +label.field { + text-align: left; + width: 80px; + float: left; + font-weight: bold; + padding-right: 10px; +} + +input.textbox100 { + width: 200px; + float: left; +} + +fieldset p { + clear: both; + padding: 5px; +} + +public-diaries { + padding-right: 100px; +} \ No newline at end of file diff --git a/src/html/index.html b/src/html/index.html index 2a847c7..edd1a84 100644 --- a/src/html/index.html +++ b/src/html/index.html @@ -1,26 +1,69 @@ + + -

Secret Diary Front End

-

Requirements for the front end are as follows:

- - -
+

Welcome to Secret Diary

+ -
+
+
+ Public Diary Entries +
+
+
+
+
+
+ My Secret Diary +
+ +

+
+
+
+
+
+
+
+
+
+ New Diary Entry +

 

+

+ +

+
+
+
- + \ No newline at end of file diff --git a/src/service/app.py b/src/service/app.py index ade6772..a2c0ccc 100644 --- a/src/service/app.py +++ b/src/service/app.py @@ -1,16 +1,89 @@ #!/usr/bin/python from flask import Flask +from flask import g +from flask import request from flask_cors import CORS +from datetime import datetime +from passlib.hash import sha256_crypt import json import os +import sqlite3 +import uuid +import pytz + +DATABASE = 'database.db' + app = Flask(__name__) # Enable cross origin sharing for all endpoints CORS(app) +def get_db(): + """UTILITY METHOD""" + db = getattr(g, '_database', None) + if db is None: + db = g._database = sqlite3.connect(DATABASE) + return db + +@app.teardown_appcontext +def close_connection(exception): + """UTILITY METHOD""" + db = getattr(g, '_database', None) + if db is not None: + db.close() + +def query_db(query, args=(), one=False): + """UTILITY METHOD""" + cur = get_db().execute(query, args) + get_db().commit() + rv = cur.fetchall() + cur.close() + print(rv) + return (rv if rv else None) if one else rv + + +def user_from_token(token): + """UTILITY METHOD""" + """Returns the corresponding user when given a token""" + """IMPT: This returns a TWO-dimensional array, an array of users, where a user is an array of attributes""" + query = "select * from users where token='%s'" % token + user = query_db(query) + """Since only one user should match the query at any time, select it""" + if user != []: + return user[0] + else: + return user + +def int_from_boolean(truth): + """UTILITY METHOD""" + if truth==True: + return 1; + else: + return 0; + +def get_current_datetime(): + """UTILITY METHOD""" + """Using UTC+8 for singapore""" + tz = pytz.timezone("Singapore") + aware_dt = tz.localize(datetime.now()) + generatedDT = aware_dt.replace(microsecond=0).isoformat() + return generatedDT + +def make_diary_dict(row): + """UTILITY METHOD""" + """This method takes in a diary (1 sql row), which is an array of attributes""" + """A diary = [id, title, author, publish_date, public, text]""" + """Then converts it to a dictionary""" + """Note that query_db returns a 2d array, or an array of rows""" + diaryDict = {"id":row[0],"title":row[1],"author":row[2],"publish_date":row[3],"public":row[4],"text":row[5]} + return diaryDict + +"""===========================================END OF UTILITY METHODS===========================================""" +"""===========================================START OF PYTHON (2.7) FLASK WEB APP===========================================""" + # Remember to update this list -ENDPOINT_LIST = ['/', '/meta/heartbeat', '/meta/members'] +ENDPOINT_LIST = ['/', '/meta/heartbeat', '/meta/members', '/users/register', '/users/authenticate', '/users/expire', '/users', '/diary', '/diary/create', '/diary/delete', '/diary/permission'] def make_json_response(data, status=True, code=200): """Utility function to create the JSON responses.""" @@ -22,7 +95,8 @@ def make_json_response(data, status=True, code=200): to_serialize['result'] = data else: to_serialize['status'] = False - to_serialize['error'] = data + if data is not None: + to_serialize['error'] = data response = app.response_class( response=json.dumps(to_serialize), status=code, @@ -51,6 +125,181 @@ def meta_members(): return make_json_response(team_members) +@app.route("/users/register", methods=['POST']) +def users_register(): + if request.method == 'POST': + """Registers users""" + paramsJSON = request.get_json() + username = paramsJSON['username'] + password = sha256_crypt.encrypt(str(paramsJSON['password'])) + fullname = paramsJSON['fullname'] + age = paramsJSON['age'] + try: + query = "insert into users(username, password, fullname, age) values ('%s','%s','%s',%s)" % (username,password,fullname,str(age)) + result = query_db(query) + return make_json_response(None, code=201) + except sqlite3.IntegrityError: + result = "User already exists!" + return make_json_response(result, status=False) + + +@app.route("/users/authenticate", methods=['POST']) +def users_authenticate(): + if request.method == 'POST': + paramsJSON = request.get_json() + username = paramsJSON['username'] + password = str(paramsJSON['password']) + + """Check if such a user exists first""" + query = "select * from users where username='%s'" % (username) + result = query_db(query) + + if result == []: + return make_json_response(data=None, status=False) + + else: + if sha256_crypt.verify(password, result[0][1]): + """Authenticates user""" + """Query to insert token to users table token column""" + generatedToken = uuid.uuid4() + query = "update users SET token='%s' where username='%s'and password='%s'" % (str(generatedToken), username,result[0][1]) + result = query_db(query) + """An update query does not return a result in query_db()""" + return make_json_response(data={"token":str(generatedToken)}) + else: + return make_json_response(data=None, status=False) + + +@app.route("/users/expire", methods=['POST']) +def users_expire(): + if request.method == 'POST': + """Authenticates users""" + paramsJSON = request.get_json() + token = paramsJSON['token'] + + """Check if such a token exists first""" + result = user_from_token(token) + + if result == []: + return make_json_response(data=None, status=False) + + else: + """De-authenticates user""" + """Query to insert blank token to users table token column""" + query = "update users SET token=%s where token='%s'" % ("NULL", token) + result = query_db(query) + """An update query does not return a result in query_db()""" + return make_json_response(data=None) + + +@app.route("/users", methods=['POST']) +def users_get(): + if request.method =='POST': + paramsJSON = request.get_json() + token = paramsJSON['token'] + + """Check if such a token exists first""" + result = user_from_token(token) + + if result == []: + return make_json_response(data="Invalid authentication token", status=False) + + else: + return make_json_response(data={"username":result[0], "fullname":result[2], "age":result[3]}) + + + +@app.route("/diary", methods=['GET', 'POST']) +def diary_get(): + if request.method =='GET': + """Retrieve all public diary entries""" + """No req params needed, only response""" + query = "select * from diaries where public='1'" + result = query_db(query) + arrOfDiaryDicts = [] + for row in result: + arrOfDiaryDicts.append(make_diary_dict(row)) + return make_json_response(arrOfDiaryDicts) + + if request.method =='POST': + """Retrieve all entries belonging to an authenticated user""" + paramsJSON = request.get_json() + token = paramsJSON['token'] + result = user_from_token(token) + if result == []: + return make_json_response(data="Invalid authentication token", status=False) + else: + username = result[0] + query = "select * from diaries where author='%s'" % (username) + result = query_db(query) + arrOfDiaryDicts = [] + for row in result: + arrOfDiaryDicts.append(make_diary_dict(row)) + return make_json_response(arrOfDiaryDicts) + + +@app.route("/diary/create", methods=['POST']) +def diary_create(): + if request.method =='POST': + """Create a new diary entry""" + paramsJSON = request.get_json() + token = paramsJSON['token'] + title = paramsJSON['title'] + public = paramsJSON['public'] + text = paramsJSON['text'] + result = user_from_token(token) + if result ==[]: + return make_json_response(data="Invalid authentication token", status=False) + else: + username = result[0] + generatedDT = get_current_datetime() + query = "insert into diaries ('title', 'author', 'publish_date', 'public', 'text') values ('%s','%s','%s','%d','%s')" % (title,username,generatedDT,int_from_boolean(public),text) + result = query_db(query) + + """Retrieve last inserted id""" + query = "select seq from sqlite_sequence where name='diaries'" + result = query_db(query) + return make_json_response(data={"id":result[0][0]}, code=201) + + +@app.route("/diary/delete", methods=['POST']) +def diary_delete(): + if request.method =='POST': + paramsJSON = request.get_json() + token = paramsJSON['token'] + id = paramsJSON['id'] + """Get user from token first""" + user = user_from_token(token) + """If no such user with this token exists, else""" + if user == []: + return make_json_response(data="Invalid authentication token", status=False) + else: + username = user[0] + query = "delete from diaries where author='%s' and id='%d'" % (username, id) + result = query_db(query) + return make_json_response(None) + + +@app.route("/diary/permission", methods=['POST']) +def diary_permission(): + if request.method =='POST': + paramsJSON = request.get_json() + token = paramsJSON['token'] + id = paramsJSON['id'] + public = int_from_boolean(paramsJSON['public']) + + """Get user from token first""" + user = user_from_token(token) + """If no such user with this token exists, else""" + if user == []: + return make_json_response(data="Invalid authentication token", status=False) + else: + username = user[0] + query = "update diaries SET 'public'='%d' where id='%d'" % (public,id) + result = query_db(query) + return make_json_response(None) + + if __name__ == '__main__': # Change the working directory to the script directory abspath = os.path.abspath(__file__) diff --git a/src/service/database.db b/src/service/database.db new file mode 100644 index 0000000..4f23fb7 Binary files /dev/null and b/src/service/database.db differ diff --git a/src/service/team_members.txt b/src/service/team_members.txt index 50bdea8..41054d5 100644 --- a/src/service/team_members.txt +++ b/src/service/team_members.txt @@ -1,3 +1,4 @@ -Jeremy Heng -John Galt -Audrey Shida +Chan Jian Hui +Eldric Lim +Joshua Che +Derek Kok