我的思路是,登录时使用用户凭证换取Token,Token存储在Redis中,每次请求验证Token与Redis中是否相同并续签,Redis控制Token过期时间。步骤如下:

添加依赖

<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <!--身份认证-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!--Redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>   
    </dependencies>

创建用户类与角色类

用户类应该实现UserDetails接口以契合SpringSecurity

//实现接口时自带方法,注意各个方法的意义,自行修改,默认为True
@Data
public class User implements UserDetails {
    private Integer id;
    private String phone;
    private Boolean enabled;
    private String username;
    private String password;
    private Integer role;
    private List<Role> roles;
    
	//JsonIgnore在接口传递时隐藏敏感信息
    @JsonIgnore
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        for (Role role:roles)
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        return authorities;
    }

    @JsonIgnore
    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

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

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

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

    @Override
    public boolean isEnabled() {
        return enabled;
    }
}

在实际开发中会设置用户角色来控制接口访问,角色类的角色名要求有前缀ROLE_,例如ADMIN,则时ROLE_ADMIN

@Data
public class Role implements GrantedAuthority {
    private Integer id;
    private String roleName;

    @JsonIgnore
    @Override
    public String getAuthority() {
        return roleName;
    }
}

Redis配置类

先设置常量,例如过期时间。

public final class ConstantKit {
    public static final Integer DEL_FLAG_TRUE=1;
    public static final Integer DEL_FLAG_FALSE=0;
    /**
     * redis存储token设置的过期时间
     * 单位:秒(1h)
     */
    public static final Long TOKEN_EXPIRE_TIME= Long.valueOf(60*60);

    /**
     * 设置可以重置token过期时间的时间界限
     * 单位:毫秒(30min)
     */
    public static final Long TOKEN_RESET_TIME= Long.valueOf(1000*30*60);
}

创建与属性文件映射的配置类。

@Data
@Configuration
@ConfigurationProperties(prefix = "spring.redis")
public class RedisConfigProperty {
    private String host;
    private String password;
    private int port;
    private int database;
    private int timeout;
}

对Jedis进行配置。

public class JedisConfig extends CachingConfigurerSupport {
    private static final Logger LOGGER = LoggerFactory.getLogger(JedisConfig.class);

    @Resource
    private RedisConfigProperty redisConfigProperty;

    @Bean(name = "jedisPoolConfig")
    @ConfigurationProperties(prefix = "spring.redis.pool-config")
    public JedisPoolConfig getRedisConfig(){
        return new JedisPoolConfig();
    }

    @Bean(name = "jedisPool")
    public JedisPool jedisPool(@Qualifier(value = "jedisPoolConfig") final JedisPoolConfig jedisPoolConfig){
        LOGGER.info("Jedis Pool build start");
        String host = redisConfigProperty.getHost();
        int timeout = redisConfigProperty.getTimeout();
        String password = redisConfigProperty.getPassword();
        int database = redisConfigProperty.getDatabase();
        int port = redisConfigProperty.getPort();
        JedisPool jedisPool = new JedisPool(jedisPoolConfig,host,port,timeout,password,database);
        LOGGER.info("Jedis Pool build success host={},port={}",host,port);
        return jedisPool;
    }
}

JWT生成验证

JWT工具类。

public class JwtUtils {

	//PayLoad密钥
    private static final String JWT_PAYLOAD_USER_KEY = "CSDN";

    /**
     * 生成Token并设置过期时间(过期时间不在此处实现,在Redis中实现)
     * @param userInfo
     * @param privateKey
     * @param expire
     * @return
     */
    public static String generateTokenExpireInMillis(Object userInfo, PrivateKey privateKey,long expire){
        return Jwts.builder()
                .claim(JWT_PAYLOAD_USER_KEY,JsonUtils.toString(userInfo))
                .setId(createJTI())
//                .setExpiration(new Date(System.currentTimeMillis()+expire))//毫秒
                .signWith(SignatureAlgorithm.RS256, privateKey)
                .compact();

    }

    /**
     * Token解密
     * @param token
     * @param publicKey
     * @return
     */
    private static Jws<Claims> parserToken(String token, PublicKey publicKey){
        return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token);
    }

    /**
     * 从Token中获取个人信息
     * @param token
     * @param publicKey
     * @param userType
     * @return
     * @param <T>
     */
    public static <T>Payload<T> getInfoFromToken(String token,PublicKey publicKey,Class<T> userType){
        Jws<Claims> claimsJws = parserToken(token,publicKey);
        Claims body = claimsJws.getBody();
        Payload<T> claims = new Payload<>();
        claims.setId(body.getId());
        claims.setUserinfo(JsonUtils.toBean(body.get(JWT_PAYLOAD_USER_KEY).toString(),userType));
        return claims;
    }

    /**
     * 生成ID
     * @return
     */
    private static String createJTI(){
        return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8)));
    }
}

