【万字长文】SpringBoot整合SpringSecurity+JWT+Redis完整教程(提供Gitee源码)

前言:最近在学习SpringSecurity的过程中,参考了很多网上的教程,同时也参考了一些目前主流的开源框架,于是结合自己的思路写了一个SpringBoot整合SpringSecurity+JWT+Redis完整的项目,从0到1写完感觉还是收获到不少的,于是我把我完整的笔记写成博客分享给大家,算是比较全的一个项目了,仅供大家参考和学习哦!

目录

一、SpringSecurity简介

二、SpringSecurity认证流程

三、问题记录(重要)

四、项目核心代码讲解

4.1、导入pom依赖

4.2、yml配置文件

4.3、实体类

4.3.1、LoginBody登录实体类

4.3.2、Role角色类

4.3.3、User用户类

4.3.4、LoginUser登录用户信息

4.4、TokenService服务类

4.4.1、生成令牌核心代码

4.4.2、生成令牌关键逻辑

4.4.3、解析令牌核心代码

4.4.4、获取请求头中携带的令牌

4.4.5、获取Redis中存放的令牌Key

4.4.6、刷新令牌有效期

4.4.7、验证令牌有效期

4.4.8、获取用户身份信息

4.4.9、删除用户身份信息

4.5、AuthenticationEntryPointImpl配置认证失败处理类

4.6、JwtAuthenticationTokenFilter认证过滤器

4.7、FastJson序列化

4.8、自定义Redis序列化 

4.9、Redis工具类

4.10、SecurityConfig核心配置类

4.11、AuthenticationContextHolder线程本地的存储

4.12、UserServiceImpl查询用户接口

4.13、PasswordServiceImpl密码验证服务类 

4.14、UserDetailsServiceImpl认证用户服务类

4.15、LoginController登录接口

4.16、LoginServiceImpl登录接口核心逻辑

4.17、LogoutSuccessHandlerImpl退出登录核心逻辑 

4.18、@PreAuthorize注解

4.19、HelloController测试接口

4.20、总结

五、运行项目 

5.1、登录成功

5.2、访问无权限接口

5.3、访问需要USER权限的接口

5.4、访问需要COMMON权限的接口

5.5、退出登录

5.6、访问失败 

5.7、登录失败

六、Gitee源码地址

七、总结


一、SpringSecurity简介

SpringSecurity是Spring生态系统中的安全管理框架,提供了一套Web应用安全性的完整解决方案。

它具有以下特点:

1、全面性:SpringSecurity提供了认证、授权、攻击防护等安全管理的全部功能。

2、扩展性:可以通过继承类、实现接口等方式轻松扩展SpringSecurity的功能。

3、与Spring无缝集成:可以与Spring框架完美整合,通过SpringIoC容器管理SpringSecurity组件。

4、防范常见攻击:可以防止脚本注入、会话固定、SQL注入等常见Web攻击。

5、配置简单:通过配置文件可以快速应用SpringSecurity带来的安全功能。

SpringSecurity的主要功能包括:

1、认证(Authentication):验证用户身份信息的合法性。

2、授权(Authorization):验证用户是否有权限执行操作。

3、防护攻击:防御如CSRF、Session固定、SQL注入等攻击。

4、方法安全:实现与系统方法的安全访问控制。

5、安全响应头:添加浏览器安全相关的响应头,提高安全性。

综上,SpringSecurity是一个MVC应用不可或缺的安全防护框架,为Java应用提供全面的安全支持。它与Spring框架集成紧密,配置简单,使用方便。

二、SpringSecurity认证流程

Spring Security认证流程中的几个核心类及其作用如下:

1Authentication:认证信息接口,表示当前用户的认证信息,通常使用UsernamePasswordAuthenticationToken作为实现。

2AuthenticationManager:认证管理器接口,authenticate()方法用来执行认证流程。

3ProviderManager:认证管理器接口常用实现,封装多个AuthenticationProvider

4AuthenticationProvider:具体的认证处理器,由它完成特定的认证机制。

5UserDetailsService:根据用户名加载用户信息,返回UserDetails接口的实现。

6UserDetails:包含用户信息的接口,框架中代表用户信息。

7UsernamePasswordAuthenticationFilter:处理表单登录认证的过滤器。

8AbstractAuthenticationProcessingFilter:认证处理过滤器基类。

9SecurityContextHolder:安全上下文容器,存取Authentication对象。 

这是完整的SpringSecurity的认证流程:

1、用户向系统提交用户名和密码进行认证。

