Authentication configuration process under Spring Security 6.0 (spring boot 3.0)

premise

It is strongly recommended to read this article after learning the configuration process of version 2.x

Recommend one: video tutorial

Functions to be implemented

  1. Use username + password + verification code + remember me function to log in
  2. CSRF verification
  3. Hand over the Session to Redis for management, and persist the remember-me function to the database

Dependency (POM)

The database operation part is omitted

<parent>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-parent</artifactId>
	<version>3.0.0</version>
	<relativePath/>
</parent>
<dependencies>
     <dependency>
         <groupId>org.projectlombok</groupId>
         <artifactId>lombok</artifactId>
         <optional>true</optional>
    </dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-security</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<!--redis-->
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-data-redis</artifactId>
	</dependency>
	<!--session-redis-->
	<dependency>
		<groupId>org.springframework.session</groupId>
		<artifactId>spring-session-data-redis</artifactId>
	</dependency>
	<!--验证码-->
	<dependency>
		<groupId>com.github.penggle</groupId>
		<artifactId>kaptcha</artifactId>
		<version>2.3.2</version>
	</dependency>	
	<!--springdoc -->
	<dependency>
		<groupId>org.springdoc</groupId>
		<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
		<version>2.0.0</version>
	</dependency>
	<!--knife4j - 接口文档UI-->
	<dependency>
		<groupId>com.github.xiaoymin</groupId>
		<artifactId>knife4j-springdoc-ui</artifactId>
		<!--在引用时请在maven中央仓库搜索3.X最新版本号-->
		<version>3.0.3</version>
	</dependency>
</dependencies>

Note: The interface documentation generated by springdoc+knife4j is included at the end, and the annotations provided by springdoc are also included in the sample code.

sample code

Basic components

verification code

Build configuration (same as in the video tutorial)

@Configuration
public class KaptchaConfig {
    
    
    @Bean
    public Producer kaptcha() {
    
    
        final 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");

        final DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        defaultKaptcha.setConfig(new Config(properties));
        return defaultKaptcha;
    }
}

interface

Generate a verification code, save it Sessionin Attribute, and take it out from here for subsequent verification. The two interfaces return verification code data in different formats.

@Controller
@RequestMapping("/sys/verifyCode")
@RequiredArgsConstructor
@Tag(name = "验证码接口")
public class VerifyCodeController {
    
    
    public static final String VERIFY_CODE_KEY = "vc";
    private final Producer producer;

    @GetMapping("/base64")
    @Operation(summary = "Base64格式")
    @ResponseBody
    public Res<String> base64(@Parameter(hidden = true) HttpSession httpSession) throws IOException {
    
    
        //生成验证码
        final BufferedImage image = createImage(httpSession);
        //响应图片
        final FastByteArrayOutputStream os = new FastByteArrayOutputStream();
        ImageIO.write(image, "jpeg", os);
        //返回 base64
        return Res.of(Base64.encodeBase64String(os.toByteArray()));
    }

    @GetMapping("/image")
    @Operation(summary = "图片格式")
    public void image(@Parameter(hidden = true) HttpServletResponse response, @Parameter(hidden = true) HttpSession httpSession) throws IOException {
    
    
        final BufferedImage image = createImage(httpSession);
        //响应图片
        response.setContentType(MimeTypeUtils.IMAGE_JPEG_VALUE);
        ImageIO.write(image, "jpeg", response.getOutputStream());
    }

    private BufferedImage createImage(HttpSession httpSession) {
    
    
        //生成验证码
        final String verifyCode = producer.createText();
        //保存到 session 中(或redis中)
        httpSession.setAttribute(VERIFY_CODE_KEY, verifyCode);
        //生成图片
        return producer.createImage(verifyCode);
    }
}

MyUserDetailsServiceImpl (authentication/permission information)

  • There is nothing special here, query and return the user's authentication information according to the user name, and SystemUserServiceprovide a database access interface
  • Since we implemented it UserDetailsPasswordService, SpringSecurityif it is found that the user's password encryption method is outdated or plaintext, the password will be automatically changed.
  • createUserThe method is to call SpringSecuritythe provided User.UserBuilderconstructor to create aUserDetails
  • Because the authentication part has not yet been involved, an empty list is directly given at the authority here, and an error will be reported if it is not written here.
  • @ServiceRegister directly to the container
@Service
@RequiredArgsConstructor
public class MyUserDetailsServiceImpl implements UserDetailsService, UserDetailsPasswordService {
    
    

    private final SystemUserService systemUserService;

    /**
     * 当前用户
     * @return 当前用户
     */
    public SystemUser currentUser() {
    
    
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        final String username = ((UserDetails) authentication.getPrincipal()).getUsername();
        return systemUserService.getByUsername(username);
    }