RSA密钥工具类,用来对Token再加密解密。

public class RsaUtils {
    private static final int DEFAULT_KEY_SIZE = 2048;

    /**
     * 从文件中读取公钥
     * @param fileName
     * @return
     * @throws IOException
     * @throws NoSuchAlgorithmException
     * @throws InvalidKeySpecException
     */
    public static PublicKey getPublicKey(String fileName) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
        byte[] bytes = readFile(fileName);
        return getPublicKey(bytes);
    }

    /**
     * 从文件中读取私钥
     * @param fileName
     * @return
     * @throws IOException
     * @throws NoSuchAlgorithmException
     * @throws InvalidKeySpecException
     */
    public static PrivateKey getPrivateKey(String  fileName) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
        byte[] bytes = readFile(fileName);
        return getPrivateKey(bytes);
    }

    /**
     * 从字节数组中读取公钥
     * @param bytes
     * @return
     * @throws NoSuchAlgorithmException
     * @throws InvalidKeySpecException
     */
    private static PublicKey getPublicKey(byte[] bytes) throws NoSuchAlgorithmException, InvalidKeySpecException {
        bytes = Base64.getDecoder().decode(bytes);
        X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePublic(spec);
    }

    /**
     * 从字节数组中读取私钥
     * @param bytes
     * @return
     * @throws NoSuchAlgorithmException
     * @throws InvalidKeySpecException
     */
    private static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException, InvalidKeySpecException {
        bytes = Base64.getMimeDecoder().decode(bytes);
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePrivate(spec);
    }

    /**
     * 生成密钥文件
     * @param publicKeyFileName
     * @param privateKeyFileName
     * @param secret
     * @param keySize
     * @throws IOException
     * @throws NoSuchAlgorithmException
     */
    public static void generateKey(String publicKeyFileName,String privateKeyFileName,String secret,int keySize) throws IOException, NoSuchAlgorithmException {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        SecureRandom secureRandom = new SecureRandom(secret.getBytes(StandardCharsets.UTF_8));
        keyPairGenerator.initialize(Math.max(keySize,DEFAULT_KEY_SIZE),secureRandom);
        KeyPair keyPair = keyPairGenerator.genKeyPair();
        byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
        publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes);
        writeFile(publicKeyFileName,publicKeyBytes);
        byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
        privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes);
        writeFile(privateKeyFileName,privateKeyBytes);
    }

    /**
     * 读文件
     * @param fileName
     * @return
     * @throws IOException
     */
    private static byte[] readFile(String fileName) throws IOException {
        return Files.readAllBytes(new File(fileName).toPath());
    }

    /**
     * 写文件
     * @param destPath
     * @param bytes
     * @throws IOException
     */
    private static void writeFile(String destPath, byte[] bytes) throws IOException {
        File dest = new File(destPath);
        if (!dest.exists())
            dest.createNewFile();
        Files.write(dest.toPath(),bytes);
    }
}

RSA配置类。

Data
@ConfigurationProperties("rsa.key")
public class RsaKeyProperties {
    private String publicKeyPath;
    private String privateKeyPath;
    private PublicKey publicKey;
    private PrivateKey privateKey;

    @PostConstruct
    public void createKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
        this.publicKey = RsaUtils.getPublicKey(publicKeyPath);
        this.privateKey = RsaUtils.getPrivateKey(privateKeyPath);
    }
}

配置文件

在resources文件夹下编辑application.properties

server.port=8181

# rsa
rsa.key.publicKeyPath=公钥地址
rsa.key.privateKeyPath=私钥地址

#redis 基础配置
# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接密码(默认为空)
spring.redis.password=redis
# Redis数据库索引(默认为0)
spring.redis.database=0
# Redis服务器连接端口
spring.redis.port=6379
# 连接超时时间(毫秒)
spring.redis.timeout=5000

#redis 连接池配置
#池中最大链接数
spring.redis.pool-config.max-total=256
# 连接池中的最大空闲连接
spring.redis.pool-config.max-idle=128
# 连接池中的最小空闲连接
spring.redis.pool-config.min-idle=8
# 调用者获取链接时,是否检测当前链接有效性
spring.redis.pool-config.test-on-borrow=false
# 向链接池中归还链接时,是否检测链接有效性
spring.redis.pool-config.test-on-return=false
# 调用者获取链接时,是否检测空闲超时, 如果超时,则会被移除-
spring.redis.pool-config.test-while-idle=true
# 空闲链接检测线程一次运行检测多少条链接
spring.redis.pool-config.num-tests-per-eviction-run=8

