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 /@meendpoint 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?
0

There are 0 answers