    /**
     * 根据用户名查询用户的认证授权信息
     * @param username 用户名
     * @return org.springframework.security.core.userdetails.UserDetails
     * @throws UsernameNotFoundException 异常
     * @since 2022/12/6 15:03
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    
    
        final SystemUser systemUser = systemUserService.getByUsername(username);
        if (systemUser == null) {
    
    
            throw new UsernameNotFoundException("用户不存在");
        }
        return systemUser.createUser()
                .authorities(new ArrayList<>())
                .build();
    }

    /**
     * 修改密码
     * @param user        用户
     * @param newPassword 新密码
     * @return UserDetails
     */
    @Override
    public UserDetails updatePassword(UserDetails user, String newPassword) {
    
    
        final SystemUser systemUser = systemUserService.getByUsername(user.getUsername());
        systemUser.setPassword(newPassword);
        systemUserService.updateById(systemUser);
        return systemUser.createUser()
                .authorities(new ArrayList<>())
                .build();
    }
}

MyAuthenticationHandler(Handler)

Because there is only one method in these interfaces, and they all need similar processing, I put them together to implement, and the process is almost the same:

  1. set Content-Typetoapplication/json;charset=UTF-8
  2. Set the status code according to the situation
  3. Write the returned result toresponse

The only thing to pay attention to is that after successful login, you need to clear the used verification code

Note: We need to use this object in two places, so register it directly in the container for easy injection

@Component
public class MyAuthenticationHandler implements AuthenticationSuccessHandler
        , AuthenticationFailureHandler
        , LogoutSuccessHandler
        , SessionInformationExpiredStrategy
        , AccessDeniedHandler, AuthenticationEntryPoint {
    
    

    public static final String APPLICATION_JSON_CHARSET_UTF_8 = "application/json;charset=UTF-8";
    public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    /**
     * 认证失败处理
     * @param request       that resulted in an <code>AuthenticationException</code>
     * @param response      so that the user agent can begin authentication
     * @param authException that caused the invocation
     * @throws IOException      异常
     * @throws ServletException 异常
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
    
    
        String detailMessage = e.getClass().getSimpleName() + " " + e.getLocalizedMessage();
        if (e instanceof InsufficientAuthenticationException) {
    
    
            detailMessage = "请登陆后再访问";
        }
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(detailMessage, "认证异常")));
    }

    /**
     * 权限不足时的处理
     * @param request               that resulted in an <code>AccessDeniedException</code>
     * @param response              so that the user agent can be advised of the failure
     * @param accessDeniedException that caused the invocation
     */
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
    
    
        String detailMessage = null;
        if (accessDeniedException instanceof MissingCsrfTokenException) {
    
    
            detailMessage = "缺少CSRF TOKEN,请从表单或HEADER传入";
        } else if (accessDeniedException instanceof InvalidCsrfTokenException) {
    
    
            detailMessage = "无效的CSRF TOKEN";
        } else if (accessDeniedException instanceof CsrfException) {
    
    
            detailMessage = accessDeniedException.getLocalizedMessage();
        } else if (accessDeniedException instanceof AuthorizationServiceException) {
    
    
            detailMessage = AuthorizationServiceException.class.getSimpleName() + " " + accessDeniedException.getLocalizedMessage();
        }
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(detailMessage, "禁止访问")));
    }

    /**
     * 认证失败时的处理
     */
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
    
    
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(exception.getLocalizedMessage(), "登陆失败")));
    }

    /**
     * 认证成功时的处理
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
    
    
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.OK.value());
        // SecurityContext在设置Authentication的时候并不会自动写入Session,读的时候却会根据Session判断,所以需要手动写入一次,否则下一次刷新时SecurityContext是新创建的实例。
        //  https://yangruoyu.blog.csdn.net/article/details/128276473
        request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(MyUserDetails.of(authentication), "登陆成功")));
        //清理使用过的验证码
        request.getSession().removeAttribute(VERIFY_CODE_KEY);
    }

    /**
     * 会话过期处理
     * @throws IOException      异常
     * @throws ServletException 异常
     */
    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
    
    
        String message = "该账号已从其他设备登陆,如果不是您自己的操作请及时修改密码";
        final HttpServletResponse response = event.getResponse();
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(event.getSessionInformation(), message)));
    }

    /**
     * 登出成功处理
     * @param request        请求
     * @param response       响应
     * @param authentication 认证信息
     * @throws IOException      异常
     * @throws ServletException 异常
     */
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
    
    
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.OK.value());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(MyUserDetails.of(authentication), "注销成功")));
    }
}

MyRememberMeServices (remember me)

The remember-me function, specifies:

  1. Get fields from request_AttributerememberMe
  2. When the field value is TRUE_VALUESa member of the table, it is considered necessary to enable the remember me function

