Java实现自定义认证功能(基于session)

2022-07-31,,,,

1.自定义认证逻辑

不破坏原有的过滤器链,又实现了自定义认证功能(基于Session,不是JSON交互)

  • (1)验证码生成工具
package com.oldbai.Util; import com.google.code.kaptcha.Producer; import com.google.code.kaptcha.impl.DefaultKaptcha; import com.google.code.kaptcha.util.Config; import org.springframework.context.annotation.Bean; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.IOException; import java.io.OutputStream; import java.util.Properties; import java.util.Random; /**
 * 生成验证码的工具类
 */ public class VerifyCode { private int width = 100;// 生成验证码图片的宽度 private int height = 50;// 生成验证码图片的高度 private String[] fontNames = { "宋体", "楷体", "隶书", "微软雅黑" }; private Color bgColor = new Color(255, 255, 255);// 定义验证码图片的背景颜色为白色 private Random random = new Random(); private String codes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; private String text;// 记录随机字符串 /**
     * 获取一个随意颜色
     *
     * @return
     */ private Color randomColor() { int red = random.nextInt(150); int green = random.nextInt(150); int blue = random.nextInt(150); return new Color(red, green, blue); } /**
     * 获取一个随机字体
     *
     * @return
     */ private Font randomFont() { String name = fontNames[random.nextInt(fontNames.length)]; int style = random.nextInt(4); int size = random.nextInt(5) + 24; return new Font(name, style, size); } /**
     * 获取一个随机字符
     *
     * @return
     */ private char randomChar() { return codes.charAt(random.nextInt(codes.length())); } /**
     * 创建一个空白的BufferedImage对象
     *
     * @return
     */ private BufferedImage createImage() { BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g2 = (Graphics2D) image.getGraphics(); g2.setColor(bgColor);// 设置验证码图片的背景颜色 g2.fillRect(0, 0, width, height); return image; } public BufferedImage getImage() { BufferedImage image = createImage(); Graphics2D g2 = (Graphics2D) image.getGraphics(); StringBuffer sb = new StringBuffer(); for (int i = 0; i < 4; i++) { String s = randomChar() + ""; sb.append(s); g2.setColor(randomColor()); g2.setFont(randomFont()); float x = i * width * 1.0f / 4; g2.drawString(s, x, height - 15); } this.text = sb.toString(); drawLine(image); return image; } /**
     * 绘制干扰线
     *
     * @param image
     */ private void drawLine(BufferedImage image) { Graphics2D g2 = (Graphics2D) image.getGraphics(); int num = 5; for (int i = 0; i < num; i++) { int x1 = random.nextInt(width); int y1 = random.nextInt(height); int x2 = random.nextInt(width); int y2 = random.nextInt(height); g2.setColor(randomColor()); g2.setStroke(new BasicStroke(1.5f)); g2.drawLine(x1, y1, x2, y2); } } public String getText() { return text; } public static void output(BufferedImage image, OutputStream out) throws IOException { ImageIO.write(image, "JPEG", out); } /**
     * 提供一个实体类,使用网上一个现成的验证码库 kaptcha
     * @return
     */ @Bean Producer verifyCode(){ 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; } } 
  • (2)验证码获取接口
package com.oldbai.controller; import com.oldbai.Util.VerifyCode; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import javax.imageio.ImageIO; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.awt.image.BufferedImage; import java.io.FileOutputStream; import java.io.IOException; @RestController public class LoginController { //提供生成图片的接口 @GetMapping("/verifyCode") public void verifyCode(HttpSession session, HttpServletResponse response) throws IOException { VerifyCode code = new VerifyCode(); BufferedImage image = code.getImage(); //        检查是否生成图片 ImageIO.write(image,"JPEG",new FileOutputStream("F:/a.jpg")); String text = code.getText(); session.setAttribute("verify_code",text); VerifyCode.output(image,response.getOutputStream()); } } 
  • (3)在过滤器中进行验证码校验
package com.oldbai.config; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; public class MyAuthenticationProvider extends DaoAuthenticationProvider { /**
     * 目的是在于,验证验证码,只需要在登陆请求中验证即可。
     * 之前的过滤器没问题,只是这个是更加高级的玩法
     * 这样既不破坏原有的过滤器链,又实现了自定义认证功能。
     * <p>
     * 首先获取当前请求,注意这种获取方式,在基于 Spring 的 web 项目中,我们可以随时随地获取到当前请求,获取方式就是我上面给出的代码。
     * 从当前请求中拿到 code 参数,也就是用户传来的验证码。
     * 从 session 中获取生成的验证码字符串。
     * 两者进行比较,如果验证码输入错误,则直接抛出异常。
     * 最后通过 super 调用父类方法,也就是 DaoAuthenticationProvider 的 additionalAuthenticationChecks 方法,该方法中主要做密码的校验。
     * </p>
     */ @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String code = req.getParameter("code"); String verify_code = (String) req.getSession().getAttribute("verify_code"); if (code == null || verify_code == null || !code.toLowerCase().equals(verify_code.toLowerCase())) { throw new AuthenticationServiceException("验证码错误"); } super.additionalAuthenticationChecks(userDetails, authentication); } } 
  • (4)在SecurityConfig中进行配置过滤器(因为我是使用MP-Security数据库认证,所以可以这样直接简单配制)
 /**
     * <p>
     *   所有的 AuthenticationProvider 都是放在 ProviderManager 中统一管理的,
     *   所以接下来我们就要自己提供 ProviderManager,
     *   然后注入自定义的 MyAuthenticationProvider
     * </p>
     * <P>
     *    我们需要提供一个 MyAuthenticationProvider 的实例,
     *    创建该实例时,需要提供 UserDetailService 和 PasswordEncoder 实例。
     * </P>
     * <p>
     *    通过重写 authenticationManager 方法来提供一个自己的 AuthenticationManager,
     *    实际上就是 ProviderManager,
     *    在创建 ProviderManager 时,加入自己的 myAuthenticationProvider。
     * </p>
     */ @Bean MyAuthenticationProvider myAuthenticationProvider(){ MyAuthenticationProvider myAuthenticationProvider = new MyAuthenticationProvider(); myAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder()); myAuthenticationProvider.setUserDetailsService(userService); return myAuthenticationProvider; } @Override @Bean protected AuthenticationManager authenticationManager() throws Exception { ProviderManager manager = new ProviderManager(Arrays.asList(myAuthenticationProvider())); return manager; } 
  • (5)实验截图

