I'd like to 1. outline the problem, 2. show my code, 3. show what happens and what I've done to diagnose the problem, 4. show where I've already looked to solve the problem, and finally, 5. ask a few questions. I apologize in advance that it's a long post, but I've looked everywhere and it's been hard to pinpoint what the problem is, so I'm trying to share everything that's relevant but also not too much.
1. Problem outline
I'm writing a login system using Flask and React. On the server side, I have a /register
endpoint to make a new user, a /login
endpoint to log them in, and an /@me
endpoint to display the user's information. I'm using a server-side flask_session
for the user's session using SQLAlchemy. I have CORS enabled with credentials via flask_cors
. On the client side, I've made up a register and login page, and a landing page to display the user's information. Requests to the server are sent using Axios with credentials.
My issue is when I register (or login) the user from my browser (Chrome), the landing page is unable to display the user's information because on the server side, the session
dictionary is empty when calling the /@me
endpoint, even though I explicitly populate the session
dictionary when I register (or login) the user in via the /register
(or /login
) endpoint.
What is even stranger, is that if I use Postman to do the exact same sequence (i.e. /register
or /login
, and then /@me
) it works just fine, and the /@me
endpoint indeed returns the user's information.
2. Code
N.B.: I cannot claim originality for this code. I followed this excellent video for a simple login system, and their code is here
Server side
I have my main Flask app as follows (app.py
):
from flask import Flask, request, session
from flask_cors import CORS
from flask_session import Session
from flask_bcrypt import Bcrypt
from models import db, User
from config import ApplicationConfig
app = Flask(__name__)
app.config.from_object(ApplicationConfig)
bcrypt = Bcrypt(app)
cors_app = CORS(
app,
supports_credentials=True
)
server_session = Session(app)
db.init_app(app)
with app.app_context():
db.create_all()
@app.route("/@me", methods=["GET"])
def get_current_user():
user_id = session.get("user_id")
# Included print statement for debugging from Flask console
print(session)
if not user_id:
return ({"error": "Unauthorized"}, 401)
user = User.query.filter_by(id=user_id).first()
return ({
"id": user.id,
"email": user.email,
"session" : session
}, 200)
@app.route("/register", methods=["POST"])
def register_user():
email = request.json["email"]
password = request.json["password"]
user_exists = User.query.filter_by(email=email).first() is not None
if user_exists:
return ({"error" : "User already exists"}, 409)
hashed_password = bcrypt.generate_password_hash(password)
new_user = User(email=email, password=hashed_password)
db.session.add(new_user)
db.session.commit()
session["user_id"] = new_user.id
# Included print statement for debugging from Flask console
print(session)
return ({
"id": new_user.id,
"email": new_user.email
}, 200)
@app.route("/login", methods=["POST"])
def login_user():
email = request.json["email"]
password = request.json["password"]
user = User.query.filter_by(email=email).first()
if user is None:
return ({"error": "Unauthorized"}, 401)
if not bcrypt.check_password_hash(user.password, password):
return ({"error": "Unauthorized"}, 401)
session["user_id"] = user.id
# Included print statement for debugging from Flask console
print(session)
return ({
"id": user.id,
"email": user.email
}, 200)
@app.route("/logout", methods=["POST"])
def logout_user():
print(session)
session.pop("user_id")
return (
{
"message" : "logged out"
},
200)
My application configuration looks like (config.py
):
import dotenv
import os
from models import db
from datetime import timedelta
dotenv.load_dotenv()
environment_values = dotenv.dotenv_values()
basedir = os.path.abspath(os.path.dirname(__file__))
class ApplicationConfig:
SECRET_KEY = "a very secret key"
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
SQLALCHEMY_DATABASE_URI = (
'sqlite:///'
+ os.path.join(basedir, 'database.db')
)
SESSION_TYPE = "sqlalchemy"
SESSION_PERMANENT = True
SESSION_USE_SIGNER = True
SESSION_SQLALCHEMY_TABLE="sessions"
SESSION_SQLALCHEMY=db
PERMANENT_SESSION_LIFETIME = timedelta(minutes=15)
and my database models looks like (models.py
):
from flask_sqlalchemy import SQLAlchemy
from uuid import uuid4
db = SQLAlchemy()
def get_uuid():
return uuid4().hex
class User(db.Model):
__tablename__ = "users"
id = db.Column(
db.String(32),
primary_key=True,
unique=True,
default=get_uuid
)
email = db.Column(db.String(345), unique=True)
password = db.Column(db.Text, nullable=False)
Also, it might be helpful, here is my requirements.txt
to install the packages I need:
Flask==2.3.2
Flask-Cors==3.0.10
Flask-Session==0.5.0
Flask-Bcrypt==1.0.1
Flask-SQLAlchemy==3.0.3
python-dotenv==1.0.0
Werkzeug==2.3.4 # Need a lower version than 3. to avoid some string/bytes issue
Client side
First, as suggested in one of Miguel Grinberg's posts, I set React's proxy to the address of my Flask backend. To package.json
, I add:
"proxy": "http://127.0.0.1:5000"
I set a router to re-direct traffic (Router.js
)
import { BrowserRouter, Routes, Route } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import NotFound from "./pages/NotFound";
import RegisterPage from "./pages/RegisterPage";
const Router = () => {
return (
<BrowserRouter>
<Routes>
<Route path="/" exact element={<LandingPage/>} />
<Route path="/login" exact element={<LoginPage/>} />
<Route path="/register" exact element={<RegisterPage />} />
<Route path="*" element={<NotFound/>} />
</Routes>
</BrowserRouter>
);
};
export default Router;
and I tell the index to use this router (index.js
)
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import Router from "./Router.js"
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Router/>
</React.StrictMode>
);
I then define an Axios http client and tell it to use credentials, which I gather is essential for CORS in httpClient.js
:
import axios from "axios";
export default axios.create({
withCredentials: true
});
I then define my login, register, landing, and not found components. First, pages/LoginPage.js
:
import React, {useState} from 'react'
import httpClient from '../httpClient'
const LoginPage = () => {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const logInUser = async (e) => {
console.log(email, password);
try {
await httpClient.post("http://127.0.0.1:5000/login",{
email,
password
});
window.location.href = "/";
}
catch(error) {
if (error.response.status === 401)
{
alert("Invalid credentials")
}
}
};
return (
<div>
<h1>Log into your account </h1>
<form>
<div>
<label>Email:</label>
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
id=""
/>
</div>
<div>
<label>Password:</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
id=""
/>
</div>
<button type="button" onClick={() => logInUser()}>
Submit
</button>
</form>
</div>
);
};
export default LoginPage;
Next, pages/RegisterPage.js
:
import React, { useState } from "react";
import httpClient from "../httpClient";
const RegisterPage =() => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const registerUser = async () => {
try {
await httpClient.post("//127.0.0.1:5000/register", {
email,
password,
});
window.location.href = "/";
} catch (error) {
if (error.response.status === 401) {
alert("Invalid credentials");
}
}
};
return (
<div>
<h1>Create an account</h1>
<form>
<div>
<label>Email: </label>
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
id=""
/>
</div>
<div>
<label>Password: </label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
id=""
/>
</div>
<button type="button" onClick={() => registerUser()}>
Submit
</button>
</form>
</div>
);
};
export default RegisterPage;
and, finally, the pages/LandingPage.js
:
import React, {useState, useEffect} from 'react'
import httpClient from '../httpClient';
const LandingPage = () => {
const [user, setUser] = useState(null)
const logoutUser = async () => {
await httpClient.post("http://127.0.0.1:5000/logout");
window.location.href = "/";
};
useEffect(() => {
(async () => {
try {
const resp = await httpClient.get("http://127.0.0.1:5000/@me");
console.log(resp)
setUser(resp.data);
} catch(error) {
console.log("Not authenticated")
}
})();
}, []);
return(
<div>
<h1>Welcome to CogniFlow!</h1>
{user != null ? (
<div>
<h2>Logged in</h2>
<h3>ID: {user.id}</h3>
<h3>Email: {user.email}</h3>
<button onClick={logoutUser}>Logout</button>
</div>
) : (
<div>
<p>You aren't logged in</p>
<div className="buttons">
<a href="/login">
<button>Login</button>
</a>
<a href="/register">
<button>Register</button>
</a>
</div>
</div>
)}
</div>
);
};
export default LandingPage;
For brevity, I exclude the NotFound.js
page, but I can show it if necessary.
3. What happens
I run the Flask server via python3 -m flask --app=app.py --debug run
, which runs on http::127.0.0.1:5000
and I run the React client using npm start
, which runs on http://localhost:3000
.
From the browser (Chrome)
I create a user with username [email protected]
and password 2b|!2b
. As shown in RegisterPage.js
, this leads me back to the landing page. However, it says "You are not logged in", which suggests that the request to /@me
was unsuccessful. If I look at the console, I get two of the exact same Failed to load resource: server responded with a status of 401 (UNAUTHORIZED)
error.
If I look to the Flask console, I see:
127.0.0.1 - - [20/Nov/2023 09:21:17] "OPTIONS /register HTTP/1.1" 200 -
<SqlAlchemySession {'_permanent': True, 'user_id': '4fe9812bb8464917bd2297dc7855d929'}>
127.0.0.1 - - [20/Nov/2023 09:21:18] "POST /register HTTP/1.1" 200 -
<SqlAlchemySession {'_permanent': True}>
127.0.0.1 - - [20/Nov/2023 09:21:18] "GET /@me HTTP/1.1" 401 -
<SqlAlchemySession {'_permanent': True}>
127.0.0.1 - - [20/Nov/2023 09:21:18] "GET /@me HTTP/1.1" 401 -
Indeed, when I registered [email protected]
, the session
dictionary has a user_id
, but when I call the /@me
endpoint, the relevant information seems to vanish.
From Postman
I effectively repeat the procedure from Postman. I post to the the /register
endpoint with a username of [email protected]
with a password of nothingcomesfromnothing
(not that it really matters). This returns:
{
"email": "[email protected]",
"id": "974f99e96e864225b38d13cd50a47a5a"
}
I get to the /@me
endpoint and I get back:
{
"email": "[email protected]",
"id": "974f99e96e864225b38d13cd50a47a5a",
"session": {
"_permanent": true,
"user_id": "974f99e96e864225b38d13cd50a47a5a"
}
}
which indeed suggests that the session dictionary works. If I look at the Flask console, I see:
<SqlAlchemySession {'_permanent': True, 'user_id': '974f99e96e864225b38d13cd50a47a5a'}>
127.0.0.1 - - [20/Nov/2023 09:27:50] "POST /register HTTP/1.1" 200 -
<SqlAlchemySession {'_permanent': True, 'user_id': '974f99e96e864225b38d13cd50a47a5a'}>
127.0.0.1 - - [20/Nov/2023 09:28:46] "GET /@me HTTP/1.1" 200 -
Comparison
In the Postman case, there is no pre-flight OPTIONS request (not entirely unexpected), there is only one GET request, and the session dictionary persists.
4. Where I've looked
Everywhere. This link suggests that Postman circumvents CORS, suggesting that maybe this is a CORS issue; this link suggests adding a session.modified = True
. This didn't work. It also suggests to make sure that I store less than 4KB of data in the session. This link suggests to add a SESSION_COOKIE_DOMAIN = "http://localhost:3000"
to my config.py
and that doesn't work. This link suggests to add a credentials : "same-origin"
to the client side when posting requests, but I couldn't find any option in axios for that. Besides, I have withCredentials: true
already set. This link suggests to double check that the flask_cors
has supports_credentials=True
, which I do. This link suggests adding "http://localhost:3000"
to the allowed origins in flask_cors
but this didn't work. This link suggests to explicitly handle the pre-flight request, but that didn't work for me either. There are many other things I've tried, which didn't work.
5. Questions
If you've got this far, thanks. My questions are:
- How can I make my session dictionary persist between endpoint requests from Chrome?
- What am I doing wrong here? I'm sure it's something very simple, but I'm just not seeing it
- What is the difference between the Chrome and Postman requests? Why does the former fail yet the latter work?