admin管理员组

文章数量:1614997

SpringBoot

内容管理

    • Shiro
      • 创建demo 用户【用户包括token ,角色,权限】
      • :fist_oncoming: Shiro配置 配置5个基本对象 + 3额外对象 路径匹配有先后
      • :peach: 实现认证 loginController + Realm的认证
      • 设置登出功能logout
      • 密码加密(hash + salt加密) salt在Realm中SimpeXXX Info中构造器中指定
      • 存储时密码转为对应方式的密文 【salt加密】 SimpleHash类
      • :pear: 实现授权 【为principal绑定role、permission】
        • 前台页面的控制:
        • 后台资源控制 【之前配置了注解的使用,可以用注解代替】
      • 多Realm认证
        • 认证策略 怎样算认证成功
      • Shiro其他的功能 记住我so on securityManager中配置
        • 记住我 where how long CookieRememberMeManager
      • Session管理 SessionManager
      • 认证缓存 CacheManager 不需要每次鉴权都进行授权
      • Shiro: 多realm认证中信息:could not be authenticated by any configured realms. Please ensure that at least one realm can authenticate these tokens. 不准确
      • Shiro: 静态资源设置为/static/** ,anon 不放行 【加路径】
      • Shiro : is not eligible for getting processed by all BeanPostProcessors
      • BeanUtil: cloneBean时实体类的set方法不能返回对象
      • yaml配置注入: @Vlaue("${}")不需要set方法, @ConfigurationProperties + Set注入,需要set方法
    • 基于Spring Security的注册登录
      • Spring Security
      • 用户注册登录 SpringSecurity 中内置PasswordEncoder, 认证授权由UserDetailsService完成【实现接口】
        • :peach: Spring Security配置类【新版SpringBoot弃用 :heart: 】 @EnableWebSecurity 复合注解
      • 密码加密BCrypt
      • SpringSecurity默认用户名和临时密码【整体项目】
        • :warning: WARNING: 解释关于Spring security访问的几个问题
      • spring security 权限管理
        • 权限管理ER: user,role,permission
        • 认证授权 userDetailsService 【自定义配置的processingUrl会最终使用该类进行认证加授权】 插入数据getXXX.add()
        • 后台路径授权管理
          • securityconfig中配置
          • 使用注解完成 @EnableGlobalMethodSecurity
        • 前台授权管理
      • 记住我Remember me
      • 会话管理Session 【普通web管理】
      • Session-cookie管理 Redis集群
        • 使用Spring-session管理Session Session集群:baggage_claim:
        • session并发配置:b: 统一设备在线数量
        • 强制下线
      • JWT(JSON Web Token)
        • Spring security 集成JWT
        • spring security JWT配置 全局配置manager,Session无状态
      • OAuth 2.0
        • OAuth2.0主要概念
        • 授权模式
      • 集成OAuth 2.0实现SSO单点登录 @EnableOAuth2Sso 开启单点登录
        • 框架默认端点URL :eight_pointed_black_star:
        • 授权服务 @EnableAuthorizationServer 资源服务@EnableResourceServer
    • Spring security 和Shiro个人Idea


SpringBoot开发技术 — 应用程序安全,Spring security,Shiro


加快进度…数据库持久化一般就是选用Mybatis、Mybatis-plus或者使用JPA,使用JPA也可以@Query书写SQL,使用redis的时候如果使用Jedis客户端,必须要排除Lettuce,同时要进行配置,可以在yaml中配置,可以通过@Value和@ConfigurationProperties将配置项注入给属性或者对象,redisTemplate可以进一步封装,方便进行缓存,一般全局的缓存使用@EnableCaching和@Cacheable来开启缓存和使用缓存,这样方法就不会真正执行,减少执行时间,还可以将高频访问数据缓存起来;使用MongoDB的时候可以使用Template或着repositroy的方式,实体要加上@Document才会由MongoDB维护; MongoDB注重的是海量,可以压缩,可以@Indexed加上索引,Redis注重的是高性能,一般用作Cache

Shiro和Spring security使用都挺普遍的,Shiro非常easy,就realm加上一些鉴权即可; spring security在配合spring boot后使用也逐渐宽泛,本文的代码分为两个Demo演示

Web开发中还有一个要点就是应用程序的安全性,比如一般通过登录验证保护用户的个人资源,会员制度将会员和普通用户享用的功能区分,管理系统要进行角色进行相关的权限的管理,安全管理框架: Spring Security和Shiro,Shiro为轻量级,主要就是一个验证器

  • 认证authentication : 确定用户身份; 包括用户名密码,或者指纹,就是token
  • 授权 authorization : 对用户访问系统资源的行为进行控制,确定权限

RBAC role based access control 访问控制基于角色,安全管理的实现思路就是前台通过相关的标签进行标记,后台通过拦截器将资源拦截,用户关联的是角色role,role关联的是权限和资源,通过资源和权限关联最终的效果

本文创建demo为cfeng-security-demo,发布在Gitee仓库

spring init -d web,mustache,jpa,mysql,devtools,security --build maven -p war cfeng-security-demo //后期的其他的比如Shiro的依赖后面加入

Shiro

Apache Shiro是Java的安全权限框架,Shiro可以完成认证,授权,加密,会话管理和Web继承,缓存

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RA1iUhSf-1658691438692)(https://tse4-mm.bing/th/id/OIP-C.uiYdh3VC6KEbbMKQIsQ-9AHaEN?pid=ImgDet&rs=1)]

Shiro的架构: 从外部应用程序角度来观察Shiro:

  • Subject: 当前访问的主体,应用程序主要和subject关联
  • Shiro SecurityManager: Shiro核心管理器,管理所有的Subject
  • Realm: 获取管理的资源data

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MiKUgA41-1658691438694)(https://ts1.mm.bing/th/id/R-C.c1e6aee336420cc5d699baecdd2d975d?rik=P4aNIrH3F5cfdQ&riu=http%3a%2f%2fwww.uml%2fsafe%2fimages%2f2020012031.png&ehk=VGH8nb1P1BoYQCwYZSWlxuU9veYVOfuGY129fMf8Neo%3d&risl=&pid=ImgRaw&r=0&sres=1&sresct=1)]

Shiro的架构: 从内部查看,就是Security Manager内部的构成,是使用到了CacheManager的

主要就是一个Authenticator,Authorizer相关的认证器,授权器,使用比较easy

Subject currectUser = SecurityUtils.getSubject();//获得当前用户
Session session = currentUser.getSession(); //获取当前用户的session
//认证
currentUser.login(token);  //登录后抛出的各种异常判断登录结果
//授权
currentUser.hasRole
currentUser.isPermitted
currentUser.isAuthenticated [是否认证]

currentUser.logout()

在Spring boot中使用shiro需要引入相关的起步依赖,也就是shiro-spring-boot-starter

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>1.6.0</version>
</dependency>

日志就使用内置的Slf4j,就不引入log4j

创建demo 用户【用户包括token ,角色,权限】

这里就直接放在一个表中,本来应该分表

@Data
@Accessors(chain = true)
public class ShiroUser {
    //token认证信息
    private String userName;

    private String userPwd;

    //用户角色
    private List<String> userRoles;
    //用户权限资源
    private List<String> userPermissions;
}

👊 Shiro配置 配置5个基本对象 + 3额外对象 路径匹配有先后

凭证匹配器,Realm,SecurityManager, ShiroFilterFactoryBean路径过滤器,委托过滤器注册器Bean ; 以及使用Shrio注解的配置,前台配置

Shiro有5个核心对象:

  • Shiro是会自动完成密码的匹配的,所以需要自定义凭证匹配器,注入散列算法hashAlgorithmName和散列次数hashIterations

  • 访问安全资源需要Realm,Realm会授权验证来放行资源,Realm的认证需要使用凭证匹配器

  • 控制路径,核心对象就是SecurityManager,【可以首先判断该类存在Conditional】,管理整个流程需要Realm来访问资源

  • 路径过滤器工厂ShiroFilterFactoryBean,从用户角度就是对于不同的路径进行了限制,就是Filter,Filter需要配置相关的路径,包括为登录访问页面loginUrl,相关的放行路径aonoUrls,授权路径authcUrls,还有登出路径loginoutUrl, 需要使用SecurityManager对象;设置过滤器链FilterChainDefintionMap《String,String》:key就是ant路径: 设置LoginUrl之后如果未登录会访问,和webconfig设置的欢迎页面不同,【最高优先级/】(只是authc的页面要跳转登录) 最核心;路径匹配是按照先后顺序的,所以一般越详细的规则先定义【如果前面的匹配上,后面的不会再匹配

    **: 匹配多级路径

    *: 匹配单级路径

    ?: 匹配单个

  • 上面将路径过滤器注册进了Spring容器,但是一般过滤器是由Tomcat管理,所以需要委托过滤器注册器Bean协助加入Servlet容器FilterRegistrationBean ; 委托的过滤器注册器不需要上面的路径过滤器,只要创建代理类,设置属性为true让Servlet管理

  • 🉑如果要使用Shiro的注解,比如@RequiredPerssions,那么还需要配置aop切面通知,因为注解就是要检查用户的Subject; 包括两个对象: AuthorizationAttributeSourceAdvisor 和DefaultAdvisorAutoProxyCreator 【切面通知的代理】

  • 🉑如果要在前台中使用shiro的相关的属性,需要配置ShiroDialect,当然需要先加入依赖:

     		<dependency>
                <groupId>com.github.theborakompanioni</groupId>
                <artifactId>thymeleaf-extras-shiro</artifactId>
                <version>2.0.0</version>
                <scope>compile</scope>
            </dependency>
    

需要创建自定义Realm类访问安全资源,配置类中直接new一个单例对象

配置类中可以加上条件注解:比如@ConditionalOnClass ,就是只有项目中存在某个类才会创建该配置类的对象Bean

yaml中的配置: 有先后,详细的在前,authc的可直接给出perms,这样就不需要判断isPermitted

shiro:
#  enabled: true  #开启shiro,SpringBoot中的,下面配置相关路径的过滤器,包括不处理anon 短横线连接自动转为驼峰
  #配置散列加密算法,散列次数 --- 配置凭证(密码)匹配器,Shiro自动匹配密码
  hash-algorithm-name: md5
  hash-iterations: 2
  anon-urls: 
    - /index.html*
    - /img/**
    - /js/**
    - /css/**
    - /login/login*
  authc-urls:
    - /**
  login-url: /index.html
  logout-url: /login/loginut*

除了该注解之外,还@ConditionalOnWebApplication: 只有当前的服务为Web服务才会创建对象; 除了这些基本的对象,还可以创建其他的对象,比如SimpleMappingExceptionResolver 注册没有权限访问时的403页面

这里可以配置多个Realm,就是setRealms,Realm对象可以不用在这里进行管理,可以直接@Configuration装载进入,凭证适配器可以不用显式配置,直接在Realm中new出来注入即可

package indv.Cshen.cfengsecuritydemo.config;

import at.pollux.thymeleaf.shiro.dialect.ShiroDialect;
import indv.Cshen.cfengsecuritydemo.realm.MyRealm;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.DelegatingFilterProxy;

import javax.servlet.Filter;
import java.util.HashMap;
import java.util.Map;

/**
 * @author Cfeng
 * @date 2022/7/19
 * Shiro的相关配置对象,包括Realm,SecurityManager,ShiroFilterFactoryBean请求过滤器工厂
 * 这里还是按照SpringBoot的思路,对应yaml中的配置,SpringBoot内置的很少,所以自定义配置shiro
 */

@Configuration //可以加上配置条件注解,只有满足条件才会创建其中的Bean
@ConditionalOnClass(value = {SecurityManager.class}) //只有导入了Shiro包存在SecurityManager对象才会创建该Config
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)//只有项目为Web项目才创建
@ConfigurationProperties(prefix = "shiro") //将配置的shiro参数注解注入,就不需要@Value逐一处理
@Data  //包含setter和getter,要将配置的属性注入必须有相关的set方法,不然不能注入属性
public class ShiroConfig {
    //散列算法,加密方式
    private String hashAlgorithmName;

    //散列次数
    private int hashIterations;

    //默认的登录页面Shiro控制
    private String loginUrl;

    //anonUrls,不登录即可访问的页面,这里为一个String数组
    private String[] anonUrls;

    //authcUrls,权限urls,不要授权才能访问的页面
    private String[] authcUrls;

    //logoutUrl,登出页面
    private String logoutUrl;

    //委托给Servlet容器管理的过滤器的名称,也就是FilterFactoroyBean的名称,要将这个Filter对象交给ServletContainer管理
    private static final String SHIRO_FILTER = "shiroFilter";

    //thymeleaf结合Shiro,设置ShiroDialect对象名称
    private static  final  String SHIRO_DIALECT = "shiroDialect";
    /**
     * 声明凭证匹配器,Shiro会帮助匹配凭证也就是密码,认证过程中,一般密码是经过加密之后的,所以需要给出加密的方式和散列的次数
     */
    @Bean(name = "credentialsMatcher")
    public HashedCredentialsMatcher hashedCredentialsMatcher() {
        HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
        credentialsMatcher.setHashAlgorithmName(hashAlgorithmName);
        credentialsMatcher.setHashIterations(hashIterations);
        return credentialsMatcher;
    }

    /**
     * 访问安全资源需要Realm桥梁,Realm认证过程使用凭证匹配器自动完成凭证匹配,所以依赖凭证
     */
    @Bean(name = "myRealm")
    public Realm getRealm(HashedCredentialsMatcher credentialsMatcher) {
        //创建一个Reaml交给SecurityManager用于访问资源
        //需要装载自定义凭证匹配器
        MyRealm realm = new MyRealm();
        realm.setCredentialsMatcher(credentialsMatcher);
        return realm;
    }

