面向微服务的 Spring Cloud Gateway 的集成解决方案:用户登录认证与访问控制

发布于:2025-02-11 ⋅ 阅读:(50) ⋅ 点赞:(0)

🎯导读:本文档详细描述了一个基于Spring Cloud Gateway的微服务网关及Admin服务的实现。网关通过定义路由规则,利用负载均衡将请求转发至不同的后端服务,并集成了Token验证过滤器以确保API的安全访问,同时支持白名单路径免验证。Admin服务负责用户管理,包括注册、登录、登出等功能,采用布隆过滤器优化用户名存在性检查,使用Redis存储会话信息并结合JWT进行身份验证。此外,文档还介绍了ShardingSphere的数据分片与加密配置,以及用户上下文在请求链路中的传递机制,确保了跨服务调用时用户信息的一致性和安全性。
🏠️ HelloDam/场快订(场馆预定 SaaS 平台)

工具类

Jwt工具

package com.vrs.utils;


import io.jsonwebtoken.*;
import org.springframework.util.StringUtils;

import java.util.Date;

/**
 * 生成JSON Web Token的工具类
 */
public class JwtUtil {

    /**
     * JWT的默认过期时间,单位为毫秒。这里设定为30天
     */
    private static final long tokenExpiration = 30L * 24L * 60L * 60L * 1000L;
    /**
     * 在实际应用中,应使用随机生成的字符串
     */
    private static String tokenSignKey = "dsahdashoiduasguiewu23114";

    /**
     * 从给定的JWT令牌中提取指定参数名对应的值。
     *
     * @param token     需要解析的JWT令牌字符串
     * @param paramName 要提取的参数名
     * @return 参数值(字符串形式),如果令牌为空、解析失败或参数不存在,则返回null
     */
    public static String getParam(String token, String paramName) {
        try {
            if (StringUtils.isEmpty(token)) {
                return null;
            }
            // 使用提供的密钥解析并验证JWT
            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            // 获取JWT的有效载荷(claims),其中包含了所有声明(参数)
            Claims claims = claimsJws.getBody();
            // 提取指定参数名对应的值
            Object param = claims.get(paramName);
            // 如果参数值为空,则返回null;否则将其转换为字符串并返回
            return param == null ? null : param.toString();
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("token过期了,需要重新登录");
        }
    }

    /**
     * 根据用户信息生成一个新的JWT令牌。
     *
     * @param userId
     * @param username
     * @return
     */
    public static String createToken(Long userId, String username, int userType) {
        // 使用Jwts.builder()构建JWT
        Date expiration = new Date(System.currentTimeMillis() + tokenExpiration);
        String token = Jwts.builder()
                // 设置JWT的主题(subject),此处为常量"AUTH-USER"
                .setSubject("AUTH-USER")
                // 设置过期时间,当前时间加上预设的过期时间(tokenExpiration)
                .setExpiration(expiration)
                // 有效载荷
                .claim("userId", userId)
                .claim("userName", username)
                .claim("userType", userType)
                // 使用HS512算法和指定密钥对JWT进行加密
                .signWith(SignatureAlgorithm.HS512, tokenSignKey)
                // 使用GZIP压缩算法压缩JWT字符串,将字符串变成一行来显示
                .compressWith(CompressionCodecs.GZIP)
                // 完成构建并生成紧凑格式的JWT字符串
                .compact();
        return token;
    }

    public static String getUserId(String token) {
        return getParam(token, "userId");
    }

    public static String getUsername(String token) {
        return getParam(token, "userName");
    }

    public static int getUserType(String token) {
        return Integer.parseInt(getParam(token, "userType"));
    }

}

网关服务

依赖

<dependency>
    <groupId>com.vrs</groupId>
    <artifactId>vrs-common</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-loadbalancer</artifactId>
</dependency>

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
    <groupId>com.alibaba.fastjson2</groupId>
    <artifactId>fastjson2</artifactId>
</dependency>

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>

common

网关访问状态码

package com.vrs.common;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @Author dam
 * @create 2024/11/16 18:15
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GatewayResult {
    /**
     * HTTP 状态码
     */
    private Integer status;

    /**
     * 返回信息
     */
    private String message;
}

获取白名单url

package com.vrs.common;

import lombok.Data;

import java.util.List;

/**
 * @Author dam
 * @create 2024/11/16 18:13
 */
@Data
public class WhitePathConfig {
    /**
     * 白名单前置路径
     */
    private List<String> whitePathList;
}

token校验过滤器

