【问题标题】:Nextjs - Auth token stored in memory + refresh token in HTTP Only cookieNextjs - 存储在内存中的身份验证令牌 + 仅 HTTP cookie 中的刷新令牌
【发布时间】:2021-01-30 10:11:15
【问题描述】:

我目前正在使用 Nextjs 和使用 Expressjs 的 api 实现身份验证流程。

我希望将JWT token 存储为身份验证令牌in memory,我可以使用存储在HTTPOnly cookie 中的刷新令牌定期刷新它。

对于我的实现,我参考了 nice OSS 项目here

我的问题是,当我在登录期间将身份验证令牌存储在inMemoryToken 中时,该值仅存储在可用的客户端但仍然可用的服务器端,反之亦然。

另一个例子是当我断开连接时:

  • inMemoryToken 等于服务器端的东西
  • 用户单击注销按钮,logout() 被称为前端,inMemoryToken = null
  • 在页面更改时,服务器上调用了getServerSideProps(),但服务器上的inMemoryToken 仍等于之前的值,因此我的用户仍显示为已连接。

这里是Nextjs 代码

//auth.js
import { Component } from 'react';
import Router from 'next/router';
import { serialize } from 'cookie';
import { logout as fetchLogout, refreshToken } from '../services/api';

let inMemoryToken;

export const login = ({ accessToken, accessTokenExpiry }, redirect) => {
    inMemoryToken = {
        token: accessToken,
        expiry: accessTokenExpiry,
    };
    if (redirect) {
        Router.push('/');
    }
};

export const logout = async () => {
    inMemoryToken = null;
    await fetchLogout();
    window.localStorage.setItem('logout', Date.now());
    Router.push('/');
};

const subMinutes = (dt, minutes) => {
    return new Date(dt.getTime() - minutes * 60000);
};

export const withAuth = (WrappedComponent) => {
    return class extends Component {
        static displayName = `withAuth(${Component.name})`;

        state = {
            accessToken: this.props.accessToken,
        };
        async componentDidMount() {
            this.interval = setInterval(async () => {
                inMemoryToken = null;
                const token = await auth();
                inMemoryToken = token;
                this.setState({ accessToken: token });
            }, 60000);
            window.addEventListener('storage', this.syncLogout);
        }

        componentWillUnmount() {
            clearInterval(this.interval);
            window.removeEventListener('storage', this.syncLogout);
            window.localStorage.removeItem('logout');
        }

        syncLogout(event) {
            if (event.key === 'logout') {
                Router.push('/');
            }
        }

        render() {
            return (
                <WrappedComponent
                    {...this.props}
                    accessToken={this.state.accessToken}
                />
            );
        }
    };
};

export const auth = async (ctx) => {
    console.log('auth ', inMemoryToken);
    if (!inMemoryToken) {
        inMemoryToken = null;
        const headers =
            ctx && ctx.req
                ? {
                      Cookie: ctx.req.headers.cookie ?? null,
                  }
                : {};
        await refreshToken(headers)
            .then((res) => {
                if (res.status === 200) {
                    const {
                        access_token,
                        access_token_expiry,
                        refresh_token,
                        refresh_token_expiry,
                    } = res.data;
                    if (ctx && ctx.req) {
                        ctx.res.setHeader(
                            'Set-Cookie',
                            serialize('refresh_token', refresh_token, {
                                path: '/',
                                expires: new Date(refresh_token_expiry),
                                httpOnly: true,
                                secure: false,
                            }),
                        );
                    }
                    login({
                        accessToken: access_token,
                        accessTokenExpiry: access_token_expiry,
                    });
                } else {
                    let error = new Error(res.statusText);
                    error.response = res;
                    throw error;
                }
            })
            .catch((e) => {
                console.log(e);
                if (ctx && ctx.req) {
                    ctx.res.writeHead(302, { Location: '/auth' });
                    ctx.res.end();
                } else {
                    Router.push('/auth');
                }
            });
    }
    const accessToken = inMemoryToken;
    if (!accessToken) {
        if (!ctx) {
            Router.push('/auth');
        }
    }
    return accessToken;
};

//page index.js

import Head from 'next/head';
import { Layout } from '../components/Layout';
import { Navigation } from '../components/Navigation';
import { withAuth, auth } from '../libs/auth';

const Home = ({ accessToken }) => (
    <Layout>
        <Head>
            <title>Home</title>
        </Head>
        <Navigation />
        <div>
            <p>
                Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
                eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
                enim ad minim veniam, quis nostrud exercitation ullamco laboris
                nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
                in reprehenderit in voluptate velit esse cillum dolore eu fugiat
                nulla pariatur. Excepteur sint occaecat cupidatat non proident,
                sunt in culpa qui officia deserunt mollit anim id est laborum.
            </p>
        </div>
    </Layout>
);

export const getServerSideProps = async (ctx) => {
    const accessToken = await auth(ctx);
    return {
        props: { accessToken: accessToken ?? null },
    };
};

export default withAuth(Home);

部分快递js代码:

app.post('/api/login', (req, res) => {
    const { username, password } = req.body;

    ....

    const refreshToken = uuidv4();
    const refreshTokenExpiry = new Date(new Date().getTime() + 10 * 60 * 1000);

    res.cookie('refresh_token', refreshToken, {
        maxAge: 10 * 60 * 1000,
        httpOnly: true,
        secure: false,
    });

    res.json({
        access_token: accessToken,
        access_token_expiry: accessTokenExpiry,
        refresh_token: refreshToken,
        user,
    });
});

