springboot + spring security简单学习,目标是学习原理 @by_TWJ

1、前言

这里只是了解每个类的含义,并且security到底是怎么使用这些类来认证的。

2、术语

注意:这里有些是比较模糊

  • SecurityContextHolder - 安全上下文持有人

  • Authentication - 身份验证信息
    * Credentials - 凭证,通常是一个密码
    * Authorities - 授予的权限。
    * Principal - 身份主体信息,通常是用户名和密码等
    * isAuthenticated - 是否已认证

  • SecurityContext - 安全上下文

  • SecurityContextHolderStrategy 针对线程存储安全上下文信息的策略。(你可以理解为:SecurityContextRepository是一个参数值,SecurityContextHolderStrategy是使用什么方式存取。)

    • ThreadLocalSecurityContextHolderStrategy 使用线程栈方式
    • GlobalSecurityContextHolderStrategy 使用全局静态变量方式,一般用于java客户端Swing
    • InheritableThreadLocalSecurityContextHolderStrategy 使用可继承的线程栈方式
    • ListeningSecurityContextHolderStrategy 使用监听器方式
  • GrantedAuthority - 在身份验证上授予主体的权限(即角色、范围等)。

  • AuthenticationManager - 定义Spring Security的过滤器如何执行身份验证的API。 这是所有入口。有以下实现类:

    • ProviderManager - 身份验证管理
  • AuthenticationProvider - 身份验证提供者,可以有多个,用来支持不同的 Authentication(身份)通过support决定,如果返回非null,则表示该提供者能处理该身份验证。

  • AbstractAuthenticationProcessingFilter - 用于身份验证的基本过滤器,处理身份验证的一个业务过程,有以下实现

    • UsernamePasswordAuthenticationFilter 使用表单方式身份验证,里面包含所有登录需要的处理。
  • SecurityContextRepository 保存安全上下文信息,有两种存储方式:

    • HttpSessionSecurityContextRepository 使用session保存
    • RequestAttributeSecurityContextRepository 使用请求Attribute保存
  • DelegatingPasswordEncoder 密码编码器,里面包含很多编码器,格式是: {id}xxxxxx ,这个是为了应对系统密码从MD5升级到bcrypt或者其他类型,而设计出来的。如果系统只使用一种密码,就不需要用这个了。

Session与SecurityContext的区别:session里有securityContext,并且多了时效。

  • Session 的属性

    • Id - 这是SessionId
    • Attribute - 这是存放其他的信息,包含有SecurityContext
    • CreationTime - 创建时间
    • LastAccessedTime - 最后访问时间
    • MaxInactiveInterval - 最大不活动间隔,就是离开后,多久会过期。
  • SecurityContext 的属性

    • authentication - 这是身份信息

3、术语翻译的例子

3.1、身份存储的例子

这里模拟了security是怎么存放身份信息的

3.1.2、代码


//new AnonymousAuthenticationToken(credentials,principal,authorities);
Authentication anAuthentication = new AnonymousAuthenticationToken("密码","用户信息",new ArrayList<>());// 创建一个身份验证信息
SecurityContext context = SecurityContextHolder.createEmptyContext();// 创建一个空的安全上下文
context.setAuthentication(anAuthentication);// 把身份验证信息交给安全上下文,
SecurityContextHolder.setContext(context);// 把安全上下文放到SecurityContextHolder里,SecurityContextHolder是一个全局静态方法

3.1.3、术语

  1. Authentication 身份验证信息,包含身份信息和验证信息,属性如下:

    • credentials 凭证,通常是一个密码
    • details 其他详细信息,通常是ip地址等
    • principal 身份主体信息,通常是用户名和密码等
    • authorities 授予的权限
    • authenticated 是否已认证
  2. SecurityContextHolder 安全上下文持有人,属性如下:

    • strategy 存放策略,,属性类是SecurityContextHolderStrategy
  3. SecurityContext 安全上下文,属性如下:

    • authentication 身份验证信息
  4. SecurityContextHolderStrategy 针对线程存储安全上下文信息的策略。有如下实现:

    • ThreadLocalSecurityContextHolderStrategy 使用线程栈方式存取
    • GlobalSecurityContextHolderStrategy 使用全局静态变量方式存取,一般用于java客户端Swing
    • InheritableThreadLocalSecurityContextHolderStrategy 使用可继承的线程栈方式存取
    • ListeningSecurityContextHolderStrategy 使用监听器方式存取

