foreword
Recently, when using Next.js, I found that the time of user authentication and token refresh is different from the previous single-page application SPA token authentication and token refresh scheme, and the implementation is more complicated, so I refer to Bilibili , Nuggets , Sifu and Jianshu 's SSR website has finally solved this problem after tossing for a while, and I will share it with you for reference.
Single-page application TOKEN authentication and refresh scheme
When we use SPA as the authentication and authorization of the background management system, we have always used the jwt token scheme. This scheme is simple and efficient. The process is as follows:
1. After the user logs in successfully, the jwt token is generated in the background and returned to the front end and saved to localstorage
2. The front-end interface carries the Authorization: token in the axios request header and passes it to the background for authentication
3. Whether the verification of the token passed by the backend through the frontend is passed
4. If the token expires, you can intercept the 401 status code through the axios interceptor and bring refresh_token to get a new token from the background and save it to localstorage
The following is the use of the axios interceptor, reference
axios.interceptors.response.use(
error => {
/*
*当响应码为 401 时,尝试刷新令牌。
*/
if (status == 401) {
return axios.post('/api/login/refresh', {}, {
headers: {
'Authorization': 'Bearer ' + getRefreshToken()
}
}).then(async response => {
const data = response.data.data
setToken(data.token)
setRefreshToken(data.refreshToken)
error.response.config.headers['Authorization'] = 'Bearer ' + data.token
return await axios(error.response.config).then(res => res.data)
}).catch(error => {
//清理token
store.dispatch('user/resetToken')
this.router.push('/login')
return Promise.reject(error)
})
}
}
)
However, when SSR is doing Internet projects, this solution is not very friendly. There are the following problems to be solved:
1. The jwt token cannot take the initiative to expire before it expires, so if you want to block or kick someone out, you have no choice (of course there is a way, you can make a blacklist, but the token will not take the initiative to expire is an objective fact)
2. The SSR background cannot get the jwt token from localstorage
3. The jwt token expired interface reports an error and returns a 401 status code. There will be a stuck phenomenon in the process of requesting a new token through the axios interceptor, and the user experience is not very good.
The blog article I found on the Internet has given several solutions . After thinking about it myself, the above problems can be solved. The methods are as follows:
Solve problem 1: jwt token + redis
Store token
282059828ba7037ffe164921f4cb0333--- in redis
, and delete it from redis when you want to block and kick people.
Solve problem 2: use cookie to save jwt token
The front and back ends store and transmit data through cookie
Solve problem 3: actively refresh the token when the interface requests
Automatically refresh the token on every request
Bilibili, Nuggets authentication and refresh token scheme
Because I am using the Next.js SSR solution, I was thinking about how the big factory can achieve authentication and refresh tokens, so I visited SSR websites such as Station B, Nuggets, Sifu and Jianshu with questions. Let's analyze how they do it.
Station B
First, we log in to station B, then analyze the front-end code and find that the front-end is a set of architecture built by ourselves. The language is vue.js. If you are interested, you can read this article Bilibili (Bilibili ) Front-end Road
Let's take a look at its login data storage. Through continuous attempts, when we delete SESSIONDATA
, we log out, so its login id should be this SESSIONDATA
, and It has an expiration date of almost a year.
Then, I will see how often this SESSIONDATA
is updated by constantly refreshing it every day, and then I find that it is updated every 3 days.
Nuggets
After we log in to Nuggets, let's take a look at its source code and find that it uses NUXT.JS
Let's take a look at its login data storage. Through continuous attempts, when we delete sessionid
, we log out, so its login id should be this sessionid
, and Its expiration time is one year.
Then, I will see how often this sessionid
is updated through continuous refresh every day, and then I find that it is updated every 14 days.
My authentication and refresh token scheme
Through the above analysis of station B and Nuggets website, I will implement my own authentication and refresh token scheme, as follows:
1. The user logs in successfully and generates a jwt token in the background (set to never expire)
- 1.1. Generate a uuid at the same time, use key=uuid, value=token to save to redis (set the ttl expiration time of redis to 7 days)
- 1.2. Set the uuid to the cookie of the client browser through set-cookie (the expiration time is one year)
2. The front-end passes the cookie carrying the uuid on the axios request header to the back-end for authentication
3. The background judges the uuid in the cookie
- 3.1: If the key=uuid is queried in redis and the ttl time of redis is less than 3 days, new_uuid will be generated and saved in redis (the time is also 7 days). The old uuid of redis will be deleted, and the new_uuid will be generated through set-cookie Save to the client browser, the time is also one year
- 3.2: If the key=uuid is queried in redis and the ttl is greater than 3 days, the ttl time of redis with key=uuid is reset to 7 days (equivalent to renewal)
- 3.3: If the data of key=uuid is not queried in redis, it will be released
Code
The front-end is next.js+redux-toolkit
, and the back-end is springboot+springsecurity+ oauth2 + springcloud gateway
.
Below we use the code to implement scheme 1
The code looks like this:
@PostMapping("/web/login")
public ResponseEntity<?> login(@RequestBody AuthRequest authRequest, HttpServletResponse response) {
String userName = authRequest.getUserName();
String password = authRequest.getPassword();
if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {
return ResponseEntity.ok(new ResultInfo<>(ResultStatus.DATA_EMPTY));
}
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("username", userName);
body.add("password", password);
body.add("client_id", serviceConfig.getClientId());
body.add("client_secret", serviceConfig.getClientSecret());
body.add("grant_type", "password");
body.add("scope", "all");
//调用auth服务
try {
Date now = new Date();
// result 对象里面是返回的jwt token信息,access_token,refresh_token,jti,token_type
Object result = authFeignClient.postAccessToken(body);
Map entity = (Map)result;
String key = UUID.randomUUID().toString().replace("-", "");
String access_token = entity.get("access_token").toString();
String token_type = entity.get("token_type").toString();
if(StringUtils.hasLength(access_token)){
HashMap<String, Object> obj = new HashMap<>();
obj.put("user_name", userName);
obj.put("access_token", access_token);
obj.put("token_type", token_type);
// 保存到redis中,过期时间设置成7天,7天之后自动删除
globalCache.hmset(key, obj, 60 * 60 * 24 * 7);
ResponseCookie cookie = ResponseCookie.from("session_jti", key) // key & value
.httpOnly(true) // 禁止js读取
.secure(true) // 在http下也传输
.domain(serviceConfig.getDomain())// 域名
.path("/") // path
.maxAge(Duration.ofDays(365)) // 1年后过期
.sameSite("Lax") // 大多数情况也是不发送第三方 Cookie,但是导航到目标网址的 Get 请求除外
.build();
// 设置Cookie到返回头Header中
response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
User user = userService.getUserByName(userName);
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("name", userName);
userInfo.put("avatar", user.getAvatar());
userInfo.put("email", user.getEmail());
return ResponseEntity.ok(new ResultSuccess<>(userInfo));
} catch (Exception ex) {
return ResponseEntity.ok(new ResultInfo<>(ResultStatus.USER_AUTH_ERROR));
}
}
Below we use the code to implement scheme 2
The code looks like this:
next.js page code (intercepted part):
export const getServerSideProps = wrapper.getServerSideProps(store => async (ctx) => {
// 1、获取cookie并保存到axios请求头cookie中
axios.defaults.headers.cookie = ctx.req.headers.cookie || null
await store.dispatch(getSessionUser())
const {isLogin, me} = store.getState().auth;
store.dispatch(setHeader({
isTransparent: true
}))
if (isLogin) {
await store.dispatch(getUserData());
}
// 2、判断请求头中是否有set-cookie,如果有,则保存并同步到浏览器中
if(axios.defaults.headers.setCookie){
ctx.res.setHeader('set-cookie', axios.defaults.headers.setCookie)
delete axios.defaults.headers.setCookie
}
return {
props: {
isLogin
}
};
});
axios configuration code:
const axiosInstance = axios.create({
baseURL: `${process.env.NEXT_PUBLIC_API_HOST}`,
withCredentials: true,
});
// 添加响应拦截器
axiosInstance.interceptors.response.use(function (response) {
console.log('response=', response)
// 目标:合并setCookie
// A、将response.headers['set-cookie']合并到axios.defaults.headers.setCookie中
// B、将axios.defaults.headers.setCookie合并到axios.defaults.headers.cookie中
// 注意:set-cookie格式和cookie格式区别
/** axios.defaults.headers.setCookie和response.headers['set-cookie']格式如下
*
* axios.defaults.headers.setCookie = [
* 'name=Justin; Path=/; Max-Age=365; Expires=Mon, 15 Aug 2022 13:35:08 GMT; Secure; HttpOnly; SameSite=None'
* ]
*
* **/
/** axios.defaults.headers.cookie 格式如下
*
* axios.defaults.headers.cookie = name=Justin;age=18;sex=男
*
* **/
// A1、判断是否是服务端,并且返回请求头中有set-cookie
if (typeof window === 'undefined' && response.headers['set-cookie']) {
// A2、判断axios.defaults.headers.setCookie是否是数组
// A2.1、如果是,则将response.headers['set-cookie']合并到axios.defaults.headers.setCookie
// 注意:axios.defaults.headers.setCookie默认是undefined,而response.headers['set-cookie']默认是数组
if (Array.isArray(axiosInstance.defaults.headers.setCookie)) {
// A2.1.1、将后台返回的set-cookie字符串和axios.defaults.headers.setCookie转化成对象数组
// 注意:response.headers['set-cookie']可能有多个,它是一个数组
/** setCookie.parse(response.headers['set-cookie'])和setCookie.parse(axios.defaults.headers.setCookie)格式如下
*
setCookie.parse(response.headers['set-cookie']) = [
{
name: 'userName',
value: 'Justin',
path: '/',
maxAge: 365,
expires: 2022-08-16T07:56:46.000Z,
secure: true,
httpOnly: true,
sameSite: 'None'
}
]
* **/
const _resSetCookie = setCookie.parse(response.headers['set-cookie'])
const _axiosSetCookie = setCookie.parse(axiosInstance.defaults.headers.setCookie)
// A2.1.2、利用reduce,合并_resSetCookie和_axiosSetCookie对象到result中(有则替换,无则新增)
const result = _resSetCookie.reduce((arr1, arr2) => {
// arr1第一次进来是等于初始化化值:_axiosSetCookie
// arr2依次是_resSetCookie中的对象
let isFlag = false
arr1.forEach(item => {
if (item.name === arr2.name) {
isFlag = true
item = Object.assign(item, arr2)
}
})
if (!isFlag) {
arr1.push(arr2)
}
// 返回结果值arr1,作为reduce下一次的数据
return arr1
}, _axiosSetCookie)
let newSetCookie = []
result.forEach(item => {
// 将cookie对象转换成cookie字符串
// newSetCookie = ['name=Justin; Path=/; Max-Age=365; Expires=Mon, 15 Aug 2022 13:35:08 GMT; Secure; HttpOnly; SameSite=None']
newSetCookie.push(cookie.serialize(item.name, item.value, item))
})
// A2.1.3、合并完之后,赋值给axios.defaults.headers.setCookie
axiosInstance.defaults.headers.setCookie = newSetCookie
} else {
// A2.2、如果否,则将response.headers['set-cookie']直接赋值
axiosInstance.defaults.headers.setCookie = response.headers['set-cookie']
}
// B1、因为axios.defaults.headers.cookie不是最新的,所以要同步这样后续的请求的cookie都是最新的了
// B1.1、将axios.defaults.headers.setCookie转化成key:value对象数组
const _parseSetCookie = setCookie.parse(axiosInstance.defaults.headers.setCookie)
// B1.2、将axios.defaults.headers.cookie字符串转化成key:value对象
/** cookie.parse(axiosInstance.defaults.headers.cookie)格式如下
*
* {
* userName: Justin,
* age: 18,
* sex: 男
* }
*
* **/
const _parseCookie = cookie.parse(axiosInstance.defaults.headers.cookie)
// B1.3、将axios.defaults.headers.setCookie赋值给axios.defaults.headers.cookie(有则替换,无则新增)
_parseSetCookie.forEach(cookie => {
_parseCookie[cookie.name] = cookie.value
})
// B1.4、将赋值后的key:value对象转换成key=value数组
// 转换成格式为:_resultCookie = ["userName=Justin", "age=19", "sex=男"]
let _resultCookie = []
for (const key in _parseCookie) {
_resultCookie.push(cookie.serialize(key, _parseCookie[key]))
}
// B1.5、将key=value的cookie数组转换成key=value;字符串赋值给axiosInstance.defaults.headers.cookie
// 转换成格式为:axios.defaults.headers.cookie = "userName=Justin;age=19;sex=男"
axiosInstance.defaults.headers.cookie = _resultCookie.join(';')
}
return response;
}, function (error) {
console.log('error=', error)
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
if ([401, 403, 405, 500].includes(error.response.status)){
location.reload()
}
return Promise.reject(error);
});
export default axiosInstance;
I believe that you have some doubts about these two pieces of code. You can refer to my previous article to understand how the Next.js server operates cookies .
Below we use the code to implement solution 3
The code looks like this:
private static final int SESSION_EXPIRE = 6 * 24 * 60 * 60;
private static final String SESSION_KEY = "uuid";
@Autowired
private IGlobalCache globalCache;
@Autowired
ServiceConfig serviceConfig;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
Date now = new Date();
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
HttpHeaders headers = request.getHeaders();
Flux<DataBuffer> body = request.getBody();
MultiValueMap<String, HttpCookie> cookies = request.getCookies();
MultiValueMap<String, String> queryParams = request.getQueryParams();
// 获取请求的URI
String url = request.getPath().pathWithinApplication().value();
// 放行登录、刷新token和登出
if (url.startsWith("/web/login") || url.startsWith("/web/refreshToken") || url.startsWith("/web/logout")) {
// 放行
return chain.filter(exchange);
}
logger.info("cookie ={}", cookies);
HttpCookie cookieSession = cookies.getFirst(SESSION_KEY);
if (cookieSession != null) {
logger.info("session id ={}", cookieSession.getValue());
String session = cookieSession.getValue();
// 从redis中获取过期时间
long sessionExpire = globalCache.getExpire(session);
if (sessionExpire > 1) {
// 从redis中获取token信息
Map<Object, Object> result = globalCache.hmget(session);
String accessToken = "";
String tokenType = "";
for (Map.Entry<Object, Object> vo : result.entrySet()) {
if ("access_token".equals(vo.getKey())) {
accessToken = (String) vo.getValue();
}
if ("token_type".equals(vo.getKey())) {
tokenType = (String) vo.getValue();
}
}
// 获取剩余时间(秒数)
// 判断剩余时间是不是小于3天
// 如果是,则重新获取token,否则续期7天
if (sessionExpire < SESSION_EXPIRE / 2) {
// 延期token
expireCookie(session, result, response);
} else {
// redis续期6天
globalCache.expire(session, SESSION_EXPIRE);
}
String token = tokenType + " " + accessToken;
// 放行之前,将令牌封装到头文件中(这一步是为了方便AUTH2校验令牌)
request.mutate().header(FilterUtils.AUTH_TOKEN, token);
} else {
// 让cookie失效
setCookie("", 0, response);
// 说明redis中的token不存在或已经过期
logger.info("session 不存在或已经过期");
response.setStatusCode(HttpStatus.METHOD_NOT_ALLOWED);
return response.setComplete();
}
}
return chain.filter(exchange);
}
// 延期cookie
private void expireCookie(String session, Map<Object, Object> result, ServerHttpResponse serverHttpResponse) {
String newKey = UUID.randomUUID().toString().replace("-", "");
// redis设置该key的值立即过期
//time要大于0 如果time小于等于0 将设置无限期
globalCache.expire(session, 1);
// 转化result
Map<String, Object> newResult = (Map) result;
// 保存到redis中
globalCache.hmset(newKey, newResult, SESSION_EXPIRE);
setCookie(newKey, 365, serverHttpResponse);
}
// 设置cookie
private void setCookie(String cookieValue, Integer cookieTime, ServerHttpResponse serverHttpResponse) {
ResponseCookie cookie = ResponseCookie.from(SESSION_KEY, cookieValue) // key & value
.httpOnly(true) // 禁止js读取
.secure(true) // 在http下也传输
.domain(serviceConfig.getDomain())// 域名
.path("/") // path,过期用秒,不过期用天
.maxAge(cookieTime == 0 ? Duration.ofSeconds(cookieTime) : Duration.ofDays(cookieTime)) // 1年后过期
.sameSite("Lax") // 大多数情况也是不发送第三方 Cookie,但是导航到目标网址的 Get 请求除外
.build();
serverHttpResponse.addCookie(cookie);
}
So far, the core code of the scheme has been completed. If you have any questions, please ask questions.
Summarize
1. For cross-domain cookie issues, please refer to the Cookie knowledge that the front end of this article should know
2. Next.js merges set-cookie, you can refer to this article Next.js server-side operation cookie
3. To be on the safe side, you can save the key:uuid, value:token values in mongodb or mysql, so that even if redis crashes, you can still query data from the alternative database
quote
JWT generates token and expires and automatically renews
"springcloud 2021 series" Spring Cloud Gateway + OAuth2 + JWT to achieve unified authentication and authentication
Token refresh concurrent processing solution
Spring Cloud Gateway -- cookie addition and modification
spring cloud gateway implements user authentication + GatewayFilter custom interceptor based on jwt (complete demo)
SpringBoot sets and gets cookies
Some details of redis's implementation of session sharing
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。