基于JWT的身份验证:实现刷新页面不丢失登录状态
本篇博客如题,讲解的是 JWT 的前端代码实现与后端代码实现,若想详细了解两种登录方式的原理,可参考此篇博客。
此次展示两个项目,
第一个项目展示前端的用 store 的 state 存储的登录信息,并能够定时在 token 过期前更新 token,关闭浏览器则清空登录信息;
第二个项目展示后端用drf实现前后端分离,jwt 实现跨端登录验证、wss连接验证,前端采用 es6 语法,能在 token 过期时更新 token,并让用户第 14 天重新登录。
项目一:
采用的是 vue 中 store 存储登录信息,实现登录会实时刷新 token,关闭浏览器则清空登录信息
state信息
尝试从 localStorage 读取信息
const ModelUser = {
state: {
id: JSON.parse(localStorage.getItem('user')) === null ? 0 : JSON.parse(localStorage.getItem('user')).id,
username: JSON.parse(localStorage.getItem('user')) === null ? "" : JSON.parse(localStorage.getItem('user')).username,
photo: JSON.parse(localStorage.getItem('user')) === null ? "" : JSON.parse(localStorage.getItem('user')).photo,
followerCount: JSON.parse(localStorage.getItem('user')) === null ? 0 : JSON.parse(localStorage.getItem('user')).followerCount,
access: JSON.parse(localStorage.getItem('user')) === null ? "" : JSON.parse(localStorage.getItem('user')).access,
refresh: JSON.parse(localStorage.getItem('user')) === null ? "" : JSON.parse(localStorage.getItem('user')).refresh,
is_login: JSON.parse(localStorage.getItem('user')) === null ? false : JSON.parse(localStorage.getItem('user')).is_login,
},
};
使用 localStorage 存储登录信息
1. 存储用户信息到 state 时,添加代码
因为在登录后我们会执行
updateUser
方法,因此我们在更新用户信息中添加两行代码。cookie
的作用是在关闭浏览器后能控制 localStorage 的信息清空。
mutations: {
...
updateUser(state, user) {
localStorage.setItem("user", JSON.stringify(user));
document.cookie = "user=user; path=/";
state.id = user.id;
state.username = user.username;
state.photo = user.photo;
state.followerCount = user.followerCount;
state.access = user.access;
state.refresh = user.refresh;
state.is_login = user.is_login;
},
...
}
2. 在更新 token 中添加更新 localStorage
的代码
mutations: {
...
updateAccess(state, access) {
if(access === null || !state.is_login) return false;
let user = JSON.parse(localStorage.getItem('user'));
user.access = access;
localStorage.setItem("user", JSON.stringify(user));
state.access = access;
},
...
};
3. 在 logout 函数中清空 cookie
信息和 localStorage
信息
mutations: {
...
logout(state) {
// 清空信息
localStorage.clear();
state.id = "";
state.username = "";
state.photo = "";
state.followerCount = 0;
state.access = "";
state.refresh = "";
state.is_login = false;
state.intervalFunc = 0;
// 清空cookie
let d = new Date();
d.setTime(d.getTime() + -1);
document.cookie = "user=user; expires=" + d.toUTCString() + "; path=/";
// 返回到登录界面
router.push({name: 'login'});
},
...
};
4. 根据 cookie
生命周期 或者 根据关闭页面函数清空 localStorage
方法一: 在 App.vue
中检测是否存在 cookie
,若 cookie
不存在,则清空 localStorage
,若不清空在 token 过期后调用接口会出现 bug。(因为 cookie 默认的生命是-1,浏览器关闭即清空)
// 当 Vue 加载完成后调用
mounted() {
const store = useStore();
// 关闭浏览器清除localStorage
// 方法一:通过cookie的生命周期控制localStorage
// 查看cookie
let cookieName = "user=";
let ck = document.cookie.split(';'); //把cookie分割成组
let isExist = false;
for (let i = 0; i < ck.length; i++) {
let t = ck[i]; //取得字符串
while (t.charAt(0) == ' ') { //判断一下字符串有没有前导空格
t = t.substring(1, t.length); //有的话,从第二位开始取
}
if (t.indexOf(cookieName) == 0) { //如果含有我们要的name
isExist = true;
break;
}
}
// 若cookie不存在就删除localStorage
if(!isExist) {
localStorage.clear();
}
},
方法二: 利用关闭浏览器函数清空 localStorage
// 当 Vue 加载完成后调用
mounted() {
const store = useStore();
// 关闭浏览器清除localStorage
// 方法二:通过在关闭浏览器前清除localStorage
/**
* 刷新页面 就算是简单helloworld都不小于5毫秒, 获取时间差来判断刷新与关闭页面
*/
let start_stamp = 0, // 开始时间
differ_time = 0; // 时间差
window.onunload = () => {
differ_time = new Date().getTime() - start_stamp;
if (differ_time <= 5) localStorage.clear();
};
window.onbeforeunload = () => {
start_stamp = new Date().getTime();
};
},
bug
bug1: 刷新页面后,更新 token 的定时函数也消失了
当我以为已经结束时,发现出现一个 bug:因此我们的 token 是用 setInterval 函数定时更新的,在我们经过刷新界面时,setInterval 也随之消失了;当 token 过期时访问接口就会报错。
- 首先将定时更新 token 的函数抽取到 store中 的
actions
中,因为
actions
才能执行异步方法。并且我们需要存储起来定时函数,在下次定时前清空,防止重复定时。
mutations: {
...
setIntervalFunc(state, intervalFunc) {
let user = JSON.parse(localStorage.getItem('user'));
if(user !== null) {
user.intervalFunc = intervalFunc;
localStorage.setItem("user", JSON.stringify(user));
}
state.intervalFunc = intervalFunc;
}
...
},
actions: {
...
refresh_access(context, refresh) {
// 每4.5min刷新jwt
let func = setInterval(() => {
axios
.post('/api/api/token/refresh/', {
refresh,
})
.then(function(resp) {
context.commit("updateAccess", resp.data.access);
});
}, 4.5 * 60 * 1000);
context.commit("setIntervalFunc", func);
},
...
};
- 在登录后我们调用
refresh_access
actions: {
...
login(context, data) {
// 通过用户名密码获取jwt信息
axios
.post('/api/api/token/', {
username: data.username,
password: data.password,
})
.then(resp => {
// 取出jwt信息
const {access, refresh} = resp.data;
// 解码获取出user_id
const access_obj = jwt_decode(access);
// 定时更新(加上此行代码)
context.dispatch("refresh_access", refresh)
axios
.get('/api/myspace/getinfo/', {
// 参数列表
params: {
user_id: access_obj.user_id,
},
// 请求头
headers: {
'Authorization': "Bearer " + access,
},
})
.then(resp => {
// 调用mutations的方法: commit
context.commit("updateUser", {
// 结构resp, 将resp的所有值粘贴在此
...resp.data,
access: access,
refresh: refresh,
is_login: true,
});
data.success();
});
});
},
...
};
- 在
App.vue
中检测重新载入时就重新定时
// 当 Vue 加载完成后调用
mounted() {
...
// 刷新
window.onload = () => {
// 刷新后定时函数则失效, 因此我们重新调用,因为可能多次进入网页,所以需要清空定时函数
window.clearInterval(store.state.user.intervalFunc);
let user = JSON.parse(localStorage.getItem("user"));
// 只有登陆了才需要更新token
if(user !== null) store.dispatch("refresh_access", user.refresh);
}
},
bug2: 关闭了页面但是,没有关闭浏览器,待 token 过期后,访问页面的带 token 接口会报错。
- 在所有的带
token
接口加上异常捕获,例:
axios
.post('/xxx/xxx/',
qs.stringify({ xxx: xxx }),
{
headers: {
'Authorization': "Bearer " + store.state.user.access,
}
})
.then(resp => {
})
.catch(error => {
store.commit('judgeError', error.response.status);
});
- 在
store
的mutations
中写上函数judgeError
mutations: {
judgeError(state, error) {
if(error === 401) {
// 清空信息
localStorage.clear();
// 清空cookie
let d = new Date();
d.setTime(d.getTime() + -1);
document.cookie = "user=user; expires=" + d.toUTCString() + "; path=/";
// 强制下线
this.commit('user/logout');
// 返回到登录界面
router.push({name: 'login'});
}
}
},
项目二:
第二个项目:后端是基于 drf,登录验证采用 JWT,前端采用 es6。本项目实现持久化登录能够让用户无感的将登录信息更新,在第 14 天重新登录,并实现给 wss 加上 token 验证。
后端
1. 安装 drf
和 jwt
pip install djangorestframework
pip install pyjwt
pip install djangorestframework-simplejwt
然后在settings.py
中添加:
INSTALLED_APPS = [
...
'rest_framework_simplejwt',
'rest_framework',
...
]
REST_FRAMEWORK = {
...
'DEFAULT_AUTHENTICATION_CLASSES': (
...
'rest_framework_simplejwt.authentication.JWTAuthentication',
)
...
}
2. 配置
在settings.py
中添加:
from datetime import timedelta
...
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': False,
'UPDATE_LAST_LOGIN': False,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'VERIFYING_KEY': None,
'AUDIENCE': None,
'ISSUER': None,
'JWK_URL': None,
'LEEWAY': 0,
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',
'JTI_CLAIM': 'jti',
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}
3. 添加获取 jwt 和刷新 jwt 的路由
from django.urls import path
from game.views.settings.getinfo import InfoView
from game.views.settings.register import PlayerView
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
...
# 相当于登录(返回token与refresh,token为了安全时间为5min,refresh时间为14day)
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
# 刷新token(返回token,refresh过期则重新登录)
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
# 获取登录信息
path("getinfo/", InfoView.as_view(), name="settings_getinfo"),
# 注册::
path("register/", PlayerView.as_view(), name="settings_register"),
...
]
4. 获取信息的接口
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from game.models.player.player import Player
class InfoView(APIView):
# 若加了这行,则需要带token访问
permission_classes = ([IsAuthenticated])
def get(self, request):
user = request.user
player = Player.objects.get(user=user)
return Response({
'result': "success",
'username': user.username,
'photo': player.photo,
})
5. 注册的接口
from rest_framework.views import APIView
from rest_framework.response import Response
from django.contrib.auth.models import User
from game.models.player.player import Player
class PlayerView(APIView):
def post(self, request):
data = request.POST
username = data.get("username", "").strip()
password = data.get("password", "").strip()
password_confirm = data.get("password_confirm", "").strip()
if not username or not password:
return Response({
'result': "用户名和密码不能为空",
})
if password != password_confirm:
return Response({
'result': "两个密码不一致",
})
if User.objects.filter(username=username).exists():
return Response({
'result': "用户名已存在",
})
user = User(username=username)
user.set_password(password)
user.save()
Player.objects.create(user=user, photo="https://cdn.acwing.com/media/user/profile/photo/29231_lg_3e166b549d.jpg")
return Response({
'result': "success",
})
前端
1. 登录代码
login_on_remote(username, password) {
username = username || this.$login_username.val();
password = password || this.$login_password.val();
this.$login_error_message.empty();
$.ajax({
url: "https://game.zzqahm.top/settings/token/",
type: "POST",
data: {
username: username,
password: password,
},
success: resp => {
this.root.access = resp.access;
this.root.refresh = resp.refresh;
// 刷新token
this.refresh_jwt_token();
// 获取登录信息
this.getinfo_web();
},
error: () => {
this.$login_error_message.html("用户名或密码错误!");
}
});
}
2. 刷新 token 代码
refresh_jwt_token() {
setInterval(() => {
$.ajax({
url: "https://game.zzqahm.top/settings/token/refresh/",
type: "post",
data: {
refresh: this.root.refresh,
},
success: resp => {
this.root.access = resp.access;
// 持久化登录信息
localStorage.setItem("access", this.root.access);
localStorage.setItem("access_expires", new Date());
}
});
}, 4.5 * 60 * 1000);
}
3. 获取信息代码
// 持久化登录信息
rem_user() {
localStorage.setItem("username", this.username);
localStorage.setItem("photo", this.photo);
localStorage.setItem("access", this.root.access);
localStorage.setItem("refresh", this.root.refresh);
localStorage.setItem("access_expires", new Date());
localStorage.setItem("refresh_expires", new Date());
}
getinfo_web() {
$.ajax({
url: "https://game.zzqahm.top/settings/getinfo/",
type: "GET",
data: {
platform: this.platform,
},
headers: {
'Authorization': "Bearer " + this.root.access,
},
success: resp => {
if(resp.result === "success") {
this.username = resp.username;
this.photo = resp.photo;
this.rem_user();
// 隐藏登录(以下两行代码与知识点无关)
this.hide();
// 登陆成功就进入网站
this.root.menu.show();
} else {
this.login();
}
}
});
}
4. 在进入网站时获取登录信息
get_user() {
this.username = localStorage.getItem("username");
this.photo = localStorage.getItem("photo");
this.root.access = localStorage.getItem("access");
this.root.refresh = localStorage.getItem("refresh");
this.root.access_expires = new Date(localStorage.getItem("access_expires"));
this.root.refresh_expires = new Date(localStorage.getItem("refresh_expires"));
}
// 若localStorage有登陆信息
if(localStorage.getItem("username") !== null) {
// 获取信息
this.get_user();
// 若超过14天
if(new Date().getTime() - this.root.refresh_expires.getTime() >= 12 * 24 * 60 * 60 * 1000 - 10 * 60 * 1000) {
// 清空登录信息
localStorage.clear();
// 登录
this.login();
return;
// 若token过期
} else if(new Date().getTime() - this.root.access_expires.getTime() >= 4.5 * 60 * 1000) {
// 则重新获取token
$.ajax({
url: "https://game.zzqahm.top/settings/token/refresh/",
type: "post",
data: {
refresh: this.root.refresh,
},
success: resp => {
this.root.access = resp.access;
localStorage.setItem("access", this.root.access);
localStorage.setItem("access_expires", new Date());
}
});
}
// 进入网站再次每五分钟更新token
this.refresh_jwt_token();
this.hide();
// 登陆成功就进入网站
this.root.menu.show();
} else {
this.login();
}
给第三方登录验证构造 token
后端
1. 给 qq 登陆的 receive_code.py
构造 token
from django.shortcuts import redirect, reverse
from django.contrib.auth.models import User
from django.http import JsonResponse
from game.models.player.player import Player
from random import randint
from rest_framework_simplejwt.tokens import RefreshToken
def receive_code(request):
...
users = User.objects.filter(username=username)
while users.exists(): # 找到一个新用户名
players = Player.objects.filter(user=users[0])
if players.exists():
player = players[0]
# 根据用户构造token
refresh = RefreshToken.for_user(player.user)
return JsonResponse ({
'result': "success",
'username': player.user.username,
'photo': player.photo,
'access': str(refresh.access_token),
'refresh': str(refresh),
})
while User.objects.filter(username=username).exists():
username += str(randint(0, 9))
user = User.objects.create(username=username)
player = Player.objects.create(user=user, photo=photo, openid=openid)
# 根据用户构造token
refresh = RefreshToken.for_user(players[0].user)
return redirect(reverse("index") + "?access=%s&refresh=%s" % (str(refresh.access_token), str(refresh)))
2. 在 index 接口取出登陆信息
from django.shortcuts import render
def index(request):
data = request.GET
context = {
'access': data.get("access", ""),
'refresh': data.get("refresh", ""),
}
# 传给前端
return render(request, "multiends/web.html", context)
前端
1. web.html
{% load static %}
<head>
<link rel="stylesheet" href="https://cdn.acwing.com/static/jquery-ui-dist/jquery-ui.min.css">
<script src="https://cdn.acwing.com/static/jquery/js/jquery-3.3.1.min.js"></script>
<!-- 最新版本的 Bootstrap 核心 CSS 文件 -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'css/game.css' %}">
<link rel="icon" href="https://cdn.acwing.com/media/article/image/2021/12/17/1_be4c11ce5f-acapp.png">
</head>
<body style="margin: 0">
<div id="ac_game_12345678"></div>
<script type="module">
import {AcGame} from "{% static 'js/dist/game.js' %}";
$(document).ready(function() {
// 取出登录信息
let ac_game = new AcGame("ac_game_12345678", null, "{{ access }}", "{{ refresh }}");
});
</script>
</body>
2. AcGame
export class AcGame {
constructor(id, AcWingOS, access, refresh) {
this.id = id;
this.$ac_game = $('#' + id);
this.AcWingOS = AcWingOS;
// 取出登录信息
this.access = access;
this.access_expires = new Date();
this.refresh = refresh;
this.refresh_expires = new Date();
}
...
3. 在弹出登录前判断
if(this.root.access) {
// 将url改成初始url,因为url后面跟着access和refresh
history.pushState({},"","https://game.zzqahm.top/");
this.getinfo_web();
this.refresh_jwt_token();
}
wss 加上 token 验证
前端
// access就是token
this.ws = new WebSocket("wss://game.zzqahm.top/wss/multiplayer/?token=" + this.playground.root.access);
后端
1. 添加 wss 中间件 channelsmiddleware.py
"""General web socket middlewares
"""
from channels.db import database_sync_to_async
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.tokens import UntypedToken
from rest_framework_simplejwt.authentication import JWTTokenUserAuthentication
from channels.middleware import BaseMiddleware
from channels.auth import AuthMiddlewareStack
from django.db import close_old_connections
from urllib.parse import parse_qs
from jwt import decode as jwt_decode
from django.conf import settings
@database_sync_to_async
def get_user(validated_token):
try:
user = get_user_model().objects.get(id=validated_token["user_id"])
# return get_user_model().objects.get(id=toke_id)
return user
except:
return AnonymousUser()
class JwtAuthMiddleware(BaseMiddleware):
def __init__(self, inner):
self.inner = inner
async def __call__(self, scope, receive, send):
# Close old database connections to prevent usage of timed out connections
close_old_connections()
# Try to authenticate the user
try:
# Get the token
token = parse_qs(scope["query_string"].decode("utf8"))["token"][0]
# This will automatically validate the token and raise an error if token is invalid
UntypedToken(token)
except:
# Token is invalid
scope["user"] = AnonymousUser()
else:
# Then token is valid, decode it
decoded_data = jwt_decode(token, settings.SIMPLE_JWT["SIGNING_KEY"], algorithms=["HS256"])
# Get the user using ID
scope["user"] = await get_user(validated_token=decoded_data)
return await super().__call__(scope, receive, send)
def JwtAuthMiddlewareStack(inner):
return JwtAuthMiddleware(AuthMiddlewareStack(inner))
2. 给asgi.py
加上这个中间件
"""
ASGI config for acapp project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'acapp.settings')
django.setup()
from game.channelsmiddleware import JwtAuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
from game.routing import websocket_urlpatterns
from channels.layers import get_channel_layer
channel_layer = get_channel_layer()
application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket": JwtAuthMiddlewareStack(URLRouter(websocket_urlpatterns))
})
3. wss 后端的 connect 函数
from channels.generic.websocket import AsyncWebsocketConsumer
...
class MultiPlayer(AsyncWebsocketConsumer):
async def connect(self):
user = self.scope['user']
if user.is_authenticated:
await self.accept()
else:
await self.close()
配置 Nginx
打开/etc/nginx/nginx.conf
,在 localtion /
的路由中添加对 PUT
、POST
、DELETE
方法的支持
location / {
include /etc/nginx/uwsgi_params;
uwsgi_pass 127.0.0.1:8000;
uwsgi_read_timeout 60;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' *;
add_header 'Access-Control-Allow-Methods' 'GET, PUT, OPTIONS, POST, DELETE';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Amz-Date';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Type' 'text/html; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
if ($request_method = 'PUT') {
add_header 'Access-Control-Allow-Origin' *;
add_header 'Access-Control-Allow-Methods' 'GET, PUT, OPTIONS, POST, DELETE';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Amz-Date';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
}
if ($request_method = 'GET') {
add_header 'Access-Control-Allow-Origin' *;
add_header 'Access-Control-Allow-Methods' 'GET, PUT, OPTIONS, POST, DELETE';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Amz-Date';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
}
if ($request_method = 'POST') {
add_header 'Access-Control-Allow-Origin' *;
add_header 'Access-Control-Allow-Methods' 'GET, PUT, OPTIONS, POST, DELETE';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Amz-Date';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
}
if ($request_method = 'DELETE') {
add_header 'Access-Control-Allow-Origin' *;
add_header 'Access-Control-Allow-Methods' 'GET, PUT, OPTIONS, POST, DELETE';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Amz-Date';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
}
}
修改完后,执行:sudo nginx -s reload
,使修改生效。