2.让 Spring Security 中的资源可以匿名访问

  • SecurityConfig 中添加配制
 /**
     * 解决Spring Security 登录成功后总是获取不到登录用户信息
     * <p>
     *     在不同线程中,不能获取同一个用户登陆信息
     * </p>
     * <p>
     *     这是不走过滤器的解决方法
     * 让 Spring Security 中的资源可以匿名访问
     * 不走 Spring Security 过滤器链
     * 登陆接口如果放在这里,登录请求将不走 SecurityContextPersistenceFilter 过滤器,
     * 也就意味着不会将登录用户信息存入 session,进而导致后续请求无法获取到登录用户信息。
     * 下面是放行静态资源:/css/**、/js/**、/index.html、/img/**、/fonts/**、/favicon.ico
     * 放行接口:/verifyCode
     * 不能把登陆接口放这
     * </p>
     */ @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/css/**","/js/**","/index.html","/img/**","/fonts/**","/favicon.ico","/verifyCode"); } 

3. 保存关于 Http 请求的更多信息(变相的验证码验证)

当然,WebAuthenticationDetails 也可以自己定制,因为默认它只提供了 IP 和 sessionid 两个信息,如果我们想保存关于 Http 请求的更多信息,就可以通过自定义 WebAuthenticationDetails 来实现。
如果我们要定制 WebAuthenticationDetails,还要连同 WebAuthenticationDetailsSource 一起重新定义。
里面主要保存 SessionId 和 用户IP地址,也可以自定义保存其他东西。

  • (1)MyWebAuthenticationDetails
package com.oldbai.config; import org.springframework.security.web.authentication.WebAuthenticationDetails; import javax.servlet.http.HttpServletRequest; public class MyWebAuthenticationDetails extends WebAuthenticationDetails { /**
     * 用来保存是否验证正确
     */ private boolean isPassed; private String v_code; /**
     * <p>
     *     如果我们想扩展属性,只需要在 MyWebAuthenticationDetails 中再去定义更多属性,
     *     然后从 HttpServletRequest 中提取出来设置给对应的属性即可,
     *     这样,在登录成功后就可以随时随地获取这些属性了。
     * </p>
     */ public MyWebAuthenticationDetails(HttpServletRequest request) { super(request); String code = request.getParameter("code"); this.v_code = code; String verify_code = (String) request.getSession().getAttribute("verify_code"); if (code != null && verify_code != null && code.toLowerCase().equals(verify_code.toLowerCase())) { isPassed = true; } } public boolean isPassed(){ return isPassed; } public String getV_code() { return v_code; } public void setV_code(String v_code) { this.v_code = v_code; } } 
  • (2)MyWebAuthenticationDetailsSource
@Component public class MyWebAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest,MyWebAuthenticationDetails> { @Override public MyWebAuthenticationDetails buildDetails(HttpServletRequest context) { return new MyWebAuthenticationDetails(context); } } 
  • (3)MyAuthenticationProvider(用上面1的代码进行改写)
