目录
添加验证码
每次登录到达 UsernamePasswordAuthenticationFilter ,都会被拦截 去查询一次数据库就行对比。防止恶意频繁发送错误登录请求,造成数据库大的开销,使用验证码 。 验证码的校验交给 springSecurity去处理,先校验验证码,再查询数据库
验证码的生成
- pom依赖
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
- 将kaptcha配置类放入容器
@Configuration
public class KaptchaConfig {
@Bean
public Producer kaptcha() {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "150");
properties.setProperty("kaptcha.image.height", "50");
properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
properties.setProperty("kaptcha.textproducer.char.length", "4");
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
- 编写验证码的controller 将生成的验证码转为流 进行输出
@Controller
public class VerifyCodeController {
private final Producer producer;
@Autowired
public VerifyCodeController(Producer producer) {
this.producer = producer;
}
@RequestMapping("/vc.jpg")
public void verifyCode(HttpServletResponse response, HttpSession session) throws IOException {
String verifyCode = producer.createText();
session.setAttribute("kaptcha",verifyCode); // 这里可以放入redis
BufferedImage bi = producer.createImage(verifyCode);
response.setContentType("image/png");
ServletOutputStream outputStream = response.getOutputStream();
ImageIO.write(bi,"jpg",outputStream);
}
}
4. 开放验证码接口 访问权限
传统web开发
流程:1. 先创建并开放获取图片验证码请求 2、重写 UsernamePasswordAuthenticationFilter的 attemptAuthentication() 方法(与前后端分离相似)。在查询数据库之前,先校验验证码
- 自定义类 MyLoginKaptchaFilter 继承 UsernamePasswordAuthenticationFilter ,重写attemptAuthentication()
在执行父类的 attemptAuthentication() 方法前 对验证码进行判断 ,如果符合在去调用父类的attemptAuthentication()(查询数据库)
KaptchaFilter
public class KaptchaFilter extends UsernamePasswordAuthenticationFilter {
public static final String KAPTCHA_KEY = "kaptcha";
private String kaptcha = KAPTCHA_KEY;
public String getKaptcha() {
return kaptcha;
}
public void setKaptcha(String kaptcha) {
this.kaptcha = kaptcha;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String kaptcha = request.getParameter(getKaptcha());
String sessionKaptcha = (String) request.getSession().getAttribute("kaptcha");// 若是redis 方式 需要从redis中获取
if (!ObjectUtils.isEmpty(kaptcha) && !ObjectUtils.isEmpty(sessionKaptcha) &&
kaptcha.equalsIgnoreCase(sessionKaptcha)) {
return super.attemptAuthentication(request, response);
}
throw new KaptchaNotMatchException("验证码错误");
}
}
KaptchaNotMatchException 自定义验证异常类
public class KaptchaNotMatchException extends AuthenticationException {
public KaptchaNotMatchException(String msg, Throwable cause) {
super(msg, cause);
}
public KaptchaNotMatchException(String msg) {
super(msg);
}
}
- 将自定义的过滤器代替原来的父类
@Bean
public KaptchaFilter kaptchaFilter() throws Exception {
KaptchaFilter filter = new KaptchaFilter();
filter.setAuthenticationManager(authenticationManagerBean());
filter.setFilterProcessesUrl("/toLogin");
filter.setPasswordParameter("passwd");
filter.setUsernameParameter("uname");
return filter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.mvcMatchers("/login.html").permitAll()
.mvcMatchers("/vc.jpg").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.successForwardUrl("/index.html")
.loginPage("/login.html")
.failureUrl("/login.html")
.successForwardUrl("/index.html")
.and()
.csrf().disable();
http.addFilterAt(kaptchaFilter(), UsernamePasswordAuthenticationFilter.class);
}
前后端分离开发
与端的不同之处:
- 传统web开发的参数 直接从请求中获取(getParam(“xxx”)) 前后端分离要从json中获取
- 后端返回的是json 图片信息也要放在json中(使用Base64)
- 将处理方式改为响应json格式
- 生成验证码
@RestController
public class VerifyCodeController {
private final Producer producer;
@Autowired
public VerifyCodeController(Producer producer) {
this.producer = producer;
}
@RequestMapping("/vc.jpg")
public String verifyCode(HttpServletResponse response, HttpSession session) throws IOException {
String verifyCode = producer.createText();
session.setAttribute("kaptcha",verifyCode);
BufferedImage bi = producer.createImage(verifyCode);
FastByteArrayOutputStream fos = new FastByteArrayOutputStream();
ImageIO.write(bi,"jpg",fos);
return Base64.encodeBase64String(fos.toByteArray());
}
}
注意 前端解析base64编码 要在最前面加上 data:image/png;base64,
- 自定义 ** UsernamePasswordAuthenticationFilter** 的 attemptAuthentication() 方法,同时顺手完成前后端分离的认证
public class KaptchaFilter extends UsernamePasswordAuthenticationFilter {
public static final String KAPTCHA_KEY = "kaptcha";
private String kaptcha = KAPTCHA_KEY;
public String getKaptcha() {
return kaptcha;
}
public void setKaptcha(String kaptcha) {
this.kaptcha = kaptcha;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
try {
Map userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
String kaptcha= (String) userInfo.get(getKaptcha());
String sessionKaptcha = (String) request.getSession().getAttribute("kaptcha");
if (!ObjectUtils.isEmpty(kaptcha) && !ObjectUtils.isEmpty(sessionKaptcha) &&
kaptcha.equalsIgnoreCase(sessionKaptcha)) {
// 前后端分离 密码 验证
String username = (String) userInfo.get(getUsernameParameter());
String passwd = (String) userInfo.get(getPasswordParameter());
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, passwd);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
} catch (IOException e) {
e.printStackTrace();
}
throw new KaptchaNotMatchException("验证码错误");
}
}
- 将自定义的过滤器代替原来的父类,将相应方式设置为json
@Bean
public KaptchaFilter kaptchaFilter() throws Exception {
KaptchaFilter filter = new KaptchaFilter();
filter.setAuthenticationManager(authenticationManagerBean());
filter.setFilterProcessesUrl("/toLogin");
filter.setPasswordParameter("passwd");
filter.setUsernameParameter("uname");
filter.setAuthenticationFailureHandler(((request, response, exception) -> {
Map<String, Object> result = new HashMap<String, Object>();
result.put("msg", "登录失败");
result.put("错误信息", exception.getMessage());
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
String s = new ObjectMapper().writeValueAsString(result);
response.getWriter().println(s);
}));
filter.setAuthenticationSuccessHandler((req, resp, authentication) -> {
Map<String, Object> result = new HashMap<String, Object>();
result.put("msg", "登录成功");
result.put("用户信息", authentication.getPrincipal());
resp.setContentType("application/json;charset=UTF-8");
resp.setStatus(HttpStatus.OK.value());
String s = new ObjectMapper().writeValueAsString(result);
resp.getWriter().println(s);
});
return filter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.mvcMatchers("/login.html").permitAll()
.mvcMatchers("/vc.jpg").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.successForwardUrl("/index.html")
.loginPage("/login.html")
.failureUrl("/login.html")
.successForwardUrl("/index.html")
.and()
.csrf().disable();
http.addFilterAt(kaptchaFilter(), UsernamePasswordAuthenticationFilter.class);
}
密码加密
通常使用SHA-256,SHA-512,MD5这些单向算法进行加密,为使其复杂 在加上盐和散列。在用户输入密码后 按照算法进行加盐,散列后与数据库进行对比。为了使其更安全,将盐和密码加密值不放在一个库中。
但仍存在暴力匹对破解情况
springSecurity使用单向自适应函数,特点是
- 相比于以上算法,即使是单向算法,但每次通过明文生成的加密字符也不相同,无法暴力破解
- 匹配时故意大量占用系统资源,降低性能,增强安全性
springSecurity默认支持 单向自适应函数类算法是 bcrypt(自身带盐)、PBKDF2。sCrypt, argon2 需要额外引入
密码校验流程
AbstractUserDetailsAuthenticationProvider 提供整个认证流程的整体框架
调用 passwordEncoder接口的 实现类 。默认是DelegatingPasswordEncoder 负责中转给其他具体算法加密,解密实现类
在DelegatingPasswordEncoder方法中, 根据密码的加密方式(默认在密码前面{加密方式)进行分配对用的算法类
使用DelegatingPasswordEncoder 这种方式使得密码十分灵活 ,升级十分方便 具体加密方式根据密码前的标识
如:此次{noop}123 最终匹配校验类
springSecurity提供加密的类可以单独拿出来 如:
默认散列10次
已定义一种加密方式 +1
默认密码加密方式
默认密码加密方式是采用 DelegatingPasswordEncoder 进行代理,根据密码的首部{}中标识的内容从而再具体选择加密的方式 。
此种方式 使得一个数据库中可以有多种密码加密方式 ,高的兼容性。当设计换掉原来的密码加密方式时,十分的方便
为什么默认是 DelegatingPasswordEncoder
指定密码加密方式
根据默认使用DelegatingPasswordEncoder 源码,改变默认条件
所以 只需要在自定义继承 WebSecurityConfigurerAdapter 的springSecurity的配置类中将 PasswordEncoder 的实现类注入即可
例如 :默认使用 SCryptPasswordEncoder
@Bean
public PasswordEncoder passwordEncoder(){
return new SCryptPasswordEncoder();
}
只有DelegatingPasswordEncoder 代理类 ,才需要密码前的{xx}去确定真正校验密码的类 ,而直接指定校验密码的类,则密码前不在noop需要{xx}
例如:
直接指定密码不加密 ,则数据源密码无需{noop}
密码自动迭代更新
具体步骤:
1.创建dao层 修改用户名密码的方法
2. 实现UserDetailsPasswordService 接口 ,重写**UserDetails updatePassword(UserDetails user, String newPassword) {}**接口
@Service
public class MyUserDetailsService implements UserDetailsService , UserDetailsPasswordService {
private final UserMapper userMapper;
@Autowired
public MyUserDetailsService(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.loadUserByUsername(username);
user.setRoles(userMapper.getRolesByUid(user.getId()));
return user;
}
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
Integer res = userMapper.updatePassword(user.getUsername(), newPassword);
if(res==1){
((User) user).setPassword(newPassword);
}
return user;
}
}
- 同自定义数据源一样,要放入完全的自定义AuthenticationManagerBuilder中 。这一步在设置数据源的时候已经完成
private final MyUserDetailsService service;
@Autowired
public securityConfigure(MyUserDetailsService service) {
this.service = service;
}
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
builder.userDetailsService(service);
}
RememberMe
功能类似于 session 存活时的免登陆 (登陆成功后 发送请求的cookie中携带JSESSIONID 能够找到服务器中对应的session,代表已登录),但session放在内存中,耗费系统资源,有过期时间(默认30min)
老式的方法 登陆成功后 将用户名和密码放在cookie中 返回给用户,下次登录携带cookie中的用户名、密码进行数据源校验 (相当于免去自己写账号,密码,基于客户端)
springSecurity 方法 基于服务端 ,但服务端无需保存数据。登录成功且选择记住我后,服务端会设置时间,以及用户非敏感信息经过加密(确保不被修改) 通过cookie交给 客户端。客户每次请求,服务端检查cookie信息(但自己不保存),校验合格后,即可认为已登录
类似于JWT
但不安全:可以复制一个网址给浏览器的cookie信息,访该网址问即可获取此用户身份
使用方式
全局
局部:
登录页面要加上 <input type="checkbox" name="remember-me">
也可以自定义name
记住我流程
注意:调试时设置session 过期时间 ,否则每次session存在都会指定为已登录,不会去使用记住我功能
server:
servlet:
session:
timeout: 1
当开启rememberMe功能时,springSecutiy会加载 RememberMeAuthenticationFilter 过滤器(默认不加载)
RememberMeAuthenticationFilter 的doFilter()
请求拦截后,真正负责记住我 功能的类 接口
登录认证时 勾选 记住我 流程
当初次登陆输入账户名、密码 ,勾选记住我功能时
默认方式一:
记住我流程
两种方式在登录成功后 ,生成cookies的分歧点
默认AbstractRememberMeServices的继承类 TokenBasedRememberMeServices
如果登录时选择记住我会多一次调用数据库
cookie值的构成
cookie在有效期没有重新登录的情况下一城不变
免密登录流程
要在服务器session 过期时测试
免去密码 登录流程
方式 一 和方式二的 分歧点 AbstractRememberMeServices类的processAutoLoginCookie()方法
两个类继承了AbstractRememberMeServices类 对processAutoLoginCookie()的实现不同,从而导致记住我的策略不同
等到session 过期 浏览器仍访问其他页面
RememberMeAuthenticationFilter 类中 拦截方法 doFilter()
最为核心的部分 整体的流程
简化后:
- 获取对应cookie
- base64解码拿到基础信息
- 进行cookie合法校验
有 key 但 value 不合法时 清除对应cookie
默认方式的安全问题
当用户认证登录成功且勾选 记住我功能 在浏览器留下
如果将其复制 ,即可具有此用户身份
如果在请求过程中,cookie被劫持,将会引起安全问题
更安全的方式,服务器中保存部分依据,每次请求携带cookie 认证,服务端查找自身的对应依据,认证成功后,返回新的cookie给客户端
使得每次请求都有不同的cookie 如果请求中 的cookie被劫持,非法拿到cookie请求一次后,将会更新cookie。 此用户的cookie无效 ,将被要求重新登录。重新登录又会使得非法拿到的cookie失效
方式二:内存令牌的使用方式
在方式一的基础上 ( .rememberMe())
方式一和方式二的验证时分歧点在:AbstractRememberMeServices类的processAutoLoginCookie()方法
两个类继承了AbstractRememberMeServices类 对processAutoLoginCookie()的实现不同,从而导致记住我的策略不同
在进行初次Base64解码后,对cookie的解密方式不同,自然加密方式就不同
生成cookie的分歧点在:
记住我流程
具体存储过程
cookie值的构成
和前者MD5层层加密不同,前者一但传给客户端 服务器没有留存,所以全靠加密来保证不被修改
二后者服务器端与留存 ,任何修改导致匹配不成功,cookies作废
服务器端保存 key-value键值对
免密登录流程
要在服务器session 过期时测试
免去密码 登录流程
和前者在校验cookies方式不同
存取过程
InMemoryTokenRepositoryImpl 类 是由HashMap存储的
更改存储方式
生成库表的语句 :
create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null);
来自
方式一:
方式二:
最终效果:
方式二的安全问题
仍会有请求被劫持后,发送一次恶意请求的风险
每一次请求被拦截后 都会先验证session中是否存在用户信息,若没有在 获取cookie ,根据cookie中的用户名查去到完整信息, 无论是否记住我 ,每次都会查询数据库 。最终 实现同登录成功一样的效果。
当手动退出时,会清除记住我的cookies
传统web开发记住我
如上
在自定义表单时, <input type="checkbox" name="remember-me" value="on|yes|true|1"/>
name属性可自定义 ,value值可为 on、yes、true、1
前后端分离开发记住我
因为在确认 记住我 时要先进行验证,由于前后端分离重写了验证方法,可以在获取前端json参数时,顺便获取 记住我 参数,并将其放入request作用域中,传递下去
整体流程
具体获取参数并传递
自定义RememberMeServices的实现的原因
如果要重写AbstractRememberMeServices,所有cookies生成、加密、解密等都要自定义,与下面两种方法并列。所以要继承它的子类,只重写rememberMeRequested()
例如 选择默认的cookies生成方式(TokenBasedRememberMeServices)
最后 在security的配置中 明确指出替代原先的类(TokenBasedRememberMeServices)