reactjsdjango-rest-frameworkreact-reduxhttponlycookie-httponly

httpOnly Presisting Authentication token status between DRF and reactJS


I seem to be having trouble persisting my athntication status between django and reactjs ( when i refresh the page ) i loose authentication. upon sending requests to my endpoints in postman with my access_token everything is working great.

Here is how my drf is setup Settings.py

# CORS settings

CORS_ALLOW_CREDENTIALS = True
CORS_ORIGIN_WHITELIST = [
    "http://localhost:3000",
]

# CSRF settings
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = "None"

views.py

class CookieTokenObtainPairView(TokenObtainPairView):
    def finalize_response(self, request, response, *args, **kwargs):
        if response.data.get("access"):
            response.set_cookie(
                "access_token",
                response.data["access"],
                httponly=True,
                secure=False,
                samesite="Lax",
            )
            del response.data["access"]
        return super().finalize_response(request, response, *args, **kwargs)


class CookieTokenRefreshView(TokenRefreshView):
    def finalize_response(self, request, response, *args, **kwargs):
        if response.data.get("access"):
            response.set_cookie(
                "access_token",
                response.data["access"],
                httponly=True,
                secure=False,
                samesite="Lax",
            )
            del response.data["access"]
        return super().finalize_response(request, response, *args, **kwargs)


@api_view(["GET"])
@permission_classes([IsAuthenticated])
def check_auth_status(request):
    # If the request reaches here, the user is authenticated
    return Response({"status": "authenticated"})

urls.py

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/token/", CookieTokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("api/token/refresh/", CookieTokenRefreshView.as_view(), name="token_refresh"),
    path("api/check_auth_status/", check_auth_status, name="check_auth_status"),
    path("api/", include("vessels.urls")),
]

frontend side authslice.js

export const verifyAuth = createAsyncThunk(
  "auth/verifyAuth",
  async (_, { rejectWithValue }) => {
    try {
      await api.get("/api/check_auth_status/");
      // If the request is successful, the user is authenticated
      return true;
    } catch (error) {
      // If there is an error (like a 401), the user is not authenticated
      return rejectWithValue(false);
    }
  }
);

export const authSlice = createSlice({
  name: "auth",
  initialState: {
    isAuthenticated: false,
    // other states...
  },
  reducers: {
    // ... your other reducers ...
  },
  extraReducers: (builder) => {
    builder
      .addCase(verifyAuth.fulfilled, (state) => {
        state.isAuthenticated = true;
      })
      .addCase(verifyAuth.rejected, (state) => {
        state.isAuthenticated = false;
      });
  },
});

export const { loginSuccess, logout } = authSlice.actions;
export default authSlice.reducer;

loginpage.js

import React, { useState } from "react";
import api from "../../api/axios";
import { useNavigate } from "react-router-dom";
import { useDispatch } from "react-redux";
import { TextField, Button, Paper, Typography, Box } from "@mui/material";
import { verifyAuth } from "../../redux/slices/authSlice";
function LoginPage() {
  const navigate = useNavigate();
  const dispatch = useDispatch();
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  const handleSubmit = async (e) => {
    e.preventDefault();

    try {
      await api.post("api/token/", { username, password });

      // Verify authentication status
      dispatch(verifyAuth()).then(() => {
        // Redirect after successful login
        navigate("/");
      });
    } catch (error) {
      setError("Invalid credentials");
      console.error("Login error", error);
    }
  };

  return (
    <Box
      display="flex"
      justifyContent="center"
      alignItems="center"
      minHeight="100vh"
    >
      <Paper elevation={3} style={{ padding: "20px", width: "300px" }}>
        <Typography variant="h5" style={{ textAlign: "center" }}>
          Login
        </Typography>
        <form onSubmit={handleSubmit}>
          <TextField
            label="Username"
            variant="outlined"
            fullWidth
            margin="normal"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
          />
          <TextField
            label="Password"
            type="password"
            variant="outlined"
            fullWidth
            margin="normal"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
          <Button
            type="submit"
            variant="contained"
            color="primary"
            fullWidth
            style={{ marginTop: "20px" }}
          >
            Login
          </Button>
          {error && (
            <Typography color="error" style={{ marginTop: "20px" }}>
              {error}
            </Typography>
          )}
        </form>
      </Paper>
    </Box>
  );
}

export default LoginPage;

App.js

function App() {
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(verifyAuth());
  }, [dispatch]);

  return (
    <Router>
      <Routes>
        <Route path="/login" element={<LoginPage />} />
        <Route
          path="/"
          element={
            <ProtectedRoute>
              <HomePage />
            </ProtectedRoute>
          }
        />
      </Routes>
    </Router>
  );
}
export default App;

and finally ProtectedRoute.js

const ProtectedRoute = ({ children }) => {
  const isAuthenticated = useSelector((state) => state.auth.isAuthenticated);
  const location = useLocation();

  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
};

export default ProtectedRoute;

Solution

  • I don't think it is the case that you are losing the authentication. I suspect that the isAuthenticated state is reinitialized to false when the page is reloaded and your ProtectedRoute component selects that initial state value and renders the redirect back to the login page.

    A trivial solution is to persist the auth state to localStorage and initialize the auth state from localStorage.

    Example:

    export const authSlice = createSlice({
      name: "auth",
      initialState: {
        isAuthenticated: !!JSON.parse(localStorage.getItem("authenticated")),
        // other states...
      },
      reducers: {
        // ... your other reducers ...
      },
      extraReducers: (builder) => {
        builder
          .addCase(verifyAuth.fulfilled, (state) => {
            state.isAuthenticated = true;
            localStorage.setItem("authenticated", true);
          })
          .addCase(verifyAuth.rejected, (state) => {
            state.isAuthenticated = false;
            localStorage.setItem("authenticated", false);
          });
      },
    });
    

    Now when the page reloads, the persisted isAuthenticated state will be reinitialized and the user should stay on the protected route they were on. The dispatched verifyAuth action when the app mounts may invalidate their authentication later.