该过滤器的作用是:

  1. 路径白名单检查:
    1. 在处理请求之前,它会检查请求的 URL 路径是否在配置的白名单路径列表中。如果请求路径匹配白名单中的任意路径,则直接放行,不需要进行后续的令牌验证。
  2. JWT 验证:
    1. 对于不在白名单中的请求路径,它会从请求头中提取名为 token 的 JWT。
    2. 使用 JwtUtil.getUsername(token) 方法从 JWT 中解析出用户名(假设 JWT 中包含用户名信息)。
    3. 如果用户名和令牌都存在,它会尝试从 Redis 缓存中查找与用户名和令牌对应的用户信息。
  3. 用户信息添加到请求头:
    1. 如果用户信息存在于 Redis 中,它会将用户的 ID、类型和名称等信息添加到原始请求头中。这样做的目的是为了简化下游服务的逻辑,使得它们可以直接从请求头中获取用户信息,而无需再次查询数据库或缓存。
  4. 错误响应:
    1. 如果用户名为空,或者提供的令牌无效(即不存在于 Redis 中),则返回 401 未授权状态码,并附带一条消息提示用户需要先登录。
  5. 请求继续:
    1. 如果一切验证通过,它会使用修改后的请求(带有额外的用户信息头)继续向下游服务转发。
package com.vrs.filter;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.vrs.common.GatewayResult;
import com.vrs.common.WhitePathConfig;
import com.vrs.constant.RedisCacheConstant;
import com.vrs.utils.JwtUtil;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Mono;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
 * @Author dam
 * @create 2024/11/16 18:14
 */
@Component
public class TokenValidateGatewayFilterFactory extends AbstractGatewayFilterFactory<WhitePathConfig> {
    private final StringRedisTemplate stringRedisTemplate;

    public TokenValidateGatewayFilterFactory(StringRedisTemplate stringRedisTemplate) {
        super(WhitePathConfig.class);
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public GatewayFilter apply(WhitePathConfig whitePathConfig) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            // 获取请求路径
            String requestPath = request.getPath().toString();
            if (!isPathInWhiteList(requestPath, whitePathConfig.getWhitePathList())) {
                // --if-- 当前请求路径不在白名单中
                String token = request.getHeaders().getFirst("token");
                // 用户名为空,或者不存在于Redis中,返回错误提示
                ServerHttpResponse response = exchange.getResponse();
                String userName = "";
                try {
                    userName = JwtUtil.getUsername(token);
                } catch (Exception e) {
                    return writeResult(response, e.getMessage());
                }
                Object userInfo;
                if (StringUtils.hasText(userName) && StringUtils.hasText(token) &&
                        (userInfo = stringRedisTemplate.opsForHash().get(RedisCacheConstant.USER_LOGIN_KEY + userName, token)) != null) {
                    JSONObject userInfoJsonObject = JSON.parseObject(userInfo.toString());
                    // 将解析出来的信息放到请求头中,避免上下文封装的时候还需要去查询一遍
                    ServerHttpRequest.Builder builder = exchange.getRequest().mutate().headers(httpHeaders -> {
                        httpHeaders.set("userId", userInfoJsonObject.getString("id"));
                        httpHeaders.set("userType", userInfoJsonObject.getString("userType"));
                        httpHeaders.set("userName", URLEncoder.encode(userInfoJsonObject.getString("userName"), StandardCharsets.UTF_8));
                    });
                    return chain.filter(exchange.mutate().request(builder.build()).build());
                }
                response.setStatusCode(HttpStatus.UNAUTHORIZED);
                return writeResult(response, "没有通过登录校验,请先登录");
            }
            return chain.filter(exchange);
        };
    }

    /**
     * 返回结果给前端
     * @param response
     * @param e
     * @return
     */
    private static Mono<Void> writeResult(ServerHttpResponse response, String e) {
        return response.writeWith(Mono.fromSupplier(() -> {
            DataBufferFactory bufferFactory = response.bufferFactory();
            GatewayResult resultMessage = GatewayResult.builder()
                    .status(HttpStatus.UNAUTHORIZED.value())
                    .message(e)
                    .build();
            ;
            return bufferFactory.wrap(JSON.toJSONString(resultMessage).getBytes());
        }));
    }

    /**
     * 判断请求路径是否存在于白名单中
     *
     * @param requestPath
     * @param whitePathList
     * @return
     */
    private boolean isPathInWhiteList(String requestPath, List<String> whitePathList) {
        if (whitePathList.isEmpty()) {
            return false;
        }
        for (String whitePath : whitePathList) {
            if (isPathMatched(whitePath, requestPath) == true) {
                return true;
            }
        }
        return false;
    }

    /**
     * 检查给定的路径是否与whitePath模式匹配。
     *
     * @param whitePath 定义的白名单路径模式
     * @param testPath  要校验的具体路径
     * @return 如果testPath匹配whitePath,则返回true;否则返回false。
     */
    public boolean isPathMatched(String whitePath, String testPath) {
        // 去除路径两边的空白字符
        whitePath = whitePath.trim();
        testPath = testPath.trim();

        // 如果whitePath是以'**'结尾,则只检查前面的部分是否匹配
        if (whitePath.endsWith("/**")) {
            // 获取whitePath中除了'/**'之外的部分
            String prefix = whitePath.substring(0, whitePath.length() - 3);
            // 检查testPath是否以prefix开头
            return testPath.startsWith(prefix);
        }

        // 对于其他类型的模式,这里可以扩展更多的匹配规则
        // 但在这个例子中我们只处理'/webjars/**'这种简单的情况

        // 默认情况下,直接比较字符串是否完全相等
        return whitePath.equals(testPath);
    }

}

