当前位置: 移动技术网 > IT编程>开发语言>Java > Spring Security 解析(一) —— 授权过程

Spring Security 解析(一) —— 授权过程

2019年08月20日  | 移动技术网IT编程  | 我要评论

spring security 解析(一) —— 授权过程

  在学习spring cloud 时,遇到了授权服务oauth 相关内容时,总是一知半解,因此决定先把spring security 、spring security oauth2 等权限、认证相关的内容、原理及设计学习并整理一遍。本系列文章就是在学习的过程中加强印象和理解所撰写的,如有侵权请告知。

项目环境:

  • jdk1.8
  • spring boot 2.x
  • spring security 5.x

一、 一个简单的security demo

1、 自定义的userdetailsservice实现

  自定义myuserdetailsuserservice类,实现 userdetailsservice 接口的 loaduserbyusername()方法,这里就简单的返回一个spring security 提供的 user 对象。为了后面方便演示spring security 的权限控制,这里使用authorityutils.commaseparatedstringtoauthoritylist("admin") 设置了user账号有一个admin的角色权限信息。实际项目中可以在这里通过访问数据库获取到用户及其角色、权限信息。

@component
public class myuserdetailsuserservice implements userdetailsservice {
    @override
    public userdetails loaduserbyusername(string username) throws usernamenotfoundexception {
        // 不能直接使用 创建 bcryptpasswordencoder 对象来加密, 这种加密方式 没有 {bcrypt}  前缀,
        // 会导致在  matches 时导致获取不到加密的算法出现
        // java.lang.illegalargumentexception: there is no passwordencoder mapped for the id "null"  问题
        // 问题原因是 spring security5 使用 delegatingpasswordencoder(委托)  替代 nooppasswordencoder,
        // 并且 默认使用  bcryptpasswordencoder 加密(注意 delegatingpasswordencoder 委托加密方法bcryptpasswordencoder  加密前  添加了加密类型的前缀)  https://blog.csdn.net/alinyua/article/details/80219500
        return new user("user",  passwordencoderfactories.createdelegatingpasswordencoder().encode("123456"), authorityutils.commaseparatedstringtoauthoritylist("admin"));
    }
}

  注意spring security 5 开始没有使用 nooppasswordencoder作为其默认的密码编码器,而是默认使用 delegatingpasswordencoder 作为其密码编码器,其 encode 方法是通过 密码编码器的名称作为前缀 + 委托各类密码编码器来实现encode的。

public string encode(charsequence rawpassword) {
        return "{" + this.idforencode + "}" + this.passwordencoderforencode.encode(rawpassword);
    }

  这里的 idforencode 就是密码编码器的简略名称,可以通过
passwordencoderfactories.createdelegatingpasswordencoder()
内部实现看到默认是使用的前缀是 bcrypt 也就是 bcryptpasswordencoder

public class passwordencoderfactories {
    public static passwordencoder createdelegatingpasswordencoder() {
        string encodingid = "bcrypt";
        map<string, passwordencoder> encoders = new hashmap();
        encoders.put(encodingid, new bcryptpasswordencoder());
        encoders.put("ldap", new ldapshapasswordencoder());
        encoders.put("md4", new md4passwordencoder());
        encoders.put("md5", new messagedigestpasswordencoder("md5"));
        encoders.put("noop", nooppasswordencoder.getinstance());
        encoders.put("pbkdf2", new pbkdf2passwordencoder());
        encoders.put("scrypt", new scryptpasswordencoder());
        encoders.put("sha-1", new messagedigestpasswordencoder("sha-1"));
        encoders.put("sha-256", new messagedigestpasswordencoder("sha-256"));
        encoders.put("sha256", new standardpasswordencoder());
        return new delegatingpasswordencoder(encodingid, encoders);
    }
}

2、 设置spring security配置

  定义springsecurityconfig 配置类,并继承websecurityconfigureradapter覆盖其configure(httpsecurity http) 方法。

@configuration
@enablewebsecurity //1
public class springsecurityconfig extends websecurityconfigureradapter {

    @override
    protected void configure(httpsecurity http) throws exception {
        http.formlogin()  //2
            .and()
                .authorizerequests() //3
                .antmatchers("/index","/").permitall() //4
                .anyrequest().authenticated(); //6
    }
}

