当前位置:   article > 正文

Spring Boot整合Spring Security + Redis实现用户认证_springboot security redis

springboot security redis

Spring Boot整合Spring Security + Redis实现用户认证

登录和用户认证是一个网站最基本的功能,在这篇博客里,将介绍如何用SpringBoot整合Spring Security + Redis实现登录及用户认证
本文参考了以下两篇文章:
Spring Security一一认证、授权的工作原理
【全网最细致】SpringBoot整合Spring Security + JWT实现用户认证
各位可以去看看

那我们现在开始吧,(~* - *)~

一、Spring Security认证流程

Spring Security认证流程
图里的具体流程可以从上面我引入的文章中获取,此处不做赘述。
我具体讲讲一下Autentication存入SecurityContextHolder中的过程。

  • 首先,SecurityContextHolder只是为SecurityContext提供一种存储策略,只是主导了他的存储方式及地址。
  • 从源码可以看到SecurityContextHolder提供了一个SecurityContextHolderStrategy存储策略进行上下文的存储,进入到Security ContextHolderStrategy接口,共有三个实现类。分别对应三种存储策略,分别对应threadlocal,global,InheritableThreadLocal三种方式。由源码我们可以得出SecurityContextHolder 默认使用的是THREADLOCAL模式。
  • 默认是将SecurityContext存储在threadlocal中,可能是spring考虑到目前大多数为BS应用,一个应用同时可能有多个使用者,每个使用者又对应不同的安全上下,Security Context Holder为了保存这些安全上下文。
  • 缺省情况下,使用了ThreadLocal机制来保存每个使用者的安全上下文。因为缺省情况下根据Servlet规范,一个Servlet request的处理不管经历了多少个Filter,自始至终都由同一个线程来完成。这样就很好的保证了其安全性。
  • 但是当我们开发的是一个CS本地应用的时候,这种模式就不太适用了。spring早早的就考虑到了这种情况,这个时候我们就可以设置为Global模式仅使用一个变量来存储SecurityContext。比如还有其他的一些应用会有自己的线程创建,并且希望这些新建线程也能使用创建者的安全上下文。这种效果,我们就可以通过将SecurityContextHolder配置成MODE_INHERITABLETHREADLOCAL策略达到。

那么security是如何通过SessionId来维持登录状态的呢。

  • 在认证完成后,信息完整的Authentication会保存至安全上下文SecurityContext中,然后SecurityContext会引用SecurityContextHelder中的存储策略存储在本地线程ThreadLocal中去。
  • 然后当login这个request结束时,ThreadLocal即将被销毁,若如此做,那么认证信息就丢失了,等于就是白登录了,所以securityrequest结束前会将ThreadLocal中的内容存储到session中去,同时获取到对应的sessionId存放在cookie返回给前端。
  • 当前端再次发送请求时,就会在cookie中携带sessionId,后端接收后获取到对应的session内容,转存到ThreadLocal,这样认证信息就被保持在了一个新的request中。这样登录的认证状态就被维持了。

二、正式开始整合Spring Security 和 Redis

1.pom.xml添加所需要的依赖
  <dependencies>
        <!--        dataRedis起步依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--        Radis连接池-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <!--        security起步依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!--        springWeb起步依赖:开启springMVC功能-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--        druid数据库连接池 起步依赖-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.16</version>
        </dependency>
        <!--        mybatis起步依赖-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.3.1</version>
        </dependency>
        <!--        mysql数据库驱动-->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!--        mybatis-plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.3</version>
        </dependency>
        <!--hutool 万能工具包-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.16</version>
        </dependency>
        <!--验证码工具类-->
        <dependency>
            <groupId>com.github.axet</groupId>
            <artifactId>kaptcha</artifactId>
            <version>0.0.9</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69

2.写登录认证成功、失败处理器LoginSuccessHandle、LoginfailureHandle

认证成功处理器 LoginSuccessHandle
这个类就是在登录成功后,自动生成一个随机Token,以Tokenkey,登录信息为value,缓存到redis中,然后将该Token返回给前端存储,约定好前端发送的后续request需要在请求头中携带此Token作为凭证才能访问。