启动类

package com.vrs;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;

/**
 * @Author dam
 * @create 2024/11/15 16:22
 */
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class VrsGatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(VrsGatewayApplication.class, args);
    }
}

配置文件

路由定义 (routes)

配置文件中定义了三个路由规则,每个规则都有一个唯一的 id、目标 URI、匹配条件 (predicates) 和过滤器 (filters)。

  • vrs-admin 路由
    • uri: lb://vrs-admin:使用负载均衡(lb://)转发到名为 vrs-admin 的服务。
    • predicates: Path=/admin/**:只有当请求路径以 /admin/ 开头时,才会匹配此路由。
    • filters: TokenValidate:应用名为 TokenValidate 的过滤器,用于验证请求中的令牌。对于特定的白名单路径(如登录、注册等),不需要进行令牌验证。
    • whitePathList:定义一组白名单url,如果访问的是admin的这些接口,不需要经过token校验
  • vrs-venue 路由
    • uri: lb://vrs-venue/venue/**:转发到名为 vrs-venue 的服务。
    • predicates: Path=/venue/**:匹配以 /venue/ 开头的请求路径。
    • filters: TokenValidate:同样应用 TokenValidate 过滤器进行令牌验证。
  • vrs-order 路由
    • uri: lb://vrs-order/order/**:转发到名为 vrs-order 的服务。
    • predicates: Path=/order/**:匹配以 /order/ 开头的请求路径。
    • filters: TokenValidate:应用 TokenValidate 过滤器进行令牌验证。
server:
  port: 7049
spring:
  profiles:
    active: damMac
  application:
    name: vrs-gateway
  cloud:
    gateway:
      routes:
        - id: vrs-admin
          uri: lb://vrs-admin
          predicates:
            - Path=/admin/**
          filters:
            - name: TokenValidate
              args:
                whitePathList:
                  - /admin/user/v1/login
                  - /admin/user/v1/has-username
                  - /admin/user/v1/register
                  - /admin/pic/
        - id: vrs-venue
          uri: lb://vrs-venue
          predicates:
            - Path=/venue/**
          filters:
            - name: TokenValidate
        - id: vrs-order
          uri: lb://vrs-order
          predicates:
            - Path=/order/**
          filters:
            - name: TokenValidate

【application-damMac.yml】

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password: 12345678
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848 

Admin服务

数据库表

DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
  `id` bigint NOT NULL COMMENT 'ID',
  `create_time` datetime,
  `update_time` datetime,
  `is_deleted` tinyint default 0 COMMENT '逻辑删除 0:没删除 1:已删除',
  `user_name` varchar(30) NOT NULL COMMENT '用户账号',
  `nick_name` varchar(30) NOT NULL COMMENT '用户昵称',
  `user_type` tinyint NULL DEFAULT 2 COMMENT '用户类型 0:系统管理员 1:机构管理员 2:普通用户',
  `email` varchar(50) NULL DEFAULT '' COMMENT '用户邮箱',
  `phone_number` varchar(11) NULL DEFAULT '' COMMENT '手机号码',
  `gender` tinyint NULL DEFAULT 2 COMMENT '用户性别(0男 1女 2未知)',
  `avatar` varchar(100) NULL DEFAULT '' COMMENT '头像地址',
  `password` varchar(100) NULL DEFAULT '' COMMENT '密码',
  `status` tinyint NULL DEFAULT 0 COMMENT '帐号状态(0正常 1停用)',
  `login_ip` varchar(128) NULL DEFAULT '' COMMENT '最后登录IP',
  `login_date` datetime(0) NULL DEFAULT NULL COMMENT '最后登录时间',
  `point` int NULL DEFAULT NULL COMMENT '积分',
  `organization_id` bigint COMMENT '机构id,如果是机构管理员,必须填写;用户如果归属于某个机构,也要填写',
  PRIMARY KEY (`id`) USING BTREE
);

-- 添加唯一约束
ALTER TABLE `user` ADD CONSTRAINT `uk_user_name` UNIQUE (`user_name`);
ALTER TABLE `user` ADD CONSTRAINT `uk_phone_number` UNIQUE (`phone_number`);
ALTER TABLE `user` ADD CONSTRAINT `uk_email` UNIQUE (`email`);

依赖

<dependency>
    <groupId>com.vrs</groupId>
    <artifactId>vrs-common</artifactId>
</dependency>

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

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

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

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
</dependency>

<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>shardingsphere-jdbc-core</artifactId>
</dependency>

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
</dependency>

枚举类

package com.vrs.common.enums;

import com.vrs.convention.errorcode.IErrorCode;

/**
 * 用户错误码
 */