配置解析:

  • @enablewebsecurity 查看其注解源码,主要是引用websecurityconfiguration.class 和 加入了@enableglobalauthentication 注解 ,这里就不介绍了,我们只要明白添加 @enablewebsecurity 注解将开启 security 功能。
  • formlogin() 使用表单登录(默认请求地址为 /login),在spring security 5 里其实已经将旧版本默认的 httpbasic() 更换成 formlogin() 了,这里为了表明表单登录还是配置了一次。
  • authorizerequests() 开始请求权限配置
  • antmatchers() 使用ant风格的路径匹配,这里配置匹配 / 和 /index
  • permitall() 用户可任意访问
  • anyrequest() 匹配所有路径
  • authenticated() 用户登录后可访问

3、 配置html 和测试接口

   在 resources/static 目录下新建 , 其内部定义一个访问测试接口的按钮

<!doctype html>
<html lang="en" >
<head>
    <meta charset="utf-8">
    <title>欢迎</title>
</head>
<body>
        spring security 欢迎你!
        <p> <a href="/get_user/test">测试验证security 权限控制</a></p>
</body>
</html>

  创建 rest 风格的获取用户信息接口

@restcontroller
public class testcontroller {

    @getmapping("/get_user/{username}")
    public string getuser(@pathvariable  string username){
        return username;
    }
}

4、 启动项目测试

1、访问 localhost:8080 无任何阻拦直接成功

image

2、点击测试验证权限控制按钮 被重定向到了 security默认的登录页面

3、使用 myuserdetailsuserservice定义的默认账户 user : 123456 进行登录后成功跳转到 /get_user 接口


二、 @enablewebsecurity 配置解析

   还记得之前讲过 @enablewebsecurity 引用了 websecurityconfiguration 配置类 和 @enableglobalauthentication 注解吗? 其中 websecurityconfiguration 就是与授权相关的配置,@enableglobalauthentication 配置了 认证相关的我们下节再细讨。

   首先我们查看 websecurityconfiguration 源码,可以很清楚的发现 springsecurityfilterchain() 方法。

    @bean(name = abstractsecuritywebapplicationinitializer.default_filter_name)
    public filter springsecurityfilterchain() throws exception {
        boolean hasconfigurers = websecurityconfigurers != null
                && !websecurityconfigurers.isempty();
        if (!hasconfigurers) {
            websecurityconfigureradapter adapter = objectobjectpostprocessor
                    .postprocess(new websecurityconfigureradapter() {
                    });
            websecurity.apply(adapter);
        }
        return websecurity.build(); //1
    }

  这个方法首先会判断 websecurityconfigurers 是否为空,为空加载一个默认的 websecurityconfigureradapter对象,由于自定义的 springsecurityconfig 本身是继承 websecurityconfigureradapter对象 的,所以我们自定义的 security 配置肯定会被加载进来的(如果想要了解如何加载进来可以看下websecurityconfiguration.setfilterchainproxysecurityconfigurer() 方法)。

   我们看下 websecurity.build() 方法实现 实际调用的是 abstractconfiguredsecuritybuilder.dobuild() 方法,其方法内部实现如下:

@override
    protected final o dobuild() throws exception {
        synchronized (configurers) {
            buildstate = buildstate.initializing;

            beforeinit();
            init();

            buildstate = buildstate.configuring;

            beforeconfigure();
            configure();

            buildstate = buildstate.building;

            o result = performbuild(); // 1 创建 defaultsecurityfilterchain (security filter 责任链 ) 

            buildstate = buildstate.built;

            return result;
        }
    }

   我们把关注点放到 performbuild() 方法,看其实现子类 httpsecurity.performbuild() 方法,其内部排序 filters 并创建了 defaultsecurityfilterchain 对象。

    @override
    protected defaultsecurityfilterchain performbuild() throws exception {
        collections.sort(filters, comparator);
        return new defaultsecurityfilterchain(requestmatcher, filters);
    }

   查看defaultsecurityfilterchain 的构造方法,我们可以看到有记录日志。

public defaultsecurityfilterchain(requestmatcher requestmatcher, list<filter> filters) {
        logger.info("creating filter chain: " + requestmatcher + ", " + filters); // 按照正常情况,我们可以看到控制台输出 这条日志 
        this.requestmatcher = requestmatcher;
        this.filters = new arraylist<>(filters);
    }

   我们可以回头看下项目启动日志。可以看到下图明显打印了 这条日志,并且把所有 filter名都打印出来了。==(请注意这里打印的 filter 链,接下来我们的所有授权过程都是依靠这条filter 链展开 )==

  那么还有个疑问: httpsecurity.performbuild() 方法中的 filters 是怎么加载的呢? 这个时候需要查看 websecurityconfigureradapter.init() 方法,这个方法内部 调用 gethttp() 方法返回 httpsecurity 对象(看到这里我们应该能想到 filters 就是这个方法中添加好了数据),具体如何加载的也就不介绍了。