app.post('/api/refresh-token', (req, res) => {
    const refreshToken = req.cookies['refresh_token'];
    
    .....

    const newRefreshToken = uuidv4();
    const newRefreshTokenExpiry = new Date(
        new Date().getTime() + 10 * 60 * 1000,
    );

    res.cookie('refresh_token', newRefreshToken, {
        maxAge: 10 * 60 * 1000,
        httpOnly: true,
        secure: false,
    });

    res.json({
        access_token: accessToken,
        access_token_expiry: accessTokenExpiry,
        refresh_token: newRefreshToken,
        refresh_token_expiry: newRefreshTokenExpiry,
    });
});

app.post('/api/logout', (_, res) => {
    res.clearCookie('refresh_token');
    res.sendStatus(200);
});

我的理解是,即使let inMemoryToken 被声明一次,它的两个单独的实例将在运行时可用,一个客户端和一个服务器端,并且修改不会影响另一个。 我说的对吗?

在这种情况下,auth方法既可以在服务端调用,又可以在客户端调用,如何解决?

【问题讨论】:

    标签: javascript reactjs authentication cookies next.js


    【解决方案1】:

    我创建了一个示例,展示了如何使用会话在内存中为单个用户跨请求存储信息。如果您只是对代码感兴趣,可以查看底部的代码框。

    有两点要记住:

    1. 您无法在客户端直接访问服务器上声明的变量
    2. 在服务器上声明的全局范围内的变量在请求之间共享

    但是,您可以使用存储在客户端 cookie 中的通用 ID,并将数据存储在内存中,并通过请求上的会话访问它。

    当你有一个请求传入时,你可以检查请求头上是否存在 cookie,如果存在则尝试从内存中加载会话,如果不存在或无法加载,则创建一个新的会话。

    | Incoming Request
    |   |--> Check the cookie header for your session key
    |       |--> If cookie exists load cookie
    |       |--> Else create session + use 'set-cookie' header to tell the client it's session key
    |          |--> Do stuff with the data stored in the session  
    

    为了能够做到这一点,我们需要有一些方法来存储会话和与之关联的数据。你说你只是想将数据存储在内存中。

    const memoryStore = new Map();
    

    好的,现在我们有了内存存储,但是我们如何让它在请求中保持不变呢?让我们将其存储为全局对象。

    const MEMORY_STORE = Symbol.for('__MEMORY_STORE');
    const getMemoryStore = () => {
      if (!global[MEMORY_STORE]) {
        global[MEMORY_STORE] = new Map();
      }
      return global[MEMORY_STORE];
    };
    

    完美,现在我们可以调用getMemoryStore 来访问持久化的数据。现在我们要创建一个处理程序,尝试从请求中加载会话,否则创建一个新会话。

    const SESSION_KEY = '__my_session_id';
    
    const loadSession = (req, res) => {
      const memory = getMemoryStore();
    
      const cookies = parseCookies(req.headers.cookie);
      const cookieSession = cookies[SESSION_KEY];
    
      // check to make sure that cookieSession is defined and that it exists in the memory store
      if (cookieSession && memory.has(cookieSession)) {
        const session = memory.get(cookieSession);
        req.session = session;
        
        // do something with the session
    
      } else {
        // okay the session doesn't exists so we need to create one, create the unique session id
        const sessionId = uuid();
        const session = { id: sessionId };
        memory.set(sessionId, session);
       
        // set the set-cookie header on the response with the session ID
        res.setHeader('set-cookie', `${SESSION_KEY}=${sessionId}`);
    
        req.session = session;
      }  
    };
    

    现在我们可以在服务器端的任何地方调用它,它会加载或创建会话。例如,您可以通过getServerSideProps 调用它

    export const getServerSideProps = ({ req, res }) => {
      loadSession(req, res);
    
      // our session exists on req.session !! 
      
      return { props: { ... } };
    };
    

    我制作了一个包含工作示例的代码框:https://codesandbox.io/s/distracted-water-biicc?file=/utils/app.js

    【讨论】:

    • 非常感谢您的回答。我一直在检查您的代码,但仍然不确定这是否解决了问题,因为从客户端调用 /login/refreshToken 时,我无法通过给定的值修改内存中的当前值通过 api。我看到我的问题可能对这一点模糊不清,所以我将对其进行编辑,看看我是否还可以提供一个简化的代码沙箱。
    • @Florian_L 我可能会误解,但您不需要在客户端显式修改任何内容。这是通过set-cookie 标头自动为您完成的。在您的客户端上,您不需要显式存储 refresh_token,它存储在浏览器的 cookie 中。由于是http cookie,所以浏览器会自动发送给服务器。
    • 在用户登录的情况下,获取请求发生在客户端。您将刷新令牌作为 httpOnly cookie 取回,因此您不必关心那个,但 access_token 是作为json客户端发送,你用它做什么?另一种情况是刷新令牌时。假设您的访问令牌每 5 分钟过期一次。用户到达一个页面,因此访问令牌从服务器传递到客户端,然后如果 5 分钟内没有页面更改,则在客户端刷新刷新令牌。那些意味着您应该在每次页面更改时刷新令牌?
    • 也许我在这方面过于复杂了,并且无法采用一种安全的方法来处理使用 SSR 和这种短寿命和长寿命令牌的身份验证。有了以前的 SPA 经验,我可能也太专注于在 SSR 中重现它,因为那里可能会使用另一种方法。
    • 您总共有 2 个服务器(您的 nextjs 服务器 + 示例中的 JWT 服务器)还是 3 个(下一个,JWT + JWT 与之通信的其他一些服务器)?
    猜你喜欢
    • 2020-04-10
    • 2021-08-30
    • 2021-07-16
    • 2020-07-07
    • 1970-01-01
    • 2017-11-08
    • 2013-10-12
    • 2021-10-07
    • 1970-01-01
    相关资源
    最近更新 更多