3.1.4、术语之间,它们的关系

它们的关系
在这里插入图片描述

它们的关系就像下面的例子:
就像是某人有100块,然后这100块,我要存到“银行、支付宝或者微信”,怎么存钱呢。

  • SecurityContextHolderStrategy,是“怎么存钱”
  • SecurityContextHolder,是“某人”
  • authentication,是“100块”
  • SecurityContext,是“银行、支付宝或者微信”,就像一个公司

3.2、身份验证的例子

这里模拟了security是怎么验证的

3.2.1、代码

TestMain 测试类

package person.twj.jwt;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
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.security.crypto.password.NoOpPasswordEncoder;

@Slf4j
public class TestMain {
    public static void main(String[] args) {
        String username = "admin";
        String password = "123456";

        // 1. SecurityContextHolder    创建身份A
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        Authentication anAuthentication = new UsernamePasswordAuthenticationToken(
                username,
                password);
        context.setAuthentication(anAuthentication);
        SecurityContextHolder.setContext(context);

        // 2. ProviderManager implements AuthenticationManager 验证身份
        // 2.1、创建身份适配器
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider(NoOpPasswordEncoder.getInstance());
        provider.setUserDetailsService(new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
                // 从数据库获取用户和密码信息
                if("admin".equals(username)){
                    return User.builder().username("admin").password("123456").build();
                }else{
                    throw new UsernameNotFoundException("用户不存在");
                }
            }
        });
        // 2.2、加入身份provider 到 身份管理器里
        ProviderManager providerManager = new ProviderManager(provider);//provider 可以多个,这里是一个适配器模式,不同的身份验证信息类型(Authentication)需要不同的适配器
        try {
            // 2.3、验证身份
            Authentication authentication = providerManager.authenticate(// 验证身份
                    SecurityContextHolder.getContext().getAuthentication()// 验证身份A是否与我们数据库里的一样
            );
            // 2.4、最后,如果上面的适配器都处理完,还是返回null,则表示验证失败
            if (authentication == null) {
                log.info("验证失败");
                return;
            }
            log.info("身份验证成功");

        }catch (AuthenticationException ae){ // 捕捉认证错误信息
            if(ae.getClass().isAssignableFrom(BadCredentialsException.class)){
                log.error("用户名或密码错误");
            }else if(ae.getClass().isAssignableFrom(CredentialsExpiredException.class)){
                log.error("凭证已过期");
            }else{
                log.error("未知错误 ",ae);
            }
        }
    }

}





备注:这里 SecurityContextHolder.getContext().getAuthentication() 等于 new UsernamePasswordAuthenticationToken( username, password); 可以不用纠结。

3.2.2、术语

  1. AuthenticationManager - 身份验证管理,用来作认证。有如下实现:

    • ProviderManager - 身份验证管理,是一个适配器模式,真正认证的是 AuthenticationProvider。
  2. AuthenticationProvider - 身份验证提供者,身份验证执行者,用来作认证,如果返回非null,则认证成功。返回null,则表示该provider处理不了,交给下个,返回AuthenticationException异常,则表示认证失败。有如下实现:

    • DaoAuthenticationProvider - 使用数据库的方式认证,这里只支持 UsernamePasswordAuthenticationToken 的身份验证信息。你可以去看看他的supports所支持类。
  3. UserDetailsService - 查询用户信息服务接口

  4. UserDetails - 是 principal ,表示身份主体信息,通常是用户名和密码等

  5. Authentication 身份验证信息,包含身份信息和验证信息,属性如下:

    • credentials 凭证,通常是一个密码
    • details 其他详细信息,通常是ip地址等
    • principal 身份主体信息,通常是用户名和密码等
    • authorities 授予的权限
    • authenticated 是否已认证

3.2.3、术语之间,它们的关系

在这里插入图片描述

3.3、模拟 SecurityContextHolder.getContext()怎么获取redis上的用户信息

3.1.1、springboot中配置redis-session数据支持

在springboot中,引入gradle,就行了,不用任何配置

 implementation 'org.springframework.boot:spring-boot-starter-data-redis'
 implementation 'org.springframework.session:spring-session-data-redis'