# 缓存
spring.cache.cache-names=c1,c2
spring.cache.redis.time-to-live=1800s

创建认证失败类

当用户没有有效凭证时,会用此类进行处理。

public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException{
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        PrintWriter out = response.getWriter();
        Map<String,Object> resultData = new HashMap<>();
        resultData.put("code","401");
        resultData.put("msg", "请登录!");
        out.write(new ObjectMapper().writeValueAsString(resultData));
        out.flush();
        out.close();
    }
}

创建权限不足类

当用户试图访问未经授权的接口时,会到这里处理。

public class UserAuthAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        PrintWriter out = response.getWriter();
        Map<String,Object> resultData = new HashMap<>();
        resultData.put("code","403");
        resultData.put("msg", "未授权");
        out.write(new ObjectMapper().writeValueAsString(resultData));
        out.flush();
        out.close();
    }
}

创建过滤器

这里创建两个过滤器,一个用于登录颁发token,一个用于续签鉴定token。

public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {;
    private AuthenticationManager authenticationManager;
    private RsaKeyProperties rsaKeyProperties;
    private JedisPool jedisPool;
    public JwtLoginFilter(AuthenticationManager authenticationManager, RsaKeyProperties rsaKeyProperties,
                          JedisPool jedisPool){
        this.authenticationManager = authenticationManager;
        this.rsaKeyProperties = rsaKeyProperties;
        this.jedisPool = jedisPool;
    }

	//首先执行此函数,如果与数据库中比对不上,则会抛出异常
	//为了防止账号枚举,这里只显示账号或密码错误
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        User user = null;
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            user = objectMapper.readValue(request.getInputStream(),User.class);
            return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(
                            user.getUsername(),
                            user.userPassword()
                    )
            );
        } catch (Exception e) {
            try {
                response.setContentType("application/json;charset=utf-8");
                response.setStatus(HttpServletResponse.SC_OK);
                PrintWriter out = response.getWriter();
                Map<String, Object> map = new HashMap<>();
                map.put("code", HttpServletResponse.SC_UNAUTHORIZED);
                map.put("message", "账号或密码错误!");
                out.write(new ObjectMapper().writeValueAsString(map));
                out.flush();
                out.close();
                e.printStackTrace();
            }catch (Exception exception){
                exception.printStackTrace();
            }
        }
        return null;
    }

	//登录成功
    @Override
    public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                         Authentication authentication){
        User user = new User();
        user.setUsername(authentication.getName());
        user.setRoles((List<Role>) authentication.getAuthorities());
        String token = JwtUtils.generateTokenExpireInMillis(user,rsaKeyProperties.getPrivateKey(),10*60*1000);
        Jedis jedis = jedisPool.getResource();
        response.addHeader("Authorization","AttackToken "+token);
        String oldToken = jedis.get(user.getUsername());
        if (oldToken!=null)
            jedis.del(oldToken,oldToken+user.getUsername());
        jedis.set(user.getUsername(),token);
        jedis.expire(user.getUsername(), ConstantKit.TOKEN_EXPIRE_TIME);
        jedis.set(token,user.getUsername());
        jedis.expire(token,ConstantKit.TOKEN_EXPIRE_TIME);
        Long currentTime = System.currentTimeMillis();
        jedis.set(token+user.getUsername(),currentTime.toString());
        jedis.close();
        try {
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpServletResponse.SC_OK);
            PrintWriter out = response.getWriter();
            Map<String,Object> map = new HashMap<>(4);
            map.put("code",HttpServletResponse.SC_OK);
            map.put("message","登陆成功!");
            out.write(new ObjectMapper().writeValueAsString(map));
            out.flush();
            out.close();
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

请求过滤类,对每一个请求的Token进行验证与续签。

public class JwtVerifyFilter extends BasicAuthenticationFilter {
    private RsaKeyProperties rsaKeyProperties;
    private JedisPool jedisPool;

    public JwtVerifyFilter(AuthenticationManager authenticationManager,RsaKeyProperties rsaKeyProperties,
                           JedisPool jedisPool) {
        super(authenticationManager);
        this.rsaKeyProperties = rsaKeyProperties;
        this.jedisPool = jedisPool;
    }

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String header = request.getHeader("Authorization");
        if (header == null||!header.startsWith("AttackToken ")){
            //不应该在此处抛出异常,抛出异常会重定向至/error,但/error并未放通
            //故会出现只要是出现了异常,如404等都会被AuthenticationEntryPoint捕获从而返回401
//            throw new AccessDeniedException("未登录");
            chain.doFilter(request,response);
            return;
        }
        String token = header.replace("AttackToken ","");
        User user = JwtUtils.getInfoFromToken(token, rsaKeyProperties.getPublicKey(),User.class).getUserinfo();
        Jedis jedis = jedisPool.getResource();
        request.setAttribute("requestUser",user.getUsername());
        if (jedis.get(token) == null)
            throw new AccessDeniedException("登录凭证已废弃");
        if (user!=null){
            Authentication authentication = new UsernamePasswordAuthenticationToken
                    (user.getUsername(),null,user.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);
            Long tokenBirthTime = Long.valueOf(jedis.get(token+user.getUsername()));
            logger.info("token birth time is:"+tokenBirthTime);
            Long diff = System.currentTimeMillis()-tokenBirthTime;//时间差
            logger.info("token has existed for:"+diff);
            if (jedis.get(user.getUsername())==null)
                throw new AccessDeniedException("登录过期");
            if (diff> ConstantKit.TOKEN_RESET_TIME){
                jedis.expire(user.getUsername(), ConstantKit.TOKEN_EXPIRE_TIME);
                jedis.expire(token,ConstantKit.TOKEN_EXPIRE_TIME);
                logger.info("Reset expire time success");
                Long newBirthTime = System.currentTimeMillis();
                jedis.set(token+user.getUsername(),newBirthTime.toString());
            }
            jedis.close();
            chain.doFilter(request,response);
        }else {
            throw new AccessDeniedException("未登录");
        }
    }
}