2AuthenticationFilter会拦截请求,并从请求中提取出用户名和密码构造一个UsernamePasswordAuthenticationToken

3AuthenticationFilterUsernamePasswordAuthenticationToken传入AuthenticationManager。

4AuthenticationManager会找到一个匹配的AuthenticationProvider来进行认证。

5AuthenticationProvider会先调用UserDetailsServiceloadUserByUsername()方法根据用户名加载用户信息。

6UserDetailsService根据用户名查询数据库,构造出一个UserDetails对象,包含用户信息、权限等。

7AuthenticationProvider使用UserDetails和用户输入的密码进行匹配验证。如果匹配上就验证成功。

8、如果验证成功,AuthenticationProvider会构造一个已认证的Authentication对象。

9AuthenticationProvider返回AuthenticationAuthenticationManager

10AuthenticationManagerAuthentication设置到SecurityContextHolder中。

11、后续的访问控制将使用SecurityContextHolder中的Authentication信息来验证用户身份和权限。

12、登录成功,用户访问系统受保护资源。

完整流程如图所示:

三、问题记录(重要)

阅读下面请务必先了解一下这个报错:

autoType is not support.org.springframework.security.core.authority.SimpleGrantedAuthority错误记录(亲测可用)

四、项目核心代码讲解

因为代码量比较庞大,所以我把整个项目的关键代码单独拿出来进行讲解,其他的次要的就不贴出来了,主要还是为了能够让大家更通俗易懂的去了解SpringSecurity的执行过程,完整的代码我会开源到Gitee,提供在文章的结尾。

4.1、导入pom依赖

完整的依赖都贴出来了。

<dependencies>
		
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<!-- lombok依赖包 -->
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<version>1.16.10</version>
			<scope>provided</scope>
		</dependency>


		<!-- spring security 安全认证 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>

		<!-- 单元测试 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

		<!-- jwt -->
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt</artifactId>
			<version>0.9.1</version>
		</dependency>

		<!-- redis依赖 对象池 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-redis</artifactId>
		</dependency>

		<!-- pool 对象池 -->
		<dependency>
			<groupId>org.apache.commons</groupId>
			<artifactId>commons-pool2</artifactId>
			<version>2.11.1</version>
		</dependency>

		<!-- 常用工具类 -->
		<dependency>
			<groupId>org.apache.commons</groupId>
			<artifactId>commons-lang3</artifactId>
			<version>3.9</version>
		</dependency>

		<!-- 阿里JSON解析器 -->
		<dependency>
			<groupId>com.alibaba</groupId>
			<artifactId>fastjson</artifactId>
			<version>2.0.22</version>
		</dependency>

	</dependencies>

4.2、yml配置文件

主要配置了一个Redis连接以及Token常量。

spring:
  redis:
    host: localhost
    port: 6379
    database: 0
    password:
    timeout: 10s
    lettuce:
      pool:
        min-idle: 0
        max-idle: 8
        max-active: 8
        max-wait: -1ms

token:
  header: Authorization
  secret: oqwe9sdladwosqwqs
  expireTime: 30

4.3、实体类

一共涉及了四个实体类,主要设计了一些关键的字段,并不是非常完整。

4.3.1、LoginBody登录实体类

这个类主要用于接收前端传递过来的用户名和密码,然后去验证登录信息用的。

完整代码:

package com.example.security.domain;

import lombok.Data;

@Data
public class LoginBody
{
    /**
     * 用户名
     */
    private String username;

    /**
     * 用户密码
     */
    private String password;

}

4.3.2、Role角色类

存放每个用户的角色信息,需要实现序列化接口。

完整代码:

package com.example.security.domain;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.io.Serializable;

@Data
@AllArgsConstructor
public class Role implements Serializable {

  /**
   * 角色主键
   */
  private Long id;

  /**
   * 角色名称
   */
  private String name;

}

4.3.3、User用户类

主要存放的是用户的信息,需要实现序列化接口。

完整代码:

package com.example.security.domain;

import lombok.Data;

import java.io.Serializable;
import java.util.Set;

@Data
public class User implements Serializable {

    /**
     * 主键
     */
    private String id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;

    /**
     * 角色集合
     */
    private Set<Role> roles;


}

4.3.4、LoginUser登录用户信息

需要实现SpringSecurity自带的UserDetails接口,并实现它所有的方法,在Spring Security中,我们可以通过GrantedAuthority接口来表示一个用户所拥有的权限。