in the constructor

  1. PersistentTokenRepositorywill be provided later
  2. UserDetailsServiceprovided earlier

Note: We need to use this object in two places, so register it directly in the container for easy injection

@Component
public class MyRememberMeServices extends PersistentTokenBasedRememberMeServices {
    
    
    public static final String REMEMBER_ME_KEY = "rememberMe";
    public static final List<String> TRUE_VALUES = List.of("true", "yes", "on", "1");

    public MyRememberMeServices(UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) {
    
    
        super(UUID.randomUUID().toString(), userDetailsService, tokenRepository);
    }

    @Override
    protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
    
    
        final String rememberMe = (String) request.getAttribute(REMEMBER_ME_KEY);
        if (rememberMe != null) {
    
    
            for (String trueValue : TRUE_VALUES) {
    
    
                if (trueValue.equalsIgnoreCase(rememberMe)) {
    
    
                    return true;
                }
            }
        }
        return super.rememberMeRequested(request, parameter);
    }
}

core components

MyLoginFilter (login filter)

  1. The parameters of the construction method can be obtained from the container, so here is also directly registered to the automatic construction of the container
  2. Inherited UsernamePasswordAuthenticationFilter, we will use it to replace the defaultUsernamePasswordAuthenticationFilter
  3. In the constructor, specify:
    1. How to deal with successful and failed logins
    2. Remember me component
    3. The path used to log in
  4. attemptAuthenticationThe method specifies the login process:
    1. If Content-Typeit is Json, get the request parameters Bodyfrom it , otherwise Form表单get it from
    2. SessionGet the previously saved verification code from Attributeand compare it with the verification code provided by the user
    3. rememberMePut the fields requestprovided by the user Attributein for subsequent MyRememberMeServicesacquisition
    4. The ending part comes from the parent class and is copied.
@Component
public class MyLoginFilter extends UsernamePasswordAuthenticationFilter {
    
    
    private final ObjectMapper objectMapper = new ObjectMapper();

    public MyLoginFilter(AuthenticationManager authenticationManager,
                         MyAuthenticationHandler authenticationHandler,
                         MyRememberMeServices rememberMeServices) throws Exception {
    
    
        super(authenticationManager);
        setAuthenticationFailureHandler(authenticationHandler);
        setAuthenticationSuccessHandler(authenticationHandler);
        //rememberMe
        setRememberMeServices(rememberMeServices);
        //登陆使用的路径
        setFilterProcessesUrl("/sys/user/login");
    }

    private static boolean isContentTypeJson(HttpServletRequest request) {
    
    
        final String contentType = request.getContentType();
        return APPLICATION_JSON_CHARSET_UTF_8.equalsIgnoreCase(contentType) || MimeTypeUtils.APPLICATION_JSON_VALUE.equalsIgnoreCase(contentType);
    }
  
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    
    