自定义UserDetailsService

UsernamePasswordAuthenticationToken以及DaoAuthenticationProvider使用UserDetailsService来查询用户名、密码和GrantedAuthority,检查用户输入的密码是否匹配。

@Service
public class UserService implements UserDetailsService {
    @Autowired
    UserMapper userMapper;
    @Autowired
    RoleMapper roleMapper;

    @Override
    public UserDetails loadUserByUsername(String username) {
        QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
        userQueryWrapper.eq("username", username);
        if (userMapper.selectList(userQueryWrapper).isEmpty()) {
            throw new UsernameNotFoundException("用户名不存在");
        }
        User user = userMapper.selectList(userQueryWrapper).get(0);
        //将角色赋予用户
        Role role = roleMapper.selectById(user.getRole());
        List<Role> roles = new ArrayList<>();
        roles.add(roles);
        user.setRoles(roles);
        return user;
    }
}

Spring Security配置类

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final UserService userService;
    private final RsaKeyProperties rsaKeyProperties;
    private final JedisPool jedisPool;

    public SecurityConfig(UserServiceImpl userServiceImpl, RsaKeyProperties rsaKeyProperties, JedisPool jedisPool) {
        this.userServiceImpl = userServiceImpl;
        this.rsaKeyProperties = rsaKeyProperties;
        this.jedisPool = jedisPool;
    }

    private String[] loadExcludePath() {
        return new String[]{
                "/static/**",
                "/templates/**",
                "/img/**",
                "/js/**",
                "/css/**",
                "/lib/**"
        };
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    AuthenticationManager authenticationManager(HttpSecurity httpSecurity) throws Exception {
        AuthenticationManager authenticationManager = httpSecurity.getSharedObject(AuthenticationManagerBuilder.class)
                .userDetailsService(userServiceImpl)
                .passwordEncoder(passwordEncoder())
                .and()
                .build();
        return authenticationManager;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(AuthenticationManager authenticationManager, HttpSecurity httpSecurity) throws Exception {
        httpSecurity.csrf().disable()
                .authorizeRequests()
                //放通所有静态资源
                .antMatchers(loadExcludePath()).permitAll()
                //放通注册
                .antMatchers(HttpMethod.POST,"/user/add").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/logger/**").hasAnyRole("ADMIN","LOGGER")
                .antMatchers("/user/**").hasAnyRole("ADMIN","LOGGER","USER")
                //其余请求都需要认证后访问
                .anyRequest()
                .authenticated()
                .and()
                .addFilter(new JwtLoginFilter(authenticationManager,rsaKeyProperties, jedisPool))
                .addFilter(new JwtVerifyFilter(authenticationManager, rsaKeyProperties,jedisPool))
                //已认证但是权限不够
                .exceptionHandling().accessDeniedHandler(new UserAuthAccessDeniedHandler())
                .and()
                //未能通过认证,也就是未登录
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);//禁用session
        return httpSecurity.build();
    }
}

注意事项

  1. 在数据库中的角色名应该为ROLE_ADMIN,ROLE_LOGGER,ROLE_USER。
  2. 本文对密码进行了加盐加密处理,请先对密码加密后再存储到数据库,才能比对成功,加密示例如下
String password = "password";
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String encodedPassword = bCryptPasswordEncoder.encode(password);
System.out.println("encodedPassword");
  1. 本文省略了诸多细节,例如Redis、MyBatisPlus等,无法直接使用。
Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