方法 解释
isAccountNonExpired() 账号是否已过期
isAccountNonLocked() 账号是否已锁定
isCredentialsNonExpired() 凭(密码)是否已过期
isEnabled() 账号是否可用

这些方法返回true的目的是简化逻辑,在没有实现对应状态判断时,默认设置为true,这样可以避免不必要的认证/授权失败。 

完整代码:

package com.example.security.domain;

import com.alibaba.fastjson2.annotation.JSONField;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Set;

@Data
public class LoginUser implements UserDetails {

    public LoginUser(User user,Set<GrantedAuthority> authorities)
    {
        this.user = user;
        this.authorities = authorities;

    }

    /**
     * 用户信息
     */
    private User user;

    /**
     * 权限信息
     */
    private Set<GrantedAuthority> authorities;

    /**
     * token信息
     */
    private String token;

    /**
     * 登录时间
     */
    private Long loginTime;
    /**
     * 过期时间
     */
    private Long expireTime;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

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

    @Override
    public String getUsername() {
        return user.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;
    }
}

4.4、TokenService服务类

先注入一些参数。

关键代码:

@Value("${token.header}")
private String header;

@Value("${token.secret}")
private String secret;

@Value("${token.expireTime}")
private int expireTime;

protected static final long MILLIS_SECOND = 1000;

protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;

private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;

4.4.1、生成令牌核心代码

关键代码:

    private String generateToken(Map<String, Object> claims)
    {
        String token = Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret).compact();
        return token;
    }

4.4.2、生成令牌关键逻辑

常量LOGIN_TOKEN_KEY:login_tokens:

1、首先生成一个随机的UUID作为token的值,设置到LoginUser对象中。

2、设置LoginUser的登录时间和过期时间(当前时间 + 过期时间)。

3、将LoginUser对象存储到Redis中,key为LOGIN_TOKEN_KEY+token,过期时间默认为yml配置的30分钟。

4、最终调用generateToken方法生成JWT token,传入claims哈希Map集合。

关键代码:

    public String createToken(LoginUser loginUser)
    {
        String token = UUID.randomUUID().toString();
        loginUser.setToken(token);
        loginUser.setLoginTime(System.currentTimeMillis());
        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
        String userKey = CacheConstants.LOGIN_TOKEN_KEY + token;
        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
        Map<String, Object> claims = new HashMap<>();
        claims.put(Constants.LOGIN_USER_KEY, token);
        return generateToken(claims);
    }

4.4.3、解析令牌核心代码

关键代码:

 private Claims parseToken(String token)
    {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }

4.4.4、获取请求头中携带的令牌

常量TOKEN_PREFIX:"Bearer "

1、从请求header中获取指定名称(yml文件配置的header)的authorization信息。

2、判断获得的token是否非空且以指定前缀(Constants.TOKEN_PREFIX)开头。

3、如果是,则移除前缀,得到最终的JWT token。

关键代码:

    private String getToken(HttpServletRequest request)
    {
        String token = request.getHeader(header);
        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX))
        {
            token = token.replace(Constants.TOKEN_PREFIX, "");
        }
        return token;
    }

4.4.5、获取Redis中存放的令牌Key

关键代码:

    private String getTokenKey(String uuid)
    {
        return CacheConstants.LOGIN_TOKEN_KEY + uuid;
    }

4.4.6、刷新令牌有效期

1、参数loginUser是当前登录的用户信息。

2、设置loginUser的新的登录时间为当前时间。

3、重新计算过期时间为当前时间+过期时间(yml文件配置的expireTime)。

4、根据登录用户的token作为key,存储更新后的loginUser到Redis中,并设置过期时间。

5、这样就相当于刷新了token的过期时间。

关键代码:

    public void refreshToken(LoginUser loginUser)
    {
        loginUser.setLoginTime(System.currentTimeMillis());
        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
        // 根据uuid将loginUser缓存
        String userKey = getTokenKey(loginUser.getToken());
        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
    }

4.4.7、验证令牌有效期

验证令牌有效期,相差不足20分钟,自动刷新缓存。

关键代码:

    public void verifyToken(LoginUser loginUser)
    {
        long expireTime = loginUser.getExpireTime();
        long currentTime = System.currentTimeMillis();
        if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
        {
            refreshToken(loginUser);
        }
    }

4.4.8、获取用户身份信息

1、先从请求中获取JWT Token。

2、如果Token不为空,则对Token进行解析,获取claims。

3、从claims中取出对应的uuid。

4、根据uuid作为key,从Redis中获取LoginUser对象。

5、如果获取成功,返回LoginUser对象。

6、如果解析token或获取用户失败,则返回null。

