019-SpringSecurity+JWT 认证授权

🧑‍🎓 个人主页:Silence Lamb
📖 本章内容:SpringSecurity+JWT 认证授权

📢💨如果文章对你有帮助【关注👍点赞❤️收藏⭐】

一、框架简介

  • SpringSecurity是一个用于Java 企业级应用程序的安全框架
  • 主要包含用户认证和用户授权两个方面
  • 相比较Shiro而言Security功能更加的强大
  • 它可以很容易地扩展以满足更多安全控制方面的需求
  • 但也相对它的学习成本会更高,两种框架各有利弊
  • 实际开发中还是要根据业务和项目的需求来决定使用哪一种
  • 快速入门:🚀Spring Security

二、JWT介绍

  • JWT是在Web应用中安全传递信息的规范,从本质上来说是Token的演变
  • 是一种生成加密用户身份信息的Token,特别适用于分布式单点登陆的场景
  • 无需在服务端保存用户的认证信息
  • 而是直接对Token进行校验获取用户信息,使单点登录更为简单灵活

三、身份验证架构

  • SecurityContextHolder: Spring Security 存储份验证者详细信息的位置

  • SecurityContext: 从获取SecurityContextHolder并包含Authentication当前经过身份验证的用户的

  • Authentication:可以是输入以AuthenticationManager提供用户提供的用于身份验证的凭据或来自SecurityContext

  • GrantedAuthority:授予主体的权限Authentication(即角色、范围等)

  • AuthenticationManager:定义 Spring Security 的过滤器如何执行身份验证的API

  • ProviderManager:最常见的实现AuthenticationManager

  • AuthenticationProvider:用于ProviderManager执行特定类型的身份验证

  • Request Credentials withAuthenticationEntryPoint:用于从客户端请求凭据(即重定向到登录页面、发送WWW-Authenticate响应等)

  • AbstractAuthenticationProcessingFilter:Filter用于身份验证的基础。这也很好地了解了身份验证的高级流程以及各个部分如何协同工作

四、前期准备

在这里插入图片描述

🕰️ 最小数据模型

在这里插入图片描述

🕰️ 数据库脚本

