前后端分离
一般来说,我们用SpringSecurity默认的话是前后端整在一起的,比如thymeleaf或者Freemarker,SpringSecurity还自带login登录页,还让你配置登出页,错误页。
但是现在前后端分离才是正道,前后端分离的话,那就需要将返回的页面换成Json格式交给前端处理了
SpringSecurity默认的是采用Session来判断请求的用户是否登录的,但是不方便分布式的扩展,虽然SpringSecurity也支持采用SpringSession来管理分布式下的用户状态,不过现在分布式的还是无状态的Jwt比较主流。 所以怎么让SpringSecurity变成前后端分离,可以采用Jwt来做认证
什么是jwt
Json web token (JWT),是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC7519).该token被设计为紧凑且==安全==的,特别适用于==分布式站点的单点登录(SSO)场景==。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
官网: JSON Web Token Introduction - jwt.io
-
- jwt的结构
Header
Header 部分是一个JSON对象,描述JWT的元数据,通常是下面的样子。
{
"alg": "HS256",
"typ": "JWT"
}
上面代码中,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256 (写成 HS256) ;typ属性表示这个令牌(token)的类型(type), JWT令牌统一写为JWT。
最后,将上面的JSON对象使用Base64URL算法转成字符串。
Payload(载荷)
Payload 部分也是一个JSON对象,==用来存放实际需要传递的数据==。JWT规定了7个官方字段,供选用。在使用时不用存放敏感数据。
iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (lssued At):签发时间
jti (JWT ID):编号
除了官方字段,==你还可以在这个部分定义私有字段==,下面就是一个例子。
{
"sub": "1234567890",
"name" : "John Doe",
“userid”:2
"admin": true
}
注意,JWT 默认是不加密的,任何人都可以读到,所以不要把==秘密信息==放在这个部分。这个JSON 对象也要使用Base64URL 算法转成字符串。
Signature
Signature部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个==密钥(secret)==。这个密钥只有==服务器才知道==,不能泄露给用户。然后,使用Header里面指定的==签名算法(默认是 HMAC SHA256)==,按照下面的公式产生签名。
HMACSHA256(
base64UrlEncode(header) + ".”"+base64UrlEncode(payload),
secret)
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。
项目添加hutool依赖
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
认证成功
实现AuthenticationSuccessHandler接口,当登录成功后,该处理类的方法被调用
其中xsy是密钥,使用时不会像作者使用的这么随意,在yml或者properties文件中也可以配置
认证失败
实现AuthenticationFailureHandler接口,当登录失败后,该处理类的方法被调用
返回的结果一定要转换为json格式,不然会报错
权限不足
实现AccessDeniedHandler接口,当登录后,访问接口没有权限的时候,该处理类的方法被调用
注销
实现LogoutSuccessHandler接口,注销的时候调用
package com.example.securitydemo.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
/**
* 退出成功之后的handler
* @return
*/
private LogoutSuccessHandler getLogOut() {
return (httpServletRequest, response, authentication) -> {
Result result = new Result(200,"退出成功",null);
printJsonData(response,result);
};
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http.formLogin()
.successHandler(getSuccessHandler())
.failureHandler(getFailtureHandler())
.permitAll()
.and()
.logout().logoutUrl("/logout").logoutSuccessHandler(getLogOut()) // 退出路径是/logout 成功 index.html
.and().addFilterBefore(jwtValidateFilter, UsernamePasswordAuthenticationFilter.class)
//.and().addFilterBefore(jwtValidateFilter, LogoutFilter.class)
//.and().authorizeRequests().antMatchers("/my/**").hasAnyRole("admin2","admin")//.hasAnyAuthority("admin","admin2")
.exceptionHandling().accessDeniedHandler(getAccessDefined())// 403异常的时候
.and()
.authorizeRequests().antMatchers("/","/login").permitAll()
.anyRequest().authenticated() // 除去上面的你写路径其他的路径全部需要认证
.and().cors()// 允许跨域
.and()
.csrf().disable();
return http.build();
}
OncePerRequestFilter
一个过滤器OncePerRequestFilter
上面我们在登录成功后,返回了一个token,那怎么使用这个token呢?
前端发起请求的时候将token放在请求头中,在过滤器中对请求头进行解析。
如果有Token的请求头(可以自已定义名字),取出token,解析token,解析成功说明token正确,将解析出来的用户信息放到SpringSecurity的上下文中
如果有Token的请求头,解析token失败(无效token,或者过期失效),取不到用户信息,放行
没有accessToken的请求头,放行
为什么token失效都要放行呢?
这是因为SpringSecurity会自己去做登录的认证和权限的校验,靠的就是我们放在SpringSecurity上下文中的SecurityContextHolder.getContext().setAuthentication(authentication);,没有拿到authentication,放行了,SpringSecurity还是会走到认证和校验,这个时候就会发现没有登录没有权限。
OncePerRequestFilter是Spring Boot里面的一个过滤器抽象类,其同样在Spring Security里面被广泛用到
这个过滤器抽象类通常被用于继承实现并在每次请求时只执行一次过滤
注意:没有登录(第一次生成token)的要设置白名单,否则会报错 :token不完整
添加自定义过滤器到配置文件中
设置白名单
思考题:
无法使token失效怎么办
-
重新生成token:如果无法使现有的token失效,可以考虑重新生成一个新的token,并将其用于替代旧的token。这样可以确保只有新的token才能被有效使用。
-
强制注销:如果系统支持,可以通过强制注销用户会话的方式来使token失效。这可以通过在服务器端维护一个会话列表,并在需要时将某个会话从列表中移除来实现。
-
修改token验证逻辑:如果无法使token失效,可以考虑修改token验证逻辑,使其在验证token时同时检查token的有效期和其他相关条件。这样可以确保即使token没有被主动失效,也只有符合一定条件的token才能被接受。
-
增加额外的安全措施:如果无法使token失效,可以考虑增加其他的安全措施来减轻风险。例如,可以增加多因素身份验证,要求用户在每次操作时都输入额外的验证码或密码。
-
修改token存储方式:如果无法使token失效,可以考虑修改token的存储方式。例如,可以将token存储在数据库中,并在需要时更新或删除对应的token记录。
token续期问题
-
增加token的有效期:可以将token的有效期延长,确保在用户使用过程中token不会过期。这样可以减少token续期的频率,但也会增加token泄露的风险。
-
使用刷新token:在token快要过期时,可以使用一个刷新token来获取新的有效token。用户在每次使用token时,都会检查token的有效期,如果快要过期了,就使用刷新token来获取新的token。这样可以实现token的无缝续期,提供更好的用户体验。
-
实时续期:在用户每次使用token时,都向服务器发送请求,服务器验证token的有效性,并在需要时自动续期。这种方法可以确保token始终保持有效,但会增加服务器的负载,需要更多的资源。
-
定期续期:在用户每次使用token时,服务器验证token的有效性,如果快要过期了,就要求用户重新登录,获取新的token。这种方法简单直接,但会增加用户的操作步骤,可能会影响用户体验。
(上述回均为gpt回答,还请各位自己多思考一下)
前端:
登陆页面
<template>
<div class="login-container">
<el-form :model="ruleForm" status-icon :rules="rules" ref="ruleForm" label-width="100px" class="login-form">
<el-form-item label="用户名" prop="username">
<el-input type="text" v-model="ruleForm.username" autocomplete="off"></el-input>
</el-form-item>
<el-form-item label="确认密码" prop="password">
<el-input type="password" v-model="ruleForm.password" autocomplete="off"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('ruleForm')">提交</el-button>
<el-button @click="resetForm('ruleForm')">重置</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
data() {
var checkName = (rule, value, callback) => {
if (!value) {
return callback(new Error('名字不能为空'));
}else{
callback();
}
};
var validatePass = (rule, value, callback) => {
if (value === '') {
callback(new Error('请输入密码'));
} else {
callback();
}
};
return {
ruleForm: {
username: '',
password: '',
},
rules: {
password: [
{ validator: validatePass, trigger: 'blur' }
],
username: [
{ validator: checkName, trigger: 'blur' }
]
}
};
},
methods: {
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
//alert('submit!');
this.$axios.post(`userlogin?uname=${this.ruleForm.username}&&pwd=${this.ruleForm.password}`)
.then((d)=>{
sessionStorage.setItem("token",d.data.t[0]);
sessionStorage.setItem("user",d.data.t[1]);
this.$router.push("/main")
});
} else {
// console.log('error submit!!');
return false;
}
});
},
resetForm(formName) {
this.$refs[formName].resetFields();
}
}
}
</script>
<style scoped>
.login-form {
width: 350px;
margin: 160px auto; /* 上下间距160px,左右自动居中*/
background-color: rgb(255, 255, 255,0.8); /* 透明背景色 */
padding: 30px;
border-radius: 20px; /* 圆角 */
}
/* 背景 */
.login-container {
position: absolute;
width: 100%;
height: 100%;
background: url("../assets/timg.png");
}
/* 标题 */
.login-title {
color: #303133;
text-align: center;
}
</style>
main
<template>
<el-container style="height: 100%;">
<el-header>
<div class="logo">
<i class="el-icon-setting"></i>MY系统-yyl
</div>
<div class="info">
{{user}}
<el-dropdown>
<span class="el-dropdown-link">
<i class="el-icon-user-solid"></i>
<i class="el-icon-caret-bottom el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item><span @click="logout">注销</span></el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-dropdown>
<span class="el-dropdown-link">
<i class="el-icon-s-tools"></i>
<i class="el-icon-caret-bottom el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item>设置</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-dropdown>
<span class="el-dropdown-link">
<i class="el-icon-phone-outline"></i>
<i class="el-icon-caret-bottom el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item>个人信息</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</el-header>
<el-container>
<el-aside width="200px">
<el-menu
:router="true"
class="el-menu-vertical-demo"
unique-opened
text-color="#333333"
active-text-color="orange"
>
<!--有二级菜单的使用el-submenu 没有二级菜单的使用el-menu-item-->
<!-- <template v-for="m,mIndex in menuData">-->
<!-- <!–因为index必须是一个字符串 我的数据库里面使用的是int类型所以在这里需要加上‘’–>-->
<!-- <el-submenu v-if="m.menus.length>0" :key="mIndex" :index="''+mIndex">-->
<!-- <template slot="title"><i :class="m.icon"></i>{{m.name}}</template>-->
<!-- <el-menu-item-group>-->
<!-- <el-menu-item v-for="sm,smIndex in m.menus"-->
<!-- :index="''+mIndex+'-'+smIndex"-->
<!-- :route="sm.path"-->
<!-- :key="smIndex"><span>{{sm.name}}</span></el-menu-item>-->
<!-- </el-menu-item-group>-->
<!-- </el-submenu>-->
<!-- <el-menu-item v-else :key="mIndex" :index="''+mIndex" :route="m.path" >-->
<!-- <i :class="m.icon"></i><span slot="title">{{m.name}}</span>-->
<!-- </el-menu-item>-->
<!-- </template>-->
</el-menu>
</el-aside>
<el-main>
<router-view ></router-view>
</el-main>
</el-container>
</el-container>
</template>
<script>
export default {
name: "MainFrame",
data(){
return{
user:"",
menuData:[],
//adminUser:this.$store.getters.getAdminUser
}
},
methods:{
hello(){
this.$axios.get("hello").then(r=>{
alert(r.data.t);
})
},
logout(){
this.$axios.get("logout").then(r=>{
alert(r.data.code);
if(r.data.code == 200){
// alert("推出成功");
sessionStorage.clear();
this.$router.push("/")
}
})
},
},mounted(){
this.hello();
},created(){
let itemssss = sessionStorage.getItem("user");
alert(itemssss);
this.user=itemssss;
}
}
</script>
<style scoped>
.el-header{
background-color: #4a4a4a;
color: #333;
line-height: 60px;
position: relative;
}
.el-header .logo
{
position: absolute;
left:0px;
top:0px;
width:190px;
height:60px;
color:white;
padding-left: 10px;
background-color: #24383a;
}
.el-header .info
{
position: absolute;
top:0px;
right:20px;
width:200px;
height:60px;
text-align: left;
}
.el-header .info .el-dropdown{
color: white;
cursor: pointer;
margin-right: 20px;
}
.el-main {
background-color: #E9EEF3;
color: #333;
text-align: center;
}
</style>
路由拦截器(如果各位想增加程序健壮性,还可增加响应拦截器,如果不想,就当我没说)
router.beforeEach((to,from,next)=>{
if(to.path=='/login' || sessionStorage.getItem('token')){
next();
}else{
alert('请登录');
next('/');
}
});
to: Route: 即将要进入的目标 路由对象
from: Route: 当前导航正要离开的路由
next: Function: 一定要调用该方法来 resolve 这个钩子。
执行效果依赖 next 方法的调用参数。
next(): 进行管道中的下一个钩子。
如果全部钩子执行完了,则导航的状态就是 confirmed (确认的)。
next(false): 中断当前的导航。
如果浏览器的 URL 改变了(可能是用户手动或者浏览器后退按钮),
那么 URL 地址会重置到 from 路由对应的地址。
next(‘/’) 或者 next({ path: ‘/’ }): 跳转到一个不同的地址。
当前的导航被中断,然后进行一个新的导航。
请求拦截器:
axios1.interceptors.request.use(function (config) {
let token =localStorage.getItem("token");
if (token) {
config.headers['Authorization'] = token;
}
return config;
})
自定义指令实现细粒度的按钮显示等控制(例:如果我们想控制某个角色或者拥有某项权限才能看到编辑按钮)
介绍自定义的指令
除了核心功能默认内置的指令 (v-model
和 v-show
),Vue 也允许注册自定义指令。注意,在 Vue2.0 中,代码复用和抽象的主要形式是组件。然而,有的情况下,你仍然需要对普通 DOM 元素进行底层操作,这时候就会用到自定义指令。
一个指令定义对象可以提供如下几个钩子函数 (均为可选):
bind
:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。inserted
:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。update
:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新 (详细的钩子函数参数见下)。componentUpdated
:指令所在组件的 VNode 及其子 VNode 全部更新后调用。unbind
:只调用一次,指令与元素解绑时调用。
接下来我们来看一下钩子函数的参数 (即 el
、binding
、vnode
和 oldVnode
)。
钩子函数参数
指令钩子函数会被传入以下参数:
el
:指令所绑定的元素,可以用来直接操作 DOM。
binding
:一个对象,包含以下 property:
-
name
:指令名,不包括v-
前缀。value
:指令的绑定值,例如:v-my-directive="1 + 1"
中,绑定值为2
。oldValue
:指令绑定的前一个值,仅在update
和componentUpdated
钩子中可用。无论值是否改变都可用。expression
:字符串形式的指令表达式。例如v-my-directive="1 + 1"
中,表达式为"1 + 1"
。arg
:传给指令的参数,可选。例如v-my-directive:foo
中,参数为"foo"
。modifiers
:一个包含修饰符的对象。例如:v-my-directive.foo.bar
中,修饰符对象为{ foo: true, bar: true }
。
vnode
:Vue 编译生成的虚拟节点。移步 VNode API 来了解更多详情。oldVnode
:上一个虚拟节点,仅在update
和componentUpdated
钩子中可用。
除了 el
之外,其它参数都应该是只读的,切勿进行修改。如果需要在钩子之间共享数据,建议通过元素的 dataset
来进行。
// 注册一个全局自定义指令 `v-hasAuthorization`
Vue.directive('hasAuthorization', {
// 初始化设置。
bind: function (el) {
//具体的自己的业务逻辑
const roles = localStorage.getItem('roles');
if(!(localStorage.getItem('roles').indexOf('test') > -1)){
el.setAttribute('style','display:none')
}
})
在需要使用的页面定义即可
js代码:
import Vue from "vue"
Vue.directive('hasAuthorization',{
bind: (el) => {
let user=localStorage.getItem("user");
const roles = localStorage.getItem('roles');
if(!(localStorage.getItem('roles').indexOf('test') > -1)){
el.setAttribute('style','display:none')
}
}
})
页面代码
<el-button type="text" icon="el-icon-edit" v-hasAuthorization >编辑</el-button>
写的灵活一点
Vue.directive('has', {
inserted: function (el, binding) {
if (!Vue.prototype.$_has(binding.value)) {
el.parentNode.removeChild(el);
}
}
});
//权限检查方法
Vue.prototype.$_has = function (value) {
let isExist = false;
let user = sessionStorage.getItem("user");
console.log("admin" === user)
if ("admin" === user) {
return true;
}
let premissionsStr = sessionStorage.getItem("roles");
if (premissionsStr == undefined || premissionsStr == null) {
return false;
}
if (premissionsStr.indexOf(value) > -1) {
isExist = true;
}
return isExist;
};