配置一下,application.yml

spring:
  application:
    name: security-session-redis
  data:
    redis:
      host: localhost
      database: 1
      password: 123456
      port: 6379
      timeout: 1000 # 连接超时时间
      lettuce:
        pool:
          max-active: 50  # 连接池最大连接数(使用负值表示没有限制)
          min-idle: 5 # 连接池中的最小空闲连接
          max-idle: 50  # 连接池中的最大空闲连接
          max-wait: 5000  # 连接池最大阻塞等待时间(使用负值表示没有限制)
          time-between-eviction-runs: 2000  #eviction线程调度时间间隔

3.3.2、连接redis,并提供Dao操作类

RedisSessionRepositoryTest.java

package person.twj.securitysessionredis;

import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.userdetails.User;
import org.springframework.session.Session;
import org.springframework.session.data.redis.RedisSessionRepository;

import java.util.Set;


/**
 * 获取所有用户session信息,打印输出用户名
 * 这是用于线上管理用户状态的
 */
@Slf4j
public class RedisSessionRepositoryTest {
    //public static void main(String[] args) {


        //RedisTemplate redisTemplate = createRedisTemplate();
        //RedisSessionRepository repository = new RedisSessionRepository(redisTemplate);

        //Set<String> keys = redisTemplate.keys("spring:session:sessions:*");
        //keys.forEach(key->{
          //  String sessionId = key.substring("spring:session:sessions:".length());
          //  Session session = repository.findById(sessionId);
          //  SecurityContext context = session.getAttribute("SPRING_SECURITY_CONTEXT");
          //  User user = (User) context.getAuthentication().getPrincipal();
          //  log.info("输出 {}",user.getUsername());
       // });



    //}
    public static RedisSessionRepository getRedisSessionRepository(){
        RedisTemplate redisTemplate = createRedisTemplate();
        RedisSessionRepository repository = new RedisSessionRepository(redisTemplate);


        return repository;
    }
    private static RedisTemplate<String, Object> createRedisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(RedisSerializer.string());
        redisTemplate.setHashKeySerializer(RedisSerializer.string());
        if (getDefaultRedisSerializer() != null) {
            redisTemplate.setDefaultSerializer(getDefaultRedisSerializer());
        }
        redisTemplate.setConnectionFactory(getRedisConnectionFactory());
        redisTemplate.setBeanClassLoader(RedisSessionRepositoryTest.class.getClassLoader());
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

    private static RedisConnectionFactory getRedisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setDatabase(1);
        redisStandaloneConfiguration.setPassword("123456");
        redisStandaloneConfiguration.setPort(6379);
        redisStandaloneConfiguration.setHostName("localhost");

        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);

        lettuceConnectionFactory.start();
        return lettuceConnectionFactory;
    }

    /**
     * 使用Java序列化
     * @return
     */
    private static RedisSerializer<?> getDefaultRedisSerializer() {
        return new JdkSerializationRedisSerializer();
    }
}

3.3.3、SecurityContextHolder获取身份信息测试类

SecurityContextHolderTest.java

package person.twj.securitysessionredis;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.session.Session;
import org.springframework.session.data.redis.RedisSessionRepository;
import org.springframework.util.Assert;

import java.util.function.Supplier;


@Slf4j
public class SecurityContextHolderTest {
    /**
     * 这里模拟了SecurityContextHolder获取用户信息的流程。
     */
    public static void main(String[] args) {
        // 1、创建redis连接库,并提供Dao操作
        RedisSessionRepository sessionRepository = RedisSessionRepositoryTest.getRedisSessionRepository();

        // 2、本地栈存放 Supplier 提供者,需要的时候才调用
        ThreadLocalSecurityContextHolderStrategy holderStrategy = new ThreadLocalSecurityContextHolderStrategy();

        holderStrategy.setDeferredContext(()->{

            log.info("第二步,从redis获取session");
            String sessionId = "cecf73be-2fd8-4665-a55d-31e2e686bcdb";
            Session session = getRequestedSession(sessionId, sessionRepository);
            log.info("第三步,从session中获取SecurityContext");
            SecurityContext contextFromSession = session.getAttribute("SPRING_SECURITY_CONTEXT");
            return contextFromSession;
        });
        SecurityContextHolder.setContextHolderStrategy(holderStrategy);
        // 3、 SecurityContext获取用户信息
        log.info("第一步,获取 SecurityContext");
        SecurityContext context = SecurityContextHolder.getContext();
        log.info("第四步,获取结果:SecurityContext={}",context);
        Authentication authentication = context.getAuthentication();
        log.info("第五步,获取结果:Authentication={}",authentication);
        log.info("第六步,身份用户:{}",authentication.getPrincipal());

    }
    public static Session getRequestedSession(String sessionId,RedisSessionRepository sessionRepository){
        return sessionRepository.findById(sessionId);

    }