public void init(final websecurity web) throws exception {
        final httpsecurity http = gethttp(); // 1 
        web.addsecurityfilterchainbuilder(http).postbuildaction(new runnable() {
            public void run() {
                filtersecurityinterceptor securityinterceptor = http
                        .getsharedobject(filtersecurityinterceptor.class);
                web.securityinterceptor(securityinterceptor);
            }
        });
    }

   用了这么长时间解析 @enablewebsecurity ,其实最关键的一点就是创建了 defaultsecurityfilterchain 也就是我们常 security filter 责任链,接下来我们围绕这个 defaultsecurityfilterchain 中 的 filters 进行授权过程的解析。

三、 授权过程解析

  security的授权过程可以理解成各种 filter 处理最终完成一个授权。那么我们再看下之前 打印的filter 链,这里为了方便,再次贴出图片

  这里我们只关注以下几个重要的 filter :

  • securitycontextpersistencefilter
  • usernamepasswordauthenticationfilter (abstractauthenticationprocessingfilter)
  • basicauthenticationfilter
  • anonymousauthenticationfilter
  • exceptiontranslationfilter
  • filtersecurityinterceptor

1、securitycontextpersistencefilter

  securitycontextpersistencefilter 这个filter的主要负责以下几件事:

  • 通过 (securitycontextrepository)repo.loadcontext() 方法从请求session中获取 securitycontext(security 上下文 ,类似 applicaitoncontext ) 对象,如果请求session中没有默认创建一个 authentication(认证的关键对象,由于本节只讲授权,暂不介绍) 属性为 null 的 securitycontext 对象
  • securitycontextholder.setcontext() 将 securitycontext 对象放入 securitycontextholder进行管理(securitycontextholder默认使用threadlocal 策略来存储认证信息)
  • 由于在 finally 里实现 会在最后通过 securitycontextholder.clearcontext() 将 securitycontext 对象 从 securitycontextholder中清除
  • 由于在 finally 里实现 会在最后通过 repo.savecontext() 将 securitycontext 对象 放入session中
httprequestresponseholder holder = new httprequestresponseholder(request,
                response);
        //从session中获取securitycontxt 对象,如果session中没有则创建一个 authtication 属性为 null 的securitycontext对象
        securitycontext contextbeforechainexecution = repo.loadcontext(holder); 

        try {
            // 将 securitycontext 对象放入 securitycontextholder进行管理 (securitycontextholder默认使用threadlocal 策略来存储认证信息)
             securitycontextholder.setcontext(contextbeforechainexecution);

             chain.dofilter(holder.getrequest(), holder.getresponse());

        }
        finally {
            securitycontext contextafterchainexecution = securitycontextholder
                    .getcontext();
            
            // 将 securitycontext 对象 从 securitycontextholder中清除
            securitycontextholder.clearcontext();
            // 将 securitycontext 对象 放入session中
            repo.savecontext(contextafterchainexecution, holder.getrequest(),
                    holder.getresponse());
            request.removeattribute(filter_applied);

            if (debug) {
                logger.debug("securitycontextholder now cleared, as request processing completed");
            }
        }

  我们在 securitycontextpersistencefilter 中打上断点,启动项目,访问 localhost:8080 , 来debug看下实现:


   我们可以清楚的看到创建了一个authtication 为null 的 securitycontext对象,并且可以看到请求调用的filter链具体有哪些。接下来看下 finally 内部处理

   你会发现这里的securitycontxt中的 authtication 是一个名为 anonymoususer (匿名用户)的认证信息,这是因为 请求调用到了 anonymousauthenticationfilter , security默认创建了一个匿名用户访问。

2、usernamepasswordauthenticationfilter (abstractauthenticationprocessingfilter)

  看filter字面意思就知道这是一个通过获取请求中的账户密码来进行授权的filter,按照惯例,整理了这个filter的职责:

  • 通过 requiresauthentication()判断 是否以post 方式请求 /login
  • 调用 attemptauthentication() 方法进行认证,内部创建了 authenticated 属性为 false(即未授权)的usernamepasswordauthenticationtoken 对象, 并传递给 authenticationmanager().authenticate() 方法进行认证,认证成功后 返回一个 authenticated = true (即授权成功的)usernamepasswordauthenticationtoken 对象
  • 通过 sessionstrategy.onauthentication() 将 authentication 放入session中
  • 通过 successfulauthentication() 调用 authenticationsuccesshandler 的 onauthenticationsuccess 接口 进行成功处理( 可以 通过 继承 authenticationsuccesshandler 自行编写成功处理逻辑 )successfulauthentication(request, response, chain, authresult);
  • 通过 unsuccessfulauthentication() 调用authenticationfailurehandler 的 onauthenticationfailure 接口 进行失败处理(可以通过继承authenticationfailurehandler 自行编写失败处理逻辑 )

  我们再看下官方源码的处理逻辑:

// 1 abstractauthenticationprocessingfilter 的 dofilter 方法
public void dofilter(servletrequest req, servletresponse res, filterchain chain)
            throws ioexception, servletexception {

        httpservletrequest request = (httpservletrequest) req;
        httpservletresponse response = (httpservletresponse) res;

        // 2 判断请求地址是否是  /login 和 请求方式为 post  (usernamepasswordauthenticationfilter 构造方法 确定的)
        if (!requiresauthentication(request, response)) {
            chain.dofilter(request, response);
            return;
        }
        authentication authresult;
        try {
            
            // 3 调用 子类  usernamepasswordauthenticationfilter 的 attemptauthentication 方法
            // attemptauthentication 方法内部创建了 authenticated 属性为 false (即未授权)的 usernamepasswordauthenticationtoken 对象, 并传递给 authenticationmanager().authenticate() 方法进行认证,
            //认证成功后 返回一个 authenticated = true (即授权成功的) usernamepasswordauthenticationtoken 对象 
            authresult = attemptauthentication(request, response);
            if (authresult == null) {
                return;
            }
            // 4 将认证成功的 authentication 存入session中
            sessionstrategy.onauthentication(authresult, request, response);
        }
        catch (internalauthenticationserviceexception failed) {
             // 5 认证失败后 调用 authenticationfailurehandler 的 onauthenticationfailure 接口 进行失败处理( 可以 通过 继承 authenticationfailurehandler 自行编写失败处理逻辑 )
            unsuccessfulauthentication(request, response, failed);
            return;
        }
        catch (authenticationexception failed) {
            // 5 认证失败后 调用 authenticationfailurehandler 的 onauthenticationfailure 接口 进行失败处理( 可以 通过 继承 authenticationfailurehandler 自行编写失败处理逻辑 )
            unsuccessfulauthentication(request, response, failed);
            return;
        }
        
        ......
         // 6 认证成功后 调用 authenticationsuccesshandler 的 onauthenticationsuccess 接口 进行失败处理( 可以 通过 继承 authenticationsuccesshandler 自行编写成功处理逻辑 )
        successfulauthentication(request, response, chain, authresult);
    }

  从源码上看,整个流程其实是很清晰的:从判断是否处理,到认证,最后判断认证结果分别作出认证成功和认证失败的处理。

  debug 调试下看 结果,这次我们请求 localhast:8080/get_user/test , 由于没权限会直接跳转到登录界面,我们先输入错误的账号密码,看下认证失败是否与我们总结的一致。

image

  结果与预想时一致的,也许你会奇怪这里的提示为啥时中文,这就不得不说security 5 开始支持 中文,说明咋中国程序员在世界上越来越有地位了!!!

   这次输入正确的密码, 看下返回的authtication 对象信息:

image

   可以看到这次成功返回一个 authticated = ture ,没有密码的 user账户信息,而且还包含我们定义的一个admin权限信息。放开断点,由于security默认的成功处理器是simpleurlauthenticationsuccesshandler ,这个处理器会重定向到之前访问的地址,也就是 localhast:8080/get_user/test。 至此整个流程结束。不,我们还差一个,session,我们从浏览器cookie中看到 session:

image

3、basicauthenticationfilter

  basicauthenticationfilter 与usernameauthticationfilter类似,不过区别还是很明显,basicauthenticationfilter 主要是从header 中获取 authorization 参数信息,然后调用认证,认证成功后最后直接访问接口,不像usernameauthticationfilter过程一样通过authenticationsuccesshandler 进行跳转。这里就不在贴代码了,想了解的同学可以直接看源码。不过有一点要注意的是,basicauthenticationfilter 的 onsuccessfulauthentication() 成功处理方法是一个空方法。

   为了试验basicauthenticationfilter, 我们需要将 springsecurityconfig 中的formlogin()更换成httpbasic()以支持basicauthenticationfilter,重启项目,同样访问
localhast:8080/get_user/test,这时由于没权限访问这个接口地址,页面上会弹出一个登陆框,熟悉security4的同学一定很眼熟吧,同样,我们输入账户密码后,看下debug数据:

image

   这时,我们就能够获取到 authorization 参数,进而解析获取到其中的账户和密码信息,进行认证,我们查看认证成功后返回的authtication对象信息其实是和usernamepasswordauthticationfilter中的一致,最后再次调用下一个filter,由于已经认证成功了会直接进入filtersecurityinterceptor 进行权限验证。