关键代码:

    public LoginUser getLoginUser(HttpServletRequest request)
    {
        // 获取请求携带的令牌
        String token = getToken(request);
        if (StringUtils.isNotEmpty(token))
        {
            try
            {
                Claims claims = parseToken(token);
                // 解析对应的权限以及用户信息
                String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
                String userKey = getTokenKey(uuid);
                LoginUser user = redisCache.getCacheObject(userKey);
                return user;
            }
            catch (Exception e)
            {
            }
        }
        return null;
    }

4.4.9、删除用户身份信息

通过token获取用户在redis中的唯一标识进行清除用户的登录数据,下次走过滤器的时候就会因没有此用户信息进行限制访问了!

    /**
     * 删除用户身份信息
     */
    public void delLoginUser(String token)
    {
        if (StringUtils.isNotEmpty(token))
        {
            String userKey = getTokenKey(token);
            redisCache.deleteObject(userKey);
        }
    }

4.5、AuthenticationEntryPointImpl配置认证失败处理类

常量UNAUTHORIZED:401

AuthenticationEntryPoint是SpringSecurity中用于处理认证失败的接口,用于未登录或登录过期的情况,会触发commence方法。

1、在方法内部,首先设置了响应状态码为401 Unauthorized。

2、然后使用StringUtils生成了一个错误信息字符串,包含请求访问的接口路径和认证失败的提示。3、最后使用AjaxResult把状态码和错误信息封装成一个结果,通过ServletUtils以JSON格式写入响应中。

4、AjaxResult是一个封装AJAX请求结果的类,可以方便地生成错误或成功的响应结果。

5、ServletUtils是一个工具类,可以方便地将String数据渲染到HttpServletResponse中。

所以这个类的作用就是在认证失败时,以JSON格式返回一个包含错误代码和消息的结果到前端,前端可以根据这个结果显示对应提示或做处理。

关键代码:

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint
{
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException {
        int code = HttpStatus.UNAUTHORIZED;
        String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
    }
}

4.6、JwtAuthenticationTokenFilter认证过滤器

OncePerRequestFilter是SpringSecurity提供的一个过滤器基类,主要用于保证过滤器在一个请求内只执行一次,JwtAuthenticationTokenFilter需要继承这个基类并重写doFilterInternal的方法。

1、通过tokenService从请求头中提取JWT token,并解析得到LoginUser对象

2、调用tokenService的verifyToken方法验证JWT token的有效性

3、使用LoginUser对象构建一个UsernamePasswordAuthenticationToken

4、设置AuthenticationToken的细节,如请求来源等

5、将构造好的UsernamePasswordAuthenticationToken对象设置到SecurityContextHolder的Context中。

6、这样登录用户的Authentication对象就保存到了安全上下文中。

7、最后过滤器链继续向后执行doFilter方法。

这样在过滤器中就实现了对token的解析和验证,并设置了Authentication对象到安全上下文中,
后续的过滤器就可以依据它来判断用户认证信息了。

