Proceso de configuración de autenticación bajo Spring Security 6.0 (spring boot 3.0)

premisa

Se recomienda encarecidamente leer este artículo después de aprender el proceso de configuración de la versión 2.x

Recomendar uno: video tutorial

Funciones a implementar

  1. Use nombre de usuario + contraseña + código de verificación + función recordarme para iniciar sesión
  2. Verificación CSRF
  3. Entregue la sesión a Redis para su administración y mantenga la función de recordarme en la base de datos.

Dependencia (POM)

Se omite la parte de la operación de la base de datos.

<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>

Nota: La documentación de la interfaz generada por springdoc+knife4j se incluye al final, y las anotaciones proporcionadas por springdoc también se incluyen en el código de muestra.

Código de muestra

Componentes básicos

código de verificación

Configuración de compilación (igual que en el 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;
    }
}

interfaz

Genere un código de verificación, guárdelo Sessionen Attributey sáquelo de aquí para su posterior verificación. Las dos interfaces devuelven los datos del código de verificación en diferentes formatos.

@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 (información de autenticación/permiso)

  • No hay nada especial aquí, consulte y devuelva la información de autenticación del usuario de acuerdo con el nombre de usuario y SystemUserServiceproporcione una interfaz de acceso a la base de datos.
  • Desde que lo implementamos UserDetailsPasswordService, SpringSecuritysi se descubre que el método de encriptación de la contraseña del usuario está desactualizado o es texto sin formato, la contraseña se cambiará automáticamente.
  • createUserEl método es llamar SpringSecurityal constructor proporcionado User.UserBuilderpara crear unUserDetails
  • Debido a que la parte de autenticación aún no se ha involucrado, aquí se proporciona directamente una lista vacía a la autoridad, y se informará de un error si no se escribe aquí.
  • @ServiceRegístrese directamente en el contenedor
@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(Manejador)

Debido a que solo hay un método en estas interfaces, y todas necesitan un procesamiento similar, lo armé para implementarlo y el proceso es casi el mismo:

  1. establecer Content-Typeenapplication/json;charset=UTF-8
  2. Establecer el código de estado según la situación
  3. Escriba el resultado devuelto enresponse

Lo único a lo que debe prestar atención es que después de un inicio de sesión exitoso, debe borrar el código de verificación utilizado

Nota: Necesitamos usar este objeto en dos lugares, así que regístrelo directamente en el contenedor para facilitar la inyección.

@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 (recuérdame)

La función recordarme, especifica:

  1. Obtener campos de request_AttributerememberMe
  2. Cuando el valor del campo es TRUE_VALUESmiembro de la tabla, se considera necesario habilitar la función recordarme

en el constructor

  1. PersistentTokenRepositoryse proporcionará más adelante
  2. UserDetailsServiceproporcionado anteriormente

Nota: Necesitamos usar este objeto en dos lugares, así que regístrelo directamente en el contenedor para facilitar la inyección.

@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);
    }
}

componentes básicos

MyLoginFilter (filtro de inicio de sesión)

  1. Los parámetros del método de construcción se pueden obtener del contenedor, por lo que aquí también se registra directamente la construcción automática del contenedor.
  2. Heredado UsernamePasswordAuthenticationFilter, lo usaremos para reemplazar el predeterminadoUsernamePasswordAuthenticationFilter
  3. En el constructor, especifique:
    1. Cómo lidiar con inicios de sesión exitosos y fallidos
    2. Recuérdame componente
    3. La ruta utilizada para iniciar sesión
  4. attemptAuthenticationEl método especifica el proceso de inicio de sesión:
    1. Si Content-Typees Json, obtenga los parámetros de solicitud Bodyde él ; de lo contrario Form表单, obténgalo de
    2. Obtenga el código de verificación previamente guardado y compárelo con el código de verificación proporcionado por el SessionusuarioAttribute
    3. rememberMeIntroducir los campos requestfacilitados por el usuario Attributepara su posterior MyRememberMeServicesadquisición
    4. La parte final proviene de la clase principal y se copia.
@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 (configuración central)

  1. @Bean authenticationManagerproporcionar MyLoginFilterlo necesarioAuthenticationManager
  2. @Bean daoAuthenticationProviderProporciona MyRememberMeServiceslo que se necesita PersistentTokenRepository, donde setCreateTableOnStartupel método necesita desatar el comentario cuando se ejecuta por primera vez para permitirle construir automáticamente la tabla.
  3. @Bean securityFilterChainEl núcleo del núcleo, la HttpSecurity httpconfiguración correcta en la versión 2.x necesita moverse aquí, aquí configuramos:
    1. Configuración de ruta, aquí se libera la ruta del documento de interfaz y el código de verificación, y otras solicitudes deben autenticarse. Las solicitudes de inicio de sesión no se ven afectadas por él y no requieren una configuración especial.
    2. MyLoginFilterReemplace el predeterminado con uno personalizado , tenga cuidado de no escribir UsernamePasswordAuthenticationFilterel original , de lo contrario podrá iniciar sesión sin pasar por el código de verificación.http.formLogin()/login
    3. Configuración de cierre de sesión, ruta especificada y método de procesamiento de cierre de sesión correcto
    4. verificación csrf, tenga en cuenta que aquí se requiere una oración más que en la versión 2.x.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
    5. Gestión de sesiones, configurada para permitir que solo un terminal inicie sesión, no es necesario configurar sessionRegistry, se inyectará automáticamente, por supuesto, la configuración manual también es posible, pero no se creará automáticamente en el contenedor, debe pasar uno manualmente , new SpringSessionBackedSessionRegistry<>(new RedisIndexedSessionRepository(redisTemplate))que redisTemplatenecesita serRedisTemplate<String,Object>
    6. Recuerde mi función, tenga en cuenta que MyLoginFilterlas dos configuraciones aquí y aquí son indispensables.
    7. Manejo de permisos insuficientes
@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();
    }
}

Finalizar

Supongo que te gusta

Origin blog.csdn.net/hjg719/article/details/128302584
Recomendado
Clasificación