    /**
     * 基于本地线程栈存储, 我们把security里的ThreadLocalSecurityContextHolderStrategy 原封不动的搬过来
     *
     * @author Ben Alex
     * @author Rob Winch
     * @see java.lang.ThreadLocal
     * @see org.springframework.security.core.context.web.SecurityContextPersistenceFilter
     */
    static final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {

        private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal<>();

        @Override
        public void clearContext() {
            contextHolder.remove();
        }

        @Override
        public SecurityContext getContext() {
            return getDeferredContext().get();
        }

        @Override
        public Supplier<SecurityContext> getDeferredContext() {
            Supplier<SecurityContext> result = contextHolder.get();
            if (result == null) {
                SecurityContext context = createEmptyContext();
                result = () -> context;
                contextHolder.set(result);
            }
            return result;
        }

        @Override
        public void setContext(SecurityContext context) {
            Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
            contextHolder.set(() -> context);
        }

        @Override
        public void setDeferredContext(Supplier<SecurityContext> deferredContext) {
            Assert.notNull(deferredContext, "Only non-null Supplier instances are permitted");
            Supplier<SecurityContext> notNullDeferredContext = () -> {
                SecurityContext result = deferredContext.get();
                Assert.notNull(result, "A Supplier<SecurityContext> returned null and is not allowed.");
                return result;
            };
            contextHolder.set(notNullDeferredContext);
        }

        @Override
        public SecurityContext createEmptyContext() {
            return new SecurityContextImpl();
        }

    }

}

4、部分流程图

4.1、整体的流程图

在这里插入图片描述

4.2、过滤器链

在这里插入图片描述

4.3、登录身份验证处理过滤器

在这里插入图片描述

4.4、授权过滤器

在这里插入图片描述

5、问题

5.1、禁用Filter

其实security提供了很多Filter供我们使用,如果我们不用,可以使用disabled禁用,例如

http.logout(logout->logout.disable());

这里禁用了退出登录功能,security就不会帮我们加载 LogoutFilter 到Filter里了

5.2、自定义自己的Form表单过滤器

对于使用jwt来说,Form表单已经不适用了,所以我们可以禁用Form表单的Filter,然后自己写一个。

这里配置,先把Basic和form登录都禁了。

http.httpBasic(basic -> basic.disable());
http.formLogin((formLogin) ->formLogin.disable());

还有没登录访问异常,我们也要接收一下

http.exceptionHandling(exception -> {
	// 未登录返回
	exception.authenticationEntryPoint(new MyNotLoginAccessHandler());
});

新建一个MyUsernamePasswordAuthenticationFilter 继承 AbstractAuthenticationProcessingFilter 类,重写 attemptAuthentication 方法
添加自己的Filter在UsernamePasswordAuthenticationFilter位置

http.addFilterAt(new MyUsernamePasswordAuthenticationFilter(
					"/token",// 记得放开/token拦截
					authenticationConfiguration.getAuthenticationManager()
				),
				UsernamePasswordAuthenticationFilter.class);

其实我们照抄UsernamePasswordAuthenticationFilter 就行了,只是我们的传参已经不是表单的类型了,我们需要从json里获取传参,剩下的校验就交给原来的代码了

// 获取json格式传参
JsonNode jsonNode =getJSON(request);

String username = jsonNode.get("username").asText();
username = (username != null) ? username.trim() : "";
String password = jsonNode.get("password").asText();
password = (password != null) ? password : "";

具体的代码