4、anonymousauthenticationfilter

  这里为什么要提下 anonymousauthenticationfilter呢,主要是因为在security中不存在没有账户这一说法(这里可能描述不是很清楚,但大致意思是这样的),针对这个security官方专门指定了这个anonymousauthenticationfilter ,用于前面所有filter都认证失败的情况下,自动创建一个默认的匿名用户,拥有匿名访问权限。还记得 在讲解 securitycontextpersistencefilter 时我们看到得匿名 autication信息么?如果不记得还得回头看下哦,这里就不再叙述了。

5、exceptiontranslationfilter

  exceptiontranslationfilter 其实没有做任何过滤处理,但别小看它得作用,它最大也最牛叉之处就在于它捕获authenticationexception 和accessdeniedexception,如果发生的异常是这2个异常 会调用 handlespringsecurityexception()方法进行处理。 我们模拟下 accessdeniedexception(无权限,禁止访问异常)情况,首先我们需要修改下 /get_user 接口:

  • 在controller 上添加
    @enableglobalmethodsecurity(prepostenabled =true) 启用security 方法级别得权限控制
  • 在 接口上添加 @preauthorize("hasrole('user')") 只允许有user角色得账户访问(还记得我们默认得user 账户时admin角色么?)
@restcontroller
@enableglobalmethodsecurity(prepostenabled =true)  // 开启方法级别的权限控制
public class testcontroller {

    @preauthorize("hasrole('user')") //只允许user角色访问
    @getmapping("/get_user/{username}")
    public string getuser(@pathvariable  string username){
        return username;
    }
}

  重启项目,重新访问 /get_user 接口,输入正确的账户密码,发现返回一个 403 状态的错误页面,这与我们之前将的流程时一致的。debug,看下处理:

image

  可以明显的看到异常对象是 accessdeniedexception ,异常信息是不允许访问,我们再看下 accessdeniedexception 异常后的处理方法accessdeniedhandler.handle(),进入到了 accessdeniedhandlerimpl 的handle()方法,这个方法会先判断系统是否配置了 errorpage (错误页面),没有的话直接往 response 中设置403 状态码。

image

6、filtersecurityinterceptor

  filtersecurityinterceptor 是整个security filter链中的最后一个,也是最重要的一个,它的主要功能就是判断认证成功的用户是否有权限访问接口,其最主要的处理方法就是 调用父类(abstractsecurityinterceptor)的 super.beforeinvocation(fi),我们来梳理下这个方法的处理流程:

  • 通过 obtainsecuritymetadatasource().getattributes() 获取 当前访问地址所需权限信息
  • 通过 authenticateifrequired() 获取当前访问用户的权限信息
  • 通过 accessdecisionmanager.decide() 使用 投票机制判权,判权失败直接抛出 accessdeniedexception 异常
protected interceptorstatustoken beforeinvocation(object object) {
           
        ......
        
        // 1 获取访问地址的权限信息 
        collection<configattribute> attributes = this.obtainsecuritymetadatasource()
                .getattributes(object);

        if (attributes == null || attributes.isempty()) {
        
            ......
            
            return null;
        }

        ......

        // 2 获取当前访问用户权限信息
        authentication authenticated = authenticateifrequired();

    
        try {
            // 3  默认调用affirmativebased.decide() 方法, 其内部 使用 accessdecisionvoter 对象 进行投票机制判权,判权失败直接抛出 accessdeniedexception 异常 
            this.accessdecisionmanager.decide(authenticated, object, attributes);
        }
        catch (accessdeniedexception accessdeniedexception) {
            publishevent(new authorizationfailureevent(object, attributes, authenticated,
                    accessdeniedexception));

            throw accessdeniedexception;
        }

        ......
        return new interceptorstatustoken(securitycontextholder.getcontext(), false,
                    attributes, object);
    }

   整个流程其实看起来不复杂,主要就分3个部分,首选获取访问地址的权限信息,其次获取当前访问用户的权限信息,最后通过投票机制判断出是否有权。

三、 个人总结

  整个授权流程核心的就在于这几次核心filter的处理,这里我用序列图来概况下这个授权流程

image(ps: 如果图片展示不清楚,可访问项目的 github 地址)

   本文介绍授权过程的代码可以访问代码仓库中的 security 模块 ,项目的github 地址 : https://github.com/bug9/spring-security

         如果您对这些感兴趣,欢迎star、follow、收藏、转发给予支持!

如对本文有疑问, 点击进行留言回复!!

相关文章:

验证码:
移动技术网