public enum ErrorCodeEnum implements IErrorCode {

    // ------------------- 用户相关 -------------------
    USER_NULL("200", "用户记录不存在"),

    USER_NAME_EXIST("201", "用户名已存在"),

    USER_EXIST("202", "用户记录已存在"),

    USER_SAVE_ERROR("203", "用户记录新增失败"),

    USER_TOKEN_EXPIRE("204", "用户登录状态过期,请重新登录"),

    USER_TOKEN_FAIL("205", "用户token异常,请重新登录"),

    ;


    private final String code;

    private final String message;

    ErrorCodeEnum(String code, String message) {
        this.code = code;
        this.message = message;
    }

    @Override
    public String code() {
        return code;
    }

    @Override
    public String message() {
        return message;
    }
}

配置类

用户名布隆过滤器

配置一个用户名的布隆过滤器

package com.vrs.config;

import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @Author dam
 * @create 2024/11/16 11:58
 */
@Configuration(value = "rBloomFilterConfigurationByAdmin")
public class RBloomFilterConfiguration {

    /**
     * 防止用户注册查询数据库的布隆过滤器
     */
    @Bean
    public RBloomFilter<String> userRegisterCachePenetrationBloomFilter(RedissonClient redissonClient) {
        RBloomFilter<String> cachePenetrationBloomFilter = redissonClient.getBloomFilter("vrs:userRegisterCachePenetrationBloomFilter");
        // 参数1:预估布隆过滤器里面要存放多少个元素
        // 参数2:误判率(误判率越低,散列函数数量越多)
        cachePenetrationBloomFilter.tryInit(100000000L, 0.001);
        return cachePenetrationBloomFilter;
    }
}

用户上下文过滤器

将UserTransmitFilter的优先级设置为最高

package com.vrs.config;

import com.vrs.common.context.UserTransmitFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;

/**
 * 配置使用用户过滤器
 * @Author dam
 * @create 2024/11/16 16:09
 */
@Configuration
public class UserConfiguration {

    /**
     * 用户信息传递过滤器
     */
    @Bean
    public FilterRegistrationBean<UserTransmitFilter> globalUserTransmitFilter(StringRedisTemplate stringRedisTemplate) {
        FilterRegistrationBean<UserTransmitFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new UserTransmitFilter());
        registration.addUrlPatterns("/*");
        registration.setOrder(0);
        return registration;
    }

}

controller

package com.vrs.controller;

import com.vrs.convention.result.Result;
import com.vrs.convention.result.Results;
import com.vrs.domain.dto.req.UserLoginReqDTO;
import com.vrs.domain.dto.req.UserRegisterReqDTO;
import com.vrs.domain.dto.resp.UserLoginRespDTO;
import com.vrs.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

/**
 * 用户管理控制层
 */