        if (!HttpMethod.POST.name().equalsIgnoreCase(request.getMethod())) {
    
    
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String username = null;
        String password = null;
        String verifyCode = null;
        String rememberMe = null;
        if (isContentTypeJson(request)) {
    
    
            try {
    
    
                Map<String, String> map = objectMapper.readValue(request.getInputStream(), new TypeReference<>() {
    
    
                });
                username = map.get(getUsernameParameter());
                password = map.get(getPasswordParameter());
                verifyCode = map.get(VERIFY_CODE_KEY);
                rememberMe = map.get(MyRememberMeServices.REMEMBER_ME_KEY);
            } catch (IOException e) {
    
    
                e.printStackTrace();
            }
        } else {
    
    
            username = obtainUsername(request);
            password = obtainPassword(request);
            verifyCode = request.getParameter(VERIFY_CODE_KEY);
            rememberMe = request.getParameter(MyRememberMeServices.REMEMBER_ME_KEY);
        }
        //校验验证码
        final String vc = (String) request.getSession().getAttribute(VERIFY_CODE_KEY);
        if (vc == null) {
    
    
            throw new BadCredentialsException("验证码不存在,请先获取验证码");
        } else if (verifyCode == null || "".equals(verifyCode)) {
    
    
            throw new BadCredentialsException("请输入验证码");
        } else if (!vc.equalsIgnoreCase(verifyCode)) {
    
    
            throw new BadCredentialsException("验证码错误");
        }

        //将 rememberMe 状态存入 attr中
        if (!ObjectUtils.isEmpty(rememberMe)) {
    
    
            request.setAttribute(MyRememberMeServices.REMEMBER_ME_KEY, rememberMe);
        }

        username = (username != null) ? username.trim() : "";
        password = (password != null) ? password : "";
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

MySecurityConfig (core configuration)

  1. @Bean authenticationManagerprovide MyLoginFilterthe neededAuthenticationManager
  2. @Bean daoAuthenticationProviderProvides MyRememberMeServiceswhat is needed PersistentTokenRepository, where setCreateTableOnStartupthe method needs to untie the comment when it is run for the first time to let it automatically build the table
  3. @Bean securityFilterChainThe core of the core, the correct HttpSecurity httpconfiguration in the 2.x version needs to be moved here, here we configure:
    1. Path configuration, where the path of the interface document and verification code is released, and other requests need to be authenticated. Login requests are not affected by it and require no special configuration.
    2. MyLoginFilterReplace the default one with a custom one, be careful not to write UsernamePasswordAuthenticationFilterthe original one , otherwise you will be able to log in by bypassing the verification codehttp.formLogin()/login
    3. Logout configuration, specified path, and successful logout processing method
    4. csrf verification, note that one more sentence is required here than in version 2.x.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
    5. Session management, configured to allow only one terminal to log in, no need to configure sessionRegistry, it will be injected automatically, of course, manual configuration is also possible, but it will not be automatically created in the container, you need to manually pass one, new SpringSessionBackedSessionRegistry<>(new RedisIndexedSessionRepository(redisTemplate))which redisTemplateneeds to beRedisTemplate<String,Object>
    6. Remember my function, note that MyLoginFilterthe two configurations here and here are indispensable.
    7. Handling of Insufficient Permissions
@Configuration
@RequiredArgsConstructor
public class MySecurityConfig {
    
    
    /**
     * 接口文档放行
     */
    public static final List<String> DOC_WHITE_LIST = List.of("/doc.html", "/webjars/**", "/v3/api-docs/**");
    /**
     * 测试接口放行
     */
    public static final List<String> TEST_WHITE_LIST = List.of("/test/**");
    /**
     * 验证码放行
     */
    public static final List<String> VERIFY_CODE_WHITE_LIST = List.of("/sys/verifyCode/**");

    /**
     * 获取AuthenticationManager(认证管理器),登录时认证使用
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    
    
        return authenticationConfiguration.getAuthenticationManager();
    }

    /**
     * 允许抛出用户不存在的异常
     * @param myUserDetailsService myUserDetailsService
     * @return DaoAuthenticationProvider
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider(MyUserDetailsServiceImpl myUserDetailsService) {
    
    
        final DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(myUserDetailsService);
        provider.setUserDetailsPasswordService(myUserDetailsService);
        provider.setHideUserNotFoundExceptions(false);
        return provider;
    }

    /**
     * 自定义RememberMe服务token持久化仓库
     */
    @Bean
    public PersistentTokenRepository persistentTokenRepository(DataSource datasource) {
    
    
        final JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        //设置数据源
        tokenRepository.setDataSource(datasource);
        //第一次启动的时候建表
//        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   MyLoginFilter loginFilter,
                                                   MyAuthenticationHandler authenticationHandler,
                                                   MyRememberMeServices rememberMeServices
    ) throws Exception {
    
    
        //路径配置
        http.authorizeHttpRequests()
                .requestMatchers(HttpMethod.GET, DOC_WHITE_LIST.toArray(new String[0])).permitAll()
                .requestMatchers(HttpMethod.GET, VERIFY_CODE_WHITE_LIST.toArray(new String[0])).permitAll()
//                .requestMatchers(HttpMethod.GET, TEST_WHITE_LIST.toArray(new String[0])).permitAll()
                .anyRequest().authenticated()
        ;

        //登陆
        http.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter.class);

        //配置自定义登陆流程后需要关闭 否则可以使用原有登陆方式

        //登出
        http.logout().logoutUrl("/sys/user/logout").logoutSuccessHandler(authenticationHandler);

        //禁用 csrf
//        http.csrf().disable();

        //csrf验证 存储到Cookie中
        http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
        ;

        //会话管理
        http.sessionManagement()
                .maximumSessions(1)
                .expiredSessionStrategy(authenticationHandler)
        //引入redis-session依赖后已不再需要手动配置 sessionRegistry
//                .sessionRegistry(new SpringSessionBackedSessionRegistry<>(new RedisIndexedSessionRepository(RedisConfig.createRedisTemplate())))
        //禁止后登陆挤下线
//               .maxSessionsPreventsLogin(true)
        ;

        //rememberMe
        http.rememberMe().rememberMeServices(rememberMeServices);

        // 权限不足时的处理
        http.exceptionHandling()
                .accessDeniedHandler(authenticationHandler)
                .authenticationEntryPoint(authenticationHandler)
        ;

        return http.build();
    }
}

Finish

Guess you like

Origin blog.csdn.net/hjg719/article/details/128302584