DROP TABLE IF EXISTS `per_user`;
CREATE TABLE `per_user`  (
  `user_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `user_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户账号',
  `nick_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户昵称',
  `user_type` varchar(2) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '00' COMMENT '用户类型(00系统用户)',
  `email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '用户邮箱',
  `phone_number` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '手机号码',
  `sex` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)',
  `avatar` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '头像地址',
  `password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '密码',
  `identity` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '身份标识(0超级管理员,1 终极管理员 2 初级管理员)',
  `status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '帐号状态(0正常 1停用)',
  `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
  `login_ip` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '最后登录IP',
  `login_date` datetime NULL DEFAULT NULL COMMENT '最后登录时间',
  `create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '创建者',
  `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
  `update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '更新者',
  `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
  `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`user_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户信息表' ROW_FORMAT = DYNAMIC;

SET FOREIGN_KEY_CHECKS = 1;

🕰️ 配置文件信息

# 用户配置
user:
  password:
    # 密码最大错误次数
    maxRetryCount: 5
    # 密码锁定时间(默认10分钟)
    lockTime: 10
    

# token配置
token:
  # 令牌自定义标识
  header: Authorization
  # 令牌密钥
  secret: K0TmVM#8O9u2end6V~QpYZ!!Xt
  # 令牌有效期(默认30分钟)
  expireTime: 30
/**
 * @author SilenceLamb
 * @apiNote 用户密码信息
 */
@Data
@Component
@ConfigurationProperties(prefix = "user.password")
public class PassWordProperties {

    @ApiModelProperty("密码最大错误次数")
    private int maxRetryCount;

    @ApiModelProperty("密码锁定时间(默认10分钟)")
    private int lockTime;
}
/**
 * @author SilenceLamb
 * @apiNote 读取token配置属性
 */
@Data
@Component
@ConfigurationProperties(prefix = "token")
public class TokenProperties {

    @ApiModelProperty("令牌自定义标识")
    private String header;

    @ApiModelProperty("令牌秘钥")
    private String secret;

    @ApiModelProperty("令牌有效期(默认30分钟)")
    private int expireTime;
}

🕰️ 创建国际化文件

  • 👉🏽 messages.properties
#错误消息
not.null=* 必须填写
user.captcha.error=验证码错误
user.captcha.expire=验证码已失效
user.not.exists=用户不存在/密码错误
user.password.not.match=用户不存在/密码错误
user.password.retry.limit.count=密码输入错误{0}次
user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定{1}分钟
user.password.delete=对不起,您的账号已被删除
user.blocked=用户已封禁,请联系管理员
role.blocked=角色已封禁,请联系管理员
user.logout.success=退出成功
  • 👉🏽 方便在非spring管理环境中获取bean
/**
 * @author SilenceLamb
 * @apiNote 在非spring管理环境中获取bean
 */
@Component
public final class SpringUtils implements BeanFactoryPostProcessor, ApplicationContextAware {

    private static ConfigurableListableBeanFactory configurableListableBeanFactory;
    private static ApplicationContext applicationContext;

    /**
     * 在标准初始化后修改应用程序上下文的内部 Bean 工厂
     * 所有 Bean 定义都将被加载,但尚未实例化任何 bean。这允许覆盖或添加属性
     * 甚至可以覆盖或添加属性到急切初始化的 bean。
     */
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        SpringUtils.configurableListableBeanFactory = beanFactory;
    }

    /**
     * 置运行此对象的应用程序上下文
     * 通常,此调用将用于初始化对象。
     * 在填充正常 bean 属性之后但在 init 回调之前调用
     */
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        SpringUtils.applicationContext = applicationContext;
    }

    /**
     * 获取对象
     *
     * @return Object 一个以所给名字注册的bean的实例
     */
    @SuppressWarnings("unchecked")
    public static <T> T getBean(String name) throws BeansException {
        return (T) configurableListableBeanFactory.getBean(name);
    }

    /**
     * 获取配置文件中的值
     *
     * @param key 配置文件的key
     * @return 当前的配置文件的值
     */
    public static String getRequiredProperty(String key) {
        return applicationContext.getEnvironment().getRequiredProperty(key);
    }
}
  • 👉 获取i18n资源文件
 /**
 * @author SilenceLamb
 * @apiNote 获取i18n资源文件 
 */
public class MessageUtils {
    /**
     * 根据消息键和参数 获取消息 委托给spring messageSource
     *
     * @param code 消息键
     * @param args 参数
     * @return 获取国际化翻译值
     */
    public static String message(String code, Object... args) {
        MessageSource messageSource = SpringUtils.getBean(MessageSource.class);
        return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
    }
}

🕰️ 登录用户身份权限

/**
 * @author SilenceLamb
 * @apiNote 登录用户信息
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@Component
public class LoginUserDetail implements UserDetails {

    private static final long serialVersionUID = 1L;

    @ApiModelProperty("用户ID")
    private Long userId;

    @ApiModelProperty("用户唯一标识")
    private String token;

    @ApiModelProperty("登录时间")
    private Date loginTime;

    @ApiModelProperty("token的过期时间")
    private Long expireTime;

    @ApiModelProperty("登录IP地址")
    private String ipAddress;

    @ApiModelProperty("登录地点")
    private String loginLocation;

    @ApiModelProperty("浏览器类型")
    private String browser;

    @ApiModelProperty("操作系统")
    private String os;

    @ApiModelProperty("权限列表")
    private Set<String> permissions;

    @ApiModelProperty("用户信息")
    private UserEntity userEntity;

    public LoginUserDetail(Long userId, UserEntity userEntity, Set<String> permissions) {
        this.userId = userId;
        this.permissions = permissions;
        this.userEntity = userEntity;
    }

    public LoginUserDetail(Long userId, Set<String> permissions) {
        this.userId = userId;
        this.permissions = permissions;
    }

    /**
     * 授予用户权限
     *
     * @return 封装用户权限信息
     */
    @JSONField(serialize = false)
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }

    @JSONField(serialize = false)
    @Override
    public String getPassword() {
        return userEntity.getPassword();
    }

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

    /**
     * 账户是否未过期,过期无法验证
     */
    @JSONField(serialize = false)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 指定用户是否解锁,锁定的用户无法进行身份验证
     *
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
     *
     */
    @JSONField(serialize = false)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 是否可用 ,禁用的用户不能身份验证
     *
     */
    @JSONField(serialize = false)
    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • 👉🏽登录信息:LoginBody
/**
 * @author  SilenceLamb
 * @apiNote  登录信息
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginBody implements Serializable {
    private static final long serialVersionUID = 2929771133606926018L;

    @NotBlank(message = "用户账号不能为空")
    @ApiModelProperty(value = "用户账号")
    private String userName;

    @ApiModelProperty("用户密码")
    @NotBlank(message = "用户密码不能为空")
    private String password;

    @ApiModelProperty("验证码")
    private String code;

    @ApiModelProperty("唯一标识")
    private String uuid;
}

🕰️ 引入security依赖

<!-- ... other dependency elements ... -->
<dependency> 
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-security</artifactId> 
</dependency>

🕰️ token工具服务类

public class TokenUtils {

    /**
     * 从数据生成令牌
     *
     * @param claims 数据
     * @return token
     */
    public String createToken(Map<String, Object> claims) {
        String token = Jwts.builder()
            .setClaims(claims)
            .signWith(SignatureAlgorithm.HS512, tokenProperties.getSecret())
            .compact();
        return token;
    }


    /**
     * 从令牌中获取数据
     *
     * @param token 令牌
     * @return 数据声明
     */
    public Claims parseToken(String token) {
        Claims body = Jwts.parser()
            .setSigningKey(tokenProperties.getSecret())
            .parseClaimsJws(token)
            .getBody();
    return body;
    
    /**
     * 创建令牌
     *
     * @param loginUserDetail 用户信息
     * @return 令牌
     */
    @Override
    public String createToken(LoginUserDetail loginUserDetail) {
        //生成UUID 去掉了横线,使用性能更好的ThreadLocalRandom生成UUID
        String uuid = IdUtils.fastSimpleUUID();
        //将token存入到用户信息中
        loginUserDetail.setToken(uuid);
        //设置用户其他信息
        setUserAgent(loginUserDetail);
        //刷新Token过期时间
        refreshToken(loginUserDetail);
        //创建HashMap集合用来存入UUID
        HashMap<String, Object> claims = new HashMap<>();
        claims.put(CommonConstant.LOGIN_USER_KEY, uuid);
        //生成Token
        return createToken(claims);
    }

    /**
     * 设置用户代理信息
     *
     * @param loginUserDetail 登录信息
     */
    private void setUserAgent(LoginUserDetail loginUserDetail) {
        //获取请求头
        String header = ServletUtils.getRequest().getHeader("User-Agent");
        //解析用户代理字符串
        UserAgent userAgent = UserAgent.parseUserAgentString(header);
        //从请求中获取IP地址
        String ipAddress = IpUtils.getIpAddress(ServletUtils.getRequest());
        loginUserDetail.setIpAddress(ipAddress);
        //通过IP获取地理位置
        loginUserDetail.setLoginLocation(AddressUtils.getRealAddressByIP(ipAddress));
        //获取浏览器类型
        loginUserDetail.setBrowser(userAgent.getBrowser().getName());
        //获取操作系统
        loginUserDetail.setOs(userAgent.getOperatingSystem().getName());
    }
        
    /**
     * 获取缓存的token键值
     *
     * @param uuid
     * @return
     */
    public String getTokenKey(String uuid) {
        return RedisCacheConstant.LOGIN_TOKEN_KEY + uuid;
    }
}

五、认证处理方法

image-20230307140120064

🧭 自定义Jwt过滤器

1🍟【自定义Jwt过滤器】

/**
 * @author SilenceLamb
 * @apiNote token过滤器 验证token有效性
 */
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
    @Resource
    private RedisUtils redisUtils;

    @Resource
    private TokenService tokenService;

    /**
     * 与 {@code doFilter} 的协定相同,但保证在单个请求线程中每个请求只调用一次
     *
     * @param request     request请求域
     * @param response    response相应
     * @param filterChain 过滤器链
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //从请求中获取token并且解析token获取UUID,通过UUID从缓存中获取用户信息
        LoginUserDetail loginUser = tokenService.getLoginUser(request);
        //判断Token中存在用户并且Authentication中不存在用户信息
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())){
            //刷新token过期时间并且把用户信息进行缓存
            tokenService.verifyToken(loginUser);
            //获取授予用户的权限
            Collection<? extends GrantedAuthority> authorities = loginUser.getAuthorities();
            //创建用户名密码身份验证令牌
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, authorities);
            //网络身份验证详细信息
            WebAuthenticationDetails webAuthenticationDetails = new WebAuthenticationDetailsSource().buildDetails(request);
            authenticationToken.setDetails(webAuthenticationDetails);
            //把用户名密码身份验证令牌存入安全上下文中
            SecurityUtils.setAuthentication(authenticationToken);
            //最后把登录成功的用户的token进行缓存
            redisUtils.setCacheObject(RedisCacheConstant.LOGIN_KEY + SecurityUtils.getLoginUser().getUsername(), SecurityUtils.getLoginUser().getToken(), 60 * 60, TimeUnit.MINUTES);
        }
        filterChain.doFilter(request, response);
    }
}

2🍟【TokenUtils工具】

  • TokenUtils工具类添加 刷新token有效期 的方法
   /**
     * 从请求中获取用户身份信息
     *
     * @return 用户信息
     */
    @Override
    public LoginUserDetail getLoginUser(HttpServletRequest request) {
        // 获取请求携带的令牌
        String token = getToken(request);
        if (StringUtils.isNotEmpty(token)) {
            try {
                Claims claims = parseToken(token);
                //解析对应的权限以及用户信息
                String uuid = (String) claims.get(CommonConstant.LOGIN_USER_KEY);
                //从缓存中获取用户信息
                return redisUtils.getCacheObject(getTokenKey(uuid));
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return null;
    }

 	/**
     * 验证令牌有效期,相差不足20分钟,自动刷新缓存
     *
     * @param loginUserDetail 用户信息
     */
    @Override
    public void verifyToken(LoginUserDetail loginUserDetail) {
        //获取token过期时间
        Long expireTime = loginUserDetail.getExpireTime();
        //获取当前系统时间
        long currentTime = System.currentTimeMillis();
        if (expireTime - currentTime <= MILLIS_MINUTE_TEN) {
            //刷新token的过期时间
            refreshToken(loginUserDetail);
        }
    }

    /**
     * 刷新令牌有效期
     *
     * @param loginUserDetail 用户信息
     */
    @Override
    public void refreshToken(LoginUserDetail loginUserDetail) {
        //获取登录时间
        loginUserDetail.setLoginTime(DateUtils.getNowDate());
        long time = loginUserDetail.getLoginTime().getTime();
        //设置token过期时间 登录时间+20分钟
        loginUserDetail.setExpireTime(time + tokenProperties.getExpireTime() * MILLIS_MINUTE);
        // 根据uuid将loginUser缓存
        String tokenKey = getTokenKey(loginUserDetail.getToken());
        redisUtils.setCacheObject(tokenKey, loginUserDetail, tokenProperties.getExpireTime(), TimeUnit.MINUTES);
    }

3🍟【SecurityUtils工具类】

image-20230307141230833

  • 我们首先创建一个空的SecurityContext. 重要的是创建一个新SecurityContext实例

  • SecurityContextHolder.getContext().setAuthentication(authentication)它来避免跨多个线程的竞争条件

/**
 * @author SilenceLamb
 * @apiNote Spring Security 存储份验证者详细信息的位置
 */
public class SecurityUtils {

    /**
     * 获取Authentication
     */
    public static Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }

    /**
     * 设置Authentication
     *
     */
    public static void setAuthentication(UsernamePasswordAuthenticationToken authentication) {
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

🧭 认证失败处理类

/**
 * @author SilenceLamb
 * @apiNote 认证失败处理类
 */
@Component
public class AuthenticationError implements AuthenticationEntryPoint, Serializable {

    private static final long serialVersionUID = 5404341484330802221L;

    /**
     * 认证失败处理类
     *
     * @param request       request请求域
     * @param response      response响应
     * @param authException 访问被拒绝异常
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        //获取未经 授权状态码
        int code = HttpStatus.UNAUTHORIZED.value();
        //将字符串渲染到客户端
        String msg = StringUtils.format("请求访问:{},没有权限,要求用户的进行身份认证", request.getRequestURI());
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
    }
}
  • 将失败信息渲染到客户端
/**
 * @author SilenceLamb
 * @apiNote 客户端响应工具类
 */
public class ServletUtils {

    /**
     * 将字符串渲染到客户端
     *
     * @param response 渲染对象
     * @param string   待渲染的字符串
     */
    public static void renderString(HttpServletResponse response, String string) {
        try {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

🧭 自定义权限校验失败

/**
 * @author SilenceLamb
 * @apiNote 权限校验失败
 */
@Component
public class AuthenticationDenied implements AccessDeniedHandler, Serializable {

    private static final long serialVersionUID = 6397846940288115264L;

    /**
     * 在用户权限校验失败时调用
     *
     * @param request       request请求域
     * @param response      response响应
     * @param accessDeniedException 身份验证异常
     */
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        //获取禁止 授权状态码
        int code = HttpStatus.FORBIDDEN.value();
        //将字符串渲染到客户端
        String msg = StringUtils.format("请求访问:{},访问受限,授权过期", request.getRequestURI());
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
    }
}

🧭 自定义退出处理类

/**
 * @author SilenceLamb
 * @apiNote 自定义退出处理类
 */
@Component
public class LogOutSuccessHandler implements LogoutSuccessHandler {

    @Resource
    private TokenService tokenService;
    @Resource
    private RedisUtils redisUtils;
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        LoginUserDetail loginUserDetail = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUserDetail)) {
            String userName = loginUserDetail.getUsername();
            //删除用户缓存记录
            tokenService.delLoginUser(loginUserDetail.getToken());
            //删除登录成功的用户
            redisUtils.deleteObject(RedisCacheConstant.LOGIN_KEY + loginUserDetail.getUsername());
        }
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HTTPCodeMessage.SUCCESS.getCode(), "退出成功")));
    }
}
  • 👉 TokenUtils添加删除用户身份信息
/**
 * 删除用户身份信息
 *
 * @param token token信息
 */
@Override
public void delLoginUser(String token) {
    if (StringUtils.isNotEmpty(token)) {
        String userKey = getTokenKey(token);
        redisUtils.deleteObject(userKey);
    }
}

六、授权处理

image-20230307145251566

☃️用户详细信息服务

/**
 * @author SilenceLamb
 * @apiNote 用户详细信息服务
 */
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {


    /**
     * 根据用户名定位用户。在实际实现中,搜索可能区分大小写或不区分大小写,具体取决于实现实例的配置方式
     *
     * @param userName 标识需要其数据的用户的用户名
     * @return 完全填充的用户记录
     */
    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        //自定义查询逻辑查询用户信息
        UserEntity userEntity = new UserEntity();
        if (StringUtils.isNull(userEntity)) {
            log.info("登录用户:{} 不存在.", userName);
            throw new ServiceException("用户验证处理", "登录用户:" + userName + " 不存在");
        } else if (UserStatus.DELETED.getCode().equals(userEntity.getDelFlag())) {
            log.info("登录用户:{} 已被删除.", userName);
            throw new ServiceException("对不起,您的账号:" + userName + " 已被删除");
        } else if (UserStatus.DISABLE.getCode().equals(userEntity.getStatus())) {
            log.info("登录用户:{} 已被停用.", userName);
            throw new ServiceException("用户验证处理", "对不起,您的账号:" + userName + " 已停用");
        }
        //自定义比对密码(通过用户名查询到的用户信息)
        passwordService.validate(userEntity);
        //创建用户对象存入到Authorities
        return createLoginUser(userEntity);
    }

    public UserDetails createLoginUser(UserEntity userEntity) {
       	//获取菜单权限信息 TODO
        Set<String> menuPermission = permissionService.getMenuPermission(userEntity);
        return new LoginUserDetail(userEntity.getUserId(), userEntity, menuPermission);
    }
}

☃️封装菜单权限信息

/**
 * 获取菜单数据权限
 *
 * @param userEntity 用户信息
 * @return 菜单数据权限 ("*.*.* 或者 system:user:list ...")
 */
@Override
public Set<String> getMenuPermission(UserEntity userEntity) {
    //封装菜单数据权限权限信息
    Set<String> menuPermission = new HashSet<>();
    // 管理员拥有所有权限
    if (SecurityUtils.isAdmin(userEntity.getIdentity())) {
        menuPermission.add("*:*:*");
    } else {
        //非管理员-从数据库中查询菜单权限 比如:{select:user:list}
        menuPermission.add("select:user:list");
    }
    return menuPermission;
}

☃️用户状态枚举

/**
 * @author SilenceLamb
 * @apiNote 用户状态枚举
 */
public enum UserStatus {
    OK("0", "正常"), DISABLE("1", "停用"), DELETED("2", "删除");

    private final String code;
    private final String info;

    UserStatus(String code, String info) {
        this.code = code;
        this.info = info;
    }

    public String getCode() {
        return code;
    }

    public String getInfo() {
        return info;
    }
}

☃️验证密码

1【身份验证信息线程】

  • 🌳 首先创建一个身份验证信息线程

    • SecurityContextHolder使用 ThreadLocal来存储这些详细信息

    • 这意味着SecurityContext始终可用于同一线程中的方法

    • 即使SecurityContext未明确将其作为参数传递给这些方法

    • ThreadLocal如果在处理当前主体的请求后注意清除线程

    • 那么以这种方式使用是非常安全的

/**
 * @author SilenceLamb
 * @apiNote 身份验证信息线程
 */
public class AuthenticationContext {
    private static final ThreadLocal<Authentication> context = new ThreadLocal<>();

    @ApiModelProperty("从线程中获取Authentication")
    public static Authentication getAuthentication() {
        return context.get();
    }

    @ApiModelProperty("把Authentication存入线程")
    public static void setAuthentication(Authentication authentication) {
        context.set(authentication);
    }

    @ApiModelProperty("从线程中移除Authentication")
    public static void removeAuthentication() {
        context.remove();
    }
}
  • Authentication包含

    1. principal:识别用户:当使用用户名/密码进行身份验证时,这通常是UserDetails
    2. credentials:通常是密码:在许多情况下,这将在用户通过身份验证后被清除,以确保它不被泄露
    3. authorities:GrantedAuthoritys:是授予用户的高级权限,一些示例是角色或范围
  • 校验密码时:使用线程获取Authentication

  • 从上下文中获取Authentication

    • Authentication authentication = AuthenticationContext.getContext();
  • 从Authentication中获取用户输入的信息

    • String inUserName = authentication .getName();
    • String inPassWord = authentication .getCredentials().toString();

2【实现密码校验】

/**
 * @author SilenceLamb
 * @apiNote 密码校验
 */
@Slf4j
@Service("permissionService")
public class PasswordServiceImpl implements PasswordService {

    @Resource
    private RedisUtils redisUtils;
    @Autowired
    private PassWordProperties passWordProperties;

    /**
     * 登录密码方法
     *
     * @param userEntity 通过用户名查询到的用户信息
     */
    @Override
    public void validate(UserEntity userEntity) {
        //从上下文中获取Authentication
        Authentication authentication = AuthenticationContext.getAuthentication();

        //从Authentication中获取用户输入的信息
        String inUserName = authentication.getName();
        String inPassWord = authentication.getCredentials().toString();

        //查询密码错误输入次数
        Integer retryCount = getRetryCount(inUserName);
        if (retryCount == null) {
            retryCount = 0;
        }
        //判断次数是否大于密码最大错误次数
        if (retryCount >= passWordProperties.getMaxRetryCount()) {
            //抛出密码过长异常
            String message = MessageUtils.message("user.password.retry.limit.exceed");
            log.info(message);
            throw new UserPasswordMaxNumberExceptions(passWordProperties.getMaxRetryCount(), passWordProperties.getLockTime());
        }
        //进行密码比对
        if (!isCheckPassword(userEntity, inPassWord)) {
            //比对失败记录输入次数
            String message = MessageUtils.message("user.password.retry.limit.count", retryCount);
            //将输入次数缓存到redis中
            cacheToRedis(inUserName, retryCount);
            throw new UserPasswordNotMatchException();
        } else {
            //登录成功之后清空记录
            clearLoginRecordCache(inUserName);
        }
    }
}
/**
 * 登录账户密码错误次数缓存键名
 *
 * @param userName 用户名
 * @return 缓存键key
 */
private String getCacheKey(String userName) {
    return RedisCacheConstant.PWD_ERR_CNT_KEY + userName;
}

/**
 * 查询密码错误输入次数
 *
 * @param inUserName 登录时输入的账户名
 * @return
 */
private Integer getRetryCount(String inUserName) {
    return redisUtils.getCacheObject(getCacheKey(inUserName));
}

/**
 * 将输入次数缓存到redis中
 *
 * @param userName   登录时输入的账户名
 * @param retryCount 记录错误次数
 */
private void cacheToRedis(String userName, Integer retryCount) {
    redisUtils.setCacheObject(getCacheKey(userName), retryCount, passWordProperties.getLockTime(), TimeUnit.MINUTES);
}

/**
 * 进行密码比对
 *
 * @param userEntity       通过用户名查询到的账户信息
 * @param inPassWord 用户登录时输入的信息
 * @return
 */
private boolean isCheckPassword(UserEntity userEntity, String inPassWord) {
    boolean isCheckPassword = SecurityUtils.matchesPassword(inPassWord, userEntity.getPassword());
    return isCheckPassword;
}

/**
 * 清空记录
 *
 * @param userName 登录时输入的账户名
 */
private void clearLoginRecordCache(String userName) {
    //清空redis中的缓存
    if (redisUtils.hasKey(getCacheKey(userName))) {
        redisUtils.deleteObject(getCacheKey(userName));
    }
}

3【封装SecurityUtils工具类】

/**
 * 生成BCryptPasswordEncoder密码
 *
 * @param password 密码
 * @return 加密字符串
 */
public static String encryptPassword(String password) {
    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    return passwordEncoder.encode(password);
}

/**
 * 判断密码是否相同
 *
 * @param rawPassword     真实密码
 * @param encodedPassword 加密后字符
 * @return 结果
 */
public static boolean matchesPassword(String rawPassword, String encodedPassword) {
    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    return passwordEncoder.matches(rawPassword, encodedPassword);
}

4【密码错误异常】

/**
 * @author SilenceLamb
 * @apiNote 用户信息异常类
 */
public class UserException extends BaseException {

    private static final long serialVersionUID = -665049271990221109L;

    public UserException(String code, Object[] args) {
        super("user", code, args, "user.error");
    }
}
/**
 * @author SilenceLamb
 * @apiNote 用户密码不正确或不符合规范异常类
 */
public class UserPasswordNotMatchException extends UserException {
    private static final long serialVersionUID = 1L;

    public UserPasswordNotMatchException() {
        super("user.password.not.match", null);
    }
}
/**
 * @author SilenceLamb
 * @apiNote 用户错误最大次数异常类
 */
public class UserPasswordMaxNumberExceptions extends UserException {

    private static final long serialVersionUID = -3827920653693087627L;

    public UserPasswordMaxNumberExceptions(int retryLimitCount, int lockTime) {
        super("user.password.retry.limit.exceed", new Object[]{retryLimitCount, lockTime});
    }
}

七、不鉴权注解

/**
 * @author SilenceLamb
 * @apiNote 匿名访问不鉴权注解
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Anonymous {
}
/**
 * @author : SilenceLamb
 * @apiNote : 获取Anonymous注解的路径
 */
@Component
public class AnonymousConfig {
    /**
     * 获取标有注解 AnonymousAccess 的访问路径
     */
    public static String[] getAnonymousUrls() {
        // 获取所有的 RequestMapping
        Map<RequestMappingInfo, HandlerMethod> handlerMethods = SpringUtils.getBean(RequestMappingHandlerMapping.class).getHandlerMethods();
        Set<String> allAnonymousAccess = new HashSet<>();
        // 循环 RequestMapping
        for (Map.Entry<RequestMappingInfo, HandlerMethod> infoEntry : handlerMethods.entrySet()) {
            HandlerMethod handlerMethod = infoEntry.getValue();
            // 获取方法上 Anonymous 类型的注解
            Anonymous methodAnnotation = handlerMethod.getMethodAnnotation(Anonymous.class);
            // 如果方法上标注了 Anonymous 注解,就获取该方法的访问全路径
            if (methodAnnotation != null) {
                allAnonymousAccess.addAll(infoEntry.getKey().getPatternsCondition().getPatterns());
            }
        }
        return allAnonymousAccess.toArray(new String[0]);
    }
}

八、核心配置类

/**
 * @author SilenceLamb
 * @apiNote spring security配置
 */
@SuppressWarnings("all")
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

}
  • 🌳解决 无法直接注入 AuthenticationManager
/**
 * 解决 无法直接注入 AuthenticationManager
 *
 * @return
 * @throws Exception
 */
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
    return super.authenticationManager();
}
  • 👉🏽 强散列哈希加密实现
/**
 * 强散列哈希加密实现
 */
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

/**
 * 身份认证接口
 */
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
  • 核心配置
/**
 * anyRequest          |   匹配所有请求路径
 * access              |   SpringEl表达式结果为true时可以访问
 * anonymous           |   匿名可以访问
 * denyAll             |   用户不能访问
 * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
 * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
 * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
 * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
 * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
 * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
 * permitAll           |   用户可以任意访问
 * rememberMe          |   允许通过remember-me登录的用户访问
 * authenticated       |   用户登录后可访问
 */
/**
 * 自定义用户认证逻辑
 */
@Autowired
private UserDetailsService userDetailsService;

/**
 * 认证失败处理类
 */
@Autowired
private AuthenticationError unauthorizedHandler;

/**
 * 退出处理类
 */
@Autowired
private LogOutSuccessHandler logoutSuccessHandler;

/**
 * token认证过滤器
 */
@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;

/**
 * 跨域过滤器
 */
@Autowired
private CorsFilter corsFilter;
@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()
            // 对于登录login 注册register 验证码captchaImage 允许匿名访问
            .antMatchers("/login", "/register", "/captchaImage").anonymous()
            // 静态资源,可匿名访问
            .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
            .antMatchers("/swagger-ui.html", "/swagger", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
            //允许匿名访问
            .antMatchers(AnonymousConfig.getAnonymousUrls()).anonymous()
            // 除上面外的所有请求全部需要鉴权认证
            .anyRequest().authenticated()
            .and()
            .headers().frameOptions().disable();

    // 添加Logout filter
    httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
    // 添加JWT filter 在UsernamePasswordAuthenticationFilter之前
    httpSecurity.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
    // 添加CORS filter 在JwtAuthorizationFilter之前
    httpSecurity.addFilterBefore(corsFilter, JwtAuthorizationFilter.class);
    httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}

九、登录逻辑处理

    /**
     * 登录验证
     *
     * @param loginBody 登录用户信息
     * @return token
     */
    @Override
    public String login(LoginBody loginBody) {
        String userName = loginBody.getUserName();
        String password = loginBody.getPassword();
        String code = loginBody.getCode();
        String uuid = loginBody.getUuid();
        Authentication authentication;
        try {
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userName, password);
            //将usernamePasswordAuthenticationToken放进线程中
            AuthenticationContext.setAuthentication(authenticationToken);
            //将封装的 authenticationToken 交给AuthenticationManager
            authentication = authenticationManager.authenticate(authenticationToken);
        } catch (AuthenticationException e) {
            if (e instanceof BadCredentialsException) {
                throw new UserPasswordNotMatchException();
            } else {
                throw new ServiceException("密码校验业务", e.getMessage());
            }
        } finally {
            AuthenticationContext.removeAuthentication();
        }
        if (redisUtils.hasKey(RedisCacheConstant.LOGIN_KEY + userName)) {
            throw new ServiceException("登陆状态业务", "该账号已在其他设备登录", 402);
        }
        LoginUserDetail loginUserDetail = (LoginUserDetail) authentication.getPrincipal();
        /*记录登录信息*/
        userService.RecordLoginInfo(loginUserDetail.getUserId());
        /*生成token*/
        return tokenService.createToken(loginUserDetail);
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Silence Lamb

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值