spring security 的核心功能主要包括:
其核心就是一组过滤器链,项目启动后将会自动配置。最核心的就是 Basic Authentication Filter 用来认证用户的身份,一个在spring security中一种过滤器处理一种认证方式。
比如,对于username password认证过滤器来说,
下一个按照自身职责判定是否是自身需要的信息,basic的特征就是在请求头中有 Authorization:Basic eHh4Onh4 的信息。中间可能还有更多的认证过滤器。最后一环是 FilterSecurityInterceptor,这里会判定该请求是否能进行访问rest服务,判断的依据是 BrowserSecurityConfig中的配置,如果被拒绝了就会抛出不同的异常(根据具体的原因)。Exception Translation Filter 会捕获抛出的错误,然后根据不同的认证方式进行信息的返回提示。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
一般权限控制有三层,即:用户
<–>角色
<–>权限
,用户与角色是多对多,角色和权限也是多对多。这里我们先暂时不考虑权限,只考虑用户
<–>角色
。
创建用户表sys_user
:
CREATE TABLE `sys_user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
创建权限表sys_role
:
CREATE TABLE `sys_role` (
`id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
创建用户-角色表sys_user_role
:
CREATE TABLE `sys_user_role` (
`user_id` int(11) NOT NULL,
`role_id` int(11) NOT NULL,
PRIMARY KEY (`user_id`,`role_id`),
KEY `fk_role_id` (`role_id`),
CONSTRAINT `fk_role_id` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_user_id` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
初始化一下数据:
INSERT INTO `sys_role` VALUES ('1', 'ROLE_ADMIN');
INSERT INTO `sys_role` VALUES ('2', 'ROLE_USER');
INSERT INTO `sys_user` VALUES ('1', 'admin', '123');
INSERT INTO `sys_user` VALUES ('2', 'jitwxs', '123');
INSERT INTO `sys_user_role` VALUES ('1', '1');
INSERT INTO `sys_user_role` VALUES ('2', '2');
用于登陆的login.html
以及用于登陆成功后的home.html
,将其放置在 resources/templates
目录下:
(1)login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登陆</title>
</head>
<body>
<h1>登陆</h1>
<form method="post" action="/login">
<div>
用户名:<input type="text" name="username">
</div>
<div>
密码:<input type="password" name="password">
</div>
<div>
<button type="submit">立即登陆</button>
</div>
</form>
</body>
</html>
(2)home.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>登陆成功</h1>
<a href="/admin">检测ROLE_ADMIN角色</a>
<a href="/user">检测ROLE_USER角色</a>
<button onclick="window.location.href='/logout'">退出登录</button>
</body>
</html>
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/security_test?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: root
thymeleaf:
cache: false # 开发配置为false,避免修改模板还要重启服务器
mode: HTML5 #模板的模式,支持HTML XML TEXT JAVASCRIPT
encoding: UTF-8
prefix: classpath:/templates/
mybatis:
configuration:
map-underscore-to-camel-case: true
SysRole.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SysRole implements Serializable {
static final long serialVersionUID = 1L;
private Integer id;
private String name;
}
SysUser.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SysUser implements Serializable {
static final long serialVersionUID = 1L;
private Integer id;
private String name;
private String password;
}
SysUserRole.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SysUserRole implements Serializable {
static final long serialVersionUID = 1L;
private Integer userId;
private Integer roleId;
}
SysRoleMapper.java
@Mapper
public interface SysRoleMapper {
@Select("SELECT * FROM sys_role WHERE id = #{id}")
SysRole selectById(Integer id);
}
SysUserMapper.java
@Mapper
public interface SysUserMapper {
@Select("SELECT * FROM sys_user WHERE id = #{id}")
SysUser selectById(Integer id);
@Select("SELECT * FROM sys_user WHERE name = #{name}")
SysUser selectByName(String name);
}
SysUserRoleMapper.java
@Mapper
public interface SysUserRoleMapper {
@Select("SELECT * FROM sys_user_role WHERE user_id = #{userId}")
List<SysUserRole> listByUserId(Integer userId);
}
SysRoleService.java
@Service
public class SysRoleService {
@Autowired
private SysRoleMapper roleMapper;
public SysRole selectById(Integer id){
return roleMapper.selectById(id);
}
}
SysUserService.java
@Service
public class SysUserService {
@Autowired
private SysUserMapper userMapper;
public SysUser selectById(Integer id) {
return userMapper.selectById(id);
}
public SysUser selectByName(String name) {
return userMapper.selectByName(name);
}
}
SysUserRoleService.java
@Service
public class SysUserRoleService {
@Autowired
private SysUserRoleMapper userRoleMapper;
public List<SysUserRole> listByUserId(Integer userId) {
return userRoleMapper.listByUserId(userId);
}
}
@Controller
public class LoginController {
private final Logger logger = LoggerFactory.getLogger(LoginController.class);
@RequestMapping("/")
public String showHome() {
String name = SecurityContextHolder.getContext().getAuthentication().getName();
logger.info("当前登陆用户:" + name);
return "home.html";
}
@RequestMapping("/login")
public String showLogin() {
return "login.html";
}
@RequestMapping("/admin")
@ResponseBody
@PreAuthorize("hasRole('ROLE_ADMIN')")
public String printAdmin() {
return "如果你看见这句话,说明你有ROLE_ADMIN角色";
}
@RequestMapping("/user")
@ResponseBody
@PreAuthorize("hasRole('ROLE_USER')")
public String printUser() {
return "如果你看见这句话,说明你有ROLE_USER角色";
}
}
首先我们需要自定义 UserDetailsService
,将用户信息和权限注入进来。
我们需要重写loadUserByUsername
方法,参数是用户输入的用户名。返回值是UserDetails
,这是一个接口,一般使用它的子类org.springframework.security.core.userdetails.User
,它有三个参数,分别是用户名、密码和权限集。
package com.hl.hl01springsecurity.security;
import com.hl.hl01springsecurity.entity.SysRole;
import com.hl.hl01springsecurity.entity.SysUser;
import com.hl.hl01springsecurity.entity.SysUserRole;
import com.hl.hl01springsecurity.service.SysRoleService;
import com.hl.hl01springsecurity.service.SysUserRoleService;
import com.hl.hl01springsecurity.service.SysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
@Service("userDetailsService")
public class CustomUserDetailsServiceImpl implements UserDetailsService {
@Autowired
private SysUserService userService;
@Autowired
private SysRoleService roleService;
@Autowired
private SysUserRoleService userRoleService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Collection<GrantedAuthority> authorities = new ArrayList<>();
// 从数据库中取出用户信息
SysUser user = userService.selectByName(username);
// 判断用户是否存在
if(user == null) {
throw new UsernameNotFoundException("用户名不存在");
}
// 添加权限
List<SysUserRole> userRoles = userRoleService.listByUserId(user.getId());
for (SysUserRole userRole : userRoles) {
SysRole role = roleService.selectById(userRole.getRoleId());
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
// 返回UserDetails实现类
return new User(user.getName(), user.getPassword(), authorities);
}
}
该类是 Spring Security 的配置类,该类的三个注解分别是标识该类是配置类、开启 Security 服务、开启全局 Securtiy 注解。
首先将我们自定义的 userDetailsService
注入进来,在 configure()
方法中使用 auth.userDetailsService()
方法替换掉默认的 userDetailsService。
这里我们还指定了密码的加密方式(5.0 版本强制要求设置),因为我们数据库是明文存储的,所以明文返回即可,如下所示:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
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.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@EnableWebSecurity //开启security服务
@EnableGlobalMethodSecurity(prePostEnabled = true)//开启全局security注解
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsServiceImpl userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//使用下面的方法替换掉默认的userDetailsService
auth.userDetailsService(userDetailsService).passwordEncoder(new PasswordEncoder() {
@Override
public String encode(CharSequence charSequence) {
return charSequence.toString();
}
@Override
public boolean matches(CharSequence charSequence, String s) {
return s.equals(charSequence.toString());
}
});
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 如果有允许匿名的url,填在下面
// .antMatchers().permitAll()
.anyRequest().authenticated()
.and()
// 设置登陆页
.formLogin().loginPage("/login")
// 设置登陆成功页
.defaultSuccessUrl("/").permitAll()
// 自定义登陆用户名和密码参数,默认为username和password
// .usernameParameter("username")
// .passwordParameter("password")
.and()
.logout().permitAll();
// 关闭CSRF跨域
http.csrf().disable();
}
@Override
public void configure(WebSecurity web) throws Exception {
// 设置拦截忽略文件夹,可以对静态资源放行
web.ignoring().antMatchers("/css/**", "/js/**");
}
}
注:如果你想要将密码加密,可以修改 configure()
方法如下:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(new BCryptPasswordEncoder());
}
ROLE_ADMIN 账户:用户名 admin,密码 123
ROLE_USER 账户:用户名 jitwxs,密码 123
这里是以admin用户进行登录的,点击上面蓝色的role_admin会看到上面的话,如果是以user用户登录的就会恰恰相反。
方法 | 说明 |
---|---|
openidLogin() | 用于基于 OpenId 的验证 |
headers() | 将安全标头添加到响应 |
cors() | 配置跨域资源共享( CORS ) |
sessionManagement() | 允许配置会话管理 |
portMapper() | 允许配置一个PortMapper(HttpSecurity#(getSharedObject(class))),其他提供SecurityConfigurer的对象使用 PortMapper 从 HTTP 重定向到 HTTPS 或者从 HTTPS 重定向到 HTTP。默认情况下,Spring Security使用一个PortMapperImpl映射 HTTP 端口8080到 HTTPS 端口8443,HTTP 端口80到 HTTPS 端口443 |
jee() | 配置基于容器的预认证。 在这种情况下,认证由Servlet容器管理 |
x509() | 配置基于x509的认证 |
rememberMe | 允许配置“记住我”的验证 |
authorizeRequests() | 允许基于使用HttpServletRequest 限制访问 |
requestCache() | 允许配置请求缓存 |
exceptionHandling() | 允许配置错误处理 |
securityContext() | 在HttpServletRequests 之间的SecurityContextHolder 上设置SecurityContext 的管理。 当使用WebSecurityConfigurerAdapter 时,这将自动应用 |
servletApi() | 将HttpServletRequest 方法与在其上找到的值集成到SecurityContext 中。 当使用WebSecurityConfigurerAdapter 时,这将自动应用 |
csrf() | 添加 CSRF 支持,使用WebSecurityConfigurerAdapter 时,默认启用 |
logout() | 添加退出登录支持。当使用WebSecurityConfigurerAdapter时,这将自动应用。默认情况是,访问URL”/ logout”,使HTTP Session无效来清除用户,清除已配置的任何#rememberMe()身份验证,清除SecurityContextHolder,然后重定向到”/login?success” |
anonymous() | 允许配置匿名用户的表示方法。 当与WebSecurityConfigurerAdapter结合使用时,这将自动应用。 默认情况下,匿名用户将使用org.springframework.security.authentication.AnonymousAuthenticationToken表示,并包含角色 “ROLE_ANONYMOUS” |
formLogin() | 指定支持基于表单的身份验证。如果未指定FormLoginConfigurer#loginPage(String) ,则将生成默认登录页面 |
oauth2Login() | 根据外部OAuth 2.0或OpenID Connect 1.0提供程序配置身份验证 |
requiresChannel() | 配置通道安全。为了使该配置有用,必须提供至少一个到所需信道的映射 |
httpBasic() | 配置 Http Basic 验证 |
addFilterAt() | 在指定的Filter类的位置添加过滤器 |
在登陆页添加自动登录的选项,注意自动登录字段的 name 必须是 remember-me
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登陆</title>
</head>
<body>
<h1>登陆</h1>
<form method="post" action="/login">
<div>
用户名:<input type="text" name="username">
</div>
<div>
密码:<input type="password" name="password">
</div>
<div>
<label><input type="checkbox" name="remember-me"/>自动登录</label>
<button type="submit">立即登陆</button>
</div>
</form>
</body>
</html>
这种方式十分简单,只要在 WebSecurityConfig 中的 configure() 方法添加一个 rememberMe()
即可,如下所示:
当我们登陆时勾选自动登录时,会自动在 Cookie 中保存一个名为 remember-me
的cookie,默认有效期为2周,其值是一个加密字符串:
虽然使用cookie存储很方便可是不是很安全,spring security还提供了一种相对安全的实现机制:
在客户端的 Cookie 中,仅保存一个无意义的加密串(与用户名、密码等敏感数据无关),然后在数据库中保存该加密串-用户信息的对应关系,自动登录时,用 Cookie 中的加密串,到数据库中验证,如果通过,自动登录才算通过。
当浏览器发起表单登录请求时,当通过 UsernamePasswordAuthenticationFilter
认证成功后,会经过RememberMeService
,在其中有个 TokenRepository
,它会生成一个 token,首先将 token 写入到浏览器的 Cookie 中,然后将 token、认证成功的用户名写入到数据库中。
当浏览器下次请求时,会经过 RememberMeAuthenticationFilter
,它会读取 Cookie 中的 token,交给 RememberMeService
从数据库中查询记录。如果存在记录,会读取用户名并去调用 UserDetailsService
,获取用户信息,并将用户信息放入Spring Security 中,实现自动登陆。
RememberMeAuthenticationFilter 在整个过滤器链中是比较靠后的位置,也就是说在传统登录方式都无法登录的情况下才会使用自动登陆。
首先在数据库创建创建一张表
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;
其次在WebSecurityConfig 中注入 dataSource
,创建一个 PersistentTokenRepository
的Bean:
@Autowired
private DataSource dataSource;
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// 如果token表不存在,使用下面语句可以初始化该表;若存在,请注释掉这条语句,否则会报错。
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
最后在在 config()
中按如下所示配置自动登陆:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 如果有允许匿名的url,填在下面
// .antMatchers().permitAll()
.anyRequest().authenticated()
.and()
// 设置登陆页
.formLogin().loginPage("/login")
// 设置登陆成功页
.defaultSuccessUrl("/").permitAll()
// 自定义登陆用户名和密码参数,默认为username和password
// .usernameParameter("username")
// .passwordParameter("password")
.and()
.logout().permitAll()
//自动登录
.and().rememberMe()
.tokenRepository(persistentTokenRepository())
//有效时间:单位s
.tokenValiditySeconds(60)
.userDetailsService(userDetailsService);
// 关闭CSRF跨域
http.csrf().disable();
}
在我们登录失败的时候它会跳转到
login?error
地址,可是我们控制台和页面都没有看到错误信息
这是因为首先 /login?error
是 Spring security 默认的失败 Url,其次如果你不手动处理这个异常,这个异常是不会被处理的。
我们先来列举下一些 Spring Security 中常见的异常:
UsernameNotFoundException
(用户不存在)DisabledException
(用户已被禁用)BadCredentialsException
(坏的凭据)LockedException
(账户锁定)AccountExpiredException
(账户过期)CredentialsExpiredException
(证书过期)(1) 指定错误Url,WebSecurityConfig
中添加.failureUrl("/login/error")
(2)在Controller中处理异常
@RequestMapping("/login/error")
public void loginError(HttpServletRequest request, HttpServletResponse response) {
response.setContentType("text/html;charset=utf-8");
AuthenticationException exception =
(AuthenticationException)request.getSession().getAttribute("SPRING_SECURITY_LAST_EXCEPTION");
try {
response.getWriter().write(exception.toString());
}catch (IOException e) {
e.printStackTrace();
}
}
验证码 Servlet,这里大家不需要关心内部怎么实现的,我也是百度直接copy的
@Component
public class VerifyServlet extends HttpServlet {
private static final long serialVersionUID = -5051097528828603895L;
/** * 验证码图片的宽度。 */
private int width = 100;
/** * 验证码图片的高度。 */
private int height = 30;
/** * 验证码字符个数 */
private int codeCount = 4;
/** * 字体高度 */
private int fontHeight;
/** * 干扰线数量 */
private int interLine = 16;
/** * 第一个字符的x轴值,因为后面的字符坐标依次递增,所以它们的x轴值是codeX的倍数 */
private int codeX;
/** * codeY ,验证字符的y轴值,因为并行所以值一样 */
private int codeY;
/** * codeSequence 表示字符允许出现的序列值 */
char[] codeSequence = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',
'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
/** * 初始化验证图片属性 */
@Override
public void init() throws ServletException {
// 从web.xml中获取初始信息
// 宽度
String strWidth = this.getInitParameter("width");
// 高度
String strHeight = this.getInitParameter("height");
// 字符个数
String strCodeCount = this.getInitParameter("codeCount");
// 将配置的信息转换成数值
try {
if (strWidth != null && strWidth.length() != 0) {
width = Integer.parseInt(strWidth);
}
if (strHeight != null && strHeight.length() != 0) {
height = Integer.parseInt(strHeight);
}
if (strCodeCount != null && strCodeCount.length() != 0) {
codeCount = Integer.parseInt(strCodeCount);
}
} catch (NumberFormatException e) {
e.printStackTrace();
}
//width-4 除去左右多余的位置,使验证码更加集中显示,减得越多越集中。
//codeCount+1 //等比分配显示的宽度,包括左右两边的空格
codeX = (width-4) / (codeCount+1);
//height - 10 集中显示验证码
fontHeight = height - 10;
codeY = height - 7;
}
/** * @param request * @param response * @throws ServletException * @throws java.io.IOException */
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, java.io.IOException {
// 定义图像buffer
BufferedImage buffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D gd = buffImg.createGraphics();
// 创建一个随机数生成器类
Random random = new Random();
// 将图像填充为白色
gd.setColor(Color.LIGHT_GRAY);
gd.fillRect(0, 0, width, height);
// 创建字体,字体的大小应该根据图片的高度来定。
Font font = new Font("Times New Roman", Font.PLAIN, fontHeight);
// 设置字体。
gd.setFont(font);
// 画边框。
gd.setColor(Color.BLACK);
gd.drawRect(0, 0, width - 1, height - 1);
// 随机产生16条干扰线,使图象中的认证码不易被其它程序探测到。
gd.setColor(Color.gray);
for (int i = 0; i < interLine; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
gd.drawLine(x, y, x + xl, y + yl);
}
// randomCode用于保存随机产生的验证码,以便用户登录后进行验证。
StringBuffer randomCode = new StringBuffer();
int red = 0, green = 0, blue = 0;
// 随机产生codeCount数字的验证码。
for (int i = 0; i < codeCount; i++) {
// 得到随机产生的验证码数字。
String strRand = String.valueOf(codeSequence[random.nextInt(36)]);
// 产生随机的颜色分量来构造颜色值,这样输出的每位数字的颜色值都将不同。
red = random.nextInt(255);
green = random.nextInt(255);
blue = random.nextInt(255);
// 用随机产生的颜色将验证码绘制到图像中。
gd.setColor(new Color(red,green,blue));
gd.drawString(strRand, (i + 1) * codeX, codeY);
// 将产生的四个随机数组合在一起。
randomCode.append(strRand);
}
// 将四位数字的验证码保存到Session中。
HttpSession session = request.getSession();
session.setAttribute("validateCode", randomCode.toString());
// 禁止图像缓存。
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
response.setContentType("image/jpeg");
// 将图像输出到Servlet输出流中。
ServletOutputStream sos = response.getOutputStream();
ImageIO.write(buffImg, "jpeg", sos);
sos.close();
}
}
然后把VerifyServlet注入到spring容器中
@Configuration
public class VerifyConfig {
/** * 注入验证码servlet */
@Bean
public ServletRegistrationBean indexServletRegistration() {
ServletRegistrationBean registration = new ServletRegistrationBean(new VerifyServlet());
registration.addUrlMappings("/getVerifyCode");
return registration;
}
}
修改login.html页面加上验证码字段
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登陆</title>
</head>
<body>
<h1>登陆</h1>
<form method="post" action="/login">
<div>
用户名:<input type="text" name="username">
</div>
<div>
密码:<input type="password" name="password">
</div>
<div>
<input type="text" class="form-control" name="verifyCode" required="required" placeholder="验证码">
<img src="getVerifyCode" title="看不清,请点我" onclick="refresh(this)" onmouseover="mouseover(this)" />
</div>
<div>
<label><input type="checkbox" name="remember-me"/>自动登录</label>
<button type="submit">立即登陆</button>
</div>
</form>
<script> function refresh(obj) { obj.src = "getVerifyCode?" + Math.random(); } function mouseover(obj) { obj.style.cursor = "pointer"; } </script>
</body>
</html>
最后修改WebSecurityConfig代码
不要忘记在 WebSecurityConfig 中允许该 Url 的匿名访问,不然没有登录是没有办法访问的:
Ajax校验的思路就是其实就是表单提交前先发个 HTTP 请求验证验证码,验证成功后再验证用户名和密码
过滤器验证的思路:在 Spring Security 处理登录验证请求前,验证验证码,如果正确,放行;如果不正确,调到异常
编写验证码过滤器:
自定义一个过滤器,实现 OncePerRequestFilter
(该 Filter 保证每次请求一定会过滤),在 isProtectedUrl()
方法中拦截了 POST 方式的 /login 请求。
逻辑处理中从 request 中取出验证码,并进行验证,如果验证成功,放行;验证失败,手动生成异常。
public class VerifyFilter extends OncePerRequestFilter {
private static final PathMatcher PATH_MATCHER = new AntPathMatcher();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if(isProtectedUrl(request)) {
String verifyCode = request.getParameter("verifyCode");
if(!validateVerify(verifyCode)) {
//手动设置异常
request.getSession().setAttribute("SPRING_SECURITY_LAST_EXCEPTION",new DisabledException("验证码输入错误"));
// 转发到错误Url
request.getRequestDispatcher("/login/error").forward(request,response);
} else {
filterChain.doFilter(request,response);
}
} else {
filterChain.doFilter(request,response);
}
}
private boolean validateVerify(String inputVerify) {
//获取当前线程绑定的request对象
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
// 不分区大小写
// 这个validateCode是在servlet中存入session的名字
String validateCode = ((String) request.getSession().getAttribute("validateCode")).toLowerCase();
inputVerify = inputVerify.toLowerCase();
System.out.println("验证码:" + validateCode + "用户输入:" + inputVerify);
return validateCode.equals(inputVerify);
}
// 拦截 /login的POST请求
private boolean isProtectedUrl(HttpServletRequest request) {
return "POST".equals(request.getMethod()) && PATH_MATCHER.match("/login", request.getServletPath());
}
}
注入过滤器:
修改 WebSecurityConfig 的 configure 方法,添加一个 addFilterBefore()
,具有两个参数,作用是在参数二之前执行参数一设置的过滤器。
Spring Security 对于用户名/密码登录方式是通过 UsernamePasswordAuthenticationFilter
处理的,我们在它之前执行验证码过滤器即可。
测试:
输入一个错误的验证码
虽然使用过滤器验证实现了验证码功能,可是他这个和ajax验证差别不大
如果我们要做的需求是用户登录是需要多个验证字段,不单单是用户名和密码,那么使用过滤器会让逻辑变得复杂,这时候可以考虑自定义 Spring Security 的验证逻辑了
WebAuthenticationDetails:
/** * 获取用户登录时携带的额外信息 */
public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {
private static final long serialVersionUID = 6975601077710753878L;
private final String verifyCode;
public CustomWebAuthenticationDetails(HttpServletRequest request) {
super(request);
// verifyCode为页面中验证码的name
verifyCode = request.getParameter("verifyCode");
}
public String getVerifyCode() {
return this.verifyCode;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(super.toString()).append("; VerifyCode: ").append(this.getVerifyCode());
return sb.toString();
}
}
AuthenticationDetailsSource:
自定义了WebAuthenticationDetails
,我i们还需要将其放入 AuthenticationDetailsSource
中来替换原本的 WebAuthenticationDetails
,因此还得实现自定义 AuthenticationDetailsSource
:
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
/** * 该接口用于在Spring Security登录过程中对用户的登录信息的详细信息进行填充 */
@Component
public class CustomAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
@Override
public WebAuthenticationDetails buildDetails(HttpServletRequest request) {
return new CustomWebAuthenticationDetails(request);
}
}
该类内容将原本的 WebAuthenticationDetails
替换为了我们的 CustomWebAuthenticationDetails
。
然后我们将 CustomAuthenticationDetailsSource
注入Spring Security中,替换掉默认的 AuthenticationDetailsSource
。
修改 WebSecurityConfig
,将其注入,然后在config()中使用 authenticationDetailsSource(authenticationDetailsSource)
方法来指定它。
@Autowired
private CustomAuthenticationDetailsSource authenticationDetailsSource;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 如果有允许匿名的url,填在下面
.antMatchers("/getVerifyCode").permitAll()
.anyRequest().authenticated()
.and()
// 设置登陆页
.formLogin().loginPage("/login")
// 登录失败Url
.failureUrl("/login/error")
// 设置登陆成功页
.defaultSuccessUrl("/").permitAll()
// 自定义登陆用户名和密码参数,默认为username和password
// .usernameParameter("username")
// .passwordParameter("password")
// 指定authenticationDetailsSource
.authenticationDetailsSource(authenticationDetailsSource)
.and()
// .addFilterBefore(new VerifyFilter(), UsernamePasswordAuthenticationFilter.class)
.logout().permitAll()
//自动登录
.and().rememberMe()
.tokenRepository(persistentTokenRepository())
//有效时间:单位s
.tokenValiditySeconds(60)
.userDetailsService(userDetailsService);
// 关闭CSRF跨域
http.csrf().disable();
}
AuthenticationProvider:
至此我们通过自定义WebAuthenticationDetails
和AuthenticationDetailsSource
将验证码和用户名、密码一起带入了Spring Security中,下面我们需要将它取出来。
这里需要我们自定义AuthenticationProvider,需要注意的是,如果是我们自己实现AuthenticationProvider
,那么我们就需要自己做密码校验了。
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 获取用户输入的用户名和密码
String inputName = authentication.getName();
String inputPassword = authentication.getCredentials().toString();
CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();
String verifyCode = details.getVerifyCode();
if(!validateVerify(verifyCode)) {
throw new DisabledException("验证码输入错误");
}
// userDetails为数据库中查询到的用户信息
UserDetails userDetails = customUserDetailsService.loadUserByUsername(inputName);
// 如果是自定义AuthenticationProvider,需要手动密码校验
if(!userDetails.getPassword().equals(inputPassword)) {
throw new BadCredentialsException("密码错误");
}
return new UsernamePasswordAuthenticationToken(inputName, inputPassword, userDetails.getAuthorities());
}
private boolean validateVerify(String inputVerify) {
//获取当前线程绑定的request对象
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
// 不分区大小写
// 这个validateCode是在servlet中存入session的名字
String validateCode = ((String) request.getSession().getAttribute("validateCode")).toLowerCase();
inputVerify = inputVerify.toLowerCase();
System.out.println("验证码:" + validateCode + "用户输入:" + inputVerify);
return validateCode.equals(inputVerify);
}
@Override
public boolean supports(Class<?> authentication) {
// 这里不要忘记,和UsernamePasswordAuthenticationToken比较
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
最后在 WebSecurityConfig
中将其注入,并在 config 方法中通过 auth.authenticationProvider()
指定使用。
@Autowired
private CustomAuthenticationProvider customAuthenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//使用下面的方法替换掉默认的userDetailsService
// auth.userDetailsService(userDetailsService).passwordEncoder(new PasswordEncoder() {
// @Override
// public String encode(CharSequence charSequence) {
// return charSequence.toString();
// }
//
// @Override
// public boolean matches(CharSequence charSequence, String s) {
// return s.equals(charSequence.toString());
// }
// });
auth.authenticationProvider(customAuthenticationProvider);
}
测试:
CREATE TABLE `sys_permission` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`url` varchar(255) DEFAULT NULL,
`role_id` int(11) DEFAULT NULL,
`permission` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_roleId` (`role_id`),
CONSTRAINT `fk_roleId` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;
/** * 权限实体类 */
@Data
public class SysPermission implements Serializable {
static final long serialVersionUID = 1L;
private Integer id;
private String url;
private Integer roleId;
private String permission;
private List<String> permissions;
public List<String> getPermissions() {
return Arrays.asList(this.permission.trim().split(","));
}
public void setPermissions(List<String> permissions) {
this.permissions = permissions;
}
}
@Mapper
public interface SysPermissionMapper {
@Select("SELECT * FROM sys_permission WHERE role_id=#{roleId}")
List<SysPermission> listByRoleId(Integer roleId);
}
@Mapper
public interface SysRoleMapper {
@Select("SELECT * FROM sys_role WHERE id = #{id}")
SysRole selectById(Integer id);
@Select("SELECT * FROM sys_role WHERE name = #{name}")
SysRole selectByName(String name);
}
@Service
public class SysPermissionService {
@Autowired
private SysPermissionMapper permissionMapper;
/** * 获取指定角色所有权限 */
public List<SysPermission> listByRoleId(Integer roleId) {
return permissionMapper.listByRoleId(roleId);
}
}
@Service
public class SysRoleService {
@Autowired
private SysRoleMapper roleMapper;
public SysRole selectById(Integer id) {
return roleMapper.selectById(id);
}
public SysRole selectByName(String name) {
return roleMapper.selectByName(name);
}
}
我们需要自定义对 hasPermission()
方法的处理,就需要自定义 PermissionEvaluator
,创建类 CustomPermissionEvaluator
,实现 PermissionEvaluator
接口。
package com.hl.hl01springsecurity.security.permissions;
import com.hl.hl01springsecurity.entity.SysPermission;
import com.hl.hl01springsecurity.service.SysPermissionService;
import com.hl.hl01springsecurity.service.SysRoleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Autowired
private SysPermissionService permissionService;
@Autowired
private SysRoleService roleService;
@Override
public boolean hasPermission(Authentication authentication, Object targetUrl, Object targetPermission) {
// 获得loadUserByUsername()方法的结果
User user = (User)authentication.getPrincipal();
// 获得loadUserByUsername()中注入的角色
Collection<GrantedAuthority> authorities = user.getAuthorities();
// 遍历用户所有角色
for(GrantedAuthority authority : authorities) {
String roleName = authority.getAuthority();
Integer roleId = roleService.selectByName(roleName).getId();
// 得到角色所有的权限
List<SysPermission> permissionList = permissionService.listByRoleId(roleId);
// 遍历permissionList
for(SysPermission sysPermission : permissionList) {
// 获取权限集
List<String> permissions = sysPermission.getPermissions();
// 如果访问的Url和权限用户符合的话,返回true
if(targetUrl.equals(sysPermission.getUrl()) && permissions.contains(targetPermission)) {
return true;
}
}
}
return false;
}
@Override
public boolean hasPermission(Authentication authentication, Serializable serializable, String s, Object o) {
return false;
}
}
在 hasPermission()
方法中,参数 1 代表用户的权限身份,参数 2 参数 3 分别和 @PreAuthorize("hasPermission('/admin','r')")
中的参数对应,即访问 url 和权限。
Authentication
取出登录用户的所有 Role
Role
,获取到每个Role
的所有 Permission
Permission
,只要有一个 Permission
的 url
和传入的url相同,且该 Permission
中包含传入的权限,返回 true/** * 注入自定义PermissionEvaluator */
@Bean
public DefaultWebSecurityExpressionHandler webSecurityExpressionHandler(){
DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
handler.setPermissionEvaluator(new CustomPermissionEvaluator());
return handler;
}
关于成功和失败的处理,前面我们是用failureUrl()
来指定失败后的URL,defaultSuccessUrl()
指定认证成功后URL,我们可以通过设置 successHandler()
和 failureHandler()
来实现自定义认证成功、失败处理。
CustomAuthenticationSuccessHandler:
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final Logger logger = LoggerFactory.getLogger(getClass());
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
logger.info("登录成功,{}", authentication);
response.sendRedirect("/");
}
}
onAuthenticationSuccess()
方法的第三个参数 Authentication
为认证后该用户的认证信息,这里打印日志后,重定向到了首页。
CustomAuthenticationFailureHandler:
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Autowired
private ObjectMapper objectMapper;
private final Logger logger = LoggerFactory.getLogger(getClass());
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
logger.info("登陆失败");
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage()));
}
}
onAuthenticationFailure()
方法的第三个参数 exception
为认证失败所产生的异常,这里也是简单的返回到前台。
修改 WebSecurityConfig:
首先需要把自定义的俩个类注入进来
测试:
登录成功日志打印出来了
登录失败日志也打印出来了
当用户登录后,我们可以设置 session 的超时时间,当达到超时时间后,自动将用户退出登录。
Session 超时的配置是 SpringBoot 原生支持的,我们只需要在 application.properties
配置文件中配置:
server:
servlet:
session:
timeout: 60 # 过期时间,单位s
Spring Security 提供了两种处理配置,一个是 invalidSessionStrategy()
,另外一个是 invalidSessionUrl()
。
这两个的区别就是一个是前者是在一个类中进行处理,后者是直接跳转到一个 Url。简单起见,我就直接用 invalidSessionUrl()
了,跳转到 /login/invalid
,我们需要把该 Url 设置为免授权访问, 配置如下:
在 controller 中写一个接口进行处理:
@RequestMapping("/login/invalid")
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ResponseBody
public String invalid() {
return "Session 已过期,请重新登录";
}
运行程序,登陆成功后等待一分钟(或者重启服务器),刷新页面:
原理:限制单个用户能够存在的最大session数
在http.sessionManagement()
下添加三行代码:
maximumSessions(int)
:指定最大登录数maxSessionsPreventsLogin(boolean)
:是否保留已经登录的用户;为true,新用户无法登录;为 false,旧用户被踢出expiredSessionStrategy(SessionInformationExpiredStrategy)
:旧用户被踢出后处理方法修改结果如下:
http.sessionManagement()
// .sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
.invalidSessionUrl("/login/invalid")
.maximumSessions(1)
//当达到最大值时,是否保留已经登录的用户
.maxSessionsPreventsLogin(false)
//当达到最大值时,旧用户被提出后的操作
.expiredSessionStrategy(new CustomExpiredSessionStrategy());
编写 CustomExpiredSessionStrategy 类,来处理旧用户登陆失败的逻辑:
public class CustomExpiredSessionStrategy implements SessionInformationExpiredStrategy {
private final ObjectMapper objectMapper = new ObjectMapper();
// private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
Map<String, Object> map = new HashMap<>(16);
map.put("code", 0);
map.put("msg", "已经另一台机器登录,您被迫下线。" + event.getSessionInformation().getLastRequest());
// Map -> Json
String json = objectMapper.writeValueAsString(map);
event.getResponse().setContentType("application/json;charset=UTF-8");
event.getResponse().getWriter().write(json);
// 如果是跳转html页面,url代表跳转的地址
// redirectStrategy.sendRedirect(event.getRequest(), event.getResponse(), "url");
}
}
执行程序,打开两个浏览器,登录同一个账户。因为我设置了 maximumSessions(1)
,也就是单个用户只能存在一个 session,因此当你刷新先登录的那个浏览器时,被提示踢出了。
下面我们来测试下 maxSessionsPreventsLogin(true)
时的情况,我们发现第一个浏览器登录后,第二个浏览器无法登录:
首先需要在容器中注入名为 SessionRegistry
的 Bean,这里我就简单的写在 WebSecurityConfig 中:
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
其次在sessionManagement中添加一行.sessionRegistry()
http.sessionManagement()
// .sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
.invalidSessionUrl("/login/invalid")
.maximumSessions(1)
//当达到最大值时,是否保留已经登录的用户
.maxSessionsPreventsLogin(true)
//当达到最大值时,旧用户被提出后的操作
.expiredSessionStrategy(new CustomExpiredSessionStrategy())
.sessionRegistry(sessionRegistry());
最后编写一个接口用于测试踢出用户:
@Controller
public class LoginController {
@Autowired
private SessionRegistry sessionRegistry;
...
@GetMapping("/kick")
@ResponseBody
public String removeUserSessionByUsername(@RequestParam String username) {
int count = 0;
// 获取session中所有的用户信息
List<Object> users = sessionRegistry.getAllPrincipals();
for (Object principal : users) {
if (principal instanceof User) {
String principalName = ((User)principal).getUsername();
if (principalName.equals(username)) {
// 参数二:是否包含过期的Session
List<SessionInformation> sessionsInfo = sessionRegistry.getAllSessions(principal, false);
if (null != sessionsInfo && sessionsInfo.size() > 0) {
for (SessionInformation sessionInformation : sessionsInfo) {
sessionInformation.expireNow();
count++;
}
}
}
}
}
return "操作成功,清理session共" + count + "个";
}
}
sessionRegistry.getAllPrincipals();
获取所有 principal 信息sessionRegistry.getAllSessions(principal, false)
获取该 principal 上的所有 sessionsessionInformation.expireNow()
使得 session 过期运行程序,分别使用 admin 和 hl 账户登录,admin 访问 /kick?username=jitwxs
来踢出用户 hl,hl刷新页面,发现被踢出。
http.logout();
是 Spring Security 的默认退出配置,Spring Security 在退出时候做了这样几件事:
Spring Security 默认的退出 Url 是 /logout
,我们可以修改默认的退出 Url,例如修改为 /signout
:
http.logout()
.logoutUrl("/signout");
我们也可以配置当退出时清除浏览器的 Cookie,例如清除 名为 JSESSIONID 的 cookie:
http.logout()
.logoutUrl("/signout")
.deleteCookies("JSESSIONID");
我们也可以配置退出后处理的逻辑,方便做一些别的操作:
http.logout()
.logoutUrl("/signout")
.deleteCookies("JSESSIONID")
.logoutSuccessHandler(logoutSuccessHandler);
创建类 DefaultLogoutSuccessHandler
:
package com.hl.hl01springsecurity.security.logout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
private final static Logger log = LoggerFactory.getLogger(CustomLogoutSuccessHandler.class);
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String username = ((User) authentication.getPrincipal()).getUsername();
log.info("退出成功,用户名:{}", username);
// 重定向到登录页
response.sendRedirect("/login");
}
}
最后把它注入到 WebSecurityConfig 即可:
@Autowired
private CustomLogoutSuccessHandler logoutSuccessHandler;
在最后补充下关于 Session 共享的知识点,一般情况下,一个程序为了保证稳定至少要部署两个,构成集群。那么就牵扯到了 Session 共享的问题,不然用户在 8080 登录成功后,后续访问了 8060 服务器,结果又提示没有登录。
(1)首先安装redis
docker安装redis
(2)配置session共享
导入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
在 application.xml
中新增配置指定 redis 地址以及 session 的存储方式:
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.session.store-type=redis
然后为主类添加 @EnableRedisHttpSession
注解。
@SpringBootApplication
@EnableRedisHttpSession
public class Hl01SpringSecurityApplication {
public static void main(String[] args) {
SpringApplication.run(Hl01SpringSecurityApplication.class, args);
}
}
测试:
修改 IDEA 配置来允许项目在多端口运行,勾选 Allow running in parallel
:
运行程序,然后修改配置文件,将 server.port
更改为 8060,再次运行。这样项目就会分别在默认的 8080 端口和 8060 端口运行。
先访问 localhost:8080
,登录成功后,再访问 localhost:8060
,发现无需登录
然后我们进入 Redis 查看下 key:
最后再测试下之前配置的 session 设置是否还有效,使用其他浏览器登陆,登陆成功后发现原浏览器用户的确被踢出。
版权说明 : 本文为转载文章, 版权归原作者所有 版权申明
原文链接 : https://blog.csdn.net/weixin_43296313/article/details/121635672
内容来源于网络,如有侵权,请联系作者删除!