    //配置SecurityManager对象,流程控制核心对象,依赖上面创建的Realm,这里可以设置优先级
    @Bean(name = "securityManager")
    public DefaultWebSecurityManager getSecurityManager(Realm myRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        //必须知道数据源 realm【访问数据源的】
        securityManager.setRealm(myRealm);
        return securityManager;
    }
    //配置ShiroFilterFactoryBean对象 请求过滤器,这里引入的名称和下面的必须相同
    @Bean(name = SHIRO_FILTER)
    public ShiroFilterFactoryBean  getShiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
        //需要依赖安全管理器
        filterFactoryBean.setSecurityManager(securityManager);
        //设置未登录时跳转的页面,也就是authc资源会要求登录
        filterFactoryBean.setLoginUrl(loginUrl);
        //过滤器路径匹配,key为ant路径,支持匹配,value为Shiro默认过滤器包括anon,authc,logout
        Map<String,String> filterChainDefintionMap = new HashMap<>();
        //设置放行路径,不登陆检查
        if(anonUrls != null && anonUrls.length > 0) {
            for(String anon : anonUrls) {
                filterChainDefintionMap.put(anon,"anon");
            }
        }
        //设置登出路径
        if(logoutUrl != null) {
            filterChainDefintionMap.put(logoutUrl,"logout");
        }
        //设置拦截路径
        if(authcUrls != null && authcUrls.length > 0) {
            for(String authc : authcUrls) {
                filterChainDefintionMap.put(authc,"authc");
            }
        }
        //配置过滤器,上面的路径匹配都是String,要将String对应为Filter
        Map<String,Filter> filterMap = new HashMap<>();
        //上面的anon等都是Shiro内置的,不需要再配置,这里用于配置自定义
//        filterMap.put("oauth2", new Oauth2Filter())
        //将路径过滤器映射map装载,包括ChainMap和FilterMap
        filterFactoryBean.setFilters(filterMap);
        filterFactoryBean.setFilterChainDefinitionMap(filterChainDefintionMap);
        //未授权时访问自动访问路径
        filterFactoryBean.setUnauthorizedUrl("/common/unauthorized");
        //返回此对象
        return filterFactoryBean;
    }

    @Bean
    //shiro委托过滤器,将Spirng中的过滤器注册到Servlet容器,代理类为DeleGatingFilterProxy过滤器代表代理类,依赖上面的FfacotyBean
    public FilterRegistrationBean<DelegatingFilterProxy> delegatingFilterProxy() {
        FilterRegistrationBean<DelegatingFilterProxy> filterRegistrationBean = new FilterRegistrationBean<>();
        //注册委托的Filter让Servlet管理
        DelegatingFilterProxy proxy = new DelegatingFilterProxy();
        //设置目标过滤器由Servlet容器管理,false表示由Spring容器管理
        proxy.setTargetFilterLifecycle(true);
        proxy.setTargetBeanName(SHIRO_FILTER);
        filterRegistrationBean.setFilter(proxy);
        //除了上面的写法还可以添加参数方式
//        filterRegistrationBean.setFilter(new DelegatingFilterProxy(SHIRO_FILTER));
//        filterRegistrationBean.addInitParameter("targetFilterLifecycle","true");
        return filterRegistrationBean;
    }
    /**
     * 使用Shiro的注解需要配置相关的切面通知的对象,和代理对象,adviser需要使用securityManager
     * 只有配置对象之后才能使用Shiro注解
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        //advisor的运行需要securityManager的协助
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

    @Bean
    public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        //开启代理类功能
        advisorAutoProxyCreator.setProxyTargetClass(true);
        return advisorAutoProxyCreator;
    }

    /**
     * 如果要在thmeleaf中结合Shiro,那么需要shiroDalect;Spring Security也是可以放在其中的,类似
     * <p shiro:hasAllRoles="developer, 2">
     */
    @Bean(name = SHIRO_DIALECT)
    public ShiroDialect getShiroDialect() {
        return new ShiroDialect();
    }
    
    //=========================其他的一些对象的配置=========================
    /**
    * 配置无权访问时的403页面,使用SimpleMappingExceptionResolver对象注册页面
    */
    @Bean
    public SimpleMappingExceptionResolver simpleMappingExceptionResolver() {
        SimpleMappingExceptionResolver resolver = new SimpleMappingExceptionResolver();
        Properties properties = new Properties();
        properties.setProperty("UnauthorizedException","/403.html");
        resolver.setExceptionMappings(properties);
        return resolver;
    }
}

🍑 实现认证 loginController + Realm的认证

  1. 创建自定义的Realm对象,继承AuthorizingRealm,Realm就是一个安全Dao,要访问安全数据,就需要realm对象,realm中主要就是完成认证,授权,认证就是根据传入的token查询数据库,最终返回一个AhthentcationInfo对象【密码比对由Shiro完成,密码敏感】,Realm中就是完成主要的认证逻辑

    • 实现父类的doGetAuthenticationInfo认证方法
    • 处理器方法使用Subjet的login传入的token最终会到达Realm【桥梁】,所以直接强制转型
    • 需要注入Service访问数据库和当前的token的身份进行比对,如果存在用户才返回info,密码比对自动完成【注入有最初的@Resource注入,以及final构造器注入,是final不是static,表示不可变,构造器可以使用lombok简化】
    /**
     * @author Cfeng
     * @date 2022/7/19
     * Shiro的关键对象,代表的是系统资源,就是一个安全资源的Dao,认证用户之后访问安全的资源
     * 该类就是设置本项目的相关的资源 ,继承AuthorizingRealm,授权范围; 还有一个认证的,一定注意是继承授权系统资源
     */
    
    @Slf4j
    @NoArgsConstructor
    public class MyRealm extends AuthorizingRealm {
    
        @Lazy
        @Resource
        private ShiroUserService shiroUserService;
    
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
            log.info("entered MyReamlm doGetAuthorizationInfo method");
            return null;
        }
    
        //认证,返回的是身份和密码principal和credentials
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
            log.info("======entered MyReamlm doGetAuthenticationInfo method");
            //之前的login登录验证最后要访问Realm,当时的token最后会传入给Realm,所以这里的token就是之前的token
            //所以类型其实是相同的,可以强制类型转换
            UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
            //获取数据库中的用户,与当前用户进行比较,这里是使用的Service进行查询
            String userName = usernamePasswordToken.getUsername();
            ShiroUser user = shiroUserService.queryUserByName(userName);
            if(null == user) {
                return null; //这里就会在login位置抛出账户不能存在的异常
            }
            //找到之后将查询到的对象返回
            //返回AuthentiacationInfo,完成认证,密码比对由Shiro完成,因为很敏感
            SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(user,user.getUserPwd(),"MyRealm");
            return simpleAuthenticationInfo;
        }
    }
    //这里只是简单进行登录认证
    
  2. 配置路径过滤器,【ShiroConfig】中配置到Factory中,这个是在配置中完成的,可以自定义路径在yaml文件中再进行注入,注入记得使用@Data ,如果是@Value单项注入的就不用

    shiro中默认的过滤器:

    anon: AnonymousFilter,没有参数,表示可以直接使用,没有安全,可以不登陆访问

    authc: 没有参数,表示要认证之后才能使用

    authcBasic: 没有参数,表示需要通过httpBasic验证,不通过就跳转登录页面

    logout: 注销登录的时候,完成一定功能: 任何现有的Session都会失效,身份失去关联

    perms: 参数可以多个,都好分割,每个参数都必须通过才通过,相当于isPermitedAll()

  3. 编写访问的controller,这个controller就是登录之后访问的处理器,在该处理器中执行login操作,这一系列操作最终会进入Realm进行验证,所以需要封装token和session【可以把当前的user放入】

//主要负责的就是登录的认证,入口就是subject.login
@RestController
@RequestMapping("/common")
@Slf4j
public class LoginController {

    @PostMapping("/login")
    //属性注入
    public Object login(ShiroUser user) {
//        System.out.println(user);
        Map<String,String> errorMsg = new HashMap<>();
        //获取当前的登录的主体
        Subject currentUser = SecurityUtils.getSubject();
        //登录当前主体,这里的登录需要使用Realm【Realm就是访问数据源的Dao】
        //获得当前用户的token
        if(!currentUser.isAuthenticated()) {
            //没有认证,认证
            UsernamePasswordToken token = new UsernamePasswordToken(user.getUserName(),user.getUserPwd());
            //登录当前主体
            try {
                currentUser.login(token);
                currentUser.getSession().setAttribute("currentUser",currentUser.getPrincipal());
                return "login succeed";
            } catch (UnknownAccountException uae) {
                log.info("There is no user with username of" + token.getPrincipal());
                errorMsg.put("errorMsg","用户不存在");
            } catch (IncorrectCredentialsException ice) {
                log.info("Password for account" + token.getPrincipal() + "was incorrect");
                errorMsg.put("errorMsg","密码不正确");
            } catch (LockedAccountException lae) {
                log.info("the account for username" + token.getPrincipal() + "is locked");
                errorMsg.put("errorMsg","账户锁定");
            }
            catch (AuthenticationException e) {
                log.info("登录失败",e);
                errorMsg.put("errorMsg","登录失败");
            }
            return errorMsg;
        }
        return null;
//        else {
//            //已经登录
//        }
    }

    @GetMapping("/toLogin")
    public String test() {
        return "你好,CHINE";
    }

    @GetMapping("/getCurrentUser")
    public  ShiroUser getCurrentUser() {
        Subject currentUser = SecurityUtils.getSubject();
        Session session = currentUser.getSession();
        return (ShiroUser) session.getAttribute("currentUser");
    }

设置登出功能logout

登出有两种方式,一种是直接前台访问后台处理器,处理器中完成登出,第二种就是直接配置登出路径即可,相当于就是第一种方式

  logout-url: /common/logout
  
  logout = function () {
            $.get("/securityDemo/common/logout",function (response) {
                location.href = "login.html"; //资源路径
            })
        }
  
//    @GetMapping("/logout"),由系统完成,配置了的
//    public void logout() {
//        Subject currentUser = SecurityUtils.getSubject();
//        //登出,销毁身份
//        currentUser.logout();
//    }

密码加密(hash + salt加密) salt在Realm中SimpeXXX Info中构造器中指定

Shiro在认证的时候会获取一个Credentials对密码进行比对,默认情况下使用的时SimpeCredentialsMacther进行比对,也就是对密码不做任何处理,但是现在一般都不要对密码进行加密,Shiro内部有很多Matcher,包括常用的Md5CredentialsMatched 继承lHashedXXXX

上面we在Shiro的配置文件中就配置了凭证匹配器,凭证匹配器就是Realm对象的一个属性【因为Realm中进行比对】

HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
credentialsMatcher.setHashAlgorithmName(hashAlgorithmName);
credentialsMatcher.setHashIterations(hashIterations);
return credentialsMatcher;

salt加密: salt加密后在认证过程中也需要让Shiro了解,就是在Realm认证时指定数据库中salt加密的salt,这个salt可以专门给每一个用户存储一个盐,或者就用用户名即可

        ByteSource salt = ByteSource.Util.bytes(user.getUserName());
		//给当前用户绑定身份
        //返回AuthentiacationInfo,完成认证,密码比对由Shiro完成,因为很敏感,构造器中还可以加入salt
        SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(user,user.getUserPwd(),salt,this.getName());
        return simpleAuthenticationInfo;

这里使用的就是HashedCredentialMatcher,下属的其中一个就是md5加密,通过设置Hash的Name即可

public class HashedCredentialsMatcher extends SimpleCredentialsMatcher {
    private String hashAlgorithm;//对应hash的加密算法对应 MD5
    private int hashIterations;  //多次加密,迭代次数
    private boolean hashSalted; //不需要
    private boolean storedCredentialsHexEncoded;//默认ture即可

这就是hashed匹配器的几个属性,包括散列算法名,散列次数,主要就是设置这两个

shiro:
  hash-algorithm-name: MD5
  hash-iterations: 2
  anon-urls:
    - /login.html*
    - /js/**
    - /common/login*
  authc-urls:
    - /**
  login-url:  /login.html
  logout-url: /common/logout

这里我们设置了加密的方式和次数,那么在进行登录认证的时候就会自动将密码进行加密再和数据库中存储的密码进行比较,在Realm中设置了salt,这样Shiro会自动使用MD5散列2次,每次都加入Salt进行比较

存储时密码转为对应方式的密文 【salt加密】 SimpleHash类

在进行用户注册的时候,对密码一般使用加密工具类加密之后再进行存储,这里可以将配置文件中的属性注入,salt可以直接使用用户名,ByteSource都是直接使用其util进行转换

//======================加密工具类===========使用SimpleHash
 * 在用户注册时,将用户的密码使用该工具类加密后再存储进入数据库中,salt使用用户名
 * 因为这里不支持给静态属性注入yaml中的属性,所以可以直接让Spring再容器中创建一个单例Bean来处理
 */
@Component //创建一个单例Bean默认
public class PwdEncryptUtil {

    @Value("${shiro.hash-algorithm-name}")//注入的时候可以先给属性设置默认值,避免用户没有注入抛出异常
    private String algorithmName;

    @Value("${shiro.hash-iterations}")
    private int hashIterations;