关键代码:

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{

    @Value("${token.header}")
    private String header;

    @Value("${token.secret}")
    private String tokenKey;

    @Resource
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException
    {

        LoginUser loginUser = tokenService.getLoginUser(request);
        if (loginUser != null)
        {
            tokenService.verifyToken(loginUser);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}

 注:如果你要获取当前登录的用户信息,可以通过以下2行代码进行获取。

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();

4.7、FastJson序列化

package com.example.security.config;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONReader;
import com.alibaba.fastjson2.JSONWriter;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;

import java.nio.charset.Charset;

/**
 * Redis使用FastJson序列化
 */
public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
{
    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    public FastJson2JsonRedisSerializer(Class<T> clazz)
    {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException
    {
        if (t == null)
        {
            return new byte[0];
        }
        return JSON.toJSONString(t, JSONWriter.Feature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException
    {
        if (bytes == null || bytes.length <= 0)
        {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);

        return JSON.parseObject(str, clazz, JSONReader.Feature.SupportAutoType);
    }
}

4.8、自定义Redis序列化 

注:详细讲解在这篇文章:SpringBoot整合RedisTemplate操作Redis数据库详解(提供Gitee源码) 

package com.example.security.config;

import com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * redis配置
 */
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport
{
    @Bean
    @SuppressWarnings(value = { "unchecked", "rawtypes" })
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory)
    {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);

        String[] acceptNames = {"org.springframework.security.core.authority.SimpleGrantedAuthority"};
        GenericFastJsonRedisSerializer serializer = new GenericFastJsonRedisSerializer(acceptNames);
        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);

        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }

}

4.9、Redis工具类

package com.example.redis.utils;
 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
 
import java.util.*;
import java.util.concurrent.TimeUnit;
 
/**
 * spring redis 工具类
 **/
@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCache
{
    @Autowired
    public RedisTemplate redisTemplate;
 
    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     */
    public <T> void setCacheObject(final String key, final T value)
    {
        redisTemplate.opsForValue().set(key, value);
    }
 
    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     * @param timeout 时间
     * @param timeUnit 时间颗粒度
     */
    public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
    {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }
 
    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout)
    {
        return expire(key, timeout, TimeUnit.SECONDS);
    }
 
    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @param unit 时间单位
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout, final TimeUnit unit)
    {
        return redisTemplate.expire(key, timeout, unit);
    }
 
    /**
     * 获取有效时间
     *
     * @param key Redis键
     * @return 有效时间
     */
    public long getExpire(final String key)
    {
        return redisTemplate.getExpire(key);
    }
 
    /**
     * 判断 key是否存在
     *
     * @param key 键
     * @return true 存在 false不存在
     */
    public Boolean hasKey(String key)
    {
        return redisTemplate.hasKey(key);
    }
 
    /**
     * 获得缓存的基本对象。
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T getCacheObject(final String key)
    {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }
 
    /**
     * 删除单个对象
     *
     * @param key
     */
    public boolean deleteObject(final String key)
    {
        return redisTemplate.delete(key);
    }
 
    /**
     * 删除集合对象
     *
     * @param collection 多个对象
     * @return
     */
    public boolean deleteObject(final Collection collection)
    {
        return redisTemplate.delete(collection) > 0;
    }
 
    /**
     * 缓存List数据
     *
     * @param key 缓存的键值
     * @param dataList 待缓存的List数据
     * @return 缓存的对象
     */
    public <T> long setCacheList(final String key, final List<T> dataList)
    {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }
 
    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public <T> List<T> getCacheList(final String key)
    {
        return redisTemplate.opsForList().range(key, 0, -1);
    }
 
    /**
     * 缓存Set
     *
     * @param key 缓存键值
     * @param dataSet 缓存的数据
     * @return 缓存数据的对象
     */
    public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
    {
        BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
        Iterator<T> it = dataSet.iterator();
        while (it.hasNext())
        {
            setOperation.add(it.next());
        }
        return setOperation;
    }
 
    /**
     * 获得缓存的set
     *
     * @param key
     * @return
     */
    public <T> Set<T> getCacheSet(final String key)
    {
        return redisTemplate.opsForSet().members(key);
    }
 
    /**
     * 缓存Map
     *
     * @param key
     * @param dataMap
     */
    public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
    {
        if (dataMap != null) {
            redisTemplate.opsForHash().putAll(key, dataMap);
        }
    }
 
    /**
     * 获得缓存的Map
     *
     * @param key
     * @return
     */
    public <T> Map<String, T> getCacheMap(final String key)
    {
        return redisTemplate.opsForHash().entries(key);
    }
 
    /**
     * 往Hash中存入数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @param value 值
     */
    public <T> void setCacheMapValue(final String key, final String hKey, final T value)
    {
        redisTemplate.opsForHash().put(key, hKey, value);
    }
 
    /**
     * 获取Hash中的数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @return Hash中的对象
     */
    public <T> T getCacheMapValue(final String key, final String hKey)
    {
        HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
        return opsForHash.get(key, hKey);
    }
 
    /**
     * 获取多个Hash中的数据
     *
     * @param key Redis键
     * @param hKeys Hash键集合
     * @return Hash对象集合
     */
    public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
    {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }
 
    /**
     * 删除Hash中的某条数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @return 是否成功
     */
    public boolean deleteCacheMapValue(final String key, final String hKey)
    {
        return redisTemplate.opsForHash().delete(key, hKey) > 0;
    }
 
    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    public Collection<String> keys(final String pattern)
    {
        return redisTemplate.keys(pattern);
    }
 
    /**
     * 存储有序集合
     * @param key 键
     * @param value 值
     * @param score 排序
     */
    public void zSet(Object key, Object value, double score){
        redisTemplate.opsForZSet().add(key, value, score);
    }
 
    /**
     * 存储值
     * @param key 键
     * @param set 集合
     */
    public void zSet(Object key, Set set){
        redisTemplate.opsForZSet().add(key, set);
    }
 
    /**
     * 获取key指定范围的值
     * @param key 键
     * @param start 开始位置
     * @param end 结束位置
     * @return 返回set
     */
    public Set zGet(Object key, long start, long end){
        Set set = redisTemplate.opsForZSet().range(key, start, end);
        return set;
    }
 
    /**
     * 获取key对应的所有值
     * @param key 键
     * @return 返回set
     */
    public Set zGet(Object key){
        Set set = redisTemplate.opsForZSet().range(key, 0, -1);
        return set;
    }
 
    /**
     * 获取对用数据的大小
     * @param key 键
     * @return 键值大小
     */
    public long zGetSize(Object key){
        Long size = redisTemplate.opsForZSet().size(key);
        return size;
    }
}

