diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bb5b49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Python outputs +__pycache__/ +*.pyc + +# MacOS stuff +.DS_Store diff --git a/README.md b/README.md index 3b4998e..b3185d9 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,58 @@ -# Authentication mechanism based on Flask + JWT -This repository represents the source code template for `Flask` implementation of `JWT` based authentication. -It can be used as starting point for more complex projects and requirements or can be completely customised to serve your needs. +# Authentication gate with Flask & JWT +This repository represents the source code template for micro webserver that provides authentication gate for your protected resources. -## Installation and usage -Perhaps the easiest way to start would be executing `startup.sh` script. Simple `(user)$ . startup.sh` would do the trick on UNIX based systems. - -Alternatively, step by step guide would be: - - `clone` this repository (*aim for master*) - - `cd flask-auth-template` - - `pip3 install -r requirements.txt` - - `python3 auth-module.py` - -**NOTE:** if you're on `MacOS` platforms, there might be a struggle with running this project due to known collision of `Python2.7` and `Python3+`. The conflict might be seen as "Module Import Error" no matter which Python you are using. -To solve this, you might have to "fix" your `PYTHONPATH`. Check out this [article](https://bic-berkeley.github.io/psych-214-fall-2016/using_pythonpath.html) for more information. +It is written in `Python` using `Flask` framework and relies on `JWT` authentication mechanism. +Some of the provided strategies are to basic/simple for **serious**, production level webserver. Use this template as starting point for more complex projects and requirements. ### JWT based -`JSON Web Tokens` - or [JWT](https://jwt.io/) in short - are the foundation of the authentication mechanism presented here. -Be sure **not to forget** to decode/encode token generation at your own strategy. Follow code comments for exact place where you need to modify or customise this behaviour. +`JSON Web Tokens` - or [JWT](https://jwt.io/) in short - is the foundation authentication principle used in this template. +Be sure **not to forget** to encode/decode token generation at your own strategy. Follow code comments for exact place where you could modify or customise this behaviour. ### No database ! -Any DB layer has been **intentionally** omitted to allow space for your own implementation. In this form, the code template stores all generated tokens **in memory** and are only valid until next server restart. For more convenient mechanism, store your `tokens` in some form of persistent storage. +DB layer has been **intentionally omitted** to allow space for your own implementation. In present form, the code handles all tokens **in memory**, making the data available only while the server is running. All registration data (as well as tokens) will disappear after the server shut down. +For more convenient mechanism, store your tokens in some form of persistent storage, or reuse them in different way. + +### Modularised +Template is designed to support modular structure. Main application modules are stored in `modules` folder. If you need more modules, you can place them inside - as long as they are connected in the main module. + +### Different authentication strategies +Presented here is basic HTTP AUTHENTICATION through Authentication field. Note there are **way secure** authentication mechanisms, such as `OAuth`. + +### Installation +Before you begin: +``` +git clone +cd flask-auth-template +``` +Then proceed with installing dependencies: +```bash +# Run prepacked script +$ . install-dependencies.sh +# or +# install manually through pip3 +$ pip3 install -r requirements.txt +``` + +### Starting server +Template will setup and start a server listening on `localhost`. Check the debug output for more information. + +Start the server using: +```bash +python3 main-module.py +# or +# run using startup script +$ . start.sh +``` + +**:NOTE:** for `MacOS` users: +There might be a struggle with starting this project, due to known collision of `Python2.xx` and `Python3.xx` coexisting on same platform. The conflict might be manifested as good ol' *"Module Import Error"* no matter which Python you are using. +To solve this, you might have to "fix" (play around with) your `PYTHONPATH`. Check out this [article](https://bic-berkeley.github.io/psych-214-fall-2016/using_pythonpath.html) for more information. + +---- #### Word of wisdom -If you ever get stuck during coding, remember that `sudo` is your friend. If that doesn't help, just one cold 🍺 can magically make your life look way more beautiful. +If you ever get stuck remember that `sudo` is your friend. If it doesn't help, start thinking how one cold 🍺 can magically improve your understanding of the 🌎. ----- -Copyright Β© 2020 Veljko TekeleroviΔ‡ +Copyright Β© 2020 Veljko TekeleroviΔ‡ +MIT License diff --git a/auth-module.py b/auth-module.py deleted file mode 100644 index 2f4df3f..0000000 --- a/auth-module.py +++ /dev/null @@ -1,79 +0,0 @@ -import jwt -import datetime -from flask import Flask, jsonify, request, make_response -from functools import wraps - -# initialize main Flask object -if __name__ == '__main__': - app = Flask(__name__) - -# used as part of your authentication strategy -app.config['SECRET_KEY'] = 'some_secret_key' - -# protector function wraping other functions -def token_access_required(f): - @wraps(f) - def decorated(*args, **kwargs): - token = request.args.get('token') #get token from URL - - if not token: - return jsonify({'message': 'Token is missing!'}), 403 - - try: - data = jwt.decode(token, app.config['SECRET_KEY']) - except: - return jsonify({'message': 'Invalid token supplied!'}), 403 - return f(*args, **kwargs) - - return decorated - -# --------------------------------- -# ROUTES DEFINITION: -@app.route('/private') -@token_access_required -def protected(): - return jsonify({'message': 'Protected area'}) - -@app.route('/public') -def unprotected(): - return jsonify({'message': 'This is public domain'}) - -@app.route('/login') -def login(): - # get authorization field from HTTP request - # and early exit if it doesn't exist - auth = request.authorization - if not auth: - return make_response("Where's your token πŸ€”", 401, {'WWW-Authenticate': 'Basic realm="Login required"'}) - - # πŸ‘‡ DIFFERENT STRATEGIES POSSIBLE πŸ‘‡ - if auth.password == 'password': - # calculate token expity and form final token - tokenExpiry = setupExpiry() - token = generateToken(tokenExpiry) - return jsonify({'token': token.decode('UTF-8')}) - - return make_response('Could not verify!', 401, {'WWW-Authenticate': 'Basic realm="Login required"'}) - -# πŸ‘‡ DIFFERENT STRATEGIES POSSIBLE πŸ‘‡ -def setupExpiry(): - # sets token expiration to 30 minutes from now - return str(datetime.datetime.utcnow() + datetime.timedelta(minutes=30)) - -# πŸ‘‡ DIFFERENT STRATEGIES POSSIBLE πŸ‘‡ -def generateToken(exipry): - # define content as a mix of username and expiration date - tokenContent = { - 'user': auth.username, - 'expiration': token_expiration - } - - # 'crypt' it this way: - fullToken = jwt.encode(tokenContent, app.config['SECRET_KEY'], algorithm='HS256') - return fullToken - - -# --------------------------------- -# Server start procedure -if __name__ == '__main__': - app.run(debug=True) diff --git a/install-dependencies.sh b/install-dependencies.sh new file mode 100644 index 0000000..478822a --- /dev/null +++ b/install-dependencies.sh @@ -0,0 +1,3 @@ +# pip packages +echo "Installing needed modules via pip..." +pip3 install -r requirements.txt diff --git a/main-module.py b/main-module.py new file mode 100644 index 0000000..aa1dd66 --- /dev/null +++ b/main-module.py @@ -0,0 +1,35 @@ + +# -*- coding: utf-8 -*- +from flask import Flask, Blueprint +from services.storage import sharedStorage + +from modules.auth import * +from modules.protected import * +# from protected import * + +# initialize main Flask object +if __name__ == '__main__': + app = Flask(__name__) + app.config['SECRET_KEY'] = 'some_secret_key' + + # register app blueprints + app.register_blueprint(authRoute) + app.register_blueprint(protectedRoute) + +# Publicly accessible routes +# ------------------------------ +@app.route('/') +def home(): + output = [] + for user in sharedStorage.asList(): + output.append(str(user)) + return jsonify({ + 'count': sharedStorage.totalCount(), + 'storage': output + }) + + +# --------------------------------- +# Server start procedure +if __name__ == '__main__': + app.run(debug=True) diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..902f8b2 --- /dev/null +++ b/models/user.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- + +class User(): + def __init__(self, username = "", password = "", email = ""): + self.username = username + self.password = password + self.email = email + + def __str__(self): + return f"[{self.username}] - [pwd:{self.password} eml:{self.email}]" diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/auth.py b/modules/auth.py new file mode 100644 index 0000000..859af79 --- /dev/null +++ b/modules/auth.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +from flask import Flask, Blueprint +from flask import jsonify, request, make_response +from flask import current_app +from services.tokenizer import Tokenizer +from services.storage import sharedStorage +from models.user import User + +# public blueprint exposure +authRoute = Blueprint('auth', __name__) + +@authRoute.route('/login') +def login(): + # get authorization field from HTTP request, early exit if it's not present + auth = request.authorization + if not auth: + return make_response("HTTP Basic Authentication required πŸ€”", 401) #, {'WWW-Authenticate': 'Basic realm="Login required"'} + + try: # search our storage to check credentials + username = auth.username + password = auth.password + storedUser = sharedStorage.find(username) + + # πŸ‘‡ perform validity check and password hashing πŸ‘‡ + if storedUser is not None and storedUser.password == password: + current_app.logger.info(f" Security check completed, passwords match.") + # create new token using Tokenizer + tokenService = Tokenizer(current_app.config['SECRET_KEY']) + newToken = tokenService.createToken(username) + + utfDecodedToken = newToken.decode('UTF-8') + current_app.logger.info(f" New token created.") + return jsonify({'token': utfDecodedToken}) + except: + make_response("Bad request parameters. Try again", 400) + + return make_response("Wrong credentials.", 401) + +@authRoute.route('/logout') +def logout(): + current_app.logger.info("Someone logged out") + # remove token from the storage + return "You have been logged out.. But who are you ??" + +@authRoute.route('/register', methods=['POST']) +def registration(): + ''' + Expecting this JSON structure in body: + { + 'username' : "abc", + 'password': "abc", + 'email': "abc@abc" + } + ''' + try: #try to get the body data as JSON, fail otherwise + body = request.json + if body: + username = body['username'] + pwd = body['password'] # πŸ‘‡ password hashing πŸ‘‡ + email = body['email'] + + # add to our storage + newUser = User(username, pwd, email) + + current_app.logger.info(f" Adding new user: {newUser.username}, email: {newUser.email}") + sharedStorage.store(newUser) + + return make_response("

