SpringSecurity
SpringSecurity是强大的,且容易定制的实现认证,与授权的基于Spring 开发的框架。
Spring Security的核心功能
- Authentication:认证,用户登陆的验证(解决你是谁的问题)
- Authorization: 授权,授权资源的访问权限(解决你能干什么的问题)
- 安全防护,防止跨站请求,session 攻击等
springsecurity与shiro
- 使用的方便度(shiro)
- 社区支持比较(几乎持平)
- 功能丰富性(spring security)
如果你只是想实现一个简单的web应用,shiro更 加的轻量级,学习成本也更低。如果您正在开发一个分布式的、微服务的、或者与Spring Cloud系列框架深度集成的项目,还是建议使用Spring Security。
需求分析与环境准备
- login.html登录页面,登录页面访问不受限制
- 在登录页面登录之后,进入index.html首页(登录验证Authentication)
- 首页可以看到syslog、sysuer. biz1. biz2四个页面选项
- 我们希望syslog (日志管理)和sysuser(用户管理)只有admin管理员可以访问(权限管理Authorization)
- biz1、 biz2普通的操作用户auser就可以访问(权限管理Authorization)
起一个新的spring boot2.0版本的web应用
集成mybatis、lombok
搭建
添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
配置
新建配置类SecurityConfig
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic()
.and()
.authorizeRequests().anyRequest()
.authenticated();
}
}
这样启动后访问
http://localhost:8888/login.html
会让我们输入用户名密码
用户名:user
密码在控制台
想要自定义密码
添加配置,在spring下
security:
user:
name: admin
password: admin
H TTPBASIC模式登录认证
使用postman模拟访问
新建get请求
http://localhost:8888/index
Authorization
Type:Basic Auth
username:admin
password:admin
查看响应,可以看到我们的html代码
观察headers
Authorization:YWRtaW46YWRtaW4=
搜索base64加密解密
YWRtaW46YWRtaW4=
可以解密密码
admin:admin
这种方式时不安全的
FORMLOGIN表单登录认证模式
三要素
- 登录认证逻辑(静态)
- 资源访问控制(动态)
- 用户角色权限(动态)
认证的方式有两种:权限、角色
二者都可是实现
配置类SecurityConfig
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()//关掉csrf,否则会将我们的请求当作是一个不合法的
.formLogin()
//逻辑
.loginPage("/login.html")//登陆页面位置
.usernameParameter("username")//表单用户名name
.passwordParameter("password")//表单密码name
.loginProcessingUrl("/login")// 登录验证请求 post方式的表单action
.defaultSuccessUrl("/index")//成功后的跳转页面
.failureUrl("/login.html")
.and()
//控制
.authorizeRequests()
.antMatchers("/login.html","login").permitAll()//哪些请求不需要验证
.antMatchers("/biz1","/biz2")//需要对外暴漏的资源的路径,user用户和admin用户可以访问
.hasAnyAuthority("ROLE_user","ROLE_admin")//user用户和admin用户 权限
.antMatchers("/syslog","/sususer")
// .antMatchers("/syslog").hasAuthority("sys:log")//通过权限进行配置
//.antMatchers("/sysuser").hasAuthority("sys:user") 前面是资源,后面是资源id
.hasAnyRole("admin")//admin用户可以访问 .hasAnyAuthority("ROLE_admin"),另一种写法 角色
.anyRequest().authenticated()
;
}
//静态配置了两个用户
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user")//用户user
.password(passwordEncoder().encode("123456"))//密码
.roles("user")//角色
.and()
.withUser("admin")//用户
.password(passwordEncoder().encode("123456"))//密码
//.authorities("sys:log","sys:user")权限 当用户有这个资源的id就可以访问这个资源,用户没有这个资源的id就不能访问这个资源
.roles("admin")//角色
.and()
.passwordEncoder(passwordEncoder());//配置BCrypt加密
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) {
//将项目中静态资源路径开放出来
web.ignoring()
.antMatchers( "/css/**", "/fonts/**", "/img/**", "/js/**");
}
}
<form action="/login" method="post">
<span>用户名称</span><input type="text" name="username" /> <br>
<span>用户密码</span><input type="password" name="password" /> <br>
<input type="submit" value="登陆">
</form>
通过以下与表单进行绑定,表单的请求方式必须是post
.usernameParameter("username")//表单用户名name
.passwordParameter("password")//表单密码name
.loginProcessingUrl("/login")// 登录验证请求 post方式的表单action
不需要我们进行登录用户名和密码的校验,springsecurity在我们请求时,自动帮我们进行认证处理
测试
访问
http://localhost:8888/login.html
使用admin:admin进行登录,发现是可以全部访问的
切换用户,user:123456
只能访问业务一、业务二
登录认证流程源码解析
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
//定义了默认的用户名密码name
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private String usernameParameter = "username";
private String passwordParameter = "password";
private boolean postOnly = true;
public UsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//指定请求为post请求
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String username = this.obtainUsername(request);
String password = this.obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
//构建了一个登录令牌,通过用户名密码
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
this.setDetails(request, authRequest);
//返回一个登录认证的主体对象,贯穿过滤器,对它进行登录认证
return this.getAuthenticationManager().authenticate(authRequest);
}
}
public interface AuthenticationManager {
//该方法进行认证
Authentication authenticate(Authentication var1) throws AuthenticationException;
}
//实现了AuthenticationManager接口
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
//存储了很多登录认证的实例
private List<AuthenticationProvider> providers;
AuthenticationProvider是一个接口,很多登录认证的方式实现了该接口
DaoAuthenticationProvider//从数据库加载信息
//加载信息
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
过滤器
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
//成功
private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
//失败
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
自定义登录验证结果处理
登陆成功的自定义结果处理接口: AuthenticationSuccesstlandler
登陆失败的自定义结果处理接口: AuthenticationfailureHandler
AuthenticationSuccesstlandler
配置
@Component
public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Value("${spring.security.loginType}")
private String loginType;
private static ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication)
throws ServletException, IOException {
if(loginType.equalsIgnoreCase("JSON")){
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(
AjaxResponse.success("/index")
));
}else{
//跳转到登陆之前请求的页面
super.onAuthenticationSuccess(request,response,authentication);
}
}
}
封装json
@Data
public class AjaxResponse {
private boolean isok;
private int code;
private String message;
private Object data;
private AjaxResponse() {
}
//请求出现异常时的响应数据封装
public static AjaxResponse error(CustomException e) {
AjaxResponse resultBean = new AjaxResponse();
resultBean.setIsok(false);
resultBean.setCode(e.getCode());
if(e.getCode() == CustomExceptionType.USER_INPUT_ERROR.getCode()){
resultBean.setMessage(e.getMessage());
}else if(e.getCode() == CustomExceptionType.SYSTEM_ERROR.getCode()){
resultBean.setMessage(e.getMessage() + ",系统出现异常,请联系管理员电话:1375610xxxx进行处理!");
}else{
resultBean.setMessage("系统出现未知异常,请联系管理员电话:13756108xxx进行处理!");
}
return resultBean;
}
public static AjaxResponse success() {
AjaxResponse resultBean = new AjaxResponse();
resultBean.setIsok(true);
resultBean.setCode(200);
resultBean.setMessage("success");
return resultBean;
}
public static AjaxResponse success(Object data) {
AjaxResponse resultBean = new AjaxResponse();
resultBean.setIsok(true);
resultBean.setCode(200);
resultBean.setMessage("success");
resultBean.setData(data);
return resultBean;
}
}
添加配置
security:
loginType: JSON
SecurityConfig
@Resource
MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
//.defaultSuccessUrl("/index")//成功后的跳转页面
.failureUrl("/login.html")
.successHandler(myAuthenticationSuccessHandler)//不能与defaultSuccessUrl一起使用
测试
访问
http://localhost:8888/syslog
会自动跳转到login.html
输入用户名密码user:123456
相应
{
isok: true,
code: 200,
message: "success",
data: "/index"
}
将json改为html
security:
loginType: html
访问
http://localhost:8888/syslog
自动跳转到ligin.html
登录用户名密码admin:123456
会直接跳转到
http://localhost:8888/syslog
会返回上一次的请求路径
配置生效
super.onAuthenticationSuccess(request,response,authentication);
配置登录失败的请求
配置类
@Component
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Value("${spring.security.loginType}")
private String loginType;
private static ObjectMapper objectMapper = new ObjectMapper();
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception)
throws IOException, ServletException {
String errorMsg = "用户名或者密码输入错误!";
if(exception instanceof SessionAuthenticationException){
errorMsg = exception.getMessage();
}
if(loginType.equalsIgnoreCase("JSON")){
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(
AjaxResponse.error(new CustomException(
CustomExceptionType.USER_INPUT_ERROR,
errorMsg))
));
}else{
//跳转到登陆页面
super.onAuthenticationFailure(request,response,exception);
}
}
}
//.defaultSuccessUrl("/index")//成功后的跳转页面
//.failureUrl("/login.html")
.successHandler(myAuthenticationSuccessHandler)//不能与defaultSuccessUrl一起使用
.failureHandler(myAuthenticationFailureHandler)
更改为JSON方式而不是html方式
security:
loginType: JSON
login.html修改
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>首页</title>
<script src="https://cdn.staticfile.org/jquery/1.12.3/jquery.min.js"></script>
</head>
<body>
<h1>业务系统登录</h1>
<form action="/login" method="post">
<span>用户名称</span><input type="text" name="username" id="username"/> <br>
<span>用户密码</span><input type="password" name="password" id="password"/> <br>
<input type="button" onclick="login()" value="登陆">
</form>
<script>
function login() {
var username = $("#username").val();
var password = $("#password").val();
if (username === "" || password === "") {
alert('用户名或密码不能为空');
return;
}
$.ajax({
type: "POST",
url: "/login",
data: {
"username": username,
"password": password,
},
success: function (json) {
if(json.isok){
location.href = json.data;
}else{
alert(json.message)
}
},
error: function (e) {
console.log(e.responseText);
}
});
}
</script>
</body>
</html>
SESSION管理及安全
Spring Security与session的创建使用
- always:如果当前请求没有session存在,Spring Security创建一个session。
- never: Spring Security将永远不会主动创建session,但是如果session已经存 在,它将使用该session
- ifRequired (默认) :: Spring Security在需要时才创建session
- stateless: Spring Security不会创建或使用任何session。适合于接口型的无状态应用,该方式节省资源。
配置
.and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
会话超时配置
server.servlet.session.timeout= 15m //springboot
spring.session.timeout = 15m
配置
server:
port: 8888
servlet:
session:
timeout: 10s
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.invalidSessionUrl("/login/html")//超时后返回登录页面
session保护
- 默认情况下,Spring Security启用了migrationSession保护方式。即对于同一个cookies的SESSIONID用户,每次登录验证将创建一个新的HTTP会话,旧的HTTP会话将无效,并且旧会话的属性将被复制。
- 设置为“none”时,原始会话不会无效
- 设置“newSession”后,将创建一个干净的会话,而不会复制旧会话中的任何属性
配置
.invalidSessionUrl("/login/html")//超时后返回登录页面
.sessionFixation().migrateSession()//session保护
Cookie的安全
- httpOnly:如果为true,则浏览器脚本将无法访问cookie
- secure:如果为true,则仅通过HTTPS连接发送cookie, HTTP无法携带cookie。
配置
session:
timeout: 10s
cookie:
http-only: true
secure: false
限制最大登录用户数量
实现SessionInformationExpiredStrategy接口
配置
.sessionFixation().migrateSession()//session保护
.maximumSessions(1)
.maxSessionsPreventsLogin(false)//true:登录之后不能再登录,false:允许再次登录,但是前一次登录会下线
.expiredSessionStrategy(new MyExpiredSessionStrategy())//超时session响应策略
实现接口
public class MyExpiredSessionStrategy implements SessionInformationExpiredStrategy {
private static ObjectMapper objectMapper = new ObjectMapper();
//session超时后会调用
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
Map<String,Object> map = new HashMap<>();
map.put("code",0);
map.put("msg","您已经在另外一台电脑或浏览器登录,被迫下线!");
event.getResponse().setContentType("application/json;charset=UTF-8");
event.getResponse().getWriter().write(
objectMapper.writeValueAsString(map)
);
}
}
两个浏览器登录同一个用户,第一个用户会显示
{
msg: "您已经在另外一台电脑或浏览器登录,被迫下线!",
code: 0
}
RBAC权限管理模型
Role-Based Access Control
- 用户:系统接口及访问的操作者
- 权限:能够访问某接口或者做某操作的授权资格
- 角色:具有一类相同操作权限的用户的总称
用户-一对多->角色-多对多->权限
创建表格
-- 导出 表 devicedb.sys_menu 结构
CREATE TABLE IF NOT EXISTS `sys_menu` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`menu_pid` int(11) NOT NULL COMMENT '父菜单ID',
`menu_pids` varchar(64) NOT NULL COMMENT '当前菜单所有父菜单',
`is_leaf` tinyint(4) NOT NULL COMMENT '0:不是叶子节点,1:是叶子节点',
`menu_name` varchar(16) NOT NULL COMMENT '菜单名称',
`url` varchar(64) DEFAULT NULL COMMENT '跳转URL',
`icon` varchar(45) DEFAULT NULL,
`icon_color` varchar(16) DEFAULT NULL,
`sort` tinyint(4) DEFAULT NULL COMMENT '排序',
`level` tinyint(4) NOT NULL COMMENT '菜单层级',
`status` tinyint(4) NOT NULL COMMENT '0:启用,1:禁用',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COMMENT='系统菜单表';
-- 正在导出表 devicedb.sys_menu 的数据:~5 rows (大约)
/*!40000 ALTER TABLE `sys_menu` DISABLE KEYS */;
INSERT INTO `sys_menu` (`id`, `menu_pid`, `menu_pids`, `is_leaf`, `menu_name`, `url`, `icon`, `icon_color`, `sort`, `level`, `status`) VALUES
(1, 0, '0', 0, '系统管理', NULL, NULL, NULL, 1, 1, 0),
(2, 1, '1', 1, '用户管理', '/sysuser', NULL, NULL, 1, 2, 0),
(3, 1, '1', 1, '日志管理', '/syslog', NULL, NULL, 2, 2, 0),
(4, 1, '1', 1, '业务一', '/biz1', NULL, NULL, 3, 2, 0),
(5, 1, '1', 1, '业务二', '/biz2', NULL, NULL, 4, 2, 0);
/*!40000 ALTER TABLE `sys_menu` ENABLE KEYS */;
-- 导出 表 devicedb.sys_org 结构
CREATE TABLE IF NOT EXISTS `sys_org` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`org_pid` int(11) NOT NULL COMMENT '上级组织编码',
`org_pids` varchar(64) NOT NULL COMMENT '所有的父节点id',
`is_leaf` tinyint(4) NOT NULL COMMENT '0:不是叶子节点,1:是叶子节点',
`org_name` varchar(32) NOT NULL COMMENT '组织名',
`address` varchar(64) DEFAULT NULL COMMENT '地址',
`phone` varchar(13) DEFAULT NULL COMMENT '电话',
`email` varchar(32) DEFAULT NULL COMMENT '邮件',
`sort` tinyint(4) DEFAULT NULL COMMENT '排序',
`level` tinyint(4) NOT NULL COMMENT '组织层级',
`status` tinyint(4) NOT NULL COMMENT '0:启用,1:禁用',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8 COMMENT='系统组织结构表';
-- 正在导出表 devicedb.sys_org 的数据:~4 rows (大约)
/*!40000 ALTER TABLE `sys_org` DISABLE KEYS */;
INSERT INTO `sys_org` (`id`, `org_pid`, `org_pids`, `is_leaf`, `org_name`, `address`, `phone`, `email`, `sort`, `level`, `status`) VALUES
(1, 0, '0', 0, '总部', NULL, NULL, NULL, 1, 1, 0),
(2, 1, '1', 0, '研发部', NULL, NULL, NULL, 1, 2, 0),
(3, 2, '1,2', 1, '研发一部', NULL, NULL, NULL, 1, 3, 0),
(4, 2, '1,2', 1, '研发二部', NULL, NULL, NULL, 2, 3, 0);
/*!40000 ALTER TABLE `sys_org` ENABLE KEYS */;
-- 导出 表 devicedb.sys_role 结构
CREATE TABLE IF NOT EXISTS `sys_role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`role_name` varchar(32) NOT NULL DEFAULT '0' COMMENT '角色名称(汉字)',
`role_desc` varchar(128) NOT NULL DEFAULT '0' COMMENT '角色描述',
`role_code` varchar(32) NOT NULL DEFAULT '0' COMMENT '角色的英文code.如:ADMIN',
`sort` int(11) NOT NULL DEFAULT '0' COMMENT '角色顺序',
`status` int(11) DEFAULT NULL COMMENT '0表示可用',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '角色的创建日期',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='系统角色表';
-- 正在导出表 devicedb.sys_role 的数据:~2 rows (大约)
/*!40000 ALTER TABLE `sys_role` DISABLE KEYS */;
INSERT INTO `sys_role` (`id`, `role_name`, `role_desc`, `role_code`, `sort`, `status`, `create_time`) VALUES
(1, '管理员', '管理员', 'admin', 1, 0, '2019-12-23 22:56:48'),
(2, '普通用户', '普通用户', 'common', 2, 0, '2019-12-23 22:57:22');
/*!40000 ALTER TABLE `sys_role` ENABLE KEYS */;
-- 导出 表 devicedb.sys_role_menu 结构
CREATE TABLE IF NOT EXISTS `sys_role_menu` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`role_id` int(11) NOT NULL DEFAULT '0' COMMENT '角色id',
`menu_id` int(11) NOT NULL DEFAULT '0' COMMENT '权限id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8 COMMENT='角色权限关系表';
-- 正在导出表 devicedb.sys_role_menu 的数据:~4 rows (大约)
/*!40000 ALTER TABLE `sys_role_menu` DISABLE KEYS */;
INSERT INTO `sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES
(1, 1, 2),
(2, 1, 3),
(3, 2, 4),
(4, 2, 5);
/*!40000 ALTER TABLE `sys_role_menu` ENABLE KEYS */;
-- 导出 表 devicedb.sys_user 结构
CREATE TABLE IF NOT EXISTS `sys_user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(64) NOT NULL DEFAULT '0' COMMENT '用户名',
`password` varchar(64) NOT NULL DEFAULT '0' COMMENT '密码',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
`org_id` int(11) NOT NULL COMMENT '组织id',
`enabled` int(11) DEFAULT NULL COMMENT '0无效用户,1是有效用户',
`phone` varchar(16) DEFAULT NULL COMMENT '手机号',
`email` varchar(32) DEFAULT NULL COMMENT 'email',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='用户信息表';
-- 正在导出表 devicedb.sys_user 的数据:~2 rows (大约)
/*!40000 ALTER TABLE `sys_user` DISABLE KEYS */;
INSERT INTO `sys_user` (`id`, `username`, `password`, `create_time`, `org_id`, `enabled`, `phone`, `email`) VALUES
(1, 'yanfa1', '$2a$10$xPNoI0sBxOY6Y5Nj1bF6iO6OePqJ8tAJUsD5x5wh6G1BPphhSLcae', '2019-12-24 01:10:14', 3, 1, NULL, NULL),
(2, 'admin', '$2a$10$xPNoI0sBxOY6Y5Nj1bF6iO6OePqJ8tAJUsD5x5wh6G1BPphhSLcae', '2019-12-24 01:10:18', 1, 1, NULL, NULL);
/*!40000 ALTER TABLE `sys_user` ENABLE KEYS */;
-- 导出 表 devicedb.sys_user_role 结构
CREATE TABLE IF NOT EXISTS `sys_user_role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`role_id` int(11) NOT NULL DEFAULT '0' COMMENT '角色自增id',
`user_id` int(11) NOT NULL DEFAULT '0' COMMENT '用户自增id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='用户角色关系表';
-- 正在导出表 devicedb.sys_user_role 的数据:~2 rows (大约)
/*!40000 ALTER TABLE `sys_user_role` DISABLE KEYS */;
INSERT INTO `sys_user_role` (`id`, `role_id`, `user_id`) VALUES
(1, 2, 1),
(2, 1, 2);
动态加载数据库数据进行认证与授权
UserDetailsService接 口有一个方法叫做loadUserByUsername, 我们实现动态加载用户、角色、权限信息就是通过实现该方法。函数见名知义:通过用户名加载用户。该方法的返回值就是UserDetails。,UserDetails就是用户信息,即:用户名、密码、该用户所具有的权限。
public interface UserDetails extends Serializable {
//获取用户的权限集合
Collection<? extends GrantedAuthority> getAuthorities();
//获取密码
String getPassword();
//获取用户名
String getUsername();
//账号是否过期
boolean isAccountNonExpired();
//账号是否被锁定
boolean isAccountNonLocked();
//密码是否过期
boolean isCredentialsNonExpired();
//账号是否可用
boolean isEnabled();
}
配置mybatis
依赖
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<!-- 数据源依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.10</version>
</dependency>
<!-- mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.2</version>
</dependency>
配置
@SpringBootApplication
@MapperScan(basePackages = {"com.mumulx"})//mapper扫描包
public class BasicServerApplication {
spring:
datasource:
url: jdbc:mysql://localhost:3306/testdb?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: xxx
driver-class-name: org.gjt.mm.mysql.Driver
type: com.alibaba.druid.pool.DruidDataSource
将之前的静态用户密码删除
# user:
# name: admin
# password: admin
通过测试类获取加密密码字段插入数据库
@RunWith(SpringRunner.class)
@SpringBootTest
public class BootLaunchApplicationTests {
@Resource
PasswordEncoder passwordEncoder;
@Test
public void contextLoads() {
System.out.println(passwordEncoder.encode("123456"));
}
}
类
public class MyUserDetails implements UserDetails {
String password; //密码
String username; //用户名
boolean accountNonExpired; //是否没过期
boolean accountNonLocked; //是否没被锁定
boolean credentialsNonExpired; //是否没过期
boolean enabled; //账号是否可用
Collection<? extends GrantedAuthority> authorities; //用户的权限集合
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
public void setPassword(String password) {
this.password = password;
}
public void setUsername(String username) {
this.username = username;
}
public void setAccountNonExpired(boolean accountNonExpired) {
this.accountNonExpired = accountNonExpired;
}
public void setAccountNonLocked(boolean accountNonLocked) {
this.accountNonLocked = accountNonLocked;
}
public void setCredentialsNonExpired(boolean credentialsNonExpired) {
this.credentialsNonExpired = credentialsNonExpired;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
}
}
public interface MyUserDetailsServiceMapper {
//根据userID查询用户信息
@Select("SELECT username,password,enabled\n" +
"FROM sys_user u\n" +
"WHERE u.username = #{userId} or u.phone = #{userId}")
MyUserDetails findByUserName(@Param("userId") String userId);
//根据userID查询用户角色列表
@Select("SELECT role_code\n" +
"FROM sys_role r\n" +
"LEFT JOIN sys_user_role ur ON r.id = ur.role_id\n" +
"LEFT JOIN sys_user u ON u.id = ur.user_id\n" +
"WHERE u.username = #{userId} or u.phone = #{userId}")
List<String> findRoleByUserName(@Param("userId") String userId);
//根据用户角色查询用户权限
@Select({
"<script>",
"SELECT url " ,
"FROM sys_menu m " ,
"LEFT JOIN sys_role_menu rm ON m.id = rm.menu_id " ,
"LEFT JOIN sys_role r ON r.id = rm.role_id ",
"WHERE r.role_code IN ",
"<foreach collection='roleCodes' item='roleCode' open='(' separator=',' close=')'>",
"#{roleCode}",
"</foreach>",
"</script>"
})
List<String> findAuthorityByRoleCodes(@Param("roleCodes") List<String> roleCodes);
}
@Component
public class MyUserDetailsService implements UserDetailsService {
@Resource
private MyUserDetailsServiceMapper myUserDetailsServiceMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//加载基础用户信息
MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(username);
//加载用户角色列表
List<String> roleCodes = myUserDetailsServiceMapper.findRoleByUserName(username);
//通过用户角色列表加载用户的资源权限列表
List<String> authorties = myUserDetailsServiceMapper.findAuthorityByRoleCodes(roleCodes);
//角色是一个特殊的权限,ROLE_前缀
roleCodes = roleCodes.stream()
.map(rc -> "ROLE_" +rc)
.collect(Collectors.toList());
authorties.addAll(roleCodes);
myUserDetails.setAuthorities(
AuthorityUtils.commaSeparatedStringToAuthorityList(
String.join(",",authorties)
)
);
return myUserDetails;
}
}
配置
SecurityConfig
@Resource
MyUserDetailsService myUserDetailsService;
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsService)
.passwordEncoder(passwordEncoder());
}
//.antMatchers("/syslog","/sususer")
//.hasAnyRole("admin")//admin用户可以访问 .hasAnyAuthority("ROLE_admin"),另一种写法 角色
.antMatchers("/syslog").hasAuthority("/sys_log")//通过权限进行配置
.antMatchers("/sysuser").hasAuthority("/sys_user") //前面是资源,后面是资源id
测试
动态加载资源鉴权规则
将
.antMatchers("/biz1","/biz2")//需要对外暴漏的资源的路径,user用户和admin用户可以访问
.hasAnyAuthority("ROLE_user","ROLE_admin")//user用户和admin用户 权限
//.antMatchers("/syslog","/sususer")
//.hasAnyRole("admin")//admin用户可以访问 .hasAnyAuthority("ROLE_admin"),另一种写法 角色
.antMatchers("/syslog").hasAuthority("/syslog")//通过权限进行配置
.antMatchers("/sysuser").hasAuthority("/sysuser") //前面是资源,后面是资源id
.anyRequest().authenticated()
变成动态的
@Component("rabcService")
public class MyRBACService {
private AntPathMatcher antPathMatcher = new AntPathMatcher();
@Resource
private MyRBACServiceMapper myRBACServiceMapper;
/**
* 判断某用户是否具有该request资源的访问权限
*/
public boolean hasPermission(HttpServletRequest request, Authentication authentication){
//获取验证主体
Object principal = authentication.getPrincipal();
if(principal instanceof UserDetails){
String username = ((UserDetails)principal).getUsername();
List<String> urls = myRBACServiceMapper.findUrlsByUserName(username);
return urls.stream().anyMatch(
url -> antPathMatcher.match(url,request.getRequestURI())
);
}
return false;
}
}
public interface MyRBACServiceMapper {
@Select("SELECT url\n" +
"FROM sys_menu m\n" +
"LEFT JOIN sys_role_menu rm ON m.id = rm.menu_id\n" +
"LEFT JOIN sys_role r ON r.id = rm.role_id\n" +
"LEFT JOIN sys_user_role ur ON r.id = ur.role_id\n" +
"LEFT JOIN sys_user u ON u.id = ur.user_id\n" +
"WHERE u.username = #{userId} or u.phone = #{userId}")
List<String> findUrlsByUserName(@Param("userId") String userId);
}
配置
.antMatchers("/login.html","login").permitAll()//哪些请求不需要验证
.antMatchers("/index").authenticated()//
.anyRequest().access("@rabcService.hasPermission(request,authentication)")//除了上面的配置外的所有请求都需要通过rabcService的hasPermission方法进行判断是否有权限
测试
权限表达式的使用方法
"@rabcService.hasPermission(request,authentication)"
//.antMatchers("/login.html","login").permitAll()//哪些请求不需要验证
.antMatchers("/login.html","login").access("permitAll()")//与上面写法等同
常用权限表达式
表达式 | 说明 |
---|---|
hasRole([role]) | 用户拥有制定的角色时返回true (Spring security默认会带有ROLE_前缀) |
hasAnyRole([role1,role2]) | 用户拥有任意一个制定的角色时返回true |
hasAuthority([authority]) | 等同于hasRole,但不会带有ROLE_前缀 |
hasAnyAuthority([auth1,auth2]) | 等同于hasAnyRole |
permitAll | 永远返回true |
denyAll | 永远返回false |
authentication | 当前登录用户的authentication对象 |
fullAuthenticated | 当前用户既不是anonymous也不是rememberMe用户时返回true |
hasIpAddress(‘192.168.1.0/24’)) | 请求发送的IP匹配时返回true |
权限表达式在全局配置中的使用
.antMatchers("/login.html","login").access("permitAll()")//与上面写法等同
.antMatchers("/system/*").access("hasAnyRole('admin') or hasAnyAuthority('ROLE_admin')")
方法级别的安全控制
- @PreAuthorize
- @ PreFilter
- @ PostAuthorize
- @ PostFilter
开启
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
编写规则
@Service
public class MethodELService {
//在该方法执行之前先进判断hasRole('admin')
@PreAuthorize("hasRole('admin')")
public List<PersonDemo> findAll(){
return null;
}
//先执行方法,方法执行完毕后对返回值进行判断
@PostAuthorize("returnObject.name == authentication.name")
public PersonDemo findOne(){
String authName =
getContext().getAuthentication().getName();
//System.out.println(authName);
return new PersonDemo("admin");
}
//对参数ids进行过滤,过滤的规则为value="filterObject%2==0"
@PreFilter(filterTarget="ids", value="filterObject%2==0")
public void delete(List<Integer> ids, List<String> usernames) {
System.out.println();
}
//针对返回值进行过滤,保留返回值中的name==authentication.name(认证主体的对象)
@PostFilter("filterObject.name == authentication.name")
public List<PersonDemo> findAllPD(){
List<PersonDemo> list = new ArrayList<>();
list.add(new PersonDemo("kobe"));
list.add(new PersonDemo("admin"));
return list;
}
}
就是一个特殊的service,在controller中调用,当规则不符合时会抛出异常
// 具体业务一
@GetMapping("/biz1")
public String updateOrder() {
/*List<Integer> ids = new ArrayList<>();
ids.add(1);
ids.add(2);
methodELService.delete(ids,null);*/
//List<PersonDemo> pds = methodELService.findAllPD();
// methodELService.findAll();
return "biz1";
}
REMEMBERME记住密码
最简实现
后端配置
http.rememberMe(); //实现记住我自动登录配置, 核心的代码只有这一行
前端实现
<label><input type="checkbox" name="remember-me"/>记住密码</1abe1>
开启
springsecurityConfig中
@Override
protected void configure(HttpSecurity http) throws Exception {
http.rememberMe()//记住功能
.and().csrf().disable()//关掉csrf,否则会将我们的请求当作是一个不合法的
.formLogin()
页面添加组件
<form action="/login" method="post">
<span>用户名称</span><input type="text" name="username" id="username"/> <br>
<span>用户密码</span><input type="password" name="password" id="password"/> <br>
<input type="button" onclick="login()" value="登陆">
<label><input type="checkbox" name="remember-me"> 记住密码</label>
</form>
function login() {
var username = $("#username").val();
var password = $("#password").val();
var rememberMe = $("#remember-me").is(":checked");
if (username === "" || password === "") {
alert('用户名或密码不能为空');
return;
}
$.ajax({
type: "POST",
url: "/login",
data: {
"username": username,
"password": password,
"remember-me":rememberMe//名称一定是remember-me
},
测试
勾选记住密码后,会新增一个cookie
值为
YWRtaW46MTU4NDYyNDY1ODE2MTo2NTgyZGExZjRmYmQ5NWUyZTdmNjAwNGI4YjcxZDUzNg
使用base64进行解密后的到
admin:1584624658161:6582da1f4fbd95e2e7f6004b8b71d536
用户:时间(从xxx年到现在的总的秒数):
- RememberMeToken = username, expiry Time, signatureValue的Base64加密
signatureValue = username、 expirationTime和passwod和一个预定义的key,并将他们经过MD5进行签名。
TokenBasedRememberMeServices类中 protected String makeTokenSignature(long tokenExpiryTime, String username, String password) { String data = username + ":" + tokenExpiryTime + ":" + password + ":" + this.getKey(); MessageDigest digest; try { digest = MessageDigest.getInstance("MD5"); } catch (NoSuchAlgorithmException var8) { throw new IllegalStateException("No MD5 algorithm available!"); } return new String(Hex.encode(digest.digest(data.getBytes()))); }
个性化配置
- tokenValiditySeconds用于设置token的有效期,默认是2周。
- 通过rememberMeParameter设置from表单“自动登录”勾选框的参数名称。
rememberMeCookieName设 置了保存在 浏览器端的cookie的名称。
http.rememberMe() .rememberMeParameter("remember-me")//传参的名称即,ajax请求中"remember-me":rememberMe .rememberMeCookieName("remember-me-cookie")//cookie的name .tokenValiditySeconds(2*24*60*60)//过期时间:两天
将cookie信息存放到内存中
使用数据库实现remember
创建表
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
配置
SecurityConfig类
@Resource
private DataSource dataSource;//对应的是application-dev.yml中的datasource
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
rememberMeParameter("remember-me")//传参的名称即,ajax请求中"remember-me":rememberMe
.rememberMeCookieName("remember-me-cookie")//cookie的name
.tokenValiditySeconds(2*24*60*60)//过期时间:两天
.tokenRepository(persistentTokenRepository())
测试
数据库中增加了数据
用户退出功能的实现
最简及最佳实践
后端配置
@Override
protected void configure(final HttpSecurity http) throws Exception {
http.logout();
}
前端代码
<a href-="/logout" >退出</a>
配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http.logout()
index.html中增加代码
<a href="/logout">退出</a>
测试,将数据库中的信息删除
页面返回到login.html
logout的默认行为
- 当前session失效,即: logout的 核心需求,session失 效就是访问权限的回收。
- 删除当前用户的remember-me“记住我”功能信息
- clear清除当前的SecurityContext
- 重定向到登录页面,loginPage配置项指定的页面
个性化配置
- 通过指定logoutUr|配置改变退出请求的默认路径,当然html退出按钮的请求url也要修改
- 通过指定logoutSuccessUrl配置,来显式指定退出之后的跳转页面
- 还可以使用deleteCookies删除指定的cookie,参数为cookie的名称
配置
http.logout()
.logoutUrl("/logout")//a标签的href请求路径
.logoutSuccessUrl(".login.html")//成功后重定向的页面
.deleteCookies("JSESSIONID")//将JESSIONID一起删除
重定向的页面要设置访问权限
.authorizeRequests()
.antMatchers("/login.html","login").permitAll()//哪些请求不需要验证
LogoutSuccesshlandler
- 编码实现个性化退出功能
- 注意logoutSuccessUrl不要与logoutSuccessHandler一起使用,否则logoutSuccessHandler将失效。
当用户退出时还想做一些其他的操作
配置
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication)
throws IOException, ServletException {
//写一些业务逻辑,比如:登录时间的统计
response.sendRedirect("/login.html");
}
}
springsecurityConfig
@Resource
MyLogoutSuccessHandler myLogoutSuccessHandler;
http.logout()
.logoutUrl("/logout")
//.logoutSuccessUrl("/login.html")
.deleteCookies("JSESSIONID")
.logoutSuccessHandler(myLogoutSuccessHandler)//与logoutSuccessUrl不能同时使用
图片验证码的实现方案
谜面用于展现,谜底用于校验
- 对于字符型验证码。比如:谜面是显示字符串”ABGH”的图片,谜底是字符串”ABGH”
- 对于计算类验证码。比如:谜面是“1+1=”的图片,谜底是“2”
- 对于拖拽类的验证码。比如:谜面是一个拖拽式的拼图,谜底是拼图位置的坐标
session存储验证码
图片验证码开发三部曲
- 验证码工具配置
- 验证码加载(重点)
- 验证码校验(重点)
验证码工具类库
- 生成验证码文字或其他用于校验的数据形式(即谜底)
- 生成验证码前端显示图片或拼图等(即谜面)
- 用于校验用户输入与谜底的校验方法(如果是纯文字,就自己比对以下就可以。如果是基于物理图形拖拽、旋转等方式,需要专用的校验方法)
使用
添加依赖
<!-- 验证码-->
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
<exclusions>
<exclusion>
<artifactId>javax.servlet-api</artifactId>
<groupId>javax.servlet</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
配置
yml配置方式不能实现,因此需要使用properties文件
新建kaptcha.properties
kaptcha.border=no
kaptcha.border.color=105,179,90
kaptcha.image.width=100
kaptcha.image.height=45
kaptcha.session.key=code
kaptcha.textproducer.font.color=blue
kaptcha.textproducer.font.size=35
kaptcha.textproducer.char.length=4
kaptcha.textproducer.font.names=宋体,楷体,微软雅黑
配置加载类让spring认识该配置文件
CaptchaConfig
@Configuration
@PropertySource(value = {"classpath:kaptcha.properties"})
public class CaptchaConfig {
@Value("${kaptcha.border}")
private String border;
@Value("${kaptcha.border.color}")
private String borderColor;
@Value("${kaptcha.textproducer.font.color}")
private String fontColor;
@Value("${kaptcha.image.width}")
private String imageWidth;
@Value("${kaptcha.image.height}")
private String imageHeight;
@Value("${kaptcha.session.key}")
private String sessionKey;
@Value("${kaptcha.textproducer.char.length}")
private String charLength;
@Value("${kaptcha.textproducer.font.names}")
private String fontNames;
@Value("${kaptcha.textproducer.font.size}")
private String fontSize;
@Bean(name = "captchaProducer")
public DefaultKaptcha getKaptchaBean(){
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Properties properties = new Properties();
properties.setProperty("kaptcha.border", border);
properties.setProperty("kaptcha.border.color", borderColor);
properties.setProperty("kaptcha.textproducer.font.color", fontColor);
properties.setProperty("kaptcha.image.width", imageWidth);
properties.setProperty("kaptcha.image.height", imageHeight);
properties.setProperty("kaptcha.session.key", sessionKey);
properties.setProperty("kaptcha.textproducer.char.length", charLength);
properties.setProperty("kaptcha.textproducer.font.names", fontNames);
properties.setProperty("kaptcha.textproducer.font.size",fontSize);
defaultKaptcha.setConfig(new Config(properties));
return defaultKaptcha;
}
}
新建controller:CaptchaController
@RestController
public class CaptchaController {
@Resource
DefaultKaptcha captchaProducer;
@RequestMapping(value="/kaptcha",method = RequestMethod.GET)
public void kaptcha(HttpSession session, HttpServletResponse response) throws IOException {
response.setDateHeader("Expires", 0);
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
response.addHeader("Cache-Control", "post-check=0, pre-check=0");
response.setHeader("Pragma", "no-cache");
response.setContentType("image/jpeg");
String capText = captchaProducer.createText();
session.setAttribute(MyContants.CAPTCHA_SESSION_KEY,
new CaptchaCode(capText,2 * 60));//两分钟
//将验证码写回到浏览器中
try(ServletOutputStream out = response.getOutputStream()){
BufferedImage bufferedImage = captchaProducer.createImage(capText);
ImageIO.write(bufferedImage,"jpg",out);
out.flush();
}
}
}
工具类MyContants
public class MyContants {
public static final String CAPTCHA_SESSION_KEY = "captcha_key";
public static final String SMS_SESSION_KEY = "sms_key";
}
新建验证码:CaptchaCode
public class CaptchaCode {
//验证码
private String code;
//过期时间
private LocalDateTime expireTime;
public CaptchaCode(String code, int expireAfterSeconds){
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireAfterSeconds);
}
//判断当前验证码是否过期
public boolean isExpired(){
return LocalDateTime.now().isAfter(expireTime);
}
public String getCode() {
return code;
}
}
前端页面添加验证码login.html
<form action="/login" method="post">
<span>用户名称</span><input type="text" name="username" id="username"/> <br>
<span>用户密码</span><input type="password" name="password" id="password"/> <br>
<span>验证码</span><input type="text" name="captchaCode" id="captchaCode"/>
<img src="/kaptcha" id="kaptcha" width="100px" height="45px"/> <br>
<input type="button" onclick="login()" value="登陆">
<label><input type="checkbox" name="remember-me" id="remember-me"> 记住密码</label>
</form>
页面js
//页面加载时加载验证码
window.onload = function () {
var kaptchaImg = document.getElementById("kaptcha");
kaptchaImg.onclick = function () {
kaptchaImg.src = "/kaptcha?" + Math.floor(Math.random() * 100)
}
};
开放验证码访问路径SecurityConfig
.antMatchers("/login.html","login","/kaptcha").permitAll()//哪些请求不需要验证
测试
访问
http://localhost:8888/login.html
验证码验证
图片验证码过滤器
- 编写自定义图片验证码过滤器CaptchaCodeFilter,过滤器中拦截登录请求
- 过滤器中从seesion获取验证码文字与用户输入比对,比对通过执行其他过滤器链
- 比对不通过,抛出SessionAuthenticationException异常,交给AuthenticationFailureHandler处理
- 最后将Captcha odeFilter放在UsernamePasswordAuthenticationFilter过滤器前执行。
新建过滤器CaptchaCodeFilter
@Component
public class CaptchaCodeFilter extends OncePerRequestFilter {
@Resource
MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
if(StringUtils.equals("/login",request.getRequestURI())
&& StringUtils.equalsIgnoreCase(request.getMethod(),"post")){
try{
//验证谜底与用户输入是否匹配
validate(new ServletWebRequest(request));
}catch(AuthenticationException e){
//交给登录失败处理类取值处理
myAuthenticationFailureHandler.onAuthenticationFailure(
request,response,e
);
return;
}
}
filterChain.doFilter(request,response);
}
private void validate(ServletWebRequest request) throws ServletRequestBindingException {
HttpSession session = request.getRequest().getSession();
String codeInRequest = ServletRequestUtils.getStringParameter(
request.getRequest(),"captchaCode");
if(StringUtils.isEmpty(codeInRequest)){
throw new SessionAuthenticationException("验证码不能为空");
}
// 3. 获取session池中的验证码谜底
CaptchaCode codeInSession = (CaptchaCode)
session.getAttribute(MyContants.CAPTCHA_SESSION_KEY);
if(Objects.isNull(codeInSession)) {
throw new SessionAuthenticationException("验证码不存在");
}
// 4. 校验服务器session池中的验证码是否过期
if(codeInSession.isExpired()) {
session.removeAttribute(MyContants.CAPTCHA_SESSION_KEY);
throw new SessionAuthenticationException("验证码已经过期");
}
// 5. 请求验证码校验
if(!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new SessionAuthenticationException("验证码不匹配");
}
}
}
MyAuthenticationFailureHandler处理异常
String errorMsg = "用户名或者密码输入错误!";
if(exception instanceof SessionAuthenticationException){
errorMsg = exception.getMessage();
}
SecurityConfig配置
//验证码
@Resource
CaptchaCodeFilter captchaCodeFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class)
.logout()
前端页面传递验证码的值
function login() {
var username = $("#username").val();
var password = $("#password").val();
//验证码
var captchaCode = $("#captchaCode").val();
var rememberMe = $("#remember-me").is(":checked");
if (username === "" || password === "") {
alert('用户名或密码不能为空');
return;
}
$.ajax({
type: "POST",
url: "/login",
data: {
"username": username,
"password": password,
"captchaCode":captchaCode,
"remember-me":rememberMe//名称一定是remember-me
},
success: function (json) {
if(json.isok){
location.href = json.data;
}else{
alert(json.message)
}
},
error: function (e) {
console.log(e.responseText);
}
});
}
测试
共享session存储验证码
基于对称算法的验证码
短信验证码登录功能
- 输入手机号码,点击获取按钮,服务端接受请求发送短信
- 用户输入验证码点击登录
- 手机号码必须属于系统的注册用户,并且唯一
- 手机号与验证码正确性及其关系必须经过校验
- 登录后用户具有手机号对应的用户的角色及权限
实现步骤
- 获取短信验证码
- 短信验证码校验过滤器
- 短信验证码登录认证过滤器
- 综合配置
获取短信验证码
新建controller:SmsController
@Slf4j
@RestController
public class SmsController {
@Resource
MyUserDetailsServiceMapper myUserDetailsServiceMapper;
@RequestMapping(value = "/smscode",method = RequestMethod.GET)
public AjaxResponse sms(@RequestParam String mobile, HttpSession session){
//手机号是否已经被注册
MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(mobile);
if(myUserDetails == null){
return AjaxResponse.error(
new CustomException(CustomExceptionType.USER_INPUT_ERROR,
"您输入的手机号未曾注册")
);
}
//四位随机数字
SmsCode smsCode = new SmsCode(
RandomStringUtils.randomNumeric(4),60,mobile
);
//TODO 调用短信服务提供商的接口发送短信(要进行购买,这里使用日志模拟)
log.info(smsCode.getCode() + "+>" + mobile);
session.setAttribute(MyContants.SMS_SESSION_KEY,smsCode);
//AjaxResponse:封装了返回给前端页面信息的数据结构后,进行了统一
return AjaxResponse.success("短信验证码已经发送");
}
}
新建短信验证码实体类
public class SmsCode {
private String code; //短信验证码
private LocalDateTime expireTime; //过期时间
private String mobile;
public SmsCode(String code, int expireAfterSeconds,String mobile){
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireAfterSeconds);
this.mobile = mobile;
}
public boolean isExpired(){
return LocalDateTime.now().isAfter(expireTime);
}
public String getCode() {
return code;
}
public String getMobile() {
return mobile;
}
}
修改mapper增加查询手机号功能
//根据userID查询用户信息
@Select("SELECT username,password,enabled\n" +
"FROM sys_user u\n" +
"WHERE u.username = #{userId} or u.phone = #{userId}")
MyUserDetails findByUserName(@Param("userId") String userId);
开放权限
.antMatchers("/login.html","login","/kaptcha","/smscode").permitAll()//哪些请求不需要验证
短信验证码校验过滤器
短信验证码校验过滤器-校验规则
- 用户登录时手机号不能为空
- 用户登录时短信验证码不能为空
- 用户登陆时在session中必须存在对应的校验谜底(获取验证码时存放的)
- 用户登录时输入的短信验证码必须和
- 谜底”中的验证码一致
- 用户登录时输入的手机号必须和‘
- “谜底”中保存的手机号一致
- 用户登录时输入的手机号必须是系统注册用户的手机号,并且唯一
SmsCodeValidateFilter
@Component
public class SmsCodeValidateFilter extends OncePerRequestFilter {
@Resource
MyUserDetailsServiceMapper myUserDetailsServiceMapper;
@Resource
MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
if(StringUtils.equals("/smslogin",request.getRequestURI())
&& StringUtils.equalsIgnoreCase(request.getMethod(),"post")){
try{
//验证谜底与用户输入是否匹配
validate(new ServletWebRequest(request));
}catch(AuthenticationException e){
myAuthenticationFailureHandler.onAuthenticationFailure(
request,response,e
);
return;
}
}
filterChain.doFilter(request,response);
}
//验证规则
private void validate(ServletWebRequest request) throws ServletRequestBindingException {
HttpSession session = request.getRequest().getSession();
SmsCode codeInSession = (SmsCode)session.getAttribute(MyContants.SMS_SESSION_KEY);
String mobileInRequest = request.getParameter("mobile");
String codeInRequest = request.getParameter("smsCode");
if(StringUtils.isEmpty(mobileInRequest)){
throw new SessionAuthenticationException("手机号码不能为空");
}
if(StringUtils.isEmpty(codeInRequest)) {
throw new SessionAuthenticationException("短信验证码不能为空");
}
if(Objects.isNull(codeInSession)) {
throw new SessionAuthenticationException("短信验证码不存在");
}
if(codeInSession.isExpired()) {
session.removeAttribute(MyContants.SMS_SESSION_KEY);
throw new SessionAuthenticationException("短信验证码已经过期");
}
if(!codeInSession.getCode().equals(codeInRequest)) {
throw new SessionAuthenticationException("短信验证码不正确");
}
if(!codeInSession.getMobile().equals(mobileInRequest)) {
throw new SessionAuthenticationException("短信发送目标与您输入的手机号不一致");
}
MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(mobileInRequest);
if(Objects.isNull(myUserDetails)){
throw new SessionAuthenticationException("您输入的手机号不是系统的注册用户");
}
session.removeAttribute(MyContants.SMS_SESSION_KEY);
}
}
开放请求路径
.antMatchers("/login.html","login","/kaptcha","/smscode","/smslogin").permitAll()//哪些请求不需要验证
短信验证码登录认证过滤器
短信验证码登录认证过滤器原理
参照UsernamePasswordAuthenticationFilter类
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private String usernameParameter = "username";
private String passwordParameter = "password";
private boolean postOnly = true;
public UsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String username = this.obtainUsername(request);
String password = this.obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
@Nullable
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(this.passwordParameter);
}
@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(this.usernameParameter);
}
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
public void setUsernameParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.usernameParameter = usernameParameter;
}
public void setPasswordParameter(String passwordParameter) {
Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
this.passwordParameter = passwordParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getUsernameParameter() {
return this.usernameParameter;
}
public final String getPasswordParameter() {
return this.passwordParameter;
}
}
参考UsernamePasswordAuthenticationToken类
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
//存放认证信息,认证之前放的是手机号,认证之后UserDetails
private final Object principal;
public SmsCodeAuthenticationToken(Object principal) {
super(null);
this.principal = principal;
setAuthenticated(false);
}
public SmsCodeAuthenticationToken(Object principal,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}
public Object getPrincipal() {
return this.principal;
}
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException(
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
}
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
@Override
public Object getCredentials() {
return null;
}
}
参照DaoAuthenticationProvider类
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
protected UserDetailsService getUserDetailsService() {
return userDetailsService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken)authentication;
UserDetails userDetails = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());
if(userDetails == null){
throw new InternalAuthenticationServiceException("无法根据手机号获取用户信息");
}
SmsCodeAuthenticationToken authenticationResult
= new SmsCodeAuthenticationToken(userDetails,userDetails.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> authentication) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
}
综合配置
新建验证码配置类SmsCodeSecurityConfig
@Component
public class SmsCodeSecurityConfig
extends SecurityConfigurerAdapter<DefaultSecurityFilterChain,HttpSecurity> {
@Resource
MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Resource
MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Resource
MyUserDetailsService myUserDetailsService;
@Resource
SmsCodeValidateFilter smsCodeValidateFilter;
@Override
public void configure(HttpSecurity http) throws Exception {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(myUserDetailsService);
http.addFilterBefore(smsCodeValidateFilter,UsernamePasswordAuthenticationFilter.class);
http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);
}
}
总配置文件中进行配置
SecurityConfig
@Resource
SmsCodeSecurityConfig smsCodeSecurityConfig;
.formLogin()
//逻辑
.loginPage("/login.html")//登陆页面位置
.usernameParameter("username")//表单用户名name
.passwordParameter("password")//表单密码name
.loginProcessingUrl("/login")// 登录验证请求 post方式的表单action
//.defaultSuccessUrl("/index")//成功后的跳转页面
//.failureUrl("/login.html")
.successHandler(myAuthenticationSuccessHandler)//不能与defaultSuccessUrl一起使用
.failureHandler(myAuthenticationFailureHandler)
.and().apply(smsCodeSecurityConfig).and()
测试
数据库添加用户yanfa1手机号123456789
详述JWT使用场景及结构安全
session不适用的场景
- 比如:非浏览器的客户端、手机移动端等等,因为他们没有浏览器自动维护cookies的功能。
- 比如:集群应用,同一个应用部署甲、乙、丙三个主机上,实现负载均衡应用,其中一个挂掉了其他的还能负载工作。要知道session是保存在服务器内存里面的,三个主机一定是不同的内存。那么你登录的时候访问甲,而获取 接口数据的时候访问乙,就无法保证session的唯一性和共享性。
JWT安全加强
- 避免网络劫持,因为使用HTTP的header传递JWT,所以使用HTTPS传输更加安全。这样在网络层面避免了JWT的泄露。
- secret是存放在服务器端的,所以只要应用服务器不被攻破,理论上JWT是安全的。因此要保证服务器的安全。那么有没有JWT加密算法被攻破的可能?当然有。但是对于JWT常用的算法要想攻破,目前已知的方法只能是暴力破解,白话说就是”试密码”。所以要定期更换secret并且保正secret的复杂度,等破解结果出来了,你的secret已经换了。
其实使用的方式很简单,就是用户名和密码换JWT令牌,然后,在后续的访问中携带JWT令牌,我们从JWT令牌中解析出来它的这个用户信息,然后进行授权,就是这样。他全程都不需要使用我们的,这个session去做这个状态保持了,那我们。
使用Spring Security实现JWT原理
我们再回顾一下JWT认证流程,那就是客户端发起HTTP请求,然后HTTP请求中携带这个用户名和密码,然后,我们服务端校验的这个用户名和密码,然后如果他的这个校验合格,合格之后,我们就给他生成这个JWT令牌,这个JWT令牌,中间会包含这个用户名这个信息,然后或者是它的用户Id这样一个信息,总之是一些非敏感的这些数据。然后,把这个JWT令牌能返回给我们的客户端。客户端再次发起请求时的时候,他需要把他的JWT放到他的HTTP请求头里面,这个需要它编码自己编码客户端的这个程序自己编码去实现。然后,把这个JWT放到HTTP的请投里面,我们根据这个JWT的令牌,把这个令牌中间的这个用户名解密,进行一个解签,如果解签的结果,和我们的这个验证它是合法的,然后我们就授权他可以访问它需要访问的这个资源。然后,把这个访问的结果响应给我们的客户端。那这个是一个整体上的一个JWT的这样一个流程,那具体到我们的这个,整个的结合spring security去实现,它还更细节一些。
我们管这个上面的这个123这三个流程,叫做认证的流程,然后,456这三个流程,叫做健全的流程,所以说,这个整个的JWT的这个使用过程中,,有两个流程,第1个流程是认证的流程,也就是说,是用户名和密码换取JWT令牌的这个过程,然后另外一个流程,是她来访问资源,然后我们鉴别这个JWT的合法性,然后给他一个结果。这个请求资源,响应结果这样的过程,那叫一个健全的流程,那我们分别来看一下这两个流程,结合spring security如何去实现JWT的认证流程?
我们更细节的给大家来说一下,当客户端发起的这个登录请求,然后携带用户名和密码,当他发起这个请求的时候,我们需要自己去实现一个这个JwtAuthController这个controller的作用就是接收这个请求,然后,他去调用这个authenticationManager,它这个是这个spring security给我们提供了这样一个这个类.。他就是专门做这个认证的,然后我们调用这个认证的这个manager,我们在使用其中的这个userDetailsService方法,这个方法相信大家都很熟了,我们通过这个方法,根据用户名去加载用户信息、角色信息、权限信息,等这样一些信息,把这个信息加载回来之后,然后AuthenticationManager,根据这些信息,然后来校验当前的这个用户名和密码是否是合格的用户名密码,返回认证结果。
如果认证失败,就是用户名和密码的校验失败,就是通知我们客户端反馈校验失败了,如果和用户名和密码认证成功之后,它就调用我们自己需要去写一个这个JWTTokenUtil这样一个util工具类。这个工具类,就是为我们生成令牌,然后ji校验令牌,然后在这样一些工具方法,放到这个工具里面,那我们调用jwtTkoenUtil生成JWT令牌,然后将JWT令牌响应给我们的客户端。这个就是整个认证流程的一个过程。
这个过程我们就把它叫做我们JWT的认证流程,对应的是123这三部分,那我们再来看一下JWT的它这个鉴权流程
当这个客户端它拿到了这个JWT令牌的时候,他就在后续的每一次访问我们系统资源的时候,都要把这个JWT令牌给携带者的,放到他的这个HTTP头请求的这个header里面。通过这个时序图,我们来看一下它的健权的流程,那它客户端发起,假如说他请求的资源是这个hello的这个资源,这个资源的定义在我们的一个controller里面叫HelloController,他访问的这个hello这样一个资源路径。他在请求的时候携带这个JWT,那我们在JwtAuthenticationTokenFilter类,这个filter过滤器也需要我们自己去实现。
然后,我们在这个过滤器里面去检查这个用户是否携带了JWT令牌,如果携带了,我们就提取其中的这个用户信息。因为我们知道我们上一节给大家讲了,这个JWT里面,是可以通过它那个附加信息,也就是第2个解析段中,我们可以拿到它的这个用户,然后用户名我们根据他的这个用户名去掉用userdetailservice。userdetailservice加载这个用户的信息,就是用户角色权限这些东西,然后返回给这个filter,然后这个filter根据userDetail信息去交验令牌的合法性。
他使用什么去校验呢?我们会写一个方法JwtTokenUtil来校验这个令牌的这个合法性,然后判断这个令牌是否进行是否合法,然后最后,如果它不合法的话,证明这个JWT有可能是超时了,或者是他自己伪造的一个这个令牌,然后我们就把这个结果返回给我们的客户端。如果这个JWT认证是合法的,然后,我们就可以通过UserDetails去构建一个UsernamePasswordAuthenticationToken。这个Token就是用来我们填充之后,表示我们认证通过了,这个Token中填写的是这个UserDetail信息,也就是说它的用户信息,角色信息,权限信息,都填充到这个usernamepasswordAuthenticationToken里面。
那就下面就进行第9步,那我们这个filter就执行完了,然后,他继续执行spring security,其他的那个过滤器链中的这个过滤器。比如说,在他后面,会执行这个UserNamePAsswordAuthenticationFilter,他发现这个usernamePasswordAuthenticationToken已经被认证过,在这个位置已经被认证过的话,这个后续的过滤器都不会拦截hello的这个请求。然后,这个请求直接就到达我们的这个hello Controller。到了HelloController之后,他将最后的访问结果返回给我们的客户端,就是这样一个过程,这其中需要我们自己去实现的,有这个JwtAuthenticationTokenFilter过滤器,这个过滤器用来这个检查令牌的合法性,然后进行这个认证授权,也就是说他那个后续他能不能访问这个相关的这个接口,都由他去来去授权决定。那他如果授权通过的话,就可以访问这个hello Controller,如果不通过的话就直接返回。整个这个就是JWT,针对你资源访问鉴权的这样一个过程。
还有一些,实际上还有一些其他的内容,比如JWT令牌如何刷新。这个有一个刷新的一个机制,因为我们的这个JWT令牌,通常有效期不会太长。我们不希望用户,使用APP的时候令牌就突然的过期了,他就必须重新登录,这样是很影响用户体验的。所以说,这个客户端需要在这个合适的时机,去刷新一下这个JWT令牌。但这个JWT令牌刷新的过程就非常简单,它就是我们需要写一个Controller就可以了。所以说,核心的这个过程,就是第1个就是认证,然后第2个就是鉴权,然后还有一个尾巴的东西,就是差一个小东西,就是这个令牌的刷新,我们在整个的后面那个编码过程中都会给大家讲解。
编码实现
package com.mumulx.jwtserver.config.auth.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Data
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtTokenUtil {
private String secret;
private Long expiration;
private String header;
/**
* 生成token令牌
*
* @param userDetails 用户
* @return 令token牌
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>(2);
claims.put("sub", userDetails.getUsername());
claims.put("created", new Date());
return generateToken(claims);
}
/**
* 从令牌中获取用户名
*
* @param token 令牌
* @return 用户名
*/
public String getUsernameFromToken(String token) {
String username;
try {
Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 判断令牌是否过期
*
* @param token 令牌
* @return 是否过期
*/
public Boolean isTokenExpired(String token) {
try {
Claims claims = getClaimsFromToken(token);
Date expiration = claims.getExpiration();
return expiration.before(new Date());
} catch (Exception e) {
return false;
}
}
/**
* 刷新令牌
*
* @param token 原令牌
* @return 新令牌
*/
public String refreshToken(String token) {
String refreshedToken;
try {
Claims claims = getClaimsFromToken(token);
claims.put("created", new Date());
refreshedToken = generateToken(claims);
} catch (Exception e) {
refreshedToken = null;
}
return refreshedToken;
}
/**
* 验证令牌
*
* @param token 令牌
* @param userDetails 用户
* @return 是否有效
*/
public Boolean validateToken(String token, UserDetails userDetails) {
String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
/**
* 从claims生成令牌,如果看不懂就看谁调用它
*
* @param claims 数据声明
* @return 令牌
*/
private String generateToken(Map<String, Object> claims) {
Date expirationDate = new Date(System.currentTimeMillis() + expiration);
return Jwts.builder().setClaims(claims)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 从令牌中获取数据声明,如果看不懂就看谁调用它
*
* @param token 令牌
* @return 数据声明
*/
private Claims getClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
} catch (Exception e) {
claims = null;
}
return claims;
}
}
package com.mumulx.jwtserver.config.auth.jwt;
import com.mumulx.jwtserver.config.exception.CustomExceptionType;
import com.mumulx.jwtserver.config.exception.AjaxResponse;
import com.mumulx.jwtserver.config.exception.CustomException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.Map;
@RestController
public class JwtAuthController {
@Resource
JwtAuthService jwtAuthService;
@RequestMapping(value = "/authentication")
public AjaxResponse login(@RequestBody Map<String,String> map){
String username = map.get("username");
String password = map.get("password");
if(StringUtils.isEmpty(username)
|| StringUtils.isEmpty(password)){
return AjaxResponse.error(
new CustomException(CustomExceptionType.USER_INPUT_ERROR,
"用户名或者密码不能为空"));
}
try {
return AjaxResponse.success(jwtAuthService.login(username, password));
}catch (CustomException e){
return AjaxResponse.error(e);
}
}
@RequestMapping(value = "/refreshtoken")
public AjaxResponse refresh(@RequestHeader("${jwt.header}") String token){
return AjaxResponse.success(jwtAuthService.refreshToken(token));
}
}
package com.mumulx.jwtserver.config.auth.jwt;
import com.mumulx.jwtserver.config.auth.MyUserDetailsService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
JwtTokenUtil jwtTokenUtil;
@Resource
MyUserDetailsService myUserDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String jwtToken = request.getHeader(jwtTokenUtil.getHeader());
if(!StringUtils.isEmpty(jwtToken)){
String username = jwtTokenUtil.getUsernameFromToken(jwtToken);
if(username != null &&
SecurityContextHolder.getContext().getAuthentication() == null){
UserDetails userDetails = myUserDetailsService.loadUserByUsername(username);
if(jwtTokenUtil.validateToken(jwtToken,userDetails)){
//给使用该JWT令牌的用户进行授权
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
filterChain.doFilter(request,response);
}
}
package com.mumulx.jwtserver.config.auth.jwt;
import com.mumulx.jwtserver.config.exception.CustomExceptionType;
import com.mumulx.jwtserver.config.exception.CustomException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
public class JwtAuthService {
@Resource
AuthenticationManager authenticationManager;
@Resource
UserDetailsService userDetailsService;
@Resource
JwtTokenUtil jwtTokenUtil;
/**
* 登录认证换取JWT令牌
* @return JWT
*/
public String login(String username,String password) throws CustomException{
try {
UsernamePasswordAuthenticationToken upToken =
new UsernamePasswordAuthenticationToken(username, password);
Authentication authentication = authenticationManager.authenticate(upToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}catch (AuthenticationException e){
throw new CustomException(CustomExceptionType.USER_INPUT_ERROR
,"用户名或者密码不正确");
}
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return jwtTokenUtil.generateToken(userDetails);
}
public String refreshToken(String oldToken){
if(!jwtTokenUtil.isTokenExpired(oldToken)){
return jwtTokenUtil.refreshToken(oldToken);
}
return null;
}
}
package com.mumulx.jwtserver.config;
import com.mumulx.jwtserver.config.auth.MyLogoutSuccessHandler;
import com.mumulx.jwtserver.config.auth.MyUserDetailsService;
import com.zimug.jwtserver.config.auth.*;
import com.mumulx.jwtserver.config.auth.jwt.JwtAuthenticationTokenFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import javax.annotation.Resource;
import javax.sql.DataSource;
import java.util.Arrays;
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
MyLogoutSuccessHandler myLogoutSuccessHandler;
@Resource
MyUserDetailsService myUserDetailsService;
@Resource
private DataSource datasource;
@Resource
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringAntMatchers("/authentication")
.and().cors().and()
.addFilterBefore(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class)
.logout()
.logoutUrl("/signout")
//.logoutSuccessUrl("/login.html")
.deleteCookies("JSESSIONID")
.logoutSuccessHandler(myLogoutSuccessHandler)
.and().rememberMe()
.rememberMeParameter("remember-me-new")
.rememberMeCookieName("remember-me-cookie")
.tokenValiditySeconds(2 * 24 * 60 * 60)
.tokenRepository(persistentTokenRepository())
.and()
.authorizeRequests()
.antMatchers("/authentication","/refreshtoken").permitAll()
.antMatchers("/index").authenticated()
.anyRequest().access("@rabcService.hasPermission(request,authentication)")
.and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsService)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) {
//将项目中静态资源路径开放出来
web.ignoring()
.antMatchers( "/css/**", "/fonts/**", "/img/**", "/js/**");
}
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(datasource);
return tokenRepository;
}
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:8888"));
configuration.setAllowedMethods(Arrays.asList("GET","POST"));
configuration.applyPermitDefaultValues();
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
解决跨域访问的问题
首先我们就得去了解一下这个浏览器的同源策略,所谓的这个同源策略,就是说在向服务端发起这个HTTP请求的时候。在浏览器里面,它这个要求域名、端口和协议必须是一致的,也就是说。你的你当前的这个应用,不能访问别人的这个数据资源,或者说不能访问别人的这个页面资源,否则浏览器就会去限制你。那我们来看一下这张图,假如说现在我们的这个应用是前后端分离的,相当于,我们前端的部署在一个主机上,然后它有一个,这样一个域名,然后什么端口,然后,我们后端部署在另外一个主机上,然后他也有自己的域名,然后有一些端口的信息,那当我们去请求的时候发生,在HTTP请求的时候,实际上是在浏览器上,去请求前端的页面,然后返回回来,浏览器响应这个页面。然后,浏览器在请求后端的这个数据的时候,这个浏览器上的这个他的这个同源策略就会去限制你,因为你最开始访问的是aaa.com,也就是说我们的前端这个应用,然后当你去请求数据的时候,你就发送给这个bbb.com,他认为你,请求别的应用资源,而不是你自己的应用资源,这个浏览器就会限制你,这个就是浏览器的这个同源策略。
但是,这个同源策略,是有一些方案有一些方法去,可以去打破这个同学车里的这个限制的,,那我们来看一下有哪些这个打破同源策略限制这样一些方法。因为的确有这样的需求,就是说我们这个前后端应用是分开部署的,,所以说,它一定是这个不符合同源策略的,那我们还要去访问我们后端的这个服务,我们应该怎么去做?、
第一类方案
第一类方案:前端解决方案 虽然浏览器对于不符合同源策略的访问是禁止的,但是仍然存在例外的情况,如以下资源引用的标签不受同源策略的限制:
- html的script标签
- html的link标签
- html的img标签
- html的iframe标签:对于使用jsp、freemarker开 发的项目,这是实现跨域访问最常见的方法,
那么第1种方式,就是说浏览器这个在这个HTML里面,有一些标签它是不受同源策略的限制的。
哪些标签不受同源策略的限制?,就是这些标签,比如说html里面的script标签,他可以去加载另外一个域下面的这个家族的脚本,然后link标签,可以加在另外一个域下面的css文件,或者是html的这个img标签,它可以加载另外一个域下面的这个图片,那还有一个比较重要的,就是iframe这个标签,它可以去加载一些页面,去加载一个另外应用的页面。就是内嵌的那种,这种方式,对于jsp和freemarker。这种开发的项目是实现这个跨越访问的比较常见的一种方法就是使用这个iframe,那我们这是第1种方案,这个方案我相信大家都比较熟悉了,就是说用这个html里面的特殊的这几个标签。这几个标签,它不受同源策略的限制。
第二类方案
那第2种解决方案,就是说我们使用在前端应用和后端应用的前面,再放上一个代理,或者说我们管它叫负载均衡的服务器也可以。这个代理的作用就是什么?在浏览器或者是其他终端访问的前面,我们加上这样一个代理之后,浏览器看到的前端资源和后端服务,就是同一个域下面的。
我们来看一下这个图,当你就是以往的,当你去访问前端资源的时候,然后你再去访问后端资源,因为这两个资源是部署在不同的这个服务器上的,所以说,它是一定是IP、域名、端口不一致。所以说,它受同源策略的限制,你先访问了前端资源,然后端的服务你就访问不了,但是,我们可以怎么去做,我们可以在前端的这个服务和后端资源的前面,加上一个代理。加上代理之后,它自己就有一个IP、端口。所以说,在我们一系列前端的浏览器,或者是一些前端的一些东西来看,它的这个都是一个来源,它都是从代理上来的。相当于,我们把后端的这部分,给他屏蔽掉,所以说,这样也是同源策略,这个也是符合同源策略,符合同源策略。
这个源是哪?这个源就是我们的这个代理,就是我们这个代理,他IP和端口上是一致的,然后把后端屏蔽掉,这种方式也可以让浏览器认不出来后面的前端资源和后端资源。因为他都通过这个代理的IP和端口去访问,所以说,这个也是符合浏览器的同源策略。
第三类方案
第三类方案: CORS
- Access-Control-Allow-Origin::允许哪些ip或域名可以跨域访问
- Access-Control-Max-Age::表示在多少秒之内不需要重复校验该请求的跨域访问权限
- Access-Control-Allow-Methods::表示允许跨域请求的HTTP方法,如:GET,POST,PUT,DELETE
- Access-Control-Allow-Headers::表示访问请求中允许携带哪些Header信息,如: Accept、 Accept-Language、 Content-Language、 Content-Type
第3种解决方案是什么?就是我们本节要给大家重点介绍的,这个叫cors,也就是跨站访问的这样一个配置。这个配置,我们可以在spring boot里面去实现,他的这个原理是什么?
当我们的浏览器去加载前端资源的情况下,他访问的是aaa.com这个域名,前端的这个服务,把它的资源给返回给我们浏览器,然后渲染到浏览器上。他在发起后端请求的时候,他先去服务上做一个叫预检的这样一个步骤,她做预检的目的是什么?就是来判断一下,你这个后端的服务是否是可以提供给不同的域的。比如说aa.com域下面的服务去加载你BB.com域下的数据。如果预检通过的话,他就会返回预检通过了,如果预检通过之后,他就不再受同源策略的限制了,也就是说可以去访问的了。
这个预检的功能就有点像什么?比如说,你到一个朋友家,然后想去朋友家去借书,你先给他打了个电话,然后他同意了。你见到他的父母,然后你就说我给那个谁已经打过电话,他同意我过来取一本书。这种情况下,他的父母会同意你把这本书取走,否则,你不是他家的人,然后,他就不会让你取这个资源,大概就是这样,用这样一个浅显的例子。就是说,你去朋友家借书,然后朋友不在家,你先给朋友打个电话,朋友说同意了,他有可能也会告诉一下他父母,等会我又有一个朋友,来回会来我们家里取书,这个书我可以借给他。那这个借书的这个人,实际上就是我们的浏览器,然后被借书的实际上就是相关技术的家庭。然后对家庭,后端服务,实际上是他的父母。后台服务,实际上是这个,而不是他的父母,是她自己,他进行一个预检,打电话就说是否同意我借这个书,他说同意,然后,等会他就可以去家里,然后去把这本书借回来,所以说,这种情况下,它就不受这个同源策略的限制。这个就是比较有钱比较浅显的这样一个理解吧
那我们来,看一下,具体的话这个协议它实现是靠什么去实现的?也就是说在预检的过程中,它也会向我们的服务端发送请求,但是他发送请求之后,他不是真正去请求资源。他去判断这个资源,是否是可以打破同源策略,那我们通过哪些方式告诉他是否可以打破同源策略?就是在请求响应请求的响应里面加上请求头,然后告诉她哪些IP或者域名可以跨域访问,实际上就是打破同源策略。
当浏览器向后端这个服务端发送这个预检请求的时候,它响应这些请求,将这些请求头响应给的浏览器。浏览器解析到这些请求头之后,它会二次发起请求,然后请求我们后台数据,此时,它携带的这些信息,就告诉浏览器,实际上它就不受同源策略限制了,所以可以跨域访问。那这个浏览器解析到这些请求头之后,就不做这个跨越这个同源策略的限制,允许跨域访问,那就是这样一个效果。
springboot环境下CORS实现方式
方式一
是全局配置,也就是说他对所有的请求权都生效的这个配置。
/*
* Created by IntelliJ IDEA.
* User: 木木
* Date: 2020/6/13
* Time: 17:03
*/
@Configuration
public class GlobalCorsConfig {
@Bean
public CorsFilter corsFilter(){
CorsConfiguration config = new CorsConfiguration();
//开放哪些ip、端口、域名的访问权限,星号表示开放所有的域
config.addAllowedOrigin("*");
//是否允许发送Cookie信息
config.setAllowCredentials(true);
//开放哪些Http方法,允许跨域访问
config.addAllowedMethod(HttpMethod.GET);
config.addAllowedMethod(HttpMethod.PUT);
config.addAllowedMethod(HttpMethod.DELETE);
config.addAllowedMethod(HttpMethod.POST);
//允许Http请求中携带的哪些Header信息
config.addAllowedHeader("*");
//暴漏哪些头部信息,(因为跨域访问默认是不能获取全部头信息)
config.addExposedHeader("*");
//添加映射路径,"/**"表示对所有的路径实行全局跨域访问权限的设置
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config);
return new CorsFilter(configSource);
}
}
方式二
/*
* Created by IntelliJ IDEA.
* User: 木木
* Date: 2020/6/13
* Time: 17:16
*/
@Configuration
public class GlobalCorsConfig02 {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")//添加映射路径,"/**"表示对所有的路径实行全局跨域访问权限的设置
.allowedOrigins("*")//开放哪些ip、端口、域名的访问权限,星号表示开放所有的域
.allowCredentials(true)//是否允许发送Cookie信息
.allowedMethods("GET", "POST", "PUT", "DELETE")//开放哪些Http方法,允许跨域访问
.allowedHeaders("*")//允许Http请求中携带的哪些Header信息
.exposedHeaders("*");//暴漏哪些头部信息,(因为跨域访问默认是不能获取全部头信息)
}
};
}
}
方式三
使用CrossOrigin注解(局部跨域配置)
- 将CrossOrigin注解加在Controller层的方法上,该方法定义的RequestMapping端点将支持跨域访问
将CrossOrigin注解加在Controller层的类定义处, 整个类所有的方法对应的RequestMapping端点都将支持跨域访问
@RequestMapping("/cors") @ResponseBody @CrossOrigin(origins = "http://localhost:8080",maxAge = 3600) public String cors() { return "cors"; }
方式四
使用httpServletResponse设置响应头,局部跨域配置
@RequestMapping("/cors")
@ResponseBody
public String cors1(HttpServletResponse response) {
//使用httpServletResponse设置响应头,最原始的方法也是最通用的方法
response.addHeader("Access-Control-Allow-Origin","http://localhost:8888");
return "cors";
}
很不幸的是:在Spring Security环境 下以上四种方式全部失效
spring security 解决跨域问题
在springsecurity配置类中增加
http.cors.and().xxxxx
前面的四种方式再springsecurity中也可以生效,但是springSecurity更建议我们使用下面的方式(也算是第五种方式吧):
再增加一个方法
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:8888"));
configuration.setAllowedMethods(Arrays.asList("GET","POST"));
configuration.applyPermitDefaultValues();
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
跨站攻击防御
- CORS(跨站资源共享)是局部打破同源策略的限制,使在一定规则下HTTP请求可以突破浏览器限制,实现跨站访问。
- CSRF是一种网络攻击方式,也可以说是一种安全漏洞,这种安全漏洞在web开发中广泛存在。我们要需要堵上这个漏洞。
那第1个它是跨站资源共享是局部打破同源策略的限制,在一定的规则下,HTTP请求可以突破这个浏览器的限制,实现这个跨站的这种访问,这个是c or s跨站资源共享。
CSRF叫做跨站资源跨站请求伪造,他准确的说是一种网络的这种攻击的一种方式,或者说是一种安全的这种漏洞,这种漏洞在我们的这个web开发这个应用中广泛的存在,我看过很多的应用,包括绝大部分的80%以上的开源软件,就是关于web开发的,都没有针对这个攻击方式进行防御,那我们本节来就来给大家讲一下,怎么针对这个攻击方式进行一下防御。。那我们要想知道怎么防御的话,我们得知道它怎么攻击,我们怎么攻击我给大家举了一个例子,就是对方如何利用这种方式去攻击,
典型的跨站攻击
- 你登录了网站A,攻击者向你的网站A账户发送留言、或者伪造嵌入页面,带有危险操作链接。
- 当你在登录状态下点击了攻击者的连接,因此该链接对你网站A的账户进行了操作。
- 这个操作是你在网站A中主动发出的,并且也是针对网站A的HTTP链接请求,同源策略无法限制该请求。
当你作为一个用户登录了一个网站,然后攻击者向你的网站的那个账户上,发生了一些留言,或者一些伪造的这些那个嵌入式的这种页面,或者是一些危险的这种链接,你在登录状态下,然后你有意无意的吧,反正你就是点了这个链接,你就把这个链接给点击了。这个链接就是针对你的,当前你登陆了这个账户进行的操作,那这个就没有办法了,这个就是这个操作是你主动发出的,因为他是你点的,而且你已经登陆了,并且这个链接,应该是对这个当前的这个网站进行的操作,。同源策略也没有办法限制他,就是他这个链接就是针对你当前登陆这个网站进行了这个操作,而且是你主动点的,任何的策略都没有办法去限制他做任何的动作。所以说,假如说这个链接,就是一个转账的链接,那你的钱就被转走了,就是这么简单,所以说,你在网站上那个别人发给你的链接,你不要随便点,那这个就是他的一种的攻击方式,他伪造了这个,一个链接,这个链接是针对网站的这样一个链接,因为他可能对这个网站进行过一个分析,把这个链接发给一些用户,这个用户点了这个链接,就针对这个网站,它自己的这个账户进行操作,这也是典型的这种,跨站的请求的伪造的这种攻击方式。
那么这种攻击方式?他有点像什么呢?他有点像那个,比如说你要办婚礼,你要办婚礼,然后有人,就伪造了自己的身份,他自己进去,还在办这个婚礼的时候,他进去混吃混喝。我举这么一个例子吧,,有点像这个。
跨站攻击防御 为系统中的每一个连接请求加上一个token,这个token是随机的,服务端对该token进行验证。破坏者在留言或者伪造嵌入页面的时候,无法预先判断CSRF token的值是什么,所以当服务端校验CSRFtoken的时候也就无法通过。
那我们怎么去防御它?那就是说我们这种防御的方式,就是说我们要给每一个来参加我们这个婚礼,或者是访问我们资源的这个用户,给他发上一个这样的一个请柬,你可以这么去认为,,这个请帖,实际上就是一个令牌,然后我们给她发生这样一个令牌,就说这个令牌是随机的。然后,它我们会对这个令牌进行验证,就有点像什么,就是我们随机制作了一批请柬,然后发给我,发给我们参加婚礼的嘉宾,然后我们把这个请柬全都发出去,这些请柬什么样的只有我自己知道,别人谁也不知道。那这个请柬在发送出去之后,完了到那天的时候,他们就会来参加我们的婚礼的宴请,然后当他进来的时候,就会有人,请出示你的请柬,然后如果这个请柬不是我们发送的,不是我们发的请柬,那他就进不来。
所以说,这个跨站攻击防御,我们就,对它进行防御的这种方式,我们就是。给他在他我们给他发送请柬,就发生了一个这个令牌,在这令牌,在他来访问我们资源的时候,你要把这个CSRF这种跨站攻击防御的这个令牌带上,如果你不带的话我就不让你进来,就是这么个意思,那我们这个跨站攻击防御,那我们这个原理,大概就这个原理,
我们知道了这个原理之后,我们来就来做一下怎么来对它进行防御,我们就来用我们的这个编码的方式来实现一下。
.and().csrf().disable()//关闭了csrf
这个我们之前是把这个防御功能给它关掉了,我们在之前的代码把它关掉,如果不关掉的话,我们的请求像post、delete、put请求,都是发不进来的,都是发不进来的。这个跨站攻击防御,它不防get请求,他针对get请求是不做防御的。它只防put,post、delete这种修改数据的这种请求,所以说,我们把这个东西给它打开,
http.csrf().and().xxxx//开启csrf
把这个功能开启之后,我们要做一些配置。我们既然要发送发送我们这个token,也就是说我们的令牌,我们的请柬。那我们的服务端要把请柬保存起来,我们就要用这样一个csrfTokenRepository
http.csrf()
.csrfTokenRepository(CookieCsrfTokenRespository.withHttpOnlyFalse())
.ignoringAntMatchers("/authentication")
我们在认证的时候它通过这个cookie,给我们返回了一个这样的令牌叫XSRF-TOKEN,也就是说这个跨站攻击防御的这样一个令牌,也就是说这个是我们的请柬,这是我们的请柬,发送请求的时候需要在请求头加上我们的token(别忘了加上我们的JWT令牌请求头),key为X-XSRF-TOKEN
我们做了一个这样的配置叫做withHttpOnlyFalse这个的意思是告诉浏览器,我这个当前的这个cookie是可以被js脚本去读取的。我为什么可以允许去CS脚本去读取,因为我们要把它读取出来,然后放到我们的请求的这个位置上,我们要发送请求的时候,我们肯定要先从这个cokie里面把它给读取出来。所以说,这个withHttpOnlyFalse的意思就是说当前我们这个cookie当前我们的这个cookie,它是允许httpOnly为false(看XSRF-TOKEN的属性的时候)表示当前的这个cookie这条记录,可以被脚本读取,那样我们才能发起请求的时候才放在请求头。这个配置的目的,是为了前端能够正确的读到我们当前的这个令牌,所以说,我们做这样一个配置
前端携带参数的方式
thymeleaf
再header中携带CSRF token
var headers={};
headers["X-CSRF-TOKEN"]="${_csrf.token}";
$ajax({
headers: headers,
});
直接作为参数提交
$ajax({
data: {
"_csrf": "${_csrf.token}";
}
});
form表单隐藏字段
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf_token}">
JWT集群应用方案
我们来先回顾一下,JWT应用的这个授权验证流程。首先,我们浏览器通过用户名和密码,然后进行登录,然后登陆成功的话,我们会在这个Controller里面,生成一个JWT的令牌,然后返回给我们的浏览器。浏览器将这个浏览器端的这个前端程序,将这个JWT的令牌把它保存起来,然后,在以后每一次访问我们后端的任何的接口资源的时候,都要需要把这个zwt令牌带上。然后,我们的整个这个应用中,会有一个这样一个过滤器,然后来验证这个令牌的合法性,如果令牌和法,我们进行授权,他访问我们的这个资源,然后我们这个资源给他一个响应的结果。那这个,就是我们这样一个JWT的这样一个单体应用,它的这样一个请求与响应的过程。
那假如说,我们想把我们的应用。部署成为一个集群,也就是说上面的那个就是我们的那一套代码的这个JWT的这一套代码,我们给他部署两份,形成一个集群应用,也就是应用a和应用b,代码是同一套的,但是我们部署两份,那我们有没有可能,是有这样的需求,因为我们部署两份的目的,就是让它达到一个负载均衡分流的这样一个效果,然后,一个应用承担一部分的流量,那我们有没有可能实现,浏览器进行授,进行认证的时候他访问的是应用a,然后他需要访问接口资源的时候,它这个请求分流到我们的应用B上。这种情况肯定是有的,那我们思考就是说这种情况,这个应用B鉴权,就是验证这个JWT令牌的时候是是可以通过吗?他能够访问我们的这个a进行这个认证,然后发令牌,然后拿他拿令牌上B去访问接口资源,这样可以吗?
当然是可以的,因为,我们两个应用中都没有使用session去保存任何的状态信息所有的信息,都是我们从数据库去加载的,所以说状态信息这个保存的不是我们的问题,所有的我们都是通过一个用户名,然后去数据库里面去加载。只要这两个应用的使用的是同一个数据库里面的这种授权数据,甚至你使用的不是同一个库,但是数据是一样的,使用同一个签名使用同一个的这个密钥就是我们的那个secret,进行签名和解签。那就可以实现的这个应用a的认证,再应用b中被承认,这样一种效果。
所以说,我们这个JWT的集群是特别容易去扩展的,然后我们只要保证我们的这个授权数据是同一套,然后我们签名和解签的密钥是一样的,然后,我们就可以把同一份代码然后部署多份,实现这样一个集群应用的这样一个效果。然后,我们前端进行一个负载均衡的这样一个分流,那我们这样这个集群应用特别容易扩散。
那我们在思考一个问题还可以思考一个问题,那就是我们的应用,假如说应用a和应用b,它不是一套代码,他部署的是两套代码,然后,他也是使用的是JWT,那可以实现应用a认证,然后应用b然后进行授权访问吗?可以思考一下这个问题。
给大家一个答案,这个仍然是可以的,应用a和应用b,虽然他们两个的业务资源的代码不一样,但是我们可以让他什么一样,我们可以让他Controller的这个代码是一样的,也就是说,你这个认证的代码要统一,就是说这个JWT颁发JWT令牌的这个代码,这两个control,要代码要一致,然后,你进行这个校验的这个future的这个代码要一致,然后,你使用同一套授权数据,然后,你使用同一个签名和签的这个密钥。然后你就可以实现这种,不是同一套的这个代码,然后部署多份,然后他们之间也是可以进行这个认证和授权的认证,然后鉴权接口资源访问,这样也是可以的。
所以说,JWT这一套东西,它核心的HTTP之间交互的都是数据,只要你的这个令牌这个签名和解签,然后授权的这些数据是一套,然后你鉴权颁发,这些代码也都是一套的,那你就可以实现。虽然你的业务接口资源不一样。应用A是以自己的接口资源,应用B也是自己的接口资源。他们两个不一样没有关系,你只要保证其他的部分数据的授权数据,然后授权的conntroller,然后,这个包括我们的验证的这个filter,然后我们的密钥是一份。这两个应用,就可以实现互相承认的这样一种效果。
JWT应用分布式部署的条件
- 认证Controller代码统一
- 鉴权Filter代码统一、校验规则是一样的。
- 使用同一套授权数据
- 同一个用于签名和解签的secret。
再来思考一个问题,基于刚才这个前提,我们完全可以把我们认证的这个部分,就是这个Controller就是颁发JWT令牌的这个Controller,单独给它抽出来形成一个应用,,也就是,这个是经常我们会听到大家说的这种叫做认证服务器进行认证服务器,他就是专门用户名和密码,然后发放在JWT令牌的这样一个认证服务器,然后,我们可以把它单独抽取出来,那我们甚至把它单独抽取出来之后,我们甚至还可以进一步把这个filter,他也是要求统一的吗?我们完全可以把这个验证的这个令牌验证的这个过滤器,把他这个代码也单独抽取出来,形成我们的这个服务网关而形成服务网关,当然,这个JWT这个过滤器的这个抽取过程,它不像这个Controller的这个抽取那么简单,因为这个服务网关不光有这个令牌验证的这个功能,它还有这个请求转发的这样一个功能,所以说,大家这个前端的这个,那就是服务网关了,这个需要在服务网关中进行这个过滤器的这样一个最大的信息平台的认证,我们通常管这个,抽取出来的内容,就是这个验证逻辑,加上我们这个请求转发逻辑,形成一个单独的这样一个前端,在这个接口资源的前面,我们管它叫服务网关,这个大家如果学过spring cloud的话,我们完全可以把这个JWT的令牌这个验证逻辑,放到这个服务网管理进行验证。那我们最后就剩下一些这个接口资源了,我们通常管它叫做这个资源服务器。