4.10、SecurityConfig核心配置类

这是核心代码,注释都在代码上面了,可以自己仔细看一下,这边就不多做阐述。

完整代码:

package com.example.security.config;

import com.example.security.filter.JwtAuthenticationTokenFilter;
import com.example.security.service.serviceImpl.AuthenticationEntryPointImpl;
import com.example.security.service.serviceImpl.LogoutSuccessHandlerImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.web.filter.CorsFilter;

import javax.annotation.Resource;

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private UserDetailsService userDetailsService;

    /**
     * token认证过滤器
     */
    @Resource
    private JwtAuthenticationTokenFilter authenticationTokenFilter;

    /**
     * 认证失败处理类
     */
    @Resource
    private AuthenticationEntryPointImpl unauthorizedHandler;

    /**
     * 退出处理类
     */
    @Resource
    private LogoutSuccessHandlerImpl logoutSuccessHandler;

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception
    {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // CSRF禁用,因为不使用session
                .csrf().disable()
                // 认证失败处理类
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 过滤请求
                .authorizeRequests()
                //允许登录接口匿名访问
                .antMatchers("/login").permitAll()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        // 添加JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 添加Logout filter
        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
    }

    /**
     * 强散列哈希加密实现
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder()
    {
        return new BCryptPasswordEncoder();
    }


    /**
     * 身份认证接口
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception
    {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }
}

4.11、AuthenticationContextHolder线程本地的存储

主要提供了以下几个静态方法:

1、getContext()获取当前线程的Authentication对象。

2、setContext(Authentication context)设置当前线程的Authentication对象。

3、clearContext()清除当前线程的Authentication对象。

它使用ThreadLocal维护线程隔离,所以每个线程拥有自己的Authentication信息,互不干扰。

在Spring Security中,可以通过该类在不同层传递认证信息。

关键代码:

public class AuthenticationContextHolder
{
    private static final ThreadLocal<Authentication> contextHolder = new ThreadLocal<>();

    public static Authentication getContext()
    {
        return contextHolder.get();
    }

    public static void setContext(Authentication context)
    {
        contextHolder.set(context);
    }

    public static void clearContext()
    {
        contextHolder.remove();
    }
}

4.12、UserServiceImpl查询用户接口

这边我偷懒了没有连接数据库,这个密码是通过如下代码加密获得的。

关键代码:

PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode("123456");
System.out.println(encode);

主要是为了演示,所以这边直接把用户信息和角色信息写死了,实际开发还是需要连接数据库的。

关键代码:

@Service
public class UserServiceImpl {

    public User selectUserByUsername(String username){
        User user = new User();
        user.setId(UUID.randomUUID().toString());
        user.setUsername(username);
        user.setPassword("$2a$10$ErrO7WgkEBAWVQwuJtbBve7R2.pSKUrfs7zt8XkASqJKqcetMvAUC");
        Set<Role> roles = new HashSet<>();
        Role role1 = new Role(1L, "ROLE_ADMIN");
        Role role2 = new Role(2L, "ROLE_USER");
        roles.add(role1);
        roles.add(role2);
        user.setRoles(roles);
        return user;
    }
}

这边用户必须以ROLE_作为前缀,具体分析看3.15的@PreAuthorize注解分析

4.13、PasswordServiceImpl密码验证服务类 

1、validate()方法用来验证用户密码。

2、它先从AuthenticationContextHolder中获取当前认证的用户名和密码。

3、然后调用matches()方法来校验密码。

4、matches()方法使用BCryptPasswordEncoder对存储的密文密码进行匹配验证。

5、如果匹配成功则验证成功,失败则验证失败。

这样通过Spring Security的AuthenticationContextHolder可以获取到当前认证principal的信息。

再结合密码加密匹配验证,就可以在服务中方便的实现密码的验证。

关键代码:

@Service
public class PasswordServiceImpl {

    public void validate(User user)
    {
        Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();
        String AuthUsername = usernamePasswordAuthenticationToken.getName();
        String AuthPassword = usernamePasswordAuthenticationToken.getCredentials().toString();

        if (matches(user, AuthPassword)) {
            System.out.println("验证成功!");
        } else {
            System.out.println("验证失败!");
        }
    }

    public boolean matches(User user, String rawPassword)
    {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return passwordEncoder.matches(rawPassword, user.getPassword());
    }
}

4.14、UserDetailsServiceImpl认证用户服务类

1、实现Spring Security的UserDetailsService接口。

UserDetailsService是Spring Security用于加载用户信息的核心接口。自定义实现可以灵活控制用户信息加载过程。

2、根据用户名加载用户信息通过userService查询数据库获取用户对象,包含用户信息如用户名、密码、角色等。

3、验证用户密码使用passwordService进行密码验证,校验登录的密码是否正确。

4、构建用户权限信息将用户的角色信息转换成GrantedAuthority授权信息集合。

5、封装用户对象返回将用户信息、权限信息封装到LoginUser对象中返回作为UserDetails。

6、在登录验证时提供用户详细信息Spring Security在登录验证时会调用此服务获取用户详细信息,以进行认证和授权。

关键代码:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private PasswordServiceImpl passwordService;

    @Resource
    private UserServiceImpl userService;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userService.selectUserByUsername(username);
        passwordService.validate(user);
        //取出角色和权限信息
        Set<Role> roles = user.getRoles();

        Set<GrantedAuthority> authorities = new HashSet<>();
        for (Role role : roles) {
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        }
        return new LoginUser(user,authorities);
    }

}

4.15、LoginController登录接口

所有的登录都必须走这个接口,登录成功以后会返回给用户一个令牌,以访问系统受保护的资源。

关键代码: 

@RestController
public class LoginController {

    @Resource
    private LoginServiceImpl loginService;

    @PostMapping("/login")
    public AjaxResult login(@RequestBody LoginBody loginBody)
    {
        AjaxResult ajax = AjaxResult.success();
        // 生成令牌
        String token = loginService.login(loginBody.getUsername(), loginBody.getPassword());
        ajax.put(Constants.TOKEN, token);
        return ajax;
    }


}

4.16、LoginServiceImpl登录接口核心逻辑

1、创建UsernamePasswordAuthenticationToken,包含用户名和密码。

2、将该authenticationToken设置到AuthenticationContextHolder,这是Spring Security提供的一个存储authentication的holder。

3、调用AuthenticationManager的authenticate方法进行认证。这个方法会根据配置调用相关的UserDetailsService等进行认证。(下面会走认证用户信息服务类中的loadUserByUsername方法),验证成功以后会返回一个authentication对象。

4、清空AuthenticationContextHolder。

5、从authentication对象中获取登录用户信息LoginUser。

6、使用TokenService生成JWT token。

7、返回JWT token。

关键代码:

@Service
public class LoginServiceImpl {

    @Resource
    private AuthenticationManager authenticationManager;

    @Resource
    private TokenService tokenService;

    public String login(String username, String password)
    {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        AuthenticationContextHolder.setContext(authenticationToken);
        Authentication authentication = authenticationManager.authenticate(authenticationToken);
        AuthenticationContextHolder.clearContext();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        return tokenService.createToken(loginUser);
    }

}

4.17、LogoutSuccessHandlerImpl退出登录核心逻辑 

LogoutSuccessHandlerImpl实现了Spring Security的LogoutSuccessHandler接口,用于处理用户退出登录成功后的逻辑。

1、通过TokenService获取当前登录的用户信息LoginUser。

2、如果LoginUser不为空,则调用TokenService的delLoginUser方法删除该用户的缓存信息。这里涉及到一个自定义的TokenService,用于处理基于令牌的用户认证。登录时生成一个令牌与用户信息对应,退出时需要删除这个缓存关系。

3、使用ServletUtils向响应写入退出成功的提示,这样ajax请求可以获取到提示信息。

关键代码:

/**
 * 退出登录
 */