    /**
     * 输入加密前的密码,得到之后的密码,salt用户名,MD5,散列2次
     */
    public String encrypt(ShiroUser user) {
        SimpleHash simpleHash = new SimpleHash(algorithmName, ByteSource.Util.bytes(user.getUserPwd()),ByteSource.Util.bytes(user.getUserName()),hashIterations);
        return simpleHash.toString();
    }
}

那么we在存储用户信息到数据库的时候就要存储密码的密文 🌶,要将密码转为密文存储,这Md5Hash是继承的SimpleHash,所以可以建立工具类来加密存储密文

public class SimpleHash extends AbstractHash {
    private static final int DEFAULT_ITERATIONS = 1;
    private final String algorithmName;
    private byte[] bytes;
    private ByteSource salt;
    private int iterations;
    private transient String hexEncoded;
    private transient String base64Encoded;

可以看到这里面就可以指定加密的方式和迭代次数,还可以进行salt加密

salt加密: 就是在密文后加上salt之后再进行MD5加密: 123 + salt --> dkjfo + slat -->MD5…; 每次都会加salt; salt可以直接指定,或者动态添加,比如可以用userName

//这里创建测试类,将TestData中的密码全部进行加密
list.add(new ShiroUser("admin","admin", Arrays.asList("admin"),Arrays.asList("mobile","salary")));
            list.add(new ShiroUser("Cfeng","Cfeng", Arrays.asList("Cfeng"),Arrays.asList("mobile")));
            list.add(new ShiroUser("worker","worker", Arrays.asList("worker"),Arrays.asList("")));
//加密之前的数据

这里可以简单测试一下这个方法: 这里的salt和认证中的salt必须保持一致: 所以也是使用ByteSource

//将加密的工具对象注入
@SpringBootTest
class SecurityDemoApplicationTests {
	@Resource
	private PwdEncryptUtil pwdEncryptUtil;

	@Test
	public void tetsMd5() {
		TestData.getAllUsers().stream().forEach(user -> System.out.println(user.getUserName() + "的加密密码为 :" + pwdEncryptUtil.encrypt(user)));
		ShiroUser user = new ShiroUser("zs","zs",null,null);
		System.out.println(user.getUserName() + " : " + pwdEncryptUtil.encrypt(user));
	}

这样得到密码之后存入数据库,这里就在TestData中修改一下:

但是这里有个问题: 没有将ShiroUser序列化,要网络传输对象必须进行序列化

public class ShiroUser implements Serializable {
private static final long serialVersionUID = 3733298813437228242L;

admin的加密密码为 :3ef7164d1f6167cb9f2658c07d3c2f0a
Cfeng的加密密码为 :3660f95345484ceec990bd052fb12613
worker的加密密码为 :95d4c46d3c1c3828073e76aa5a97bfbe

Realm根据设置的参数来加密登录的pwd与数据库的密文进行匹配,匹配成功就可;salt必须保持一致

🍐 实现授权 【为principal绑定role、permission】

shiro的授权: 前台控制主页上的标签的访问权限,后台控制资源路径的访问权限

前台页面的控制:
currentUser.getSession().setAttribute("currentUser",currentUser.getPrincipal());

主要就是CurrentUser.getPricipal()来自于认证Realm中的impleAuthenticationInfo(user,user.getUserPwd(),“MyRealm”)中的user,也就是说包含了角色和权限,所以就可以通过getPerms获得其权限

然后在标签上面加上自定义权限标记,过滤出所有的权限,有权限标记的标签show即可

后台资源控制 【之前配置了注解的使用,可以用注解代替】

授权默认不是登录之后就会立刻进行授权,而是在访问授权资源的时候才会进行授权,默认情况下每次都会进行授权

  • MyRealm中进行授权,将用户与资源进行绑定;Realm在认证之后就会进行授权;绑定就是return一个绑定的SimpleAuthorizationInfo
    /**
     * 认证就是将当前登录的Subject与数据库中用户比对,成功就将当前的User与特定的身份进行绑定
     * 授权就是对于有身份的用户,在知晓身份时,将特定的角色与权限与其身份绑定
     */
    @Lazy
    @Resource
    private ShiroUserService shiroUserService;

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        log.info("entered MyReamlm doGetAuthorizationInfo method");
        //获得用户,从认证的info中直接获取的principal
        ShiroUser user = (ShiroUser) principalCollection.asList().get(0); //第一个就是下面的user
        //创建一个授权Info
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        //需要绑定的角色资源
        simpleAuthorizationInfo.addRoles(user.getUserRoles());
        simpleAuthorizationInfo.addStringPermissions(user.getUserPermissions());
        return simpleAuthorizationInfo;
    }

资源授权一共有3种方式:硬编码,配置类中给出,注解

  1. 首先就是使用Subject的相关方法直接进行权限验证,比如isPermitted(),对应注解==@RequiresPermissions==, 也就是需要什么权限才能处理, subject.isPermitted,isAuthenticated,is hasRole,【guest就是pincipals.isEmpty】,user与之相反
相当于使用Subject
@RequestMapping("xxxx")
public void createAccount() {
	Subject currentUser = SecurityUtils.getSubject();
    if(!subject.isPermitted("account:create")) {
    	throw new AuthorizationException();
    }
    //......有权限就会正常处理,没有
}

直接编码方式的错误处理方式 :就是手动给出相关信息,比如上面就是为未认证手动抛出异常,还可以打印日志,或者其他处理方式,非常灵活