package person.twj.jwt.core.security.jwt.handler;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import person.twj.jwt.core.security.jwt.constant.TokenConstants;
import person.twj.jwt.core.security.jwt.model.UserToken;
import person.twj.jwt.core.security.jwt.util.TokenUtil;
import person.twj.jwt.domain.vo.RsVo;

import java.io.BufferedReader;
import java.io.IOException;

public class MyUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {


    // 只允许使用post请求
    private boolean postOnly = true;

    public MyUsernamePasswordAuthenticationFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
        super(defaultFilterProcessesUrl, authenticationManager);
        LoginPostHandler loginPostHandler = new LoginPostHandler();
        setAuthenticationFailureHandler(loginPostHandler);
        setAuthenticationSuccessHandler(loginPostHandler);

    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        /*if(!requiresAuthentication(request,response)){ // 这个在 AbstractAuthenticationProcessingFilter 开始就已经过滤了
            // 路径是否是 defaultFilterProcessesUrl
            return null;
        }*/
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }

        // 获取json格式传参
        JsonNode jsonNode =getJSON(request);

        String username = jsonNode.get("username").asText();
        username = (username != null) ? username.trim() : "";
        String password = jsonNode.get("password").asText();
        password = (password != null) ? password : "";
        // 组装成 没认证的token,给AuthenticationManager 去真正的验证
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken
                .unauthenticated(username,password);

        // 这里是使用 DaoAuthenticationProvider 来验证身份。
        // 1. 验证成功后,会交给AuthenticationSuccessHandler去处理
        // 2. 验证失败后,会交给AuthenticationFailureHandler去处理
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    private JsonNode getJSON(HttpServletRequest request) {
        try {
            // 1. 从HttpServletRequest对象中获取输入流,并读取请求正文。
            StringBuilder buffer = new StringBuilder();
            BufferedReader reader = request.getReader();
            String line;
            while ((line = reader.readLine()) != null) {
                buffer.append(line);
            }
            String requestBody = buffer.toString();
            // 2. 使用JSON库(如Jackson、Gson等)将字符串解析为JsonNode或任何其他适合你的数据结构。
            ObjectMapper mapper = new ObjectMapper(); // Jackson JSON库示例
            JsonNode jsonNode = mapper.readTree(requestBody); // 解析为JsonNode对象
            return jsonNode;
        }catch (Exception e){
            e.printStackTrace();
            throw new AuthenticationCredentialsNotFoundException("获取json传参失败");
        }

    }


    class LoginPostHandler implements AuthenticationFailureHandler, AuthenticationSuccessHandler {
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
            if(e instanceof BadCredentialsException) {
                RsVo.failed(1,"用户名或密码错误").writeTo(response);

            } else {
                RsVo.failed(-1,"未知错误").writeTo(response);
            }
        }

        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
            UserToken userToken = (UserToken) authentication.getPrincipal();
            // token里不存在用户密码
            RsVo.success("登录成功", map -> {
                        map.put("access_token", TokenUtil.createToken(userToken));//有效期2个小时
                        map.put("refresh_token", TokenUtil.createRefreshToken(userToken));//有效期12小时
                        map.put("expires_in", TokenConstants.EXPIRES_IN);
                    })
                    .writeTo(response);
        }
    }
}

5.3、session管理

http.sessionManagement(sessionManagementCustomizer->{
//		用户登录成功后,信息保存在服务器Session中。SpringSecurity提供了4种方式控制会话创建。
//		    * always:如果一个会话尚不存在,将始终创建一个会话。
//			* ifRequired:仅在需要时创建会话,默认。
//			* never:框架永远不会创建会话本身,但如果它已经存在,它将使用一个。
//			* stateless:不会创建或使用任何会话,完全无状态。

	// 因为使用jwt,所以禁用session
	sessionManagementCustomizer.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
});

6、使用例子

  • springboot+security 使用 jwt方式登录
  • springboot+security 使用 session方式登录
  • springboot+security 使用 session+redis 方式登录

仓库地址:https://gitcode.com/u010101252/security-study/tree/main

7、security学习记录

流程图仓库路径:https://gitcode.com/u010101252/security-study/tree/main
security官方文档:https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/index.html

8、学习总结

其实如果你学会了上面,其实你可以去Security官网看,我相信官网绝大部分,你都会。

例如下方的:
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/index.html
在这里插入图片描述

  • 20
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值