@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
{
    @Autowired
    private TokenService tokenService;

    /**
     * 退出处理
     * @return
     */
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
    {
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser))
        {
            // 删除用户缓存记录
            tokenService.delLoginUser(loginUser.getToken());
        }
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success("退出成功")));
    }
}

4.18、@PreAuthorize注解

这个注解是打在接口上的,用于标记该接口只有指定的用户才有权限访问。

1、在接口上使用@PreAuthorize("hasRole('USER')")这个注解时候,内部源码执行的流程我可以给大家剖析一下。

首先获取上面我们配置好的接口参数为USER

进入hasAnyAuthorityName方法,获取当前用户的所有权限(roleSet集合),然后用for循环去遍历注解上的参数数组,这边只循环了一次,因为注解上只配了一个USER,所以数组长度为1,如果当前用户的所有权限集合中包含当前接口的权限信息则放行,否则不放行。 

3、getRoleWithDefaultPrefix方法是用来检测当前传递进来的role是否以默认的ROLE_为前缀,如果有则直接返回,没有的话再拼上ROLE_的默认前缀进行返回。

这就是这个注解的验证过程,以此也解释了3.9中为什么用户权限必须以ROLE_为前缀打头的原因。

4.19、HelloController测试接口

分别写个2个测试接口,一个是登陆后谁都可以访问的接口,一个是登陆后需要USER权限的用户才可以访问的接口,还有一个是登录后需要COMMON权限才可以访问的接口。