  1. 除了上面的硬编码方式之外,还可以在配置类中直接给出authc的perms,就是在配置过滤器的时候,authc过滤器直接后面跟上perms[xxx,xxxx,xxxx]即可
//factoryBean对象 请求过滤器中
filterChainMap.put("/salary/**","authc,perms[mobie]")
//可以二者结合,因为authc一般就是/**,具体的在配置类中加入
//但是这样就不能在配置文件中体现了,所以还是使用注解,注解也挺方便的

配置方式错误处理: 过滤器factoryBean配置UnauthorizedUrl,然后在处理器中给出相关访问路径的处理方式【跳转页面或者返回data】 没有权限会直接给出异常,不能访问【直接访问】,可以设置一下未授权会访问什么路径或者页面,template下不能直接 (该方式和

设置logInUrl和logoutUrl类似,但是logoutUrl是发起对相关路径的请求后Shiro内部执行subject.logout, 而UnauthorizedUrl是为授权时Shiro控制访问设置的Url进行相关的处理

filterFactoryBean.setUnauthorizedUrl("/common/unauthorized");

 @RequestMapping("/unauthrized")
    public String unauthrized() {
        return "unauthrized";
    }
  1. Shiro注解的方式非常方便,就不需要在配置类中逐一配置【much】,注解的粒度都是方法,也就是控制某一个处理器方法的权限

    • @RequiresAuthentication : 需要认证,上面的就是isAuthenticated方法,也就是要求登录成功
    • @RequiresUser: 需要有身份,和上面的Authentication的区别在于User需要在登录的时候设置Remember me记住我的功能
    //login中的currentUser.login(token)就是认证的入口,这里的登录就是检测出其是否已经登录,没有登录才认证
    if(!currentUser.isAuthenticated()) {
                //没有认证,认证
                UsernamePasswordToken token = new UsernamePasswordToken(user.getUserName(),user.getUserPwd());
                //登录当前主体
    //这里的就是@RequiresAuthentication的相反,如果没有认证才会知晓login认证
        
    //=======@RequiresUser 需要认证的基础上加上记住我功能====================
    if(!currentUser.isAuthenticated()) {
                //没有认证,认证
                UsernamePasswordToken token = new UsernamePasswordToken(user.getUserName(),user.getUserPwd());
                //这里的用户令牌设置为RememberMe,这就符合@RequiresUser,只是获取令牌为@RequiresAuthentication
                token.setRememberMe(true);//开启记住我
    
    • @RequiresGuest
    • @RequiresRoles
    • @RequiresPermission

注解方式的错误处理方式 : 就是直接抛出AuthorizationException异常, 如果不处理异常前台给出5xx错误,所以这里使用Spirng进行全局的异常处理

在handler下面定义一个全局异常处理类,定义一个类方法捕获该类异常

@RestControllerAdvice  //全局异常处理其实就是一个处理器,所以也可以给出Rest风格的
public class GlogbalExceptionHandler {

    /**
     * 定义处理器方法捕获未授权异常AuthorizationException
     */
    @ExceptionHandler(value = {AuthorizationException.class})
    public String shiroExceptionHandler() {
        //这里就会捕获未授权异常并且给出相关处理方法
        return "对不起,你还没有登录,请先登录";
    }
}

Shiro的认证授权很easy,就是进行相关属性的绑定,Realm中的处理还是很easy,凭证匹配器的相关参数要和数据库密码加密时保持一致

多Realm认证

在登录验证时,可能不只是可以输入用户名,还可以输入手机号,支持多种方式登录,Shiro中处理时,ModularRealmAuthenticator认证器中

 protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
        this.assertRealmsConfigured();
        Collection<Realm> realms = this.getRealms();
        return realms.size() == 1 ? this.doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken) : this.doMultiRealmAuthentication(realms, authenticationToken);
    }

可以看到这里就对realms.size进行了判断,当为单Realm就单例认证,如果多个Realm,就进行doMultiRealmAuthentication;可以定义多个Realm进行不同的认证

只需要有一个Realm具有授权功能即可,继承AuthorizingRealm,是AuthenticatingRealm的子类,其他的认证直接继承AuthenticatingRealm即可,只需要实现do认证方法

@Slf4j
@NoArgsConstructor //在配置文件中创建Realm
public class MobileRealm extends AuthenticatingRealm {
    @Lazy
    @Resource
    private ShiroUserService shiroUserService;

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        log.info("entered the method =========================" + this.getName() + ": doGetAuthenticationInfo");
        //获得用户token的手机号mobile
        UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
        //这里的mobile就是用户输入的之前的UserName
        ShiroUser user = shiroUserService.queryUserByMobile(token.getUsername());
        if(user != null) {
            ByteSource salt = ByteSource.Util.bytes(user.getUserName());
            return new SimpleAuthenticationInfo(user,user.getUserPwd(),salt,this.getName());
        }
        return null;
    }
}

定义了Realm之后就是需要将其配置到SecurityManager中

public DefaultWebSecurityManager getSecurityManager(AuthorizingRealm userNameRealm, AuthenticatingRealm mobileRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        //必须知道数据源 realm【访问数据源的】
//        securityManager.setRealm(myRealm);
        //多Realm验证直接将所有Realm注入
        securityManager.setRealms(Arrays.asList(userNameRealm,mobileRealm));

这样就可以进行多Realm认证,自动会匹配认证多个Realm,但是提示信息还有点问题

认证策略 怎样算认证成功

AuthenticationStrategy接口下面有3个实现类:

  • AllSuccessfulStategy : 需要所有的Realm认证成功,才能认证成功
  • AtLeastOneSuccessfulStrategy 默认 : 至少一个Realm认证成功,才能最终认证成功
  • FirstSuccessfulStategy: 第一个Realm认证成功后就算认证成功,不再进行其他认证

如果要设置就是设置认证器即可,在config的SecurityManager中进行设置:

//设置多Realm策略,通过认证其设置
      ModularRealmAuthenticator authenticator = new ModularRealmAuthenticator();
        authenticator.setAuthenticationStrategy(new AtLeastOneSuccessfulStrategy());
        authenticator.setRealms(Arrays.asList(userNameRealm,mobileRealm));
        securityManager.setAuthenticator(authenticator);

Shiro其他的功能 记住我so on securityManager中配置

记住我 where how long CookieRememberMeManager

这里可以在前台设置要给checkbox,传输过来之后,将数据获取,直接token.setRememberme()即可完成 记住我对应的是Use过滤器

设置记住我的时长: 使用RememberManager管理,也是在securityManager中进行配置

//设置记住我功能RemembermeManager
        CookieRememberMeManager rememberMeManager = new CookieRememberMeManager();
        Cookie cookie = new SimpleCookie("rememberMe");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(318240000); //时长
        rememberMeManager.setCookie(cookie);
        securityManager.setRememberMeManager(rememberMeManager);

对于分布式项目使用Ngix负载均衡,那么最重要的就是状态一致,Cookie和Session共享; 那么cookie应该放到一台管理的Redis服务器中就可以相同,Session也可以放Redis,基于redis的就实现Abstract方法即可

Session管理 SessionManager

和Cookie类似,也是使用SessionManager管理,相关配置类似,基于Redis后面分布式再进行扩展

认证缓存 CacheManager 不需要每次鉴权都进行授权

缓存管理也是securityManager中进行设置,设置之后就不需要每次都进行授权

//认证缓存
CacheManager cacheManager = new MemoryConstrainedCacheManager();
securityManager.setCacheManager(cacheManager);

CacheManager的只有一个普通的实现类MemoryConstrainedCacheManager,同样缓存也是需要进行一致性处理,在分布式应用中,要继承CacheManager实现自定义的缓存管理器来进行同步

下面是一些项目可能的Question,这是当时配置时踩的坑🕳,但是最主要的就是不要同时引入security依赖

Shiro: 多realm认证中信息:could not be authenticated by any configured realms. Please ensure that at least one realm can authenticate these tokens. 不准确

之前就知道多Realm是执行doMultilA…方法,代码没有保存某个Realm具体抛出的异常,,而是在最后抛出AuthenticationException异常,解决方案: 重构该方法

 /**
     * 重写该方法保证异常正确抛出,需要多个Realm支持不同Token,否则会出现异常覆盖
     */
    @Override
    protected AuthenticationInfo doMultiRealmAuthentication(Collection<Realm> realms, AuthenticationToken token) {

        AuthenticationStrategy strategy = getAuthenticationStrategy();

        AuthenticationInfo aggregate = strategy.beforeAllAttempts(realms, token);

        if (log.isTraceEnabled()) {
            log.trace("Iterating through {} realms for PAM authentication", realms.size());
        }

        AuthenticationException authException = null;

        for (Realm realm : realms) {

            aggregate = strategy.beforeAttempt(realm, token, aggregate);

            if (realm.supports(token)) {

                log.trace("Attempting to authenticate token [{}] using realm [{}]", token, realm);

                AuthenticationInfo info = null;

                try {
                    info = realm.getAuthenticationInfo(token);
                } catch (Throwable throwable) {

                    // 记录异常
                    if (throwable instanceof AuthenticationException) {
                        authException = (AuthenticationException) throwable;
                    } else {
                        authException = new AuthenticationException("账号登录异常", throwable);
                    }

                    if (log.isDebugEnabled()) {
                        String msg = "Realm [" + realm + "] threw an exception during a multi-realm authentication attempt:";
                        log.debug(msg, throwable);
                    }
                }

                aggregate = strategy.afterAttempt(realm, token, info, aggregate, authException);

            } else {
                log.debug("Realm [{}] does not support token {}.  Skipping realm.", realm, token);
            }
        }

        // 存在异常直接抛出
        if (authException != null) {
            throw authException;
        }

        aggregate = strategy.afterAllAttempts(token, aggregate);

        return aggregate;
    }

这根据具体情况具体编写即可

Shiro: 静态资源设置为/static/** ,anon 不放行 【加路径】

这里是因为资源在被编译之后就没有static这个路径了,资源是放在classpath下面的,像浏览器访问路径为: http://localhost:8081/securityDemo/JQuery.js,这里没有static,springboot是将static目录内容作为classes根目录发布到web服务器,想要一次性放行所有的静态资源,那么就再加一级目录即可

比如staic下面: /img, /js, /css等,再匹配: /img/** /js/

或者直接在staic下面再建一级目录,statics,这样/statics/**就会放行所有的静态资源,但是I 感觉没必要,多配置几项即可,不然html中路径过长

Shiro : is not eligible for getting processed by all BeanPostProcessors

Bean 不能被所有的BeanPostProcessors处理,自动代理AOP,AOP比较重要的就是AOP事务,如果事务不能代理,拿就无法控制事务; Shiro的在关键的bean处实现了BeanPostProcessor,所以会导致其他提前IOC,无法AOP,所以不能完全去掉该提示

解决方案:

延迟初始化: 延迟初始化会在IOC的时候给出一个代理类,IOC之后AOP这个BeanPostProcessor正常处理,处理就是加上==@Lazy==, 可以加载字段上面或者参数上面【一般加载与业务有关的Service上面】其他的可不,因为不影响功能

@Lazy
private static ShiroUserService shiroUserService;


@Bean(name = "securityManager")
    public DefaultWebSecurityManager getSecurityManager(@Lazy Realm myRealm) {

BeanUtil: cloneBean时实体类的set方法不能返回对象

BeanUtil克隆的对象的类不能有@Accessor(chain = true);也就是返回当前对象,应该为默认的void,不然没有WirtingMethod,克隆对象属性为null

yaml配置注入: @Vlaue(“${}”)不需要set方法, @ConfigurationProperties + Set注入,需要set方法

配置文件中的属性要注入给配置类,如果没有相关的setter是注入失败的,所以ShiroConfig的配置类就要加上@Data,这样配置的属性如拦截的路径才会进入配置类中; 这里验证是否有属性就stream打印: Arrays.stream(authcUrls).forEach(System.out :: println);Arrays.stream(anonUrls).forEach(System.out :: println);即可 ; Shiro使用的是一次性注入ConfigurationProperties ,需要有set方法,所以加上@Data最方便

基于Spring Security的注册登录

Shiro总体来说使用还是很方便的,就是配置之后编写项目的Realm,在Realm中进行授权和认证,这两个操作也是很简单的,而加密直接使用Shiro的SimpleHash就可以便捷将用户密码加密,密码比对由Shiro完成,而前后端的访问权限控制直接使用注解,或者加入标签即可;主要就是当前登录用户Subject currentUser,根据当前用户的permissions和roles进行权限控制

Spring Security相比Shiro更加重量级,但是结合Spring Boot使用非常方便,本部分的所有的代码创建在cfeng-security-demo;就像jpa和mybatis一样,二者各有所长

引入依赖包括security和security-test

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

		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>

Spring Security

Spring Security概括为一组过滤器链,在项目启动时进行自动配置,不同过滤器不同职责,共同作用实现程序的安全管理;Shiro核心是SecurityManager管理,Spring Security架构如下,相比Shiro的架构复杂一些: Shiro就一个Subject主体,管理器和Realm

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rXMr77PI-1658691438695)(https://tse3-mm.bing/th/id/OIP-C.Ah-tG4EzW3fP3HNIDgxI5AHaFO?w=254&h=180&c=7&r=0&o=5&dpr=1.25&pid=1.7)]

可以看到主要的就是Filter,各种Filter,还有FilterChainProxy【shiro中在factory位置也会配置】,本质就是一个过滤器,自身不会做实质上的验证,请求委托给Spiring Security内部的FilterChain,内部的FilterChain多个,根据策略分开,常见的就是基于匹配请求路径调度

用户注册登录 SpringSecurity 中内置PasswordEncoder, 认证授权由UserDetailsService完成【实现接口】

因为已经详细分享过Shiro了,二者很多相似之处,这里直接开始项目: 首先创建一个用户实体类: TUser,为了方便表结构的修改,直接使用JPA管理

  • 创建用户实体类Tuser; 这就是鉴权的主要用户
@Data
@Entity
@Table("t_user")
@Accessors(chain = true) //BeanUtils不能进行克隆
public class Tuser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id",nullable = false)
    private Integer id;

    @Column(name = "user_name", nullable = false)
    private String userName;

    @Column(name = "user_pwd",nullable = false)
    private String userPwd;

}
  • 操作该表记录的Repository,直接继承即可,设置一个ByName
public interface TuserRepository extends JpaRepository<Tuser,Integer> {
    //自动配置
    Tuser findByUserName(String userName);
}

完成数据访问层,接下来因为Demo,就不创建Service了,直接定义一个处理器,实现用户注册相关的,控制器内容包括用户注册和受保护资源部分

  • 自定义的响应实体包装类
//建立一个范型的响应体类,定义响应的格式,可以使用ReponseEntity
/**
 * @author Cfeng
 * @date 2022/7/21
 * 该类定义响应实体,定义相关的属性
 * 也可以直接使用ResponseEntity<T>
 */

@Accessors(chain = true)
@Data
public class Resp<T> {

    private Integer code; //响应状态码

    private T data; //响应数据

    private String message; //错误提示信息

    //访问成功或者失败
    private static final Integer SUCCESS = 0;

    private static final Integer FAILED = -1;

    //访问ok成功
    public static <T> Resp<T> ok(T data) {
        return new Resp<T>().setCode(SUCCESS).setData(data);
    }

    //访问失败
    public static <T> Resp<T> failed(String message) {
        return new Resp<T>().setCode(FAILED).setMessage(message);
    }
}

上面的Tuser是存储在数据库中的,取出的数据为MO,model object; 而在网络传输过程中的数据为DTO,可能和MO不同,Data transfer object; 这里传输过程中是不需要存储的自然主键id的,所以需要创建一个userDTO

  • userDto类: 传输的User实体类domain
 * 前后台交互时的user实体,和数据库中存储的MO有区别,Dto不需要自然主键id,交互的验证直接加上@NotNull,在处理器中使用@Valid就可以自动校验
 */

@Accessors(chain = true)
@Data
public class UserDto {

    @NotNull //之前Rest中提过,验证Validation,配置后前台接收userDto就会校验
    private String userName;

    @NotNull
    private String userPwd;
}

这里自动校验需要@Valid,需要引入jakarta的Validation包

<dependency>
	<groupId>jakarta.validation</groupId>
	<artifactId>jakarta.validation-api</artifactId>
</dependency>
  • 接下里就是创建处理器方法,在该方法中注入PassWordDecoder进行数据加密【Shiro中是自己封装一个PasswordEnscyptUtil使用new SimpleHash进行salt hash加密】spring security也会自动进行密码的匹配
@RestController
@RequiredArgsConstructor
public class LoginController {

    private final TuserRepository tuserRepository;
    //spring security中封装的密码加密类,shiro手工封装基于提供的SimpleHash
    private final PasswordEncoder passwordEncoder;

    @PostMapping("/register")
    public Resp<Void> register(@RequestBody @Valid UserDto userDto) {
        Tuser tuser = new Tuser().setUserName(userDto.getUserName())//这里要进行默认加密,{id前缀} + 密码指定加密方式
                .setUserPwd(passwordEncoder.encode(userDto.getUserPwd()));
        tuserRepository.save(tuser);
        return Resp.ok(null);
    }

    @GetMapping("/hello")
    public Resp<String> greetString() {
        //代表受保护资源,shiro中直接注解方便
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        //获取当前用户名
        return Resp.ok("hello, This is " + authentication.getName());
    }
}

而用户登录依赖Spring security提供的formLogin模式,formLogin将表单需要的逻辑实现,开发了接口和配置供we自定义登录的相关业务逻辑,UserDetailService是关键,实现该类表示用户信息载入的方式

  • 实现用户信息载入方式认证授权UserDetailService,也就是用户登录的Service,调用userRepository,实现接口,不要自己创建,在该类中完成用户的认证和授权,在Shiro中是由Realm完成的
package indv.Cning.cfengsecuritydemo.service;

import indv.Cning.cfengsecuritydemo.entity.Tuser;
import indv.Cning.cfengsecuritydemo.repository.TuserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Collection;
import java.util.Collections;

/**
 * @author Cfeng
 * @date 2022/7/21
 * 用户登录的Service由spring security定义,we只需要实现该接口,调用userRepository实现
 */

@RequiredArgsConstructor
@Service
public class UserDetailServiceImpl implements UserDetailsService {
    private final TuserRepository repository;

    //默认就是用户名-密码模式
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Tuser tuser = repository.findByUserName(username);
        if(tuser == null) {
            throw new UsernameNotFoundException("username not found");
        }
        return new User(tuser.getUserName(),tuser.getUserPwd(),this.getAuthorities());
    }

    //授权的业务方法
    private Collection<GrantedAuthority> getAuthorities() {
        //获取用户的角色权限,就是之前Shiro的Realm中完成的事情,上面返回的User就类似一个Subject,为系统的用户
        //上面查询数据库完成认证,该方法完成授权,这样最后就是一个完整的用户,shiro是分开的,认证只是返回了principa,授权中再将principal和权限绑定
        return Collections.singletonList(new SimpleGrantedAuthority("ROLE USER"));
    }
}
🍑 Spring Security配置类【新版SpringBoot弃用 ❤️ 】 @EnableWebSecurity 复合注解

该复合注解: @Import({WebSecurityConfiguration.class, SpringWebMvcImportSelector.class, OAuth2ImportSelector.class, HttpSecurityConfiguration.class})@EnableGlobalAuthentication@Configuration;首先就是是配置类因为@Configuration, 其次可以开启全局认证功能,同时会按需导入配置类,用于注册,不同环境导入不同的配置

配置SecurityFilterChain,WebSecurityCustomizer,AuthenticationManager 3个主要对象

shiro的配置类是直接手工书写,而Security是继承WebSecurityConfigAdapter(过时,直接书写配置类,和Shiro一样)框架已经将登录验证相关的逻辑在框架实现,剩下的工作由配置完成,在其中还可以完成PasswordEncoder加密器的配置,security中只需要这样配置就可,shiro中还要认证中进行处理

  • HttpSecurity的配置: 配置SecurityfilterChian,也就是Http访问路径的过滤器,通过HttpSecurity对象进行antMatcher路径,加上处理,再build即可 【配置拦截资源,拦截对应的角色权限,定义认证方式HttpBasic,自定义登录页面、登录请求地址,错误处理方式,自定义SpringSecurity过滤器】
  • WebSecurity配置: 配置WebSecurityCustomizer对象,使用webSecurity.ignoring()忽略一些UTL请求,这些请求被忽略,可能受到网络攻击: 也就是不被保护,在Shrio中就是anon路径【用于影响全局安全性(配置资源,设置调式模式,自定义防火墙拒绝请求)一般配置全局通用的如静态资源】
  • AuthenticationManager配置: 分为全局和本地local : 本地local配置可以直接借助HttpSecurity.authenticationManager实现; 全局配置不需要WebSecurityConfigurerAdapter.authticationManagerBean,只需要定义一个AuthenticationManager类型的Bean【配置认证的参数:比如LDAP、基于JDBC,添加UserDetailsService,添加AuthenticationProvider】

在新版的配置中,只需要定义一个普通的配置类,加上EnableWebSecurity 注解,声明PasswordEncoder,SecurityFilterChain,WebSecurityCustomizer,AuthenticationManager几个核心对象; SecurityFilterChain利用框架的HttpSecurity对象构建,AuthenticationManager利用AuthenticationConfiguration构建 【UserDetailService和Encoder对象不需要再config,声明为对象放入容器即可,框架自动装载】

package indv.Cning.cfengsecuritydemo.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import indv.Cning.cfengsecuritydemo.filter.JwtRequestFilter;
import indv.Cning.cfengsecuritydemo.handler.JwtAuthenticationEntryPoint;
import indv.Cning.cfengsecuritydemo.session.ParallelInformationExpiredStrategy;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher;

import javax.sql.DataSource;

/**
 * @author Cfeng
 * @date 2022/7/21
 * Spring Security 核心配置类,注入PasswordEncoder对象,加密使用的【Security相比Shiro来说,shiro关于加密的配置更加复杂,手工encode,并且需要配置凭证器,如果使用salt,还要定义】
 * 完成httpsecurity,websecurity,securityManager对象的配置
 * 因为认证逻辑大部分由框架完成,需要将之前定义的Service注入,(Shiro中也可以直接注入Realm)还有ObjectMapper和DataSource对象
 * 不支持继承Adapter,新版直接提供了一个原型HttpSecurity对象,引入即可
 */

//@Configuration    springSecurity的配置类注解使用复合注解
@RequiredArgsConstructor
@EnableWebSecurity //包含配置@Configuration,还有启用authenticationManager
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true,jsr250Enabled = true) //开启注解的使用
public class SecurityConfig {
    //HttpSecurity对象,配置路径相关信息,之前的shiro的factoryBean对象,不需要直接引入,容器中有原型对象
    //security中完成认证和授权的Service【类似realm】,不需要在auth中配置
    private final UserDetailsService userDetailsService;
//    //数据源
    private final DataSource dataSource;
    //jackson工具,对象转为json字符串
    private final ObjectMapper objectMapper;