@RestController
@RequestMapping("/user/")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    /**
     * 查询用户名是否存在
     */
    @GetMapping("/v1/has-username")
    public Result<Boolean> hasUsername(@RequestParam("username") String username) {
        return Results.success(userService.hasUsername(username));
    }

    /**
     * 注册用户
     */
    @PostMapping("/v1/register")
    public Result<Void> register(@RequestBody UserRegisterReqDTO requestParam) {
        userService.register(requestParam);
        return Results.success();
    }

    /**
     * 用户登录
     */
    @PostMapping("/v1/login")
    public Result<UserLoginRespDTO> login(@RequestBody UserLoginReqDTO requestParam) {
        return Results.success(userService.login(requestParam));
    }

    /**
     * 用户退出登录
     *
     * @return
     */
    @DeleteMapping("/v1/logout")
    public Result<Void> logout(HttpServletRequest request) {
        String token = request.getHeader("token");
        userService.logout(token);
        return Results.success();
    }

}

service

package com.vrs.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.vrs.domain.dto.req.UserLoginReqDTO;
import com.vrs.domain.dto.req.UserRegisterReqDTO;
import com.vrs.domain.dto.resp.UserLoginRespDTO;
import com.vrs.domain.entity.UserDO;

/**
* @author dam
* @description 针对表【user】的数据库操作Service
* @createDate 2024-11-15 16:52:24
*/
public interface UserService extends IService<UserDO> {

    /**
     * 注册用户
     *
     * @param requestParam 注册用户请求参数
     */
    void register(UserRegisterReqDTO requestParam);

    /**
     * 用户登录
     *
     * @param requestParam 用户登录请求参数
     * @return 用户登录返回参数 Token
     */
    UserLoginRespDTO login(UserLoginReqDTO requestParam);

    /**
     * 查询用户名是否存在
     *
     * @param username 用户名
     * @return 用户名存在返回 True,不存在返回 False
     */
    Boolean hasUsername(String username);

    /**
     * 注销用户登录
     * @param token
     */
    void logout(String token);
}

impl

  1. 用户注册 (register 方法):
    1. 使用布隆过滤器快速检查用户名是否已存在,以防止缓存穿透。
    2. 如果用户名不存在,则尝试获取一个基于用户名的分布式锁来确保并发环境下的数据一致性。
    3. 将用户信息保存到数据库中,并在成功后更新布隆过滤器。
    4. 如果数据库插入过程中发生唯一索引冲突(即用户名重复),则抛出异常通知客户端用户名已存在。
    5. 如果未能获取锁,假设其他进程正在处理相同用户名的注册请求,并认为注册将会成功,因此直接告知客户端用户名已存在。
    6. 注意:这个注册只是最简单的注册,建议加上手机验证码或者邮箱验证码,然后给用户进行绑定,否则一个人可以注册大量的账号
  2. 检查用户名是否存在 (hasUsername 方法):
    1. 利用布隆过滤器快速判断用户名是否存在。由于布隆过滤器可能会产生误判(即假阳性),所以这里的逻辑是:如果布隆过滤器返回不存在,则可以确定用户名确实不存在;如果返回存在,虽然可能是误判,但是不管了,大不了这个用户名不让用户用。
  3. 用户登录 (login 方法):
    1. 根据提供的用户名和密码查询数据库,验证用户的身份。
    2. 检查用户状态,如果账户被停用,则拒绝登录。
    3. 检查用户是否已经登录,如果是,则刷新会话的有效时间并返回现有的 token。
    4. 如果用户未登录,则生成一个新的 JWT token 并将其存储在 Redis 中,同时设置过期时间。
    5. 返回包含 token 的响应给客户端。
  4. 用户登出 (logout 方法):
    1. 从 Redis 中删除与指定 token 相关的用户会话信息,实现用户的登出操作。
package com.vrs.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vrs.convention.exception.ClientException;
import com.vrs.domain.dto.req.UserLoginReqDTO;
import com.vrs.domain.dto.req.UserRegisterReqDTO;
import com.vrs.domain.dto.resp.UserLoginRespDTO;
import com.vrs.domain.entity.UserDO;
import com.vrs.mapper.UserMapper;
import com.vrs.service.UserService;
import com.vrs.utils.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.concurrent.TimeUnit;

import static com.vrs.common.enums.ErrorCodeEnum.*;
import static com.vrs.constant.RedisCacheConstant.LOCK_USER_REGISTER_KEY;
import static com.vrs.constant.RedisCacheConstant.USER_LOGIN_KEY;

/**
 * @author dam
 * @description 针对表【user】的数据库操作Service实现
 * @createDate 2024-11-15 16:52:24
 */