关键代码:

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello(){
        return "Hello World!";
    }

    @GetMapping("/user")
    @PreAuthorize("hasRole('USER')")
    public String user(){
        return "Hello USER!";
    }

    @GetMapping("/common")
    @PreAuthorize("hasRole('COMMON')")
    public String common(){
        return "Hello COMMON!";
    }
}

注意:必须用public方法修饰,否则@PreAuthorize注解失效!

4.20、总结

最后我们通过实际的项目来总结一下本次SpringSecurity的认证流程:

1、在LoginService的login方法中,构造一个UsernamePasswordAuthenticationToken,包含用户名和密码,这是开始认证的入口。

2、LoginService调用AuthenticationManager的authenticate方法启动认证流程。

3、AuthenticationManager会找到一个匹配的AuthenticationProvider来进行认证。

4、AuthenticationProvider会调用UserDetailsService的loadUserByUsername方法加载用户信息。这里我们通过UserDetailsServiceImpl来查询用户。

5、在UserDetailsServiceImpl中,根据用户名查询用户信息,然后调用PasswordService进行密码验证。

6、PasswordService通过AuthenticationContextHolder获取登录的用户名和密码。然后与数据库中存储的用户密码(经过编码)进行匹配,如果匹配上就验证成功。

7、PasswordService验证成功后,UserDetailsServiceImpl将根据用户信息构造一个UserDetails对象(这里是LoginUser),包含了用户名,密码,权限信息等。

8、UserDetailsServiceImpl将UserDetails返回给AuthenticationProvider。

9、AuthenticationProvider收到UserDetails后,完成验证,并生成一个已认证的Authentication对象。

10、AuthenticationProvider将Authentication返回给AuthenticationManager。

11、AuthenticationManager设置该Authentication到SecurityContextHolder中,供后续访问控制使用。

12、LoginService拿到已认证的Authentication,从中取出UserDetails,生成JWTtoken并返回。

综上,结合项目的逻辑SpringSecurity的认证流程大体可以分为:获取用户信息->用户验证->构建UserDetails->生成Authentication。我们通过自定义UserDetailsService和PasswordService来实现了用户验证逻辑。 

五、运行项目 

5.1、登录成功

通过post请求发送json格式的数据进行登录。

登录成功了并返回了Token令牌!

5.2、访问无权限接口

把刚才获得的令牌设置到请求头当中,进行访问测试接口。

5.3、访问需要USER权限的接口

把hello改成user。

因为这个用户默认配置了ADMIN和USER两个权限,所以可以访问成功! 

5.4、访问需要COMMON权限的接口

把user改成common。

很明显,返回了403的错误信息。 

5.5、退出登录

将common改成logout。

退出登录成功。

5.6、访问失败 

上面退出登录了,看看还能不能通过携带之前的token访问测试接口。

可以很明显的看到,系统返回了401的错误。 

5.7、登录失败

上面我们都是登录成功的例子,我们这次故意输错密码,看看能不能登录成功。

可以很明显的看到,系统返回了401的错误。 

六、Gitee源码地址

因为本篇博客提供的代码不是完整的,所以我把完整的项目开源到了码云上,供大家学习和参考!

项目地址:SpringBoot整合SpringSecurity+JWT+Redis完整教程

七、总结

以上就是我对于SpringSecurity以及如何在实际项目当中开发应用的个人理解,如有问题欢迎评论区留言!

猜你喜欢

转载自blog.csdn.net/HJW_233/article/details/131969622