Spring Boot整合Spring 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>

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

认证失败处理器 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();
    }
}

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

public class CaptchaException extends AuthenticationException {
    public CaptchaException(String msg) {
        super(msg);
    }
}

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

生成验证码方法 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);
    }
}

验证码过滤器 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);
    }
}

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

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

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;
   }
}
/**
 * 这是上文提到的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;
}

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

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

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

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();

    }
}

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();
//    }
}

就此大功告成了。

9、前端需要做什么?

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

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

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

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

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

在这里插入图片描述

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

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

Spring Boot 是一个用于构建微服务的开源框架,它能够快速搭建项目并且提供了许多便捷的功能和特性。Spring Security 是一个用于处理认证和授权的框架,可以保护我们的应用程序免受恶意攻击。JWT(JSON Web Token)是一种用于身份验证的开放标准,可以被用于安全地传输信息。Spring MVC 是一个用于构建 Web 应用程序的框架,它能够处理 HTTP 请求和响应。MyBatis 是一个用于操作数据库的框架,可以简化数据库操作和提高效率。Redis 是一种高性能的键值存储系统,可以用于缓存与数据存储。 基于这些技术,可以搭建一个商城项目。Spring Boot 可以用于构建商城项目的后端服务,Spring Security 可以确保用户信息的安全性,JWT 可以用于用户的身份验证,Spring MVC 可以处理前端请求,MyBatis 可以操作数据库,Redis 可以用于缓存用户信息和商品信息。 商城项目的后端可以使用 Spring BootSpring Security 来搭建,通过 JWT 来处理用户的身份验证和授权。数据库操作可以使用 MyBatis 来简化与提高效率,同时可以利用 Redis 来缓存一些常用的数据和信息,提升系统的性能。前端请求则可以通过 Spring MVC 来处理,实现商城项目的整体功能。 综上所述,借助于 Spring BootSpring Security、JWT、Spring MVC、MyBatis 和 Redis 这些技术,可以构建出一个高性能、安全可靠的商城项目,为用户提供良好的购物体验。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值