Welcome to the system


Have a pleasant stay {} and enjoy the system :)".format(newUser.username), 201) + except: + current_app.logger.error(" Unable to parse POST request.") + + return make_response("Wrong parameters. Try again", 400) diff --git a/modules/protected.py b/modules/protected.py new file mode 100644 index 0000000..fd92393 --- /dev/null +++ b/modules/protected.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from flask import Flask, Blueprint +from flask import jsonify, request, make_response +from flask import current_app +from services.tokenizer import Tokenizer +from services.storage import sharedStorage +from models.user import User +from functools import wraps + +# public blueprint exposure +protectedRoute = Blueprint('protected', __name__) + +# protector function wraping other functions +def token_access_required(f): + @wraps(f) + def decorated(*args, **kwargs): + token = request.args.get('token') #get token from URL + + if not token: + return jsonify({'message': 'Protected area. Valid access token required'}), 403 + try: # make sure we can decode token + tokenSupport = Tokenizer(current_app.config['SECRET_KEY']) + decodedUser = tokenSupport.decodeToken(token)['user'] + # and that we have a storage entry for this user + storedUser = sharedStorage.find(decodedUser) + if storedUser and storedUser.username == decodedUser: + current_app.logger.info(f" Username pair matches, protected entry allowed.") + else: + return jsonify({'message': 'Invalid access token supplied.'}), 401 + except: + return jsonify({'message': 'Invalid access token supplied.'}), 401 + return f(*args, **kwargs) + + return decorated + +# Protected ROUTES DEFINITION: (split further to standalone Blueprints) +# ----------------------------- +@protectedRoute.route('/protected') +@token_access_required +def protected(): + resp_body = jsonify({'message': 'Welcome to protected area, you made it'}) + return resp_body + +@protectedRoute.route('/protected2') +@token_access_required +def protected2(): + resp_body = jsonify({'message': 'Welcome to protected area, you made it'}) + return resp_body diff --git a/paw_testkit.paw b/paw_testkit.paw new file mode 100644 index 0000000..6b8ec98 Binary files /dev/null and b/paw_testkit.paw differ diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/storage.py b/services/storage.py new file mode 100644 index 0000000..c51043d --- /dev/null +++ b/services/storage.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from models.user import User + +# represents a basic in memory storage heap +class UserDataStorage(): + def __init__(self): + self.allUsers = [] + + def store(self, userData): + self.allUsers.append(userData) + print(f" New user stored: {userData.username}") + + # search all usernames and return matching user + def find(self, targetUsername): + result = None + for user in self.allUsers: + if user.username == targetUsername: + result = user + break + return result + + # returns a list of all stored objects + def asList(self): + return list(self.allUsers) + + def totalCount(self): + return len(list(self.allUsers)) + + +# shared reference to a single storage +sharedStorage = UserDataStorage() diff --git a/services/tokenizer.py b/services/tokenizer.py new file mode 100644 index 0000000..030b471 --- /dev/null +++ b/services/tokenizer.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +import jwt +import datetime + +class Tokenizer(): + def __init__(self, key): + self.secretKey = key + + # πŸ‘‡ DIFFERENT STRATEGIES POSSIBLE πŸ‘‡ + def createToken(self, username): + # define content as a mix of username and expiration date + tokenExpiry = self.setupExpiry() + tokenContent = { + 'user': username, + 'expiration': tokenExpiry + } + + # 'crypt' it this way: + fullToken = jwt.encode(tokenContent, self.secretKey, algorithm='HS256') + return fullToken + + # returns a decoded token + def decodeToken(self, rawToken): + output = jwt.decode(rawToken, self.secretKey, algorithms=['HS256']) + return output + + + # πŸ‘‡ DIFFERENT STRATEGIES POSSIBLE πŸ‘‡ + def setupExpiry(self): + # sets token expiration to 30 minutes from now + return str(datetime.datetime.utcnow() + datetime.timedelta(minutes=30)) diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..9baf654 --- /dev/null +++ b/start.sh @@ -0,0 +1,3 @@ +# server start +echo "Starting server in DEBUG mode" +python3 main-module.py diff --git a/startup.sh b/startup.sh deleted file mode 100644 index a2a6207..0000000 --- a/startup.sh +++ /dev/null @@ -1,12 +0,0 @@ -# Startup script. Will install needed modules and run the server -# origin clone and hard reset -echo "Cloning latest master" -# add git shell later - -# pip packages -echo "Installing needed modules via pip..." -pip3 install -r requirements.txt - -# server start -echo "Starting server in DEBUG mode" -python3 auth-module.py