@Slf4j
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final StringRedisTemplate stringRedisTemplate;
    private final AccountMapper accountMapper;
    private final RoleMapper roleMapper;

    public LoginSuccessHandler(StringRedisTemplate stringRedisTemplate, AccountMapper accountMapper, RoleMapper roleMapper) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.accountMapper = accountMapper;
        this.roleMapper = roleMapper;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        System.out.println("=====================LoginSuccessHandler=========================");
        response.setContentType("application/json;charset=UTF-8");
        ServletOutputStream outputStream = response.getOutputStream();
        //1.生成tokenId
        String token = UUID.randomUUID().toString(true);
        //2.将account对象转化为hashMap存储
        //--2.1先从security中生成的authentication对象中获取到account信息
        Account account = accountMapper.selectByUsername(authentication.getName());
        List<Role> roles = roleMapper.selectByAccountId(account.getAccountId());
        //--2.2将密码赋值为空串,消除敏感信息
        account.setPassword("");
        //--2.3将account转化为HashMap
        AccountDTO accountDTO = BeanUtil.copyProperties(account, AccountDTO.class);
        Map<String, Object> accountMap = BeanUtil.beanToMap(accountDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((field, fieldValue) -> fieldValue.toString()));
        //--2.4将roles转化为json数据;
        String rolesJson = JSONUtil.toJsonStr(roles);
        //3.存储到redis中去
        log.info("存入redis数据:{}", accountMap);
        String accountKey = RedisConst.LOGIN_ACCOUNT_KEY + token;
        String rolesKey = RedisConst.LOGIN_ROLES_KEY + token;
        stringRedisTemplate.opsForHash().putAll(accountKey, accountMap);
        stringRedisTemplate.opsForValue().set(rolesKey, rolesJson, RedisConst.LOGIN_ACCOUNT_TTL, TimeUnit.MINUTES);
        //4.设置有效期
        stringRedisTemplate.expire(accountKey, RedisConst.LOGIN_ACCOUNT_TTL, TimeUnit.MINUTES);
        //.将token存入Result
        log.info("Token:{}", token);
        Result result = Result.ok("登录成功", token);
        //5.返回Token
        outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52

认证失败处理器 LoginFailureHandler
这个类就是在登录失败后将失败的信息返回给前端处理。

@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        PrintWriter pw = response.getWriter();

        String msg = "用户名或密码错误";
        Result result;
        if (exception instanceof CaptchaException) {
            msg = exception.getMessage();
        }
        result = Result.unAuthentication(msg);
        pw.println(JSONUtil.toJsonStr(result));
        pw.flush();
        pw.close();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

此外,我们还需要定义一个验证码错误异常:

public class CaptchaException extends AuthenticationException {
    public CaptchaException(String msg) {
        super(msg);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5

3.编写验证码配置、接口、过滤器

验证码配置类 KaptchaConfig
验证码使用的是谷歌的验证码工具类,pom.xml已经引入了依赖。
DefaultKaptcha实现了Producer接口,Producer接口用于生成验证码,调用其createText()方法即可生成字符串验证码。
配置如下:

@Configuration
public class KaptchaConfig {
    @Bean
    DefaultKaptcha producer() {
        Properties properties = new Properties();
        properties.put("kaptcha.border", "no");
        properties.put("kaptcha.textproducer.font.color", "black");
        properties.put("kaptcha.textproducer.char.space", "4");
        properties.put("kaptcha.image.height", "40");
        properties.put("kaptcha.image.width", "120");
        properties.put("kaptcha.textproducer.font.size", "30");
        Config config = new Config(properties);
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

生成验证码方法 Captcha()
这个方法就是,生成了一个随机Key和随机Code,将(key,code)传入redis,并将Key和Code的图片的base64编码后的密文返回给前端,约定好前端在登录时,携带验证code的同时需要携带Key,以便于校验code。

@Slf4j
@RestController
public class LoginController {
    private final Producer producer;

    private final StringRedisTemplate redis;

    @Autowired
    public LoginController(Producer producer, StringRedisTemplate redis) {
        this.producer = producer;
        this.redis = redis;
    }

    @GetMapping("/captcha")
    public Result Captcha() throws IOException {
        //1.生成随机key
        String key = UUID.randomUUID().toString();
        //2.生成验证码
        String code = producer.createText();
        log.info("生成验证码:{}", code);
        //3.生成验证码图片
        BufferedImage image = producer.createImage(code);
        //4.
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ImageIO.write(image, "jpg", outputStream);
        //5.设定图片格式,将图片转化为base64编码
        Base64.Encoder encoder = Base64.getEncoder();
        String str = "data:image/jpeg;base64,";
        String base64Img = str + encoder.encodeToString(outputStream.toByteArray());
        //6.将数据存储到Redis
        redis.opsForValue().set(RedisConst.LOGIN_CODE_KEY + key, code, RedisConst.LOGIN_CODE_TTL, TimeUnit.MINUTES);
        //7.返回
        HashMap<Object, Object> map = new HashMap<>();
        map.put("userKey", key);
        map.put("captcherImg", base64Img);
        return Result.ok("这是验证码图片发送,前端需要将userKey返回,才能完成校验", map);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

验证码过滤器 CaptchaFiter
在验证码过滤器中,需要先判断请求是否是登录请求,若是登录请求,则进行验证码校验,从redis中通过userKey查找对应的验证码,看是否与前端所传验证码参数一致,当校验成功时,因为验证码是一次性使用的,一个验证码对应一个用户的一次登录过程,所以需要将存储在redis的验证码删除。当校验失败时,则交给登录认证失败处理器LoginFailureHandler进行处理。
使用了一个包装类RequestWrapper包装request,使得RequestBody中的数据能持续获取。

@Slf4j
@Component
public class CaptchaFilter extends OncePerRequestFilter {
    private final StringRedisTemplate redis;
    private final LoginFailureHandler loginFailureHandler;

    @Autowired
    public CaptchaFilter(StringRedisTemplate redis, LoginFailureHandler loginFailureHandler) {
        this.redis = redis;
        this.loginFailureHandler = loginFailureHandler;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        System.out.println("=========================CaptchaFilter=========================");
        // SpringBoot也是通过获取request的输入流来获取参数,这样上面的疑问就能解开了,为什么经过过滤器来到Controller请求参数就没了,
        // 这是因为 InputStream read方法内部有一个,postion,标志当前流读取到的位置,每读取一次,位置就会移动一次,如果读到最后,InputStream.read方法会返回-1,标志已经读取完了,
        // 如果想再次读取,可以调用inputstream.reset方法,position就会移动到上次调用mark的位置,mark默认是0,所以就能从头再读了。
        // 但是呢 是否能reset又是由markSupported决定的,为true能reset,为false就不能reset,
        // 从源码可以看到,markSupported是为false的,而且一调用reset就是直接异常

        // 这个是使用了一层包装类,对request中的InputStream做备份
        RequestWrapper requestWrapper = new RequestWrapper(request);

        String url = request.getRequestURI();
        if ("/login".equals(url) && request.getMethod().equals("POST")) {
            // 校验验证码
            try {
                validate(requestWrapper);
            } catch (CaptchaException e) {
                // 交给认证失败处理器
                loginFailureHandler.onAuthenticationFailure(requestWrapper, response, e);
            }
        }
        log.info("跳过非/login请求");
        filterChain.doFilter(requestWrapper, response);
    }

    // 校验验证码逻辑
    private void validate(RequestWrapper request) throws IOException {
        System.out.println("=================validate================");
        String bodyJson = request.getBodyString();
        //1.获取值
        HashMap<String, String> userInfo = JSONUtil.parseObj(bodyJson).toBean(HashMap.class);
        String userKey = userInfo.get("userKey");
        String code = userInfo.get("code");
        //2.校验是否有值
        if (StrUtil.isBlank(code) || StrUtil.isBlank(userKey)) {
            throw new CaptchaException("验证码不存在");
        }
        //3.从redis中获取到系统生成的验证码
        String key = RedisConst.LOGIN_CODE_KEY + userKey;
        String redisCode = redis.opsForValue().get(key);

        log.info("code:{} ,redisCode:{}", code, redisCode);
        //4.校验验证码是否匹配
        if (!code.equals(redisCode)) {
            throw new CaptchaException("验证码不匹配");
        }
        //5.若校验成功,那么就需要删除redis中的验证码
        redis.opsForValue().getOperations().delete(userKey);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63

4.Token过滤器TokenAuthenticationFilter以及Token认证失败过滤器TokenAuthenticationEntryPoint

Token过滤器TokenAuthenticationFilter
TokenAuthenticationFilter继承了BasicAuthenticationFilter,该类用于普通http请求进行身份认证,该类有一个重要属性:AuthenticationManager,表示认证管理器,它是一个接口,它的默认实现类是ProviderManager,它与用户名密码认证息息相关。
  若Token验证成功·,我们构建了一个UsernamePasswordAuthenticationToken对象,用于保存用户信息,之后将该对象交给SecurityContextHolder,set进它的context中,这样后续我们就能通过调用SecurityContextHolder.getContext().getAuthentication().getPrincipal()等方法获取到当前登录的用户信息了。

/**
 * token 过滤器
 * <p>
 * 在首次登录成功后,LoginSuccessHandler将生成token,并返回给前端。
 * 在之后的所有请求中(包括再次登录请求),都会携带此token信息。
 * 我们需要写一个token过滤器TokenAuthenticationFilter,
 * 当前端发来的请求有JWT信息时,该过滤器将检验JWT是否正确以及是否过期,
 * 若检验成功,则获取token中的信息,组合前缀生成一个key,检索redis数据库获得用户实体类,并将用户信息告知Spring Security,
 * 后续我们就能调用security的接口获取到当前登录的用户信息。
 * <p>
 * 若前端发的请求不含JWT,我们也不能拦截该请求,因为一般的项目都是允许匿名访问的,
 * 有的接口允许不登录就能访问,没有JWT也放行是安全的,因为我们可以通过Spring Security进行权限管理,
 * 设置一些接口需要权限才能访问,不允许匿名访问
 */
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
    private final StringRedisTemplate redis;

    public TokenAuthenticationFilter(AuthenticationManager authenticationManager, StringRedisTemplate redis) {
        super(authenticationManager);
        this.redis = redis;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        //1.获取前端token,并校验
        String token = request.getHeader("token");
        if (StrUtil.isBlankOrUndefined(token)) {
            //token为空直接放行,让后续过滤器链来执行
            chain.doFilter(request, response);
            return;
        }
        //2.获取redis中存储的数据
        String accountKey = RedisConst.LOGIN_ACCOUNT_KEY + token;
        String rolesKey = RedisConst.LOGIN_ROLES_KEY + token;
        Map<Object, Object> accountMap = redis.opsForHash().entries(accountKey);
        String rolesJson = redis.opsForValue().get(rolesKey);
        //3.判断是否为空,即是否为无效key
        if (accountMap.isEmpty()) {
            throw new TokenException("token 已过期");
        }
        //4.若token对应账户不为空,那么将查询出来的对象转化为account对象
        Account account = BeanUtil.fillBeanWithMap(accountMap, new Account(), false);
        account.setRoles(JSONUtil.toList(rolesJson, Role.class));
        //5.构建UsernamePasswordAuthenticationToken,这里密码为null,是因为提供了正确的Token,实现自动登录
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(account.getUsername(), null, new AccountUser(account).getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //6.刷新token有效期
        redis.expire(accountKey, RedisConst.LOGIN_ACCOUNT_TTL, MINUTES);
        redis.expire(rolesKey, RedisConst.LOGIN_ACCOUNT_TTL, MINUTES);
        //7.过滤器继续执行
        chain.doFilter(request, response);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54

Token认证失败过滤器TokenAuthenticationEntryPoint

/**
 * token验证失败处理类
 * <p>
 * 当BasicAuthenticationFilter认证失败的时候会进入AuthenticationEntryPoint,
 * 我们定义JWT认证失败处理器JwtAuthenticationEntryPoint,使其实现AuthenticationEntryPoint接口,
 * 该接口只有一个commence方法,表示认证失败的处理,我们重写该方法,向前端返回错误信息,
 * 不论是什么原因,JWT认证失败,我们就要求重新登录,所以返回的错误信息为请先登录
 */
@Component
public class TokenAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        ServletOutputStream outputStream = response.getOutputStream();

        Result result = Result.unAuthentication("请先登录");

        outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

5. 基于数据库信息做认证,UserDetails、UserServiceDetails、LoginFilter.

SpringSecurity中的认证管理器AuthenticationManager是一个抽象接口,用以提供各种认证方式。一般我们都使用从数据库中验证用户名、密码是否正确这种认证方式。
  AuthenticationManager的默认实现类是ProviderManager,ProviderManager提供很多认证方式,DaoAuthenticationProvider是AuthenticationProvider的一种实现,可以通过实现UserDetailsService接口的方式来实现数据库查询方式登录。
  UserDetailsService定义了loadUserByUsername方法,该方法通过用户名去查询出UserDetails并返回,UserDetails是一个接口,实际重写该方法时需要返回它的实现类
  Spring Security在拿到UserDetails之后,会去对比Authentication(Authentication如何得到?我们使用的是默认的UsernamePasswordAuthenticationFilter,它会读取表单中的用户信息并生成Authentication),若密码正确,则Spring Secuity自动帮忙完成登录

AccountUser类
UserDetails是一个元数据类,是提供给security做认证的一个权威数据,可以基于多种方式实现,此处我们使用的是基于数据库获取元数据。

public class AccountUser implements UserDetails {

   private Account account;

   public AccountUser(Account account) {
       this.account = account;
   }

   public Account getAccount() {
       return account;
   }

   @Override
   public Collection<? extends GrantedAuthority> getAuthorities() {
       return account.getRoles().stream().map(r -> new SimpleGrantedAuthority(r.getRoleName())).collect(Collectors.toList());

   }

   @Override
   public String getPassword() {
       return account.getPassword();
   }

   @Override
   public String getUsername() {
       return account.getUsername();
   }

   @Override
   public boolean isAccountNonExpired() {
       return true;
   }

   @Override
   public boolean isAccountNonLocked() {
       return true;
   }

   @Override
   public boolean isCredentialsNonExpired() {
       return true;
   }

   @Override
   public boolean isEnabled() {
       return true;
   }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
/**
 * 这是上文提到的Account对象,可以自己任意定义
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Account {
    private Long accountId;
    private String username;
    private String password;
    private String phone;
    private String email;
    private java.sql.Timestamp createTime;
    private java.sql.Timestamp updateTime;
    private Boolean isDeleted;
    private List<Role> roles;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

UserDetailsServiceImpl
这里就具体实现了从数据库获取数据的过程

/**
 * 提供认证元数据
 */
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final AccountMapper accountMapper;
    private final RoleMapper roleMapper;

    @Autowired
    public UserDetailsServiceImpl(AccountMapper accountMapper, RoleMapper roleMapper) {
        this.accountMapper = accountMapper;
        this.roleMapper = roleMapper;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //1.根据用户名查询账户信息
        Account account = accountMapper.selectByUsername(username);
        if (account == null) {
            throw new UsernameNotFoundException("用户名不存在");
        }
        //2.获取该用户角色信息
        List<Role> roles = roleMapper.selectByAccountId(account.getAccountId());
        account.setRoles(roles);
        log.info("登录用户信息:{}", account);
        return new AccountUser(account);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

LogingFilter
为了适应前后端分离时的登录过程,我们需要重写UsernamePasswordAuthenticationFilter,使得能通过RequestBody中的json获得前端传过来的username,password数据

public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        System.out.println("=========================LoginFilter=========================");
        // 1.判断是否是post方式请求 ( 这里的操作和UsernamePasswordAuthenticationFilter是一样的 )
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        // 2.判断是否是json格式请求类别
        if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {
            // 3.从json数据中获取用户名和密码进行认证
            try {
                Map<String, String> userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
                String username = userInfo.get(getUsernameParameter());
                String password = userInfo.get(getPasswordParameter());
                System.out.println("username = " + username);
                System.out.println("password = " + password);
                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
                setDetails(request, authRequest);
                return this.getAuthenticationManager().authenticate(authRequest);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        return super.attemptAuthentication(request, response);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

6、权限异常处理AccessDenieHandler

我们之前放行了匿名请求,但有的接口是需要权限的,当用户权限不足时,会进入AccessDenieHandler进行处理,我们定义JwtAccessDeniedHandler类来实现该接口,需重写其handle方法。

@Component
public class TokenAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        ServletOutputStream outputStream = response.getOutputStream();

        Result result = Result.unAuthorization(accessDeniedException.getMessage());

        outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

7、登出处理器AccessDenieHandler

/**
 * 退出成功处理器
 * <p>
 * 我们将我们之前置入SecurityContext中的用户信息进行清除,
 * 这可以通过创建SecurityContextLogoutHandler对象,调用它的logout方法完成
 * 然后清除redis中保持的对应用户数据
 * <p>
 * 我们定义LogoutSuccessHandler接口的实现类TokenLogoutSuccessHandler,
 * 重写其onLogoutSuccess方法
 */
@Component
public class TokenLogoutSuccessHandler implements LogoutSuccessHandler {

    private final StringRedisTemplate redis;

    @Autowired
    public TokenLogoutSuccessHandler(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        if (authentication != null) {
            new SecurityContextLogoutHandler().logout(request, response, authentication);
            //TODO:获取到token信息,删除Redis中的数据
            //1.获取到的token信息
            String token = request.getHeader("token");
            //2.使用token来删除redis数据
            redis.opsForHash().getOperations().delete(RedisConst.LOGIN_ACCOUNT_KEY + token);
        }

        response.setContentType("application/json;charset=UTF-8");
        ServletOutputStream outputStream = response.getOutputStream();
        Result result = Result.ok("退出成功!");

        outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();

    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41

8、!!!整合所有组件,Spring Security全局配置:SecurityConfig!!!

直接上代码!!!

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 配置白名单
     */
    private static final String[] URL_WHITELIST = {
            "/login", "/logout", "/captcha", "/favicon.ico"
    };
    /**
     * 登录失败控制器
     */
    private final LoginFailureHandler loginFailureHandler;
    /**
     * 登录成功控制器
     */
    private final LoginSuccessHandler loginSuccessHandler;
    /**
     * 验证码过滤器
     */
    private final CaptchaFilter captchaFilter;
    /**
     * Token校验失败处理
     */
    private final TokenAuthenticationEntryPoint tokenAuthenticationEntryPoint;
    /**
     * 权限不足处理
     */
    private final TokenAccessDeniedHandler tokenAccessDeniedHandler;
    /**
     * 退出成功处理
     */
    private final TokenLogoutSuccessHandler tokenLogoutSuccessHandler;
    /**
     * 基于mysql数据库的认证元数据获取方式
     */
    private final UserDetailsService userDetailsService;
    /**
     * 引入redis
     */
    private final StringRedisTemplate redis;

    @Autowired
    public MySecurityConfig(LoginFailureHandler loginFailureHandler, LoginSuccessHandler loginSuccessHandler, CaptchaFilter captchaFilter, TokenAuthenticationEntryPoint tokenAuthenticationEntryPoint, TokenAccessDeniedHandler tokenAccessDeniedHandler, TokenLogoutSuccessHandler tokenLogoutSuccessHandler, UserDetailsService userDetailsService, StringRedisTemplate redis) {
        this.loginFailureHandler = loginFailureHandler;
        this.loginSuccessHandler = loginSuccessHandler;
        this.captchaFilter = captchaFilter;
        this.tokenAuthenticationEntryPoint = tokenAuthenticationEntryPoint;
        this.tokenAccessDeniedHandler = tokenAccessDeniedHandler;
        this.tokenLogoutSuccessHandler = tokenLogoutSuccessHandler;
        this.userDetailsService = userDetailsService;
        this.redis = redis;
    }

    /**
     * 将自定义的userDetailsService配置到认证管理器中去
     *
     * @param auth the {@link AuthenticationManagerBuilder} to use
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    /**
     * 将自定义Manager暴露
     *
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 纳入登录校验过滤器
     *
     * @return
     * @throws Exception
     */
    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() throws Exception {
        return new TokenAuthenticationFilter(authenticationManagerBean(), redis);
    }

    //    自定义 filter 交给工厂管理
    @Bean
    public LoginFilter loginFilter() throws Exception {
        LoginFilter loginFilter = new LoginFilter();
        //指定认证 url
        loginFilter.setFilterProcessesUrl("/login");
        //指定接收json中的 用户名/密码 的key
        loginFilter.setUsernameParameter("username");
        loginFilter.setPasswordParameter("password");
        //设置认证数据源
        loginFilter.setAuthenticationManager(authenticationManagerBean());
        //指定 认证成功/认证失败 处理
        loginFilter.setAuthenticationSuccessHandler(loginSuccessHandler);
        loginFilter.setAuthenticationFailureHandler(loginFailureHandler);
        return loginFilter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                // 登录配置
                .formLogin()
                .successHandler(loginSuccessHandler)
                .failureHandler(loginFailureHandler)

                .and()
                .logout()
                .logoutSuccessHandler(tokenLogoutSuccessHandler)

                // 禁用session
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // 配置拦截规则
                .and()
                .authorizeRequests()
                .antMatchers(URL_WHITELIST).permitAll()
                .anyRequest().authenticated()

                // 异常处理器
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(tokenAuthenticationEntryPoint)
                .accessDeniedHandler(tokenAccessDeniedHandler)

                // 配置自定义的过滤器
                .and()
                .addFilter(tokenAuthenticationFilter())
                // 验证码过滤器放在UsernamePassword过滤器之前
                .addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
        ;

        //at: 将filter替换过滤器链中的哪个过滤器
        //before: 将filter放在过滤器链中哪一个之前
        //after: 将filter放在过滤器链中哪一个之后
        http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    /**
     * 此处配置一个加密方法Bean,指定加密方式,供Security加密,解析取用
     * @return
     */
//    @Bean
//    public PasswordEncoder passwordEncoder() {
//        return new BCryptPasswordEncoder();
//    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157

就此大功告成了。

9、前端需要做什么?

前端需要做两件事,一是登录成功后把token存到localStore里面。
二是在每次请求之前,都在请求头中添加token
还要注意:在发送登录表单信息时,需要将userKey一同返回,这样后端才能去redis中获取到对应的验证码做校验

10、若先不考虑security的话,那么整个流程应该如下:

验证码流程
在这里插入图片描述

登录认证流程
在这里插入图片描述

再次访问,维持登录状态流程

在这里插入图片描述

好了所有的流程就都结束了,这里最后解释一下,为什么要将用户的信息也存入redis,而不是直接转化为JWT,将JWT作为一个Token来处理,了解JWT的盆友都知道,JWT由三部分组成,且都是必要部分,即使只存储一个字符串,JWT整体都会显得比较臃肿,每次都需要将这样一个臃肿的字段写入请求头发送的话,会造成无意义的开销,so,秉着只传输必要部分,尽量精简token的思路,我们仅将一个redis存储的key作为token使用。

萌新宝宝第一次写CSDN,应该会有不少纰漏,请各路大佬指正,CU。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/酷酷是懒虫/article/detail/783747
推荐阅读
相关标签
  

闽ICP备14008679号