@Service
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, UserDO>
        implements UserService {

    private final RBloomFilter<String> userRegisterCachePenetrationBloomFilter;
    private final RedissonClient redissonClient;
    private final StringRedisTemplate stringRedisTemplate;
    private static final long EXPIRE_TIME = 300L;
    private static final TimeUnit EXPIRE_TIME_UNIT = TimeUnit.HOURS;

    @Override
    public void register(UserRegisterReqDTO requestParam) {
        // 开始注册之前,判断用户名有没有被注册
        if (hasUsername(requestParam.getUserName())) {
            // --if-- 用户名已经存在了,抛异常
            throw new ClientException(USER_NAME_EXIST);
        }
        // 使用Redisson的分布式锁,有看门狗机制,底层使用Netty来实现,网络通讯更加高效
        // LOCK_USER_REGISTER_KEY + requestParam.getUsername():只锁注册的用户名
        RLock lock = redissonClient.getLock(LOCK_USER_REGISTER_KEY + requestParam.getUserName());
        try {
            if (lock.tryLock()) {
                try {
                    // 将用户数据保存到数据库
                    UserDO userDO = BeanUtil.toBean(requestParam, UserDO.class);
                    userDO.setNickName(userDO.getUserName());
                    int inserted = baseMapper.insert(userDO);
                    if (inserted < 1) {
                        throw new ClientException(USER_SAVE_ERROR);
                    }
                    // 保存成功,将注册成功的用户名保存到布隆过滤器
                    userRegisterCachePenetrationBloomFilter.add(requestParam.getUserName());
                } catch (DuplicateKeyException ex) {
                    // 数据库唯一索引异常(按理说这个是不会执行)
                    throw new ClientException(USER_EXIST);
                }
            } else {
                // --if-- 没有获取到锁,说明有其他用户正在注册,大概率注册都会成功的,返回用户名已经存在
                throw new ClientException(USER_NAME_EXIST);
            }
        } finally {
            lock.unlock();
        }
    }


    /**
     * 直接用布隆过滤器判断用户名是否存在
     * - 布隆过滤器不存在,说明肯定不存在
     * - 布隆过滤器存在,可能产生误判,但是问题不大,部分用户名用不了也没啥关系
     *
     * @param username 用户名
     * @return
     */
    @Override
    public Boolean hasUsername(String username) {
        return userRegisterCachePenetrationBloomFilter.contains(username);
    }

    @Override
    public UserLoginRespDTO login(UserLoginReqDTO requestParam) {

         根据用户名密码查询,看看有没有匹配的用户
        LambdaQueryWrapper<UserDO> queryWrapper = Wrappers.lambdaQuery(UserDO.class)
                .eq(UserDO::getUserName, requestParam.getUserName())
                .eq(UserDO::getPassword, requestParam.getPassword())
                .eq(UserDO::getIsDeleted, 0);
        UserDO userDO = baseMapper.selectOne(queryWrapper);
        if (userDO == null) {
            throw new ClientException("用户不存在或者密码错误");
        }
        if (userDO.getStatus() != 0) {
            throw new ClientException("该账号已经停用");
        }

         判断用户之前有没有登录,如果登录了直接返回token即可,防止有人一直刷接口
        Map<Object, Object> hasLoginMap = stringRedisTemplate.opsForHash().entries(USER_LOGIN_KEY + requestParam.getUserName());
        if (CollUtil.isNotEmpty(hasLoginMap)) {
            // 用户又登录了,刷新过期时间
            stringRedisTemplate.expire(USER_LOGIN_KEY + requestParam.getUserName(), EXPIRE_TIME, EXPIRE_TIME_UNIT);
            // 如果已经登录,返回缓存的token
            String token = hasLoginMap.keySet().stream()
                    .findFirst()
                    .map(Object::toString)
                    .orElseThrow(() ->
                            // token为空
                            new ClientException("用户登录错误"));
            try {
                JwtUtil.getUserId(token);
                return new UserLoginRespDTO(token);
            } catch (Exception e) {
                // --if-- 如果抛异常,说明token有问题,可能过期了,需要继续执行下面的流程来重新生成token
            }
        }

         存储用户信息
        // 使用jwt创建token
        String token = JwtUtil.createToken(userDO.getId(), userDO.getUserName(), userDO.getUserType());
        // 将生成的token和用户信息存储到redis里面
        stringRedisTemplate.opsForHash().put(USER_LOGIN_KEY + requestParam.getUserName(), token, JSON.toJSONString(userDO));
        // 设置过期时间
        stringRedisTemplate.expire(USER_LOGIN_KEY + requestParam.getUserName(), EXPIRE_TIME, EXPIRE_TIME_UNIT);
        return new UserLoginRespDTO(token);
    }

    /**
     * 用户退出登录
     * @param token
     */
    @Override
    public void logout(String token) {
        // 拦截器已经帮我验证了token的有效性,直接删除缓存即可
        String username = JwtUtil.getUsername(token);
        stringRedisTemplate.delete(USER_LOGIN_KEY + username);
    }
}