public class MyAuthenticationProvider extends DaoAuthenticationProvider { @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { MyWebAuthenticationDetails details = (MyWebAuthenticationDetails) authentication.getDetails(); if (!details.isPassed()) { throw new AuthenticationServiceException("验证码错误"); } super.additionalAuthenticationChecks(userDetails, authentication); } } 
  • (4)SecurityConfig(添加配制)
 /**
     * 自定义的myWebAuthenticationDetailsSource替换系统默认的WebAuthenticationDetailsSource
     */ @Autowired MyWebAuthenticationDetailsSource myWebAuthenticationDetailsSource; @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() .xxx .authenticationDetailsSource(myWebAuthenticationDetailsSource) .xxx } 
  • (5)测试接口
/**
     * 测试接口
     * @return
     */ @GetMapping("/hello") public MyWebAuthenticationDetails HelloWorld() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); MyWebAuthenticationDetails details = (MyWebAuthenticationDetails) auth.getDetails(); return details; } 
  • (6)测试结果

4.踢掉上一个登陆用户

(1)基于用户内存的方法

  • 直接在 SecurityConfig 中配制
 @Bean HttpSessionEventPublisher httpSessionEventPublisher(){ return new HttpSessionEventPublisher(); } @Override protected void configure(HttpSecurity http) throws Exception { /**
         * <p>
         *     想要用新的登录踢掉旧的登录,我们只需要将最大会话数设置为 1 即可
         * </p>
         * <p>
         *     设置session会话最大会话数为 1
         * </p>
         * <p>
         *      禁止新的登陆操作
         * </p>
         *
         */ http.sessionManagement() .maximumSessions(1) .maxSessionsPreventsLogin(true); } 

(2)前后端分离,使用数据库认证

  • 直接在User类里面添加这两个,重写方法
 @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(username, user.username); } @Override public int hashCode() { return Objects.hash(username); } 

(3)前后端分离,JSON交互

  • ①SecurityConfig进行配置

这里我们要自己提供 SessionAuthenticationStrategy,
而前面处理 session 并发的是 ConcurrentSessionControlAuthenticationStrategy,
也就是说,我们需要自己提供一个 ConcurrentSessionControlAuthenticationStrategy 的实例,
然后配置给 LoginFilter,
但是在创建 ConcurrentSessionControlAuthenticationStrategy 实例的过程中,
还需要有一个 SessionRegistryImpl 对象

@Bean SessionRegistryImpl sessionRegistry() { return new SessionRegistryImpl(); } 
  • ②在 SecurityConfig 中的 LoginFilter 中配置 SessionAuthenticationStrategy

在这里自己手动构建 ConcurrentSessionControlAuthenticationStrategy 实例,构建时传递 SessionRegistryImpl 参数,然后设置 session 的并发数为 1,最后再将 sessionStrategy 配置给 LoginFilter。

/**
*手动构建 ConcurrentSessionControlAuthenticationStrategy 实例,构建时传递 *SessionRegistryImpl 参数,然后设置 session 的并发数为 1,最后再将 sessionStrategy 配置给 *LoginFilter
*/ @Bean LoginFilter loginFilter() throws Exception { LoginFilter loginFilter = new LoginFilter(); loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> { //... } ); loginFilter.setAuthenticationFailureHandler((request, response, exception) -> { //... } ); loginFilter.setAuthenticationManager(authenticationManagerBean()); loginFilter.setFilterProcessesUrl("/doLogin"); ConcurrentSessionControlAuthenticationStrategy sessionStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry()); sessionStrategy.setMaximumSessions(1); loginFilter.setSessionAuthenticationStrategy(sessionStrategy); return loginFilter; } 
  • ③在SecurityConfig 中的 http 的config 中添加配制

重新创建一个 ConcurrentSessionFilter 的实例,代替系统默认的即可。
在创建新的 ConcurrentSessionFilter 实例时,需要两个参数:
sessionRegistry 就是我们前面提供的 SessionRegistryImpl 实例。
第二个参数,是一个处理 session 过期后的回调函数,也就是说,当用户被另外一个登录踢下线之后,你要给什么样的下线提示,就在这里来完成。

@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() ... http.addFilterAt(new ConcurrentSessionFilter(sessionRegistry(), event -> { HttpServletResponse resp = event.getResponse(); resp.setContentType("application/json;charset=utf-8"); resp.setStatus(401); PrintWriter out = resp.getWriter(); out.write(new ObjectMapper().writeValueAsString(RespBean.error("您已在另一台设备登录,本次登录已下线!"))); out.flush(); out.close(); }), ConcurrentSessionFilter.class); http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class); } 
  • ④手动向 SessionRegistryImpl 中添加一条记录

