1
0
mirror of https://github.com/LukePeters/flask-mongo-api-boilerplate.git synced 2026-05-05 18:01:10 +09:00

First push to GitHub

This commit is contained in:
Luke Peters
2019-02-17 17:04:03 -05:00
commit 6d77347505
18 changed files with 751 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.vscode
.DS_Store
*.pyc
Pipfile.lock
api/main/config/*.cfg

16
Pipfile Normal file
View File

@@ -0,0 +1,16 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
[packages]
flask = "*"
flask-cors = "*"
pymongo = "*"
python-jose = "*"
passlib = "*"
[requires]
python_version = "3.7"

32
README.md Normal file
View File

@@ -0,0 +1,32 @@
## Requirements
- pipenv
- MongoDB
- Python 3 (defaults to Python 3.7, but you can change this in the Pipfile before setup)
## Setup instructions
1. Clone this repo to your local web server
2. `cd` into the directory within the terminal
3. Run `./setup.sh` to setup pipenv and configure the Flask app
## Running the app
1. Run `pipenv shell` to activate the virtual environment
2. Run `./run.sh` to start the Flask application
## Further configuration
You can configure the app manually by editing the `api/main/config/config.cfg` file.
## Auth tokens
There is a very basic front-end example in place within the `/web` directory. It demonstrates making a few API calls (User Add and User Login).
A successful login request will return two tokens: `AccessToken` and `RefreshToken`. These should be saved to localStorage and used to set the `AccessToken` and `RefreshToken` request headers for all protected routes (e.g. `GET /user/`).
You can refresh the `AccessToken` when it returns as expired by submitting a request to `GET /user/auth/`.
## Notes
Please excuse the brief instructions. I've only run this in my own environment (MacOS, Python 3.7, MongoDB 4.0.4, pipenv 2018.11.14) so it may not run out of the box on your computer, but I'd be happy to help debug if you get stuck. Reach out on Twitter [@MoonlightLuke](https://twitter.com/MoonlightLuke)

38
api/main/__init__.py Normal file
View File

@@ -0,0 +1,38 @@
from flask import Flask, request
from flask_cors import CORS
from pymongo import MongoClient
from main.tools import JsonResp
from jose import jwt
import os
# Import Routes
from main.user.routes import user_blueprint
def create_app():
# Flask Config
app = Flask(__name__)
app.config.from_pyfile("config/config.cfg")
cors = CORS(app, resources={r"/*": { "origins": app.config["FRONTEND_DOMAIN"] }})
# Misc Config
os.environ["TZ"] = app.config["TIMEZONE"]
# Database Config
if app.config["ENVIRONMENT"] == "development":
mongo = MongoClient(app.config["MONGO_HOSTNAME"], app.config["MONGO_PORT"])
app.db = mongo[app.config["MONGO_APP_DATABASE"]]
else:
mongo = MongoClient("localhost")
mongo[app.config["MONGO_AUTH_DATABASE"]].authenticate(app.config["MONGO_AUTH_USERNAME"], app.config["MONGO_AUTH_PASSWORD"])
app.db = mongo[app.config["MONGO_APP_DATABASE"]]
# Register Blueprints
app.register_blueprint(user_blueprint, url_prefix="/user")
# Index Route
@app.route("/")
def index():
return JsonResp({ "status": "Online" }, 200)
return app

65
api/main/auth/__init__.py Normal file
View File

@@ -0,0 +1,65 @@
from flask import current_app as app
from flask import request
from functools import wraps
from main.tools import JsonResp
from jose import jwt
import datetime
# Auth Decorator
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
access_token = request.headers.get('AccessToken')
try:
data = jwt.decode(access_token, app.config['SECRET_KEY'])
except Exception as e:
return JsonResp({ "message": "Token is invalid", "exception": str(e) }, 401)
return f(*args, **kwargs)
return decorated
def encodeAccessToken(user_id, email, plan):
accessToken = jwt.encode({
"user_id": user_id,
"email": email,
"plan": plan,
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=15) # The token will expire in 15 minutes
}, app.config["SECRET_KEY"], algorithm="HS256")
return accessToken
def encodeRefreshToken(user_id, email, plan):
refreshToken = jwt.encode({
"user_id": user_id,
"email": email,
"plan": plan,
"exp": datetime.datetime.utcnow() + datetime.timedelta(weeks=4) # The token will expire in 4 weeks
}, app.config["SECRET_KEY"], algorithm="HS256")
return refreshToken
def refreshAccessToken(refresh_token):
# If the refresh_token is still valid, create a new access_token and return it
try:
user = app.db.users.find_one({ "refresh_token": refresh_token }, { "_id": 0, "id": 1, "email": 1, "plan": 1 })
if user:
decoded = jwt.decode(refresh_token, app.config["SECRET_KEY"])
new_access_token = encodeAccessToken(decoded["user_id"], decoded["email"], decoded["plan"])
result = jwt.decode(new_access_token, app.config["SECRET_KEY"])
result["new_access_token"] = new_access_token
resp = JsonResp(result, 200)
else:
result = { "message": "Auth refresh token has expired" }
resp = JsonResp(result, 403)
except:
result = { "message": "Auth refresh token has expired" }
resp = JsonResp(result, 403)
return resp

View File

@@ -0,0 +1,21 @@
# Config files are not tracked in Git and must be placed manually in each
# app environment (e.g. development, staging, production).
# General
DEBUG = True
TIMEZONE = "US/Eastern"
SECRET_KEY = ""
ENVIRONMENT = "development"
FLASK_DIRECTORY = "##FLASK_DIRECTORY##"
FLASK_DOMAIN = "##FLASK_DOMAIN##"
FLASK_PORT = ##FLASK_PORT##
FRONTEND_DOMAIN = "##FRONTEND_DOMAIN##"
HTTP_HTTPS = "http://"
# Database
MONGO_HOSTNAME = "##MONGO_HOSTNAME##"
MONGO_PORT = 27017
MONGO_AUTH_DATABASE = ""
MONGO_AUTH_USERNAME = ""
MONGO_AUTH_PASSWORD = ""
MONGO_APP_DATABASE = "##MONGO_APP_DATABASE##"

View File

@@ -0,0 +1,43 @@
import random
import time
def nowEpoch():
return int(time.time()) * 1000
def JsonResp(data, status):
from flask import Response
import json
return Response(json.dumps(data), mimetype="application/json", status=status)
def randID():
randId = randString(3) + randString(3) + randString(3) + randString(3) + randString(3) + randString(3)
return randId
def randString(length):
randString = ""
for _ in range(length):
randString += random.choice("AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890")
return randString
def randStringCaps(length):
randString = ""
for _ in range(length):
randString += random.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
return randString
def randStringNumbersOnly(length):
randString = ""
for _ in range(length):
randString += random.choice("23456789")
return randString
def validEmail(email):
import re
if re.match("^.+\\@(\\[?)[a-zA-Z0-9\\-\\.]+\\.([a-zA-Z]{2,3}|[0-9]{1,3})(\\]?)$", email) != None:
return True
else:
return False

View File

152
api/main/user/models.py Normal file
View File

@@ -0,0 +1,152 @@
from flask import current_app as app
from flask import Flask, request
from passlib.hash import pbkdf2_sha256
from jose import jwt
from main import tools
from main import auth
import json
class User:
def __init__(self):
self.defaults = {
"id": tools.randID(),
"ip_addresses": [request.remote_addr],
"acct_active": True,
"date_created": tools.nowEpoch(),
"last_login": tools.nowEpoch(),
"first_name": "",
"last_name": "",
"email": "",
"plan": "free"
}
def get(self):
token_data = jwt.decode(request.headers.get('AccessToken'), app.config['SECRET_KEY'])
user = app.db.users.find_one({ "id": token_data['user_id'] }, {
"_id": 0,
"password": 0
})
if user:
resp = tools.JsonResp(user, 200)
else:
resp = tools.JsonResp({ "message": "User not found" }, 404)
return resp
def getAuth(self):
access_token = request.headers.get("AccessToken")
refresh_token = request.headers.get("RefreshToken")
resp = tools.JsonResp({ "message": "User not logged in" }, 401)
if access_token:
try:
decoded = jwt.decode(access_token, app.config["SECRET_KEY"])
resp = tools.JsonResp(decoded, 200)
except:
# If the access_token has expired, get a new access_token - so long as the refresh_token hasn't expired yet
resp = auth.refreshAccessToken(refresh_token)
return resp
def login(self):
resp = tools.JsonResp({ "message": "Invalid user credentials" }, 403)
try:
data = json.loads(request.data)
email = data["email"].lower()
user = app.db.users.find_one({ "email": email }, { "_id": 0 })
if user and pbkdf2_sha256.verify(data["password"], user["password"]):
access_token = auth.encodeAccessToken(user["id"], user["email"], user["plan"])
refresh_token = auth.encodeRefreshToken(user["id"], user["email"], user["plan"])
app.db.users.update({ "id": user["id"] }, { "$set": {
"refresh_token": refresh_token,
"last_login": tools.nowEpoch()
} })
resp = tools.JsonResp({
"id": user["id"],
"email": user["email"],
"first_name": user["first_name"],
"last_name": user["last_name"],
"plan": user["plan"],
"access_token": access_token,
"refresh_token": refresh_token
}, 200)
except Exception:
pass
return resp
def logout(self):
try:
tokenData = jwt.decode(request.headers.get("AccessToken"), app.config["SECRET_KEY"])
app.db.users.update({ "id": tokenData["user_id"] }, { '$unset': { "refresh_token": "" } })
# Note: At some point I need to implement Token Revoking/Blacklisting
# General info here: https://flask-jwt-extended.readthedocs.io/en/latest/blacklist_and_token_revoking.html
except:
pass
resp = tools.JsonResp({ "message": "User logged out" }, 200)
return resp
def add(self):
data = json.loads(request.data)
expected_data = {
"first_name": data['first_name'],
"last_name": data['last_name'],
"email": data['email'].lower(),
"password": data['password']
}
# Merge the posted data with the default user attributes
self.defaults.update(expected_data)
user = self.defaults
# Encrypt the password
user["password"] = pbkdf2_sha256.encrypt(user["password"], rounds=20000, salt_size=16)
# Make sure there isn"t already a user with this email address
existing_email = app.db.users.find_one({ "email": user["email"] })
if existing_email:
resp = tools.JsonResp({
"message": "There's already an account with this email address",
"error": "email_exists"
}, 400)
else:
if app.db.users.save(user):
# Log the user in (create and return tokens)
access_token = auth.encodeAccessToken(user["id"], user["email"], user["plan"])
refresh_token = auth.encodeRefreshToken(user["id"], user["email"], user["plan"])
app.db.users.update({ "id": user["id"] }, {
"$set": {
"refresh_token": refresh_token
}
})
resp = tools.JsonResp({
"id": user["id"],
"email": user["email"],
"first_name": user["first_name"],
"last_name": user["last_name"],
"plan": user["plan"],
"access_token": access_token,
"refresh_token": refresh_token
}, 200)
else:
resp = tools.JsonResp({ "message": "User could not be added" }, 400)
return resp

27
api/main/user/routes.py Normal file
View File

@@ -0,0 +1,27 @@
from flask import Blueprint
from flask import current_app as app
from main.auth import token_required
from main.user.models import User
user_blueprint = Blueprint("user", __name__)
@user_blueprint.route("/", methods=["GET"])
@token_required
def get():
return User().get()
@user_blueprint.route("/auth/", methods=["GET"])
def getAuth():
return User().getAuth()
@user_blueprint.route("/login/", methods=["POST"])
def login():
return User().login()
@user_blueprint.route("/logout/", methods=["GET"])
def logout():
return User().logout()
@user_blueprint.route("/", methods=["POST"])
def add():
return User().add()

8
api/run.py Normal file
View File

@@ -0,0 +1,8 @@
from main import create_app
import logging
if __name__ == "__main__":
app = create_app()
app.run(host=app.config["FLASK_DOMAIN"], port=app.config["FLASK_PORT"])
else:
logging.basicConfig(app.config["FLASK_DIRECTORY"] + "trace.log", level=logging.DEBUG)

0
api/trace.log Executable file
View File

1
run.sh Executable file
View File

@@ -0,0 +1 @@
pipenv run python api/run.py

104
setup.sh Executable file
View File

@@ -0,0 +1,104 @@
#!/bin/sh
WHITE='\033[1;37m'
RED='\033[0;31m'
GREEN='\033[0;32m'
BROWN='\033[1;33m'
BLUE='\033[1;34m'
PURPLE='\033[1;35m'
CYAN='\033[0;36m'
GREY='\033[1;30m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "${GREY}"
cat << "EOF"
####### _____ __
###### / ___/___ / /___ ______
##### \__ \/ _ \/ __/ / / / __ \
#### ___/ / __/ /_/ /_/ / /_/ /
### /____/\___/\__/\__,_/ .___/
## /_/
#
EOF
# PIPENV SETUP
echo "${BLUE}VIRTUAL ENVIRONMENT SETUP${NC}"
echo
pipenv install
echo
# FLASK SETUP
echo "${BLUE}FLASK CONFIGURATION${NC}"
echo
# Defaults
SECRET_KEY=`cat /dev/urandom | head -c 24 | base64`
FLASK_DOMAIN="localhost"
FLASK_PORT_DEFAULT=5000
FLASK_DIRECTORY="$(pwd)/api/"
MONGO_HOSTNAME_DEFAULT="localhost"
# Domain the Flask app will be running on
read -p "Flask App Domain [$FLASK_DOMAIN]: " FLASK_DOMAIN
FLASK_DOMAIN=${FLASK_DOMAIN:-$FLASK_DOMAIN}
echo
# Port the Flask app will be running on
read -p "Flask App Port [$FLASK_PORT_DEFAULT]: " FLASK_PORT
FLASK_PORT=${FLASK_PORT:-$FLASK_PORT_DEFAULT}
echo
# URL of the front-end JavaScript application
read -p "Front-End Base URL, port included (e.g. http://localhost, http://localhost:3000): " FRONTEND_DOMAIN
echo
# MongoDB hostname
read -p "Mongo Hostname [$MONGO_HOSTNAME_DEFAULT]: " MONGO_HOSTNAME
MONGO_HOSTNAME=${MONGO_HOSTNAME:-$MONGO_HOSTNAME_DEFAULT}
echo
# MongoDB database name for the app
while [[ $MONGO_APP_DATABASE == '' ]]
do
read -p "Mongo App Database Name: " MONGO_APP_DATABASE
if [[ $MONGO_APP_DATABASE == '' ]]
then
echo "${RED}Required${NC}"
fi
echo
done
# Rename config.cfg.sample to config.cfg
CONFIG_EXAMPLE_FILE=./api/main/config/config.cfg.sample
CONFIG_FILE=./api/main/config/config.cfg
mv $CONFIG_EXAMPLE_FILE $CONFIG_FILE
# Save configuration values to config.cfg
sed -i '' -e "s~##SECRET_KEY##~$SECRET_KEY~g" $CONFIG_FILE
sed -i '' -e "s~##FLASK_DOMAIN##~$FLASK_DOMAIN~g" $CONFIG_FILE
sed -i '' -e "s~##FLASK_PORT##~$FLASK_PORT~g" $CONFIG_FILE
sed -i '' -e "s~##FRONTEND_DOMAIN##~$FRONTEND_DOMAIN~g" $CONFIG_FILE
sed -i '' -e "s~##FLASK_DIRECTORY##~$FLASK_DIRECTORY~g" $CONFIG_FILE
sed -i '' -e "s~##MONGO_HOSTNAME##~$MONGO_HOSTNAME~g" $CONFIG_FILE
sed -i '' -e "s~##MONGO_APP_DATABASE##~$MONGO_APP_DATABASE~g" $CONFIG_FILE
echo "${GREEN}Flask configuration saved!${NC}"
echo
# INSTRUCTIONS TO START THE APP
echo "${BLUE}FIRE IT UP!${NC}"
echo
echo "To start the Flask app run these two commands:"
echo
echo "${GREY}> ${NC}pipenv shell"
echo "${GREY}> ${NC}./run.sh"
echo

90
web/app.css Normal file
View File

@@ -0,0 +1,90 @@
* {
position: relative;
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: sans-serif;
font-size: 16px;
line-height: 1.5;
color: #2C3A47;
background: #f4f4f4;
}
.centered {
text-align: center;
}
h1, h2, h3, h4, h5, h6, p {
margin: 0 0 1em;
}
.app-wrapper {
margin: 0 auto;
padding: 60px 0 120px;
max-width: 400px;
}
.app-panel {
margin: 0 0 60px;
padding: 26px 30px 32px;
border-radius: 6px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
background: #ffffff;
}
.input-label {
margin: 0 0 4px;
font-size: 13px;
color: #777777;
display: block;
}
.input-field {
margin: 0 0 24px;
padding: 7px 12px 7px;
width: 100%;
font-size: 16px;
line-height: 1.5;
border: 1px solid #cccccc;
border-radius: 4px;
display: block;
transition: border-color .2s ease;
}
.input-field:focus {
border-color: #2C3A47;
}
.btn {
padding: 10px 20px 11px;
font-size: 16px;
color: #ffffff;
border: none;
border-radius: 4px;
outline: none;
box-shadow: none;
cursor: pointer;
display: inline-block;
background: #2bcbba;
transition: background-color .2s ease;
}
.btn:hover {
background: #10ddc9;
}
.btn[type="submit"] {
margin-top: 6px;
}
.success-message {
padding: 12px 30px 11px;
text-align: center;
color: #044e48;
border-radius: 4px;
display: none;
background: #e0f8f6;
}

71
web/app.js Normal file
View File

@@ -0,0 +1,71 @@
$(function() {
// Add User Submission
var $addUserForm = $("#add-user-form"),
$addUserSuccess = $("#add-user-success");
$addUserForm.on("submit", function(e) {
var data = {
first_name: $addUserForm.find("#first_name").val(),
last_name: $addUserForm.find("#last_name").val(),
email: $addUserForm.find("#email").val(),
password: $addUserForm.find("#password").val(),
};
console.log(data);
$.ajax({
url: "http://localhost:5000/user/",
type: "POST",
dataType: "json",
contentType: "application/json; charset=utf-8",
data: JSON.stringify(data),
success: function(resp) {
console.log(resp);
$addUserForm.hide();
$addUserSuccess.show();
},
error: function(error) {
console.error(error);
alert(error.responseJSON.message)
}
});
e.preventDefault();
});
// User Login Submission
var $loginForm = $("#login-form"),
$loginSuccess = $("#login-success");
$loginForm.on("submit", function(e) {
var data = {
email: $loginForm.find("#email").val(),
password: $loginForm.find("#password").val(),
};
console.log(data);
$.ajax({
url: "http://localhost:5000/user/login/",
type: "POST",
dataType: "json",
contentType: "application/json; charset=utf-8",
data: JSON.stringify(data),
success: function(resp) {
console.log(resp);
$loginForm.hide();
$loginSuccess.show();
},
error: function(error) {
console.error(error);
alert(error.responseJSON.message)
}
});
e.preventDefault();
});
});

76
web/index.html Normal file
View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Front-End App</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<div class="app-wrapper">
<!-- Add User Form -->
<div class="app-panel">
<h3 class="centered">Add User</h3>
<form id="add-user-form">
<label for="first_name" class="input-label">First Name</label>
<input type="text" name="first_name" id="first_name" class="input-field" required>
<label for="last_name" class="input-label">Last Name</label>
<input type="text" name="last_name" id="last_name" class="input-field" required>
<label for="email" class="input-label">Email Address</label>
<input type="email" name="email" id="email" class="input-field" required>
<label for="password" class="input-label">Password</label>
<input type="password" name="password" id="password" class="input-field" required>
<div class="centered">
<input type="submit" value="Submit" class="btn">
</div>
</form>
<div id="add-user-success" class="success-message">
User successfully added!
</div>
</div>
<!-- Add User Form -->
<div class="app-panel">
<h3 class="centered">Add User</h3>
<form id="login-form">
<label for="email" class="input-label">Email Address</label>
<input type="email" name="email" id="email" class="input-field" required>
<label for="password" class="input-label">Password</label>
<input type="password" name="password" id="password" class="input-field" required>
<div class="centered">
<input type="submit" value="Submit" class="btn">
</div>
</form>
<div id="login-success" class="success-message">
User successfully authenticated!
</div>
</div>
</div>
<script src="jquery.js"></script>
<script src="app.js"></script>
</body>
</html>

2
web/jquery.js vendored Normal file

File diff suppressed because one or more lines are too long