    //使用BcryptPasswordEncoder进行加密和解密
    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
    //配置记住我功能的TokenRepository,也是记住我功能数据库模式security自动调用维护的,表结构也是固定的
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        //这里直接创建实现类,该类会直接获取连接数据库中的persistent_logins表中数据据
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        return tokenRepository;
    }


    //配置路径过滤器链,HttpSecurity
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http
               //配置csrf和httpbasic
               .csrf() //关闭网络跨域检查
               .disable()
               .httpBasic()//关闭基础配置
               .disable()
               //配置登录表单
               .formLogin()
               .loginPage("/toLogin/login")//访问登录页
               .loginProcessingUrl("/authentication/form")  //需要和登录表单处理一致,登录访问路径,spring security自动完成
               .defaultSuccessUrl("/toLogin/hello")  //没有具体路径访问,跳转该
               .usernameParameter("userName")  //提交的默认参数
               .passwordParameter("userPwd")
               .permitAll()  //表单相关的路径直接放行
               //配置路径过滤器
               .and()
               .authorizeRequests()
//               .antMatchers("/admin/**").hasRole("admin")   //适合规模小的项目,过多就配置太多
//               .antMatchers("/user/**").hasAuthority("user")
               .anyRequest().authenticated() //登录后都可,不登陆不行
               //配置无权异常处理路径
               .and()
               .exceptionHandling()
               .accessDeniedPage("/403.html")
               //配置登出路径
               .and()
               .logout()
               .logoutUrl("/logout")   //配置登出路径,也是security内部完成
               //配置RemeberMe的相关内容,数据库模式的
               .and()
               .rememberMe()
               .rememberMeParameter("rememberMe") //设置表单中登录时checkbox提交的name
               .rememberMeCookieName("rememberMe") //设置访问的cookie的名称
               .tokenValiditySeconds(5 * 60 * 50) //有效期
               .tokenRepository(persistentTokenRepository()) //设置访问的仓库,这里也是security维护
               .userDetailsService(userDetailsService)
               //配置Session,这里使用Spring session避免服务器重启集体下线  内存JVM,最后配置因为配置spring session后下面还是session
               .and()
               .sessionManagement()
               .invalidSessionUrl("/timeout.html") //配置状态过期后的页面
               .maximumSessions(1)  //设置session并发上限为1
               .maxSessionsPreventsLogin(true) //达到上限后阻止登录
               .expiredSessionStrategy(new ParallelInformationExpiredStrategy(objectMapper))//失效的处理策略
               .sessionRegistry(sessionRegistry());
        return http.build();
    }

    /**
     * 放行静态资源,全局配置
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().antMatchers("/js/**", "/img/**","/css/**");
    }

    /**
     * 认证管理器,登录认证相关需要使用,直接注入AuthenticationConfiguration对象(security中提供,和HttpSecurity一样),利用该对象就可以创建
     * 而之前要从数据库中查询对象进行UserService认证鉴权   auth.userDetailsService(userService).passwordEncoder(passwordEncoder)
     * 现在Security会自动注入这两个对象,只需要声明即可
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SessionRegistry sessionRegistry() {
        //用于访问Session
        return new SessionRegistryImpl();
    }

    @Bean
    public static ServletListenerRegistrationBean httpSessionEventPublisher() {
        //必须要告诉Spring信息将Session存储在sessionRegistry
        return new ServletListenerRegistrationBean(new HttpSessionEventPublisher());
    }
}

密码加密BCrypt

Spirng security相比shiro直接配置了很多Encoder,只需要声明Encoder对象就可以自动进行验证和注册

加密手段可以直接使用BCryptEncoder.encode,但是构建慢,推荐使用BCrypt.haspw(password,salt); salt可以直接BCrypt.genesalt()即可

可以测试一下加密:

	@Test
	public void testBcrypt() {
		List<Tuser> users = tuserRepository.findAll();
		//BcyptPasswordDecoder和shiro中设置的Hash凭证类似,也会有salt,但是是系统自动生成匹配即可BCrypt.haspw( Bcypt.gensalt)
		users.stream().forEach(user -> System.out.println(user.getUserName() + " : " + BCrypt.hashpw(user.getUserPwd(),BCrypt.gensalt())));
	}

//=========================
admin : $2a$10$EDUI7v0hycQKSaauOHN5eeIf0sA2YAc7yzx162NPVUDLoMUgxK0aC
Cfeng : $2a$10$iGC/4aFjjomhHLPgHQj6vuKgiwXpxI/Z1sM4Pvf3MywkFIxs8Bz8.

SpringSecurity默认用户名和临时密码【整体项目】

SpringSecutity依赖加入后就会自动保护项目的所有资源,要访问项目的资源,首先就要输入Spring Security设置的用户名和密码【非常方便,什么都不用做,加入一个依赖就保护了所有资源

默认的用户名为User,密码为临时生成的UUID

Using generated security password: c1f81f3c-1abd-4d1e-953a-9844d5e3b13f

This generated password is for development use only. Your security configuration must be updated before running your application in production.

上面就是启动后后台的效果

if (user.isPasswordGenerated()) {
	logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
}

/**
 * Default user name.
 */
private String name = "user";
/**
 * Password for the default user name.
 */
private String password = UUID.randomUUID().toString();
private boolean passwordGenerated = true;

这里就可以看到用户名为User,密码每次动态生成,着很不方便,所以可以自己配置一下(😢,在没有配置的前提下引入了Security依赖,锁定了所有的资源,本来使用Shiro,锁住所有source,很是郁闷了会)

配置的方式也是配置文件yaml和配置类的方式: 配置类SecurityConfig 需要继承WebSecurityConfigurerAdapter ,在configure中使用认证对象来设置,这里先使用配置文件的方式; 配置项就是security.user.xxx:

  security:
    user:
      name: cfeng
      password: 123456
      roles: admin
⚠️ WARNING: 解释关于Spring security访问的几个问题
  1. 自定义登录页面的跳转问题,we只需要保证form的action和spring security config的formLogin的loginProcessingUrl配置的相同; 关于上传的name参数只需要配置parameters,默认就是username,password,rememberme; 同理logoutUrl也是只要配置路径即可,也是spring security内部自动完成匹配,内部的相关provider
  2. 我们配置的defaultSuccessUrl是没有明确的访问路径的时候才会跳转的路径,如果输入XXXX/admin,跳转的就是这个路径
  3. spring security的角色role和权限authories没有单独区分,都是authority,比如User角色,对应的权限就是ROLE_user; 在UserDetails角色授权需要加上ROLE_前缀,但是使用的时候直接hasRole不需要前缀; 权限授权设置和使用时名称保持一致即可; 所以加入的时候时一起将用户角色和权限加入集合中

spring security 权限管理

关于角色和权限其实时互通的,为不同的粒度,角色对应多个权限,角色也就是权限,在security中,如果粒度为角色,那么对应的权限就是ROLE_角色

登录之后的认证授权是UserDetailService完成【shiro中realm】,而权限的配置是通过HttpSecurity对象完成,build一个FilterChain对象【Shiro中直接在fatoryBean中完成各种路径和过滤器的配置】

shiro中登录页面自定义: loginUrl,通过facotoyBean.setLoginUrl定义登录界面,并且登录的处理需要创建loginUrl进行subject.login,rememberMe在token处设置; 登出路径Logout配置路径之后,对应logout过滤器,前台就可以直接使用; 而authc和anon直接配置路径的一个映射Map

spring security中Login如果不设置就是默认的一个登录页面,处理的路径默认/login,默认所有的路径登录成功后就可访问

而如果要自定义登录,则需要配置HttpSecurity的FormLogin,配置登录页,处理路径(框架内部处理),默认登录成功跳转页面,这里一般是直接访问端口,所以先配置一下欢迎页面,在MVC配置类中配置

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/").setViewName("forward:index.html");
        registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
    }
权限管理ER: user,role,permission

权限管理基本的结构就是有用户,角色,权限表,这里首先建立表的结构;这里设置加载模式为多表联查,EAGER

  • 权限authority表
* 权限管理类型
 */

@Entity
@Accessors(chain = true)
@Data
public class TAuthority {
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Id
    @Column(nullable = false)
    private Integer id;

    @Column(nullable = false)
    private String authName;

    @ManyToMany(mappedBy = "authorities", fetch = FetchType.EAGER)
    private List<TRole> roles;

}
  • 角色role
* 角色类型
 */

@Entity
@Accessors(chain = true)
@Data
public class TRole {
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Id
    @Column(nullable = false)
    private Integer id;

    @Column(nullable = false)
    private String roleName;

    @ManyToMany(mappedBy = "roles",fetch = FetchType.EAGER)
    private List<Tuser> users;

    @ManyToMany
    @JoinTable(name = "roles_authorities", joinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "authority_id",referencedColumnName = "id"))
    private List<TAuthority> authorities = new ArrayList<>();
}
  • 用户表user
* demo的用户实体类,由Jpa管理
 */

@Data
@Entity
@Accessors(chain = true) //BeanUtils不能进行克隆
public class Tuser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(nullable = false)
    private Integer id;

    @Column(nullable = false)
    private String userName;

    @Column(nullable = false)
    private String userPwd;
    //用户对应的角色,多对多,设置中间关联表users_roles
    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "users_roles",joinColumns = @JoinColumn(name = "user_id",referencedColumnName = "id"),inverseJoinColumns = @JoinColumn(name = "role_id",referencedColumnName = "id"))
    private List<TRole> roles = new ArrayList<>();
}

用户和角色、角色和权限都是多对多关联关系,所以一共会创建5张表来管理权限; 再分别创建其对应的Repository即可,直接继承

认证授权 userDetailsService 【自定义配置的processingUrl会最终使用该类进行认证加授权】 插入数据getXXX.add()

Shiro中是分开在两个方法中,这直接一个方法就可以,认证就直接通过userRepository查询数据库找到用户即可,直接返回一个security中的User对象,在构造方法中指定principal,credentials,authorities; 这里的权限可以指定一个private方法来指定

这里user表中关联的是role,将角色ROLE_XXX和角色的权限一起加入到GrantedAuthority集合中, 这里加入的是SimpleGrantedAuthority

@RequiredArgsConstructor
@Service
public class UserDetailServiceImpl implements UserDetailsService {

    private final TuserRepository userRepository;

    //默认就是用户名-密码模式
    //认证和授权一起完成,框架自动调用
    @Override
    @Transactional  //这里出现懒加载问题,因为提前关闭了session,开启事务,原子性,就可以解决问题
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Tuser tuser = userRepository.findByUserName(username);
        if(tuser == null) {
            throw new UsernameNotFoundException("用户名不存在");
        }
        return new User(tuser.getUserName(),tuser.getUserPwd(),this.getAuthorities(tuser));
    }

    //授权的业务方法
    private List<GrantedAuthority> getAuthorities(Tuser tuser) {
        //获取用户的角色权限,就是之前Shiro的Realm中完成的事情,上面返回的User就类似一个Subject,为系统的用户
        //上面查询数据库完成认证,该方法完成授权,这样最后就是一个完整的用户,shiro是分开的,认证只是返回了principa,授权中再将principal和权限绑定
        List<GrantedAuthority> authorities = new ArrayList<>();
        //将角色加入权限集合中
        for(TRole role : tuser.getRoles()) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleName()));
            //角色对应的权限
            for(TAuthority authority : role.getAuthorities()) {
                authorities.add(new SimpleGrantedAuthority(authority.getAuthName()));
            }
        }
        return authorities;
    }
}

这里创建表之后就是要插入数据,@JoinTable放在哪里就由哪个表维护,所以这里在注册的时候就通过user.getRoles().add(),维护的时候,要将链表初始化,否则get抛null,new ArrayList()

    @GetMapping("/hello")
    public Resp<String> greetString() {
        //shiro中直接注解方便,认证信息,代表的就是当前的用户,getName获得用户名
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        //============
        Tuser usrA= tuserRepository.findByUserName("admin");
        Tuser usrB = tuserRepository.findByUserName("Cfeng");
        TRole adRole = tRoleRepository.findById(1).get(); //admin角色
        TRole userRole = tRoleRepository.findById(2).get(); //user角色
        TAuthority readAuth = tAuthorityRepository.findById(1).get();
        TAuthority writeAuth = tAuthorityRepository.findById(2).get();
        //设置角色和权限对应关系
        List<TAuthority> authoritiesAdmin = new ArrayList<>();
        authoritiesAdmin.add(readAuth);
        authoritiesAdmin.add(writeAuth);
        adRole.getAuthorities().addAll(authoritiesAdmin);
        //
        List<TAuthority> authoritiesUser = new ArrayList<>();
        authoritiesUser.add(readAuth);
        userRole.getAuthorities().addAll(authoritiesUser);
        //设置用户和角色对应关系
        usrA.getRoles().addAll(new ArrayList<>(Arrays.asList(adRole)));
        usrB.getRoles().addAll(new ArrayList<>(Arrays.asList(userRole)));
         TAuthority wite = tAuthorityRepository.findById(2).get();
         TRole writer = new TRole();
         writer.setRoleName("writer");
         writer.getAuthorities().add(wite);
         tRoleRepository.save(writer);
        //获取当前用户名
        return Resp.ok("hello, This is " + authentication.getName() + authentication.getAuthorities().toString());
    }