当然这里实现的是最简单的登录、注册功能,如果说需要添加验证码验证,请改写实现方法,这里提供一个简单的思路

  • 前端首先发起一个请求,获取验证码,后台可以用uuid分发一个编号给前端,并将编号和验证码存储到Redis中,设置一个过期时间,例如60s
  • 前端输入验证码之后进行登录或注册,同时需要带上的上面的随机编号,方便后台核验验证码是否正确
  • 后台验证
    • 如果前端上传的验证码编号在Redis中找不到,返回验证码失效
    • 如果前端输入的验证码与后台存储的验证码不匹配,返回验证码错误,让用户重新输入

后面还可以进一步限制用户获取验证码的频率,防范恶意攻击

用户上下文封装

【UserInfoDTO】

package com.vrs.common.context;

import com.alibaba.fastjson2.annotation.JSONField;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @Author dam
 * @create 2024/11/16 16:02
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserInfoDTO {

    /**
     * 用户 ID
     */
    @JSONField(name = "id")
    private String userId;

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

    /**
     * 用户类型 0:系统管理员 1:机构管理员 2:普通用户
     */
    private Integer userType;
}

【UserContext】 这个类使用了 TransmittableThreadLocal 类来存储用户信息,这允许用户信息不仅可以在同一个线程中传递,而且可以跨线程传递(例如,在异步任务或新线程中仍然可以访问到原始线程的用户信息)。TransmittableThreadLocal 是阿里巴巴开源的一个库,旨在解决 Java 中 ThreadLocal 无法在线程切换时传递数据的问题。

package com.vrs.common.context;

import com.alibaba.ttl.TransmittableThreadLocal;

import java.util.Optional;

/**
 * @Author dam
 * @create 2024/11/16 16:01
 */
public final class UserContext {
    /**
     * <a href="https://github.com/alibaba/transmittable-thread-local" />
     */
    private static final ThreadLocal<UserInfoDTO> USER_THREAD_LOCAL = new TransmittableThreadLocal<>();

    /**
     * 设置用户至上下文
     *
     * @param user 用户详情信息
     */
    public static void setUser(UserInfoDTO user) {
        USER_THREAD_LOCAL.set(user);
    }

    /**
     * 获取上下文中用户 ID
     *
     * @return 用户 ID
     */
    public static String getUserId() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getUserId).orElse(null);
    }

    /**
     * 获取上下文中用户名称
     *
     * @return 用户名称
     */
    public static String getUsername() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getUserName).orElse(null);
    }

    /**
     * 获取上下文中用户名称
     *
     * @return 用户名称
     */
    public static Integer getUserType() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getUserType).orElse(null);
    }

    /**
     * 清理用户上下文
     */
    public static void removeUser() {
        USER_THREAD_LOCAL.remove();
    }
}

【UserTransmitFilter】

过滤器,用来获取网关服务放行之后的请求的请求头上面的信息,并设置到用户上下文中

package com.vrs.common.context;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jodd.util.StringUtil;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;

/**
 * @Author dam
 * @create 2024/11/16 16:10
 */
@RequiredArgsConstructor
public class UserTransmitFilter implements Filter {

    @SneakyThrows
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String username = httpServletRequest.getHeader("userName");
        if (StringUtil.isNotBlank(username)) {
            // --if-- username不为空,说明是经过了过滤器的
            String userId = httpServletRequest.getHeader("userId");
            Integer userType = Integer.parseInt(httpServletRequest.getHeader("userType"));
            UserContext.setUser(new UserInfoDTO(userId, username, userType));
        }
        try {
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            // 不移除,会有内存泄漏风险
            UserContext.removeUser();
        }
    }
}

配置文件

【application.yml】

server:
  port: 7050
  servlet:
    context-path: /admin
spring:
  profiles:
    active: damMac
  application:
    name: vrs-admin
logging:
  level:
    org.springframework.web: DEBUG
    org.springframework.web.servlet: DEBUG

【application-damMac.yml】

spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
  datasource:
    # ShardingSphere 对 Driver 自定义,实现分库分表等隐藏逻辑
    driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
    # ShardingSphere 配置文件路径
    url: jdbc:shardingsphere:classpath:shardingsphere-config-damMac.yaml
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password: 12345678
      database: 0
      timeout: 1800000
      jedis:
        pool:
          max-active: 20 #最大连接数
          max-wait: -1    #最大阻塞等待时间(负数表示没限制)
          max-idle: 5    #最大空闲
          min-idle: 0     #最小空闲 

【shardingsphere-config-damMac.yaml】

这段配置是用于设置一个数据分片(Sharding)和加密的规则,它定义了如何在多个数据库表之间分配数据以及如何对特定字段的数据进行加密存储。以下是配置文件的详细解释:

数据源 (Data Sources)

  • ds_0:定义了一个名为 ds_0 的数据源,使用的是 HikariCP 连接池,连接到本地 MySQL 数据库 venue-reservation,端口为 3308。设置了字符编码、允许批量语句重写、允许多查询以及时区。

分片规则 (Sharding Rules)

  • 分片策略 (tableStrategy):
    • 定义了一个分片策略,针对 user 表。根据 user_name 字段进行哈希取模运算来决定数据应该插入到哪个物理表中。
    • actualDataNodes 指定了实际存在的数据节点,这里指出了 user 表被分成了 16 个物理表,即 user_0user_15
    • shardingAlgorithmName 引用了分片算法 user_table_hash_mod
  • 分片算法 (shardingAlgorithms):
    • user_table_hash_mod 是一种基于哈希取模的分片算法,它会根据 user_name 字段的哈希值对 16 取模,结果决定了该条记录应该存放在哪一个 user_${0..15} 表中。

加密规则 (Encrypt Rules)

  • 加密表 (tables):
    • user 表中的 phone_number, email, 和 password 字段进行了加密配置。每个字段都指定了一个 cipherColumn,这是存储加密后数据的实际字段名,同时指定了相同的加密器 common_encryptor
    • queryWithCipherColumn 设置为 true 表示在查询时也可以使用加密后的字段。
  • 加密器 (encryptors):
    • common_encryptor 使用的是 AES 加密算法,并提供了一个 AES 密钥 aes-key-value 用于加密和解密操作。

属性 (Props)

  • sql-show: true 表示开启 SQL 显示功能,当执行 SQL 语句时,框架会打印出实际执行的 SQL 语句,这有助于调试。
# 数据源集合
dataSources:
  ds_0:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    jdbcUrl: jdbc:mysql://127.0.0.1:3308/venue-reservation?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
    username: root
    password: 12345678 

rules:
  - !SHARDING
    tables:
      user:
        # 真实数据节点,比如数据库源以及数据库在数据库中真实存在的
        actualDataNodes: ds_0.user_${0..15}
        # 分表策略
        tableStrategy:
          # 用于单分片键的标准分片场景
          standard:
            # 分片键
            shardingColumn: user_name
            # 分片算法,对应 rules[0].shardingAlgorithms
            shardingAlgorithmName: user_table_hash_mod
    # 分片算法
    shardingAlgorithms:
      # 数据表分片算法 使用的分片算法,根据数据的hashcode来进行取模(根据上面的配置知道是mod 16),值是多少就被分配到哪个表中
      user_table_hash_mod:
        # 根据分片键 Hash 分片
        type: HASH_MOD
        # 分片数量
        props:
          sharding-count: 16
    # 展现逻辑 SQL & 真实 SQL
    # 逻辑SQL:select * from t_user where username = 'admin'
    # 真实SQL:select * from t_user_0 where username = 'admin'
  # 数据加密存储规则
  - !ENCRYPT
    # 需要加密的表集合
    tables:
      # 用户表
      user:
        # 用户表中哪些字段需要进行加密
        columns:
          # 手机号字段,逻辑字段,不一定是在数据库中真实存在
          phone_number:
            # 手机号字段存储的密文字段,这个是数据库中真实存在的字段
            cipherColumn: phone_number
            # 身份证字段加密算法
            encryptorName: common_encryptor
          email:
            cipherColumn: email
            encryptorName: common_encryptor
          password:
            cipherColumn: password
            encryptorName: common_encryptor
        # 是否按照密文字段查询
        queryWithCipherColumn: true
    # 加密算法
    encryptors:
      # 自定义加密算法名称
      common_encryptor:
        # 加密算法类型
        # AES 可逆
        type: AES
        props:
          # AES 加密密钥,密钥千万不能泄露,不然拿到数据就可以破解了
          aes-key-value: dadh423h343hg
props:
  sql-show: true

其他服务

其他如果需要使用到用户上下文,也需要添加以下几个类

在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到