手动调用 sessionRegistry.registerNewSession 方法,向 SessionRegistryImpl 中添加一条 session 记录。

public class LoginFilter extends UsernamePasswordAuthenticationFilter { @Autowired SessionRegistry sessionRegistry; @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { //省略 UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, password); setDetails(request, authRequest); User principal = new User(); principal.setUsername(username); sessionRegistry.registerNewSession(request.getSession(true).getId(), principal); return this.getAuthenticationManager().authenticate(authRequest); } ... ... } } 
  • ⑤使用JSON交互的登陆,比基于数据库需要多几个步骤是:

1.配制一个SessionRegistryImpl
2.在 SecurityConfig 中的 LoginFilter 中配置 SessionAuthenticationStrategy
3.在SecurityConfig 中的 http 的config 中添加配制
4.手动向 SessionRegistryImpl 中添加一条记录
推荐有项目进行测试,反正我在postman测试没成功

5.跨域配制

  • SecurityConfig
@Override protected void configure(HttpSecurity http) throws Exception { /**
         * <p>
         *     开启跨域
         * </p>
         */ http.cors().configurationSource(corsConfigurationSource()); } /**
     * <p>
     * 开启跨域
     * </p>
     * <p>
     * 通过 CorsConfigurationSource 实例对跨域信息作出详细配置,
     * 例如允许的请求来源、
     * 允许的请求方法、
     * 允许通过的请求头、
     * 探测请求的有效期、
     * 需要处理的路径
     * 等等。
     * </p>
     */ @Bean CorsConfigurationSource corsConfigurationSource() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowCredentials(true); configuration.setAllowedOrigins(Arrays.asList("*")); configuration.setAllowedMethods(Arrays.asList("*")); configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setMaxAge(Duration.ofHours(1)); source.registerCorsConfiguration("/**", configuration); return source; } 

6.csrf 攻击如何防御

 /**
        * 前后端分离中
        * 不是将 _csrf 放在 Model 中返回前端了,
        * 而是放在 Cookie 中返回前端
         * <p>
         *     前端需要从cookie 中的'XSRF-TOKEN' 提取 _csrf 的值交给后端
         *     通过一个 POST 请求执行操作,注意携带上 _csrf 参数
         * </p>
        */ http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); 

7.更新当前用户信息

 /**
     * 先获取当前用户信息
     */ @GetMapping("/hr/info") public User getCurrentHr(Authentication authentication) { return ((User) authentication.getPrincipal()); } /**
     * 更新最新当前用户信息
     *
     */ @PostMapping("/hr/info") public String updata(@RequestBody User user, Authentication authentication) { if (userService.saveOrUpdate(user)){ SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,authentication.getCredentials(),authentication.getAuthorities())); return "更新成功"; } return "更新失败"; } 

8.防止会话固定攻击

默认的 migrateSession ,在用户匿名访问的时候是一个 sessionid,当用户成功登录之后,又是另外一个 sessionid,这样就可以有效避免会话固定攻击。

  • migrateSession 表示在登录成功之后,创建一个新的会话,然后把旧的 session 中的信息复制到新的 session 中,「默认即此」

http.sessionManagement().sessionFixation().migrateSession();

  • none 表示不做任何事情,继续使用旧的 session。

http.sessionManagement().sessionFixation().none ();

  • changeSessionId 表示 session 不变,但是会修改 sessionid,这实际上用到了 Servlet 容器提供的防御会话固定攻击。

http.sessionManagement().sessionFixation().changeSessionId ();

  • newSession 表示登录后创建一个新的 session。

http.sessionManagement().sessionFixation().newSession ();

写在最后

  • 以上这些,对于个人的练习开放小项目,足够了。
  • 个人思路构建一个小型项目逻辑

1.创建工程,导入MyBatis-Plus 依赖,进行配置,单元测试是否连接成功
2.导入Security 依赖,进行配置
3.使用MP与Security进行整合,基于数据库的认证。
4.进行角色等级配置
5.设置最大会话数,也就是踢掉登陆或者不让登陆
6.配置登陆成功、失败、无状态访问、注销回调
7.配置验证码生成工具,开放验证码接口
8.自定义一个登陆逻辑过滤器用于验证验证码是否正确
9.如果是前后端分离项目,进行跨域配制
10.防止固定会话、开启csrf 防御,前端记得要从cookid中拿到并携带 _csrf 的参数进行请求
11.更新用户信息的配制,主要是在接口中进行配置
12.修改密码的配制,还没会,所以先不急。

本文地址:https://blog.csdn.net/qq_45031575/article/details/107876749

《Java实现自定义认证功能(基于session).doc》

下载本文的Word格式文档,以方便收藏与打印。