有的时候莫名其妙,我最初几次尝试全部都不能向中间表插入数据,最后有增加了一条save,就直接将前面的所有执行了,所以在确保自己代码没有错误的情况下,多尝试几次,可能是Cache的原因

有了关联关系就可以正常进行认证和授权了,认证的时候需要注意加上@Transactional注解,原子性保证session不会提前关闭🌿

执行之后,“data”:“hello, This is Cfeng[ROLE_user, article_read]”,可以看到正确得到了授权,shiro中获取当前用户为SecurtiyUtils.getSubject获取Subject主体,而这里是SecurityContextHolder.getContext().getAuthentication()获得当前主体

在security中角色可以转化为权限,在授权的时候没有role一级,都是permission,区分的方式就是ROLE; 当鉴权时使用了hasRole(),就会从权限列表中找出ROLE_XX,去掉ROLE_,得到role角色

后台路径授权管理

Shiro中后台授权一共有3种方式,硬编码,配置,注解;

在spring security中权限管理4种方式,一种就是spEl表达式,直接在配置类中配置即可;还有注解方式和动态权限,过滤器…,主要分析ant匹配,和注解使用

securityconfig中配置

可以直接使用ant路径,配置相关的过滤权限,利用hasRole,hasAnyRole…

.and()
               .authorizeRequests()
               .antMatchers("/admin/**").hasRole("admin")
               .antMatchers("/user/**").hasAuthority("user")

hasRole会自动检查当前登录用户权限列表,找寻ROLE-XXXX; hasAnyRole只需要有参数中任何一个Role就可以访问,hasAuthority和hasAnyAuthority类似

路径匹配采用antPattern; ?匹配单字符 ,** 匹配多级路径 … 也是按顺序匹配,所以详细的先配置; 在pom中配置resources路径也是antPattern

com/t?st.jsp 匹配com/tsst.jsp com/tast.jsp

com/*.jsp 匹配com下所有的jsp

user/** user下所有目录

错误处理方式:在配置中配置AccessDeniedPage,没有权限时跳转到相关的页面; 单独的配置就是exceptionHandler配置,使用and()

//配置无权异常处理路径
       .and()
       .exceptionHandling()
       .accessDeniedPage("/403.html")

配置之后,没有权限时,就会跳转到自定义的403.html页面

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pb2LlQRT-1658691438697)(C:\Users\OMEY-PC\AppData\Local\Temp\1658628989974.png)]

admin没有user权限,访问就会跳转到403.html,但是可以看到浏览器路径不变,只是响应内容变化

使用注解完成 @EnableGlobalMethodSecurity

shiro支持注解,使用注解配置2个对象即可,spring security也支持注解在方法上完成低粒度的控制,使用注解需要开启@EnableGlobalMethodSecurity,同时开启其中的3个参数使用不同的注解

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

配置之后就开启了@Secured,@PreAuthority @PostAuthority @RolesAllowed @PermitAll @DenyAll等注解,可以直接放置在方法上

  • @Secured用于标注方法所具有的权限,直接列举所需要具有的权限,如果为角色,就是ROLE_XXX权限
@Secured({"ROLE_admin"})  //String[]  权限数组,角色可以转为权限
    @RequestMapping("/admin")
    public String toAdmin() {
        Authentication currentUser = SecurityContextHolder.getContext().getAuthentication();
        return "welcome ," + currentUser.getPrincipal().toString() + ", 只能admin访问的";
    }
  • @PreAuthority @PostAuthority支持Spel表达式,也就是hasRole等,Pre是在方法执行前鉴定权限,post是在方法执行后鉴权
    @PreAuthorize("hasAuthority('article_write') AND hasAuthority('article_read')")  //可以直接使用Spel表达式,使用hasRole等,单引号,使用AND OR连接
    @RequestMapping("/all")
    public String toUser() {
        return "两种权限都有才可以访问";
    }

这上面配置就是使用的spEL

  • @PermitAll @DenyAll 是都可以访问和不能访问,@RolesAllowed就类似hasAnyRole
    @PermitAll
    @RequestMapping("/welcome")
    public String toAccess() {
        return "欢迎,你们不登陆也能访问,都可以访问";
    }

    @RequestMapping("/user")
    public String userAccess() {
        return SecurityContextHolder.getContext().getAuthentication().getName()  + ", 只能User访问";
    }

注解类型的错误处理: 和上面一样都交给了ExceptionHandler管理,只需要配置即可;这样就实现了权限管理,相比shiro来说,spring security配置复杂了些,但是入门之后还是spring security更强大

前台授权管理

如果不使用依赖只能后天将currentUser权限传给前台,获取之后将有对应权限的标签show()

直接引入整合thymeleaf的依赖就可以

<!-- spring security前端控权 使用标签 使用5-->
		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity5</artifactId>
		</dependency>

使用之后还需要在页面上引入其命名空间

<html xmlns="http://www.w3/1999/xhtml"
	xmlns:th="http://www.thymeleaf" xmlns:sec="http://www.thymeleaf/extras/spring-security">

这样之后就可以在页面使用sec的相关

在页面中可以通过sec:authentication=""获取UsernamePasswordAuthenticationToken中所有get的内容
有以下属性
name:登录账号名
principal:登录主体,自定义登录逻辑中是UserDetails
credentials:凭证
authenties:权限和角色
detail:实际上是WebAuthenticationDetails的实例。可以获取remoteAddress(客户端ip)和sessionid(当前sessionid)

这里也是可以使用spEl表达式

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf" xmlns:sec="http://www.thymeleaf/extras/spring-security">
    <head>
        <meta charset="UTF-8">
        <title>welcome</title>
    </head>
    <body style="background-color: mediumvioletred">
        <p>This is a home page.</p>
        <p>Username: <th:block sec:authentication="principal.username"></th:block></p>
        <p>Authorities: <th:block sec:authentication="principal.authorities"></th:block></p>

        <!--按需show,使用spEL表达式-->
        <a href="/admin" sec:authorize="hasRole('admin')">管理员页面跳转</a>
        <a href="/user" sec:authorize="hasRole('user')">用户登录</a>
        <br/>
        <a href="/logout">退出登录</a>
    </body>
</html>

和后端使用spEL表达式相同,所以这里就不过多赘述,只要引入sec,通过authorities就可以实现hasRole等权限管理

记住我Remember me

在shiro中,直接在token中设置即可,同时时长管理通过CookieRemembermeManager管理; 而在Spring Security中,需要在HttpSecurity中配置;【客户端在获得token凭证后就会免登录】

记住我功能就是在有效时间内可以实现自动登录,自动登录Spring security是通过token + cookie是实现,也就是一个过期时间 : 用户 : 密码MD5加密生成的字符串保存到用户机器上,下次访问自动携带cookie,访问时就会检查该cookie,从而实现自动登录;记住我功能有两种方式: 数据库模式 、 内存模式

内存模式只要服务器重启就必须重新登录,所以一般使用数据库模式,数据库模式就首先需要建立一张token表记录登录的信息

首先在数据库中创建一张凭证相关的表persistent_logins【表的内容是security维护,结构是固定的

USE security;

CREATE TABLE persistent_logins(
	username VARCHAR(64) NOT NULL,
    series  VARCHAR(64) PRIMARY KEY,
    token VARCHAR(64) NOT NULL,
    last_used TIMESTAMP NOT NULL
)

创建一个Repository供Spring securty调用

    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        //这里直接创建实现类,该类会直接获取连接数据库中的persistent_logins表中数据据
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        return tokenRepository;
    }

接下来就是增加配置,直接配置到HttpSecurity中即可,配置的就是类似与shiro的rememberManager,这里也可以设置存储的cookieName,还是使用and

			  .and()
               .rememberMe()
               .rememberMeParameter("remember-me") //设置表单中登录时checkbox提交的name
               .rememberMeCookieName("rememberMe") //设置访问的cookie的名称
               .tokenRepository(persistentTokenRepository()) //设置访问的仓库,这里也是security维护
               .userDetailsService(userDetailsService)

会话管理Session 【普通web管理】

security中用户登录状态时间就是放在session中的,security自动维护,这里session的管理的用户登录状态就是存放在session中,这里可以简单配置sessin的保存时间,并且还可以配置过期后的处理路径

.and()
               .sessionManagement()
               .invalidSessionUrl("/timeout.html") //配置状态过期后的页面

而session的保存时间则在application.yml中进行配置

server:
    session:
      timeout: 60

这样用户登录60s后状态过期之后自动访问/timeout

Session-cookie管理 Redis集群

HTTP是无状态协议,而很多场景需要保存用户状态,比如直接登录就是RememberMe需要依赖Cookie,当前登录,身份权限等数据依赖session,安全框架就需要对状态进行验证

Http Cookie是一小片被存储在浏览器的数据,Cookie真实存在,Session相对抽象, Session-cookie体系中Cookie只要就是记录sessin-id

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MLeThweh-1658691438697)(https://tse3-mm.bing/th/id/OIP-C.YXpbpkTCSMBOmZmLGhP_ggAAAA?pid=ImgDet&rs=1)]

使用Spring-session管理Session Session集群🛄

在项目中,Session-cookie默认由Web容器维护,Session默认情况下保存在服务端的JVM内存中,服务端重启时,用户就会集体下线,后续就需要重新登录,所以需要从JVM内存剥离session

spirng session可选的容器有Redis,MongoDB,JDBC和HAZELCAST,比如选择Redis作为sessin外部容器

  • 首先引入依赖spring-session-data-redis,spring-boot-starter-data-redis和jedis客户端
		<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>
		<!-- 使用jedis作为客户端-->
		<dependency>
			<groupId>redis.clients</groupId>
			<artifactId>jedis</artifactId>
		</dependency>

		<!-- spring session以redis作为容器,引入redis和spring-session-redis-->
		<dependency>
			<groupId>org.springframework.session</groupId>
			<artifactId>spring-session-data-redis</artifactId>
		</dependency>
	</dependencies>
  • 配置redis的依赖和spring-session的依赖【测试使用windows redis】
# redis的配置类配置见之前的blog,主要注入pool对象,jedisConnectFactory和template对象【序列化器】
redis:
    port: 6379
    host: localhost
    database: 1
    timeout: 1000
    password:
    jedis:
      pool:
        max-active: 10
        max-idle: 8
        min-idle: 1
        max-wait: 1

之后就配置spring session的相关依赖

###配置spring session, 由于spring boot的自动配置,相当于使用注解@EnableRedisHttpSession,创建springSessionRepositoryFilter过滤器,将容器的HttpSession替换为Spring sesion
  session:
    timeout: 18000  #会话超时时间,默认后缀为秒
    store-type: redis  #设置session的外部容器为redis
    redis:
      flush-mode: on_save  #ON SAVE 还是IMMEDIATE  刷新策略
      namespace: spring:session  #存储session的命名空间

配置之后系统就是使用的redis作为spring sesison的外部容器,这样原本存在于服务器中的用户登录的用户信息就会存放到外部的Redis集群中去

这里test以admin登录之后外部redis数据库

127.0.0.1:6379[2]> keys *
1) "spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:admin"
2) "spring:session:sessions:f7a846dc-a619-457d-8675-b307d72805b6"
3) "spring:session:sessions:expires:f7a846dc-a619-457d-8675-b307d72805b6"
4) "spring:session:expirations:1658652360000"

可以看到记录了当前登录用户的名称,并且记录了登录的信息和过期的信息,如果超时就会跳转页面

如果将其删除,那么客户端就会处于登出状态

session并发配置🅱️ 统一设备在线数量

spring security默认情况下对于session的并发数量没有限制,也就是一个用户的账号和密码可以在任意数量客户端使用,但是比如付费类视频是不允许的,所以需要对在线数量限制,可以使用spring security实现

实现SessionInformationExpiredStrategy作为失效策略,用于返回异常

package indv.Cning.cfengsecuritydemo.session;

import com.fasterxml.jackson.databind.ObjectMapper;
import indv.Cning.cfengsecuritydemo.domain.Resp;
import lombok.RequiredArgsConstructor;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;

import javax.servlet.ServletException;
import java.io.IOException;

/**
 * @author Cfeng
 * @date 2022/7/24
 */

@RequiredArgsConstructor
public class ParallelInformationExpiredStrategy implements SessionInformationExpiredStrategy {
    //处理对象转化的mapper,jackson工具将对象转为JSON格式,自动配置了对象
    private final ObjectMapper objectMapper;
    
    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
        //返回异常信息
        event.getResponse().setContentType("application/json;charset=utf-8");
        event.getResponse().getWriter().write(objectMapper.writeValueAsString(Resp.failed("已达到并发上限")));
    }
}

当达到并发上限就会触发异常,这里需要更改配置sessionManagement,定义并发上限,是否阻止登录和失效之后的策略

 //配置Session,这里使用Spring session避免服务器重启集体下线  内存JVM,最后配置因为配置spring session后下面还是session
               .and()
               .sessionManagement()
               .invalidSessionUrl("/timeout.html") //配置状态过期后的页面
               .maximumSessions(1)  //设置session并发上限为1
               .maxSessionsPreventsLogin(true) //达到上限后阻止登录
               .expiredSessionStrategy(new ParallelInformationExpiredStrategy(objectMapper));//失效的处理策略

这样之后就会阻止统一账户的登录,多登录会login?error

强制下线

Session-cookie可以在服务端监听控制用户会话状态,需求是强制下线,来保护系统,需要进一步配置,配置Registry

.sessionRegistry(sessionRegistry());

