27
loading...
This website collects cookies to deliver better user experience
AuthenticatedView
, while public facing routes continue to use the Resource
base class.decode_cookie
function will use PyJWT to verify the token and store it in the Flask global context. We'll register the decoding function as a before_request
handler so that verifying and storing the token is the first step in the request lifecycle.from app.services.auth import decode_cookie
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SQLALCHEMY_DATABASE_URI"] = create_db_uri()
app.config["SQLALCHEMY_POOL_RECYCLE"] = int(
os.environ.get("SQLALCHEMY_POOL_RECYCLE", 300)
)
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "placeholder_key")
app.config["SQLALCHEMY_ECHO"] = False
app.before_request_funcs.setdefault(None, [decode_cookie])
create_celery(app)
return app
decode_cookie
function will run for every request, and before any route handler logic. This step only verifies the token and stores the object on g.cookie
– it does not authenticate the user. We'll see that happen later in the require_login
function. Below is the implementation for the decode_cookie
function.import os
import logging
import jwt
from flask import g, request, abort
def decode_cookie():
cookie = request.cookies.get("user")
if not cookie:
g.cookie = {}
return
try:
g.cookie = jwt.decode(cookie, os.environ["SECRET_KEY"], algorithms=["HS256"])
except jwt.InvalidTokenError as err:
logging.warning(str(err))
abort(401)
require_login
function does the actual check against the database. At this point, we've verified the token, and have a user ID extracted from that token. Now we just need to make sure that the user ID matches a real user in the database.import logging
from flask import make_response, g, abort
from flask_restful import Resource, wraps
from app.models.user import User
def require_login(func):
@wraps(func)
def wrapper(*args, **kwargs):
if "id" not in g.cookie:
logging.warning("No authorization provided!")
abort(401)
g.user = User.query.get(g.cookie["id"])
if not g.user:
response = make_response("", 401)
response.set_cookie("user", "")
return response
return func(*args, **kwargs)
return wrapper
class AuthenticatedView(Resource):
method_decorators = [require_login]
g.user
so that the User instance is available wherever we might need it. If, for some reason the given ID is not found in the database, then we clear the cookie and send the user back to the login page with a 401.User
database model.from werkzeug.security import generate_password_hash, check_password_hash
from app import db
class User(db.Model):
__tablename__ = "user"
__table_args__ = (db.UniqueConstraint("google_id"), db.UniqueConstraint("email"))
id = db.Column(db.Integer, primary_key=True)
# An ID to use as a reference when sending email.
external_id = db.Column(
db.String, default=lambda: str(uuid.uuid4()), nullable=False
)
google_id = db.Column(db.String, nullable=True)
activated = db.Column(db.Boolean, default=False, server_default="f", nullable=False)
# When the user chooses to set up an account directly with the app.
_password = db.Column(db.String)
given_name = db.Column(db.String, nullable=True)
email = db.Column(db.String, nullable=True)
picture = db.Column(db.String, nullable=True)
last_login = db.Column(db.DateTime, nullable=True)
@property
def password(self):
raise AttributeError("Can't read password")
@password.setter
def password(self, password):
self._password = generate_password_hash(password)
def verify_password(self, password):
return check_password_hash(self._password, password)
password
attribute, meaning that while it may look like an attribute on the outside, we're actually calling methods when that attribute is accessed.user = User()
user.username = "Bob"
user.password = "PasswordForBob"
generate_password_hash
to create a scrambled version of the password that even we can't unscramble. The real value is stored in the _password
attribute. This process ensures that even if an attacker gained access to the database, they would not find any user passwords.UniqueConstraint
values added to the User class are also worth pointing out. Constraints at the database level are a great way to prevent certain kinds of bugs. Here we're saying it should be impossible to have two users with identical email addresses, or with the same Google ID. We'll also check for this situation in the Flask app, but it's good to have constraints as a fail-safe, in case there's a bug in the Python code.MAIL_DOMAIN
MAIL_SENDER
MAILGUN_API_KEY
POSTGRES_USER
POSTGRES_PASSWORD
POSTGRES_HOST
POSTGRES_DB
MAIL_DOMAIN
and MAIL_SENDER
, which in my case are mail.openranktracker.com and [email protected] respectively. The MAILGUN_API_KEY
value is used to authenticate your requests to the Mailgun API.create_app
function to add these new values to the global config dictionary, so that we can access them from anywhere.app.config["MAILGUN_API_KEY"] = os.environ["MAILGUN_API_KEY"]
app.config["MAIL_SUBJECT_PREFIX"] = "[OpenRankTracker]"
app.config["MAIL_SENDER"] = os.environ.get("MAIL_SENDER")
app.config["MAIL_DOMAIN"] = os.environ["MAIL_DOMAIN"]
def send_email(to, subject, template, **kwargs):
rendered = render_template(template, **kwargs)
response = requests.post(
"https://api.mailgun.net/v3/{}/messages".format(app.config["MAIL_DOMAIN"]),
auth=("api", app.config["MAILGUN_API_KEY"]),
data={
"from": app.config["MAIL_SENDER"],
"to": to,
"subject": app.config["MAIL_SUBJECT_PREFIX"] + " " + subject,
"html": rendered,
},
)
return response.status_code == 201
app/templates/verify_email.html
template itself is very basic, but functional.<p>Please follow the link below in order to verify your email address!</p>
<a href="{{ root_domain }}welcome/activate?user_uuid={{ user_uuid }}">Verify email and activate account</a>
root_domain
variable makes this code independent of the server it's deployed to, so that if we had a staging or test server, it would continue to work there. The user_uuid
value is a long string of random letters and digits that identifies users outside of the system – we do this instead of using the primary key because it's best not to rely on an easily enumerated value that an attacker could iterate through.activated
column on the user table.signup.py
route handler.from app.services.user import send_email
from app.serde.user import UserSchema
from app.models.user import User
from app import db
class SignUpView(Resource):
def post(self):
data = request.get_json()
user = User.query.filter(
func.lower(User.email) == data["email"].strip().lower()
).first()
if user:
abort(400, "This email address is already in use.")
user = User()
user.email = data["email"].strip()
user.password = data["password"].strip()
user.last_login = datetime.now()
db.session.add(user)
db.session.commit()
send_email(
user.email,
"Account activation",
"verify_email.html",
root_domain=request.url_root,
)
response = make_response("")
response.set_cookie(
"user",
jwt.encode(
UserSchema().dump(user), app.config["SECRET_KEY"], algorithm="HS256"
),
)
return response
user.password
, the plain-text password is never permanently stored anywhere – the one-way hashed value is stored in the _password
table column.const App = () => {
const [loadingApp, setLoadingApp] = useState(true);
const [loggedIn, setLoggedIn] = useState(false);
/*
** Check for a user token when the app initializes.
**
** Use the loadingApp variable to delay the routes from
** taking effect until loggedIn has been set (even logged in
** users would be immediately redirected to login page
** otherwise).
*/
useEffect(() => {
setLoggedIn(!!getUser());
setLoadingApp(false);
}, []);
return (
<UserContext.Provider value={{ loggedIn, setLoggedIn }}>
{!loadingApp && (
<Router style={{ minHeight: "100vh" }}>
<Splash path="/welcome/*" />
<ProtectedRoute path="/*" component={Home} />
</Router>
)}
</UserContext.Provider>
);
};
UserContext
is provided at the top level of the app, so code anywhere can determine if the user is currently logged in, and potentially change that state. The ProtectedRoute
component simply wraps another component, and prevents that component from loading if the user isn't logged in, instead sending them back to the login page.ProtectedRoute
, we can see that it uses the UserContext
to determine if it should load the wrapped component, or redirect to the login page.const ProtectedRoute = ({ component: Component }) => {
const { loggedIn } = useContext(UserContext);
return loggedIn ? (
<Component />
) : (
<Redirect from="" to="welcome/login" noThrow />
);
};
GOOGLE_CLIENT_ID
and GOOGLE_CLIENT_SECRET
environment vars will need to find their way into variables.env
so that the app container can pick them up.oauthsignup.py
and oauthlogin.py
are very simple, and just redirect the browser over to Google while generating a callback URL. The React front-end will do a form submission to one of these, causing the browser to leave our application.from flask import request, redirect
from flask_restful import Resource
from app.services.auth import oauth2_request_uri
class Oauth2SignUpView(Resource):
def post(self):
return redirect(
oauth2_request_uri(request.url_root + "api/users/oauth2callback/signup/")
)
from app.services.auth import get_user_info
from app.serde.user import UserSchema
from app.models.user import User
from app import db
class Oauth2SignUpCallbackView(Resource):
def get(self):
oauth_code = request.args.get("code")
userinfo = get_user_info(oauth_code)
google_id = userinfo["sub"]
# Find existing authenticated Google ID or an existing email that the
# user previously signed up with (they're logging in via Google for
# the first time).
user = User.query.filter(
or_(
User.google_id == google_id,
func.lower(User.email) == userinfo["email"].lower(),
)
).first()
if not user:
user = User()
user.google_id = google_id
user.given_name = userinfo["given_name"]
user.email = userinfo["email"]
user.last_login = datetime.now()
user.activated = True
db.session.add(user)
db.session.commit()
response = redirect(request.url_root)
response.set_cookie(
"user",
jwt.encode(
UserSchema().dump(user), app.config["SECRET_KEY"], algorithm="HS256"
),
)
return response
get_user_info
utility function combines the oAuth code returned from Google with our client ID and secret in order to fetch non-sensitive data about the user, including email address and given name.