//同时给出SessionRegisty的Bean,给出ServletListenerRegistrationBean,servlet监听注册器Bean
@Bean
    public SessionRegistry sessionRegistry() {
        //用于访问Session
        return new SessionRegistryImpl();
    }

    @Bean
    public static ServletListenerRegistrationBean httpSessionEventPublisher() {
        //必须要告诉Spring信息将Session存储在sessionRegistry
        return new ServletListenerRegistrationBean(new HttpSessionEventPublisher());
    }
  • 完成上面的配置后可以实现工具类,但是必须将session存储在SessionRegistry中,通过ServletListenerRegistrationBean进行EventPublish
 * 会话管理工具,可以强制下线,还是创建一个单例Bean
 */

@Component
@RequiredArgsConstructor   //创建一个构造器
public class SessionUtils {

    //引入sessionRegistry对象,config中配置的Impl,可以获取当前登录的所有用户
    private final SessionRegistry sessionRegistry;

    public void expireUserSessions(String userName) {
        for(Object principal : sessionRegistry.getAllPrincipals()) {
            UserDetails userDetails = (UserDetails) principal;
            //找到Session中的principal找到对应的用户,获取到当前用户的所有info,allSessions
            if(userDetails.getUsername().equals(userName)) {
                List<SessionInformation> sessionInfos = sessionRegistry.getAllSessions(userDetails,true);
                for(SessionInformation sessionInformation : sessionInfos) {
                    //让存储的session立刻失效
                    sessionInformation.expireNow();
                }
            }
        }
    }
}

使用sessionRegistry对象获取所有的principals并且比较对应的username,找到之后获取所有的SessionInformation,让其expireNow();需要某个用户下线就直接调用该expireUserSessions即可

	@Test
	public void testSessionRegistry() {
		sessionUtils.expireUserSessions("Cfeng");
	}

测试之后发现Cfeng强制下线,不能登录

JWT(JSON Web Token)

Session-cookie模式就是在后台记录用户的状态,相关的token会存放在数据库中,DataSorce会自动注入,而JWT是与Session-cookie模式token有鲜明对比的差异

JWT是一个开放标准,定义了一种自包含的格式,在各端将安全信息以JSON格式传递,此信息经过数字签名,可以进行验证,还可以使用HMAC和RSA对JWT加密; JWT以紧凑的三部分组成: 以. 分割,xxx.yyy.zzz

  • Header:标头 由两部分组成,令牌的类型【JWT】和签名所使用的算法,比如RSA等
  • payload 负载: 负载中包含不同类型的声明Claims,比如已注册声明,公有声明,私有声明
  • signature 签名:基于header定义加密算法进行加密的签名,比如HMACSHA256的签名过程
HMACSHA(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret)

签名就可以保证消息传递过程没有被更改,如果使用私钥加密,还可以进一步验证,【非对称加密】

基于JWT加密以及携带安全信息,后端可以将原本保存在Session的数据保存在JWT的私有声明中,要求客户端每次请求都携带JWT,这样就可以实现无状态访问

JWT可以实现后端无状态化,信息由客户端携带,没有存在session中,同时还可以实现:

  • 跨域和CORS: 传统认证方式Cookies默认只能用于单个域名及子域名的访问,跨域的处理很繁琐,但是JWT不依赖Cookie,都是以Authorization Bearer(JWT)格式违请求头,不被跨域影响
  • 跨平台解决方案 : cookies和移动平台融合不好,存在限制,JWT更适合
  • 易扩展: session-cookie需要访问存储模块,token-logins默认表; 分布式应用的认证中,服务器访问外部存储,时间损耗,JWT只需解密即可,性能更好

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XPAVQTO2-1658691438698)(https://ts1.mm.bing/th/id/R-C.392b912d304cb06096981ba706cb27a5?rik=AhiRe1LBUsUYGA&riu=http%3a%2f%2fstatic.zybuluo%2fwpcfan%2f999mi2nt99w5xujsqm2qoeeu%2fimage_1bar35dmim9k197p4c81peitrr9.png&ehk=d4BKytD0piT9d8FREZTi%2fs0VO8fukU3ErORlSiKCd1A%3d&risl=&pid=ImgRaw&r=0)]

JWT通过访问一个登录的URL获取,客户端将会在之后的请求的每个请求体中,以Authorization:Bearer(JWT)的格式将JWT设置为请求头,服务端就会对JWT解密,做出响应

Spring security 集成JWT

首先要引入JWT依赖:

<!-- 引入JWT作为认证-->
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt</artifactId>
			<version>0.9.1</version>
		</dependency>

引入该依赖,就可以进行JWT相关的验证、读写声明

创建存储安全信息的Jwtuser,这里简单就存储用户名,密码和权限 等安全信息

@Accessors(chain = true)
@Data
public class JwtUser implements Serializable {
    private static final long serialVersionUID = -1099124301682839110L;

    private String userName;

    private String userPwd;

    private List<String> authorities; //JWT存储的权限信息,这里直接以String类型
}

封装JWT工具类,操作token获取claims中的信息

这里需要在配置文件中配置私钥

  jwt:
    secret: $2a$10$PHlIvvobgZFe2E3opVF4aOc8MMXqdxFMwEJXod90A1HjqHS/ECaW.
package indv.Cning.cfengsecuritydemo.jwt;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import indv.Cning.cfengsecuritydemo.domain.JwtUser;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.io.Serializable;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * @author Cfeng
 * @date 2022/7/24
 * 封装JWT相关功能用于JWT验证,要进行序列化进行网络传输
 */

@Component
@RequiredArgsConstructor
public class JwtTokenUtil implements Serializable {

    private static final long serialVersionUID = -275260418023383596L;

    //设置token的有效期,单位为s
    private static final long JWT_TOKEN_VALIDITY = 5 * 60 * 60;

    @Value("${spring.jwt.secret}")
    private String secret;
    //Json工具类
    private final ObjectMapper objectMapper;

    /**
     * 从JWT中获取用户名
     */
    public String getUserNameFromToken(String token) {
        return this.getClaimFromToken(token,Claims::getSubject);
    }

    /**
     * 从JWT中获取过期时间
     */
    public Date getExpirationDateFromToken(String token) {
        return this.getClaimFromToken(token,Claims::getExpiration);
    }

    /**
     * 从JWT中获取声明Claim,这里直接创建一个功能性接口直接使用方法引用
     */
    public <T> T getClaimFromToken(String token, Function<Claims,T> claimsResolvers) {
        final  Claims claims = this.getAllClaimsFromToken(token);
        return claimsResolvers.apply(claims);
    }

    /**
     * 通过密钥获取JWT中所有的claims
     * secret就可以得到
     */
    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }

    /**
     * token是否过期
     */
    private Boolean isTokenExpired(String token) {
        final Date expiration = this.getExpirationDateFromToken(token);
        return expiration.before(new Date()); //日期比较直接使用before,new Date()当前时间
    }

    /**
     * 为用户生成Token
     */
    public String generateToken(UserDetails userDetails) throws JsonProcessingException {
        Map<String,Object> claims = new HashMap<>();
        //根据需求在claims新增状态信息,jsonString格式
        JwtUser user = new JwtUser().setUserName(userDetails.getUsername()).setUserPwd(userDetails.getPassword());
        //权限信息
        user.setAuthorities(userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
        //加入claim
        claims.put("principal",objectMapper.writeValueAsString(user));
        //将上面的claim等信息一起生成token
        //JWT中的subject只是principal.name
        return this.doGenerateToken(claims,userDetails.getUsername());
    }

    /**
     * 生成Token: 定义令牌的声明信息 eg 主体,过期时间; 使用你HS512算法与配置好的密钥对JWT进行加密,压缩JWT
     */
    public String doGenerateToken(Map<String,Object> claims, String subject) {
        return Jwts
                .builder()
                //定义令牌信息
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis())) //令牌创建时间
                .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000))
                //签名加密,使用密钥
                .signWith(SignatureAlgorithm.HS512,secret)
                //压缩为string
                .compact();
    }

    /**
     * 验证token
     */
    public Boolean validateToken(String token, String userName) {
        return Objects.equals(this.getUserNameFromToken(token),userName)  && !this.isTokenExpired(token);
    }
}
  • 创建JWT登录控制器,使用AuthenticationManager【spirng security配置】和UserDetailService对象进行验证;JWT的作用就是签发对应的JWT; 之前是进行Remeber并将用户加入Session【Session-cookie】
 * @author Cfeng
 * @date 2022/7/24
 * 主要完成登录认证和签发JWT【登录认证都是spring security完成】
 */

@RestController
@RequiredArgsConstructor
public class JwtAuthenticationController {
    //securityConfig中配置过
    private final AuthenticationManager authenticationManager;

    private final JwtTokenUtil jwtTokenUtil;

    private final UserDetailsService userDetailsService;

    @PostMapping("/authenticate")
    public Resp<?> createAuthenticationToken(@RequestBody UserDto authenticationUser, HttpServletResponse response) throws Exception {
        this.authenticate(authenticationUser.getUserName(),authenticationUser.getUserPwd());
        //进行认证和授权,获得登录用户
        UserDetails userDetails =  userDetailsService.loadUserByUsername(authenticationUser.getUserName());
        //进行jwt签发,生成一个jwtUser的token
        String token = jwtTokenUtil.generateToken(userDetails);
        //通过resp将token写入
        this.setCookie(token,response);
        return Resp.ok(token); //响应token信息
    }

    /**
     * 不是在此类中进行验证用户名和密码,而是委托给Spring security中的AuthenticationManager
     */
    private void authenticate(String username, String password) throws Exception {
        //springSecurity依靠此管理器进行认证
        try {
            authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username,password));
        } catch (DisabledException e) {
            throw new Exception("USER_DISABLED",e);
        } catch (BadCredentialsException e) {
            throw new Exception("INVALID_CREDENTIALS",e);
        }
    }

    /**
     * 将JWT写入cookie,减少客户端对Header的操作
     */
    private void setCookie(String token, HttpServletResponse response) {
        Cookie cookie = new Cookie("jwt",token);
        //cookie响应给客户端,response即可
        response.addCookie(cookie);
    }
}
  • jwt过滤器 : 主要从请求中获取jwt的相关信息,得到token转为UsernamePasswordToken并将其设置到当前登录用户的security 上下文的authentication【手动完成授权】,这里继承OncePreXXX ,主要完成Http交互手动授权
package indv.Cning.cfengsecuritydemo.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import indv.Cning.cfengsecuritydemo.domain.JwtUser;
import indv.Cning.cfengsecuritydemo.jwt.JwtTokenUtil;
import io.jsonwebtoken.ExpiredJwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.stream.Collectors;

/**
 * @author Cfeng
 * @date 2022/7/24
 * JWT验证依赖于实现了OnceRerRequestFilter的过滤器
 * JWT过滤器主要完成从请求中获取JwtToken,token有效将token转换为UserXXXX 并且放入security上下文中
 */

@Component //创建对象放入容器
@Slf4j
@RequiredArgsConstructor
public class JwtRequestFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;

    private  final JwtTokenUtil jwtTokenUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //取出请求头中的认证信息authorization,jwt
        String requestTokenHeader = request.getHeader("authorization");
        String username = null;
        String jwtToken = null;
        //JWT为Bearer token格式,去掉Bearer,就是Token
        String bearerPrefix = "Bearer ";
        if(requestTokenHeader != null && requestTokenHeader.startsWith(bearerPrefix)) {
            jwtToken = requestTokenHeader.substring(7); //截取后面的部分
        } else {
            log.warn("JWT Token does not begin with Bearer String");
            if(request.getCookies() != null) {
                for(Cookie cookie : request.getCookies()) {
                    if("jwt".equals(cookie.getName())) {
                        jwtToken = cookie.getValue();
                    }
                }
            }
        }
        //上面的token要么直接从Header的authorization中获取,要么从cookies中获取
        try {
            username = jwtTokenUtil.getUserNameFromToken(jwtToken);
        } catch (IllegalArgumentException e) {
            System.out.println("Unable to get Jwt Token");
        } catch (ExpiredJwtException e) {
            System.out.println("JWT Token has expired");
        }

        //验证token,主要是用户名是否存在,当前登录账户是否为空,以及登录
        boolean isAuthentication = username != null && SecurityContextHolder.getContext().getAuthentication() == null && jwtTokenUtil.validateToken(jwtToken,username);
        if(isAuthentication) {
            //如果token有效,手动授权,并且将其设置到Spring security上下文,找到claims中的身份信息JwtUser
            String userJson = jwtTokenUtil.getClaimFromToken(jwtToken,claims -> claims.get("principal",String.class));
            //将json转为对象readValue
            JwtUser jwtUser = objectMapper.readValue(userJson,JwtUser.class);
            //完成授权
            User userDetails = new User(jwtUser.getUserName(),jwtUser.getUserPwd(),jwtUser.getAuthorities().stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()));
            //token转换
            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities());
            usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            //放入上下文
            SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        }
        filterChain.doFilter(request,response);
    }
}
  • 除了配置过滤器处理JWTtoken的转换获取之外,还需要配置AuthenticationEntryPoint类型,进行JWT认证异常处理
package indv.Cning.cfengsecuritydemo.handler;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author Cfeng
 * @date 2022/7/24
 * JWT认证异常处理类,也就是没有授权的处理类型,JWT认证进入点,异常处理
 */

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED,"Unauthorized");
    }
}
spring security JWT配置 全局配置manager,Session无状态

与Cookie-session相比,这个的差异还是比较大,需要修改session为无状态,authenticateManager获取即可【同时引入JWT的请求过滤器和其异常处理的EntryPoint】

//修改的配置 ,主要谈变化, 像引入Manager上面就已经引入了

//新增两个属性
    //引入jwt的过滤器和异常处理类 =============jwt
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    private final JwtRequestFilter jwtRequestFilter;

//httpsecurity中异常处理配置修改session的page为jwt的异常处理entrypoint
 			  .and()
               .exceptionHandling()
//               .accessDeniedPage("/403.html")
               .authenticationEntryPoint(jwtAuthenticationEntryPoint)  //使用的是JWt的异常处理,不用session那一套 
                  
//修改sesion无状态
 .and()
               .sessionManagement()
//               .invalidSessionUrl("/timeout.html") //配置状态过期后的页面
//               .maximumSessions(1)  //设置session并发上限为1
//               .maxSessionsPreventsLogin(true) //达到上限后阻止登录
//               .expiredSessionStrategy(new ParallelInformationExpiredStrategy(objectMapper))//失效的处理策略
//               .sessionRegistry(sessionRegistry());
               .sessionCreationPolicy(SessionCreationPolicy.STATELESS)  //使用jwt修改为无状态的session
       
//插入过滤器
.and()  //将jwt过滤器插入过滤器链的头部,处理所有的jwt请求
               .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
           
//重新配置登录页面访问controller,这里是手动认证的,放行,并且设置loginPage
 //方便起见,这里直接将jwtUser的路径放开
        return (web) -> web.ignoring().antMatchers("/js/**", "/img/**","/css/**","/authenticate");

这样配置之后就可以使用JWT来进行验证了,替代Session

OAuth 2.0

第三方应用授权大部分会选择这个授权技术,OAuth是一个用于授权的网络标准,OAuth可以方便操作第三方应用授权,这里介绍使用SpirngBoot集成

OAuth2.0主要概念

OAuth2.0授权标准,可以让第三方应用程序获得用户在某一网络服务【比如QQ,Github】上面的有限账户访问权限,比如登录豆瓣网站使用QQ登录,这个时候就需要QQ授权登录,获取QQ的呢称等

实现的方式就是通过将身份验证步骤委托给承载用户账户的服务器【认证服务器】Authorization Server,授权给第三方应用【资源服务器 resource server】访问用户账户

OAuth2主要就是在四个角色之间进行信息交换

  • Client:第三方应用,客户端: 想要访问用户账户的应用程序
  • Resource Owner: 用户 用户拥有授权/资源服务器上面的用户账户,在流程中就是授予客户端访问账户的权限
  • Resource Server: 资源服务器,保存用户账户,拥有受保护资源,请求正确包含令牌,可以访问资源,第三方应用的服务端
  • Authorization Server: 认证服务器 认证用户的服务器,客户端认证通过,发放访问令牌给客户端;这里就是QQ

工作流程如下

     +--------+                               +---------------+
     |        |--(A)- Authorization Request ->|   Resource    |
     |        |                               |     Owner     |
     |        |<-(B)-- Authorization Grant ---|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(C)-- Authorization Grant -->| Authorization |
     | Client |                               |     Server    |
     |        |<-(D)----- Access Token -------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(E)----- Access Token ------>|    Resource   |
     |        |                               |     Server    |
     |        |<-(F)--- Protected Resource ---|               |
     +--------+                               +---------------+

认证工作是由Authrization server完成

  1. 用户打开客户端后,客户端要求用户给与授权
  2. 用户同意给与客户端授权
  3. 用户基于用户的授权向认证服务器申请令牌
  4. 认证服务器对客户端成功认证后发放令牌
  5. 客户端使用令牌向资源服务器申请资源
  6. 资源服务器确认令牌无误,同意向客户端开发资源
授权模式

OAuth2 提供四种不同的授权模式适应不同的场景: 授权码模式,密码模式,简化模式,客户端模式

  • 授权码模式

授权码模式最常用授权模式,针对服务端应用程序优化的方案,该模式下源码不公开,客户端私钥保证机密性,基于重定向流程,应用程序必须要能够和用户代理【浏览器】进行交互,并且通过接收通过用户代理路由的API授权代码; 客户端先将用户导向认证服务器,登录后获取授权码,然后授权,最后根据授权码获取令牌

用户访问客户端,后者将前者导向认证服务器,用户选择是否给客户端授权,授权之后认证服务器导向重定向URL,附上授权码,客户端收到授权码,附上重定向URL,向服务器申请令牌,该交互过程用户不可见,核对授权码发放令牌

  • 密码模式

使用密码模式授权,用户直接向应用程序提供凭证,该应用程序使用令牌从服务器获取访问令牌,当其他流程不行时,才考虑在授权服务器启用该类型,也就是仅在用户信任该应用程序时使用

用户向客户端提供用户名和密码,客户端将用户名和密码发给认证服务器,向后者申请令牌,认证服务器确认无误,向客户端提供访问令牌

  • 简化模式

多用于移动互联网,网站; 客户端机密性无法保证,与授权码模式相似,也是基于重定向,但是访问令牌由用户代理转发给应用程序,令牌公共可见,并且不对应用程序进行身份验证,只是依赖重定向URL需要实现注册达到目的

没有授权码中间步骤,适合没有后端的应用,纯前端应用,token暴露,令牌有效期必须短,也为授权码隐藏模式

客户端将用户导向认证服务器,用户决定是否授权,假设授权,认证服务器将用户导向重定向URL,在URL的hash部分包含令牌,浏览器向服务器发出请求,不包括上一步的hash,资源服务器返回一个网页,js可以获取hash种的令牌,执行js获得令牌,浏览器将令牌发给客户端

  • 客户端模式

客户端将代替用户以自己的名义向服务端提交认证,与用户无关

客户端向认证服务器进行身份认证,要求一个访问的令牌,认证服务器确认无误,向客户端提供令牌

集成OAuth 2.0实现SSO单点登录 @EnableOAuth2Sso 开启单点登录

这里就演示使用springboot + spring security + oauth2 使用授权码模式

spring security要实现前端的控权需要引入thymeleaf-extras-springsecurity4,引入相关的依赖

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<!-- spring security前端控权 使用标签-->
		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity5</artifactId>
		</dependency>
		<!-- 使用OAuth2 实现 SSO单点登录-->
		<dependency>
			<groupId>org.springframework.security.oauth.boot</groupId>
			<artifactId>spring-security-oauth2-autoconfigure</artifactId>
			<version>2.0.1.RELEASE</version>
		</dependency>
  • 客户端配置,这里就以上面的SecurityDemo为客户端,引入OAuth2之后配置不需要怎么修改,加上注解@EnableOAth2Sso即可
@RequiredArgsConstructor
@EnableWebSecurity 
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true,jsr250Enabled = true) 
@EnableOAuth2Sso  //开启OAuth2单点登录
public class SecurityConfig {
    XXXXX //配置基本不变

配置中注解可以开启单点登录功能,配置的内容用于分配请求的处理范围

框架默认端点URL ✴️

框架默认的Url有

  • /aouth/authorize : 授权端点
  • /aouth/token : 令牌端点
  • /aouth/confirm_access: 用户确认授权提交点
  • /aouth/error: 授权服务错误信息端点
  • /aouth/check_token: 资源服务访问令牌解析端点
  • /aouth/token_key : 使用JWT令牌需要用到的提供公有密钥端点

这几个端点应该被保护只供授权用户访问,也可以通过pathMapping方法自定义默认的端口

yaml中配置oauth的客户端信息 作为应用程序

  security:
    oauth2:
      client: #客户端配置
        clientId: 42660590
        clientSecret: $2a$10$PHlIvvobgZFe2E3opVF4aOc8MMXqdxFMwEJXod90A1HjqHS/ECaW.
        #获取令牌的访问地址
        accessTokenUri: http://localhost:8081/auth/oauth/token
        #将用户重定向到授权地址
        userAuthorizationUri: http://localhost:8081/auth/oauth/authorize
      resource:
        #获取定义用户信息的地址
        userInfoUri: http://localhost:8081/auth/user/current

⭐️⭐️⭐️⭐️⭐️: 这里实际上是将上面demo当作了应用程序,需要到8081端口的auth项目获取登录权限用于展示

再创建两个前端页面用于展示登录

index.html

<h1>
    Spirng security SSO
</h1>
<a href = "securedPage">Login</a>


secured.html
<h1>
    secured Page
</h1>
Welcome,
<span th:text="${#authentication.name}">Name</span>
授权服务 @EnableAuthorizationServer 资源服务@EnableResourceServer

这里将授权服务和资源服务放在同一项目中,创建一个module,导入依赖主要就是web的依赖和oauth2的依赖,不需要security

  • 起始类加上server注解,可以直接继承initializer当作web.xml
@SpringBootApplication
@EnableAuthorizationServer //开启授权服务
@EnableResourceServer// 开启资源服务
//继承initialier可以当作web.xml
public class AuthorizationServerApplication extends SpringBootServeletInitializer() {
    public static void main(String[] args) {
        SpringBootApplication.run(AuthorizationServerApplication.class,args);
    }
}
  • 授权服务配置类 继承AuthorizationServerConfigurerAdapter
@Configuration
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {

    @Resource
    private BCryptPasswordEncoder passwordEncoder;

    @Override
    //权限管理配置
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.tokenKeyAccess("permitAll()").checkTokenAccess("isAuthenticated()");
    }

    @Override
    //使用Inmemory内存方式注册客户端,当然也可以是使用数据库模式JDBC
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("42660590")
                .secret(passwordEncoder.encode("$2a$10$PHlIvvobgZFe2E3opVF4aOc8MMXqdxFMwEJXod90A1HjqHS/ECaW."))
            //加密还可以采用Bcrypt.haspw 更快    
            //授权模式为授权码
                .authorizedGrantTypes("authorization_code")
                .scopes("user_info")
                .autoApprove(true)
                //注册重定向Uri
                .redirectUris("http://localhost:8082/ui/login","http://localhost:8083/ui2/login");
    }
}

配置当前提供资源的服务端口8081, 路径/auth

  • 安全配置
@EnableSecurity
@Order(1)
public class ServerSecurityConfig() {
    //和上面的类似,自定义即可
}
  • 声明返回用户信息的UserController
@RestController
public class UserController {
    @GetMapping("/user/me")
    public Principal user(Principal principal) {
        return principal;
    }
}

这样就可以成功实现单点登录,8082上面的客户端登录会自动跳转访问8081 的/auth服务,验证其身份,生成授权码,当然,博主这里的项目代码只是截取了一小部分🌩

OAuth2.0使用非常广泛,是一个重要的知识点,后面博主会结合实际项目具体分析相关的应用,这里的demo就简单使用即可

Spring security 和Shiro个人Idea

上面基本完整分享了Spirng security和Shiro的简单的使用,对于一个轻量级项目来说够用了,像Spirng session分布式集群管理,OAth2 实现SSO单点登录则在具体应用场景中才会体现,这里就来简单说明几个概念综述上面

一般情况下,shiro相比spring securiry更简单,容易上手,配置简单,只是需要在配置类中简单配置几个对象,其余的配置基本都可以依靠yaml和注解完成,并且注解使用简单,内部机制较security简单,如果不是Spring生态,建议使用; 但是如果是spring boot构建项目,那么还是security更方便,因为自动配置很强大

  1. 登录页面Shiro自定义的,在factoryBean中设置loginUrl,表单的提交路径为自定义处理器路径,在处理器中手动完成相关的处理login; 而security默认情况下使用默认的页面,用户user,密码随机; 如果使用自定义页面,需要设置formLogin的logPage,并且设置ProcessUrl和表单的action保持一致;处理器由框架实现,一般不自定义处理
  2. Shiro的认证授权依靠的是Realm,授权Realm中的认证和授权,认证Realm只有认证功能,认证过程就是页面访问自定义的处理器,在处理器中使用Subject.login方法,最终会进入Realm认证,授权只会在需要权限验证的时候才会执行; 而security依赖的是UserDetailService,直接完成认证加授权,在登录过程中都会自动调用验证
  3. 二者密码处理都是框架完成,Shiro需要设置凭证匹配器,设置散列算法和次数,在认证时需要指定盐,并且注册用户加密密码需要手工封装工具类,使用SimpleHash即可; security框架提供了PasswordEncoder类,we只需要指定其实现类即可,一般采用Bcrypt,在配置中指定后就不需要在认证中操作,注册的时候直接使用Bcrypt.haspw(,Bcrypt.genesalt) 比Encoder.encode更快速
  4. 登出logout都是框架自动完成, 在Shrio中设置登出路径,将其与logout过滤器对应就可以在前台直接使用路径,自动退出; 在security中需要在HttpSecurity配置Logout相关配置,更详细,配置之后也是会自动退出,还可以设置其他的Url等
  5. 配置类注解不同: shiro核心就是一个@configuration即可, 但是security是@EnableWebSecurity 复合注解,会开启全局认证等
  6. 放行路径和认证路径: 在Shiro中都是将路径与anon或者auhtc默认过滤器匹配达到效果,在security汇总静态资源的放行一般在WebSecurity中完成,表单放行直接在formLogin下permittAll,而路径authc则是anyRequest.isXXX就可以
  7. 开启注解的方式: 在shiro中需要在配置类中配置两个对象就可以使用; 在spirng security中则需要添加注解【配置类上面】开启全局的注解识别
  8. 前后台控制权限二者类似,后台一般都采用注解控制权限,而前台就是引入依赖,采用标签控制权限
  9. Shiro的remeberMe配置一般直接在token位置开启即可,同时配置是在factoryBean中配置RememberMeManager,spirng securtiy直接在登录页面加上checkbox,设置参数name,在HttpSecurity的RememberMeManXX配置即可; Session也是类似,都是配置

总的来看,Spring security的功能更强大,并且shiro只是比spring security入门简单一些,个人更喜欢security,方便分布式操作⤴️

本文标签: 两大入门级万字框架教程