详解SpringBoot+SpringSecurity+jwt整合及初体验
原来一直使用shiro做安全框架,配置起来相当方便,正好有机会接触下springsecurity,学习下这个。顺道结合下jwt,把安全信息管理的问题扔给客户端,
准备
首先用的是springboot,省去写各种xml的时间。然后把依赖加入一下
<!--安全--> <dependency> <groupid>org.springframework.boot</groupid> <artifactid>spring-boot-starter-security</artifactid> </dependency> <!--jwt--> <dependency> <groupid>io.jsonwebtoken</groupid> <artifactid>jjwt</artifactid> <version>0.9.1</version> </dependency>
application.yml加上一点配置信息,后面会用
jwt: secret: secret expiration: 7200000 token: authorization
可能用到代码,目录结构放出来一下
配置
securityconfig配置
首先是配置securityconfig,代码如下
@configuration @enablewebsecurity// 这个注解必须加,开启security @enableglobalmethodsecurity(prepostenabled = true)//保证post之前的注解可以使用 public class securityconfig extends websecurityconfigureradapter { @autowired jwtauthenticationentrypoint jwtauthenticationentrypoint; @autowired jwtuserdetailsservice jwtuserdetailsservice; @autowired jwtauthorizationtokenfilter authenticationtokenfilter; //先来这里认证一下 @autowired public void configureglobal(authenticationmanagerbuilder auth) throws exception { auth.userdetailsservice(jwtuserdetailsservice).passwordencoder(passwordencoderbean()); } //拦截在这配 @override protected void configure(httpsecurity http) throws exception { http .exceptionhandling().authenticationentrypoint(jwtauthenticationentrypoint) .and() .authorizerequests() .antmatchers("/login").permitall() .antmatchers("/haha").permitall() .antmatchers("/sysuser/test").permitall() .antmatchers(httpmethod.options, "/**").anonymous() .anyrequest().authenticated() // 剩下所有的验证都需要验证 .and() .csrf().disable() // 禁用 spring security 自带的跨域处理 .sessionmanagement().sessioncreationpolicy(sessioncreationpolicy.stateless); // 定制我们自己的 session 策略:调整为让 spring security 不创建和使用 session http.addfilterbefore(authenticationtokenfilter, usernamepasswordauthenticationfilter.class); } @bean public passwordencoder passwordencoderbean() { return new bcryptpasswordencoder(); } @bean @override public authenticationmanager authenticationmanagerbean() throws exception { return super.authenticationmanagerbean(); } }
ok,下面娓娓道来。首先我们这个配置类继承了websecurityconfigureradapter,这里面有三个重要的方法需要我们重写一下:
configure(httpsecurity http):这个方法是我们配置拦截的地方,exceptionhandling().authenticationentrypoint(),这里面主要配置如果没有凭证,可以进行一些操作,这个后面会看jwtauthenticationentrypoint这个里面的代码。进行下一项配置,为了区分必须加入.and()。authorizerequests()这个后边配置那些路径有需要什么权限,比如我配置的那几个url都是permitall(),及不需要权限就可以访问。值得一提的是antmatchers(httpmethod.options, "/**"),是为了方便后面写前后端分离的时候前端过来的第一次验证请求,这样做,会减少这种请求的时间和资源使用。csrf().disable()是为了防止csdf攻击的,至于什么是csdf攻击,请自行百度。
另起一行,以示尊重。sessionmanagement().sessioncreationpolicy(sessioncreationpolicy.stateless);因为我们要使用jwt托管安全信息,所以把session禁止掉。看下sessioncreationpolicy枚举的几个参数:
public enum sessioncreationpolicy { always,//总是会新建一个session。 never,//不会新建httpsession,但是如果有session存在,就会使用它。 if_required,//如果有要求的话,会新建一个session。 stateless;//这个是我们用的,不会新建,也不会使用一个httpsession。 private sessioncreationpolicy() { } }
http.addfilterbefore(authenticationtokenfilter, usernamepasswordauthenticationfilter.class);这行代码主要是用于jwt验证,后面再说。
configure(websecurity web):这个方法我代码中没有用,这个方法主要用于访问一些静态的东西控制。其中ignoring()方法可以让访问跳过filter验证。configureglobal(authenticationmanagerbuilder auth):这个方法是主要进行验证的地方,其中jwtuserdetailsservice代码待会会看,passwordencoder(passwordencoderbean())是密码的一种加密方式。
还有两个注解:@enablewebsecurity,这个注解必须加,开启security。
@enableglobalmethodsecurity(prepostenabled = true),保证post之前的注解可以使用
以上,我们可以确定了哪些路径访问不需要任何权限了,至于哪些路径需要什么权限接着往下看。
securityuserdetails
security 中也有类似于shiro中主体的概念,就是在内存中存了一个东西,方便程序判断当前请求的用户有什么权限,需要实现userdetails这个接口,所以我写了这个类,并且继承了我自己的类sysuser。
public enum sessioncreationpolicy { always,//总是会新建一个session。 never,//不会新建httpsession,但是如果有session存在,就会使用它。 if_required,//如果有要求的话,会新建一个session。 stateless;//这个是我们用的,不会新建,也不会使用一个httpsession。 private sessioncreationpolicy() { } }
authorities就是我们的权限,构造方法中我手动把密码set进去了,这不合适,包括权限我也是手动传进去的。这些东西都应该从数据库搜出来,我现在只是体验一把security,角色权限那一套都没写,所以说明一下就好了,这个构造方法就是传进来一个标志(我这里用的是username,或者应该用userid什么的都可以),然后给你一个完整的主体信息,供其他地方使用。ok,next。
jwtuserdetailsservice
securityconfig配置里面不是有个方法是做真正的认证嘛,或者说从数据库拿信息,具体那认证信息的方法就是在这个方法里面。
@service public class jwtuserdetailsservice implements userdetailsservice { @override public userdetails loaduserbyusername(string user) throws usernamenotfoundexception { system.out.println("jwtuserdetailsservice:" + user); list<grantedauthority> authoritylist = new arraylist<>(); authoritylist.add(new simplegrantedauthority("role_user")); return new securityuserdetails(user,authoritylist); } }
继承了security提供的userdetailsservice接口,实现loaduserbyusername这个方法,我们这里手动模拟从数据库搜出来一个叫user的权限,通过刚才的构造方法,模拟生成当前user的信息,供后面jwt filter一大堆验证。至于为什么user权限要加上“role_”前缀,待会会说。
ok,现在我们知道了怎么配置各种url是否需要权限才能访问,也知道了哪里可以拿到我们的主体信息,那么继续。
jwtauthorizationtokenfilter
千呼万唤始出来,jwt终于可以上场了。至于怎么生成这个token凭证,待会会说,现在假设前端已经拿到了token凭证,要访问某个接口了,看看怎么进行jwt业务的拦截吧。
@component public class jwtauthorizationtokenfilter extends onceperrequestfilter { private final userdetailsservice userdetailsservice; private final jwttokenutil jwttokenutil; private final string tokenheader; public jwtauthorizationtokenfilter(@qualifier("jwtuserdetailsservice") userdetailsservice userdetailsservice, jwttokenutil jwttokenutil, @value("${jwt.token}") string tokenheader) { this.userdetailsservice = userdetailsservice; this.jwttokenutil = jwttokenutil; this.tokenheader = tokenheader; } @override protected void dofilterinternal(httpservletrequest request, httpservletresponse response, filterchain chain) throws servletexception, ioexception { final string requestheader = request.getheader(this.tokenheader); string username = null; string authtoken = null; if (requestheader != null && requestheader.startswith("bearer ")) { authtoken = requestheader.substring(7); try { username = jwttokenutil.getusernamefromtoken(authtoken); } catch (expiredjwtexception e) { } } if (username != null && securitycontextholder.getcontext().getauthentication() == null) { userdetails userdetails = this.userdetailsservice.loaduserbyusername(username); if (jwttokenutil.validatetoken(authtoken, userdetails)) { usernamepasswordauthenticationtoken authentication = new usernamepasswordauthenticationtoken(userdetails, null, userdetails.getauthorities()); securitycontextholder.getcontext().setauthentication(authentication); } } chain.dofilter(request, response); } }
提前说一下,关于@value注解参数开头写了。
dofilterinternal() 这个方法就是这个过滤器的精髓了。首先从header中获取凭证authtoken,从中挖掘出来我们的username,然后看看上下文中是否有我们以这个username为标识的主体。没有,ok,去new一个(如果对象也可以new就好了。。。)。然后就是验证这个authtoken 是否在有效期呢啊,验证token是否对啊等等吧。其实我们刚刚把我们securityuserdetails这个对象叫做主体,到这里我才发现有点自做多情了,因为生成security承认的主体是通过usernamepasswordauthenticationtoken类似与这种类去实现的,之前之所以叫securityuserdetails为主体,只是它存了一些关键信息。然后将主体信息————authentication,存入上下文环境,供后面使用。
我的很多工具类代码都放到了jwttokenutil,下面贴一下代码:
@component public class jwttokenutil implements serializable { private static final long serialversionuid = -3301605591108950415l; @value("${jwt.secret}") private string secret; @value("${jwt.expiration}") private long expiration; @value("${jwt.token}") private string tokenheader; private clock clock = defaultclock.instance; public string generatetoken(userdetails userdetails) { map<string, object> claims = new hashmap<>(); return dogeneratetoken(claims, userdetails.getusername()); } private string dogeneratetoken(map<string, object> claims, string subject) { final date createddate = clock.now(); final date expirationdate = calculateexpirationdate(createddate); return jwts.builder() .setclaims(claims) .setsubject(subject) .setissuedat(createddate) .setexpiration(expirationdate) .signwith(signaturealgorithm.hs512, secret) .compact(); } private date calculateexpirationdate(date createddate) { return new date(createddate.gettime() + expiration); } public boolean validatetoken(string token, userdetails userdetails) { securityuserdetails user = (securityuserdetails) userdetails; final string username = getusernamefromtoken(token); return (username.equals(user.getusername()) && !istokenexpired(token) ); } public string getusernamefromtoken(string token) { return getclaimfromtoken(token, claims::getsubject); } public <t> t getclaimfromtoken(string token, function<claims, t> claimsresolver) { final claims claims = getallclaimsfromtoken(token); return claimsresolver.apply(claims); } private claims getallclaimsfromtoken(string token) { return jwts.parser() .setsigningkey(secret) .parseclaimsjws(token) .getbody(); } private boolean istokenexpired(string token) { final date expiration = getexpirationdatefromtoken(token); return expiration.before(clock.now()); } public date getexpirationdatefromtoken(string token) { return getclaimfromtoken(token, claims::getexpiration); } }
根据注释你能猜个大概吧,就不再说了,有些东西是jwt方面的东西,今天就不再多说了。
jwtauthenticationentrypoint
前面还说了一个发现没有凭证走一个方法,代码也贴一下。
@component public class jwtauthenticationentrypoint implements authenticationentrypoint { @override public void commence(httpservletrequest request, httpservletresponse response, authenticationexception authexception) throws ioexception, servletexception { system.out.println("jwtauthenticationentrypoint:"+authexception.getmessage()); response.senderror(httpservletresponse.sc_unauthorized,"没有凭证"); } }
实现authenticationentrypoint这个接口,发现没有凭证,往response中放些东西。
run code
下面跑一下几个接口,看看具体是怎么具体访问某个方法的吧,还有前面一点悬念一并解决。
登录
先登录一下,看看怎么生成token扔给前端的吧。
@restcontroller public class logincontroller { @autowired @qualifier("jwtuserdetailsservice") private userdetailsservice userdetailsservice; @autowired private jwttokenutil jwttokenutil; @postmapping("/login") public string login(@requestbody sysuser sysuser, httpservletrequest request){ final userdetails userdetails = userdetailsservice.loaduserbyusername(sysuser.getusername()); final string token = jwttokenutil.generatetoken(userdetails); return token; } @postmapping("haha") public string haha(){ userdetails userdetails = (userdetails) org.springframework.security.core.context.securitycontextholder.getcontext().getauthentication().getprincipal(); return "haha:"+userdetails.getusername()+","+userdetails.getpassword(); } }
我们前面配置中已经把login设置为随便访问了,这边通过jwt生成一个token串,具体方法请看jwttokenutil.generatetoken,已经写了。只要知道这里面存了username、加密规则、过期时间就好了。
然后跑下haha接口,发现没问题,正常打印,说明主体也在上下文中了。
需要权限
然后我们访问一个需要权限的接口吧。
@restcontroller @requestmapping("/sysuser") public class sysusercontroller { @getmapping(value = "/test") public string test() { return "hello spring security"; } @preauthorize("hasanyrole('user')") @postmapping(value = "/testneed") public string testneed() { return "testneed"; } }
访问testneed接口,看到没,@preauthorize("hasanyrole('user')")这个说明需要user权限!我们在刚刚生成securityuserdetails这个的时候已经模拟加入了user权限了,所以可以访问。现在说说为什么加权限的时候需要加入前缀“role_”.看hasanyrole源码:
public final boolean hasanyrole(string... roles) { return hasanyauthorityname(defaultroleprefix, roles); } private boolean hasanyauthorityname(string prefix, string... roles) { set<string> roleset = getauthorityset(); for (string role : roles) { string defaultedrole = getrolewithdefaultprefix(prefix, role); if (roleset.contains(defaultedrole)) { return true; } } return false; } private static string getrolewithdefaultprefix(string defaultroleprefix, string role) { if (role == null) { return role; } if (defaultroleprefix == null || defaultroleprefix.length() == 0) { return role; } if (role.startswith(defaultroleprefix)) { return role; } return defaultroleprefix + role; } 关键是 defaultroleprefix 看这个类最上面 private string defaultroleprefix = "role_";
人家源码这么干的,咱们就这么写呗,咱也不敢问。其实也有不需要前缀的方式,去看看securityexpressionroot这个类吧,用的方法不一样,也就是@preauthorize里面有另外一个参数。
一个重要的问题
先说结论:security上下文环境(里面有主体)生命周期只限于一次请求。
我做了一个测试:
把securityconfig里面configure(httpsecurity http)这个方法里面
http.addfilterbefore(authenticationtokenfilter, usernamepasswordauthenticationfilter.class);
这行代码注释掉,不走那个jwt filter。就是不每次都添加上下上下文环境。
然后logincontroller改成
@restcontroller public class logincontroller { @autowired @qualifier("jwtuserdetailsservice") private userdetailsservice userdetailsservice; @autowired private jwttokenutil jwttokenutil; @postmapping("/login") public string login(@requestbody sysuser sysuser, httpservletrequest request){ final userdetails userdetails = userdetailsservice.loaduserbyusername(sysuser.getusername()); final string token = jwttokenutil.generatetoken(userdetails); //添加 start usernamepasswordauthenticationtoken authentication = new usernamepasswordauthenticationtoken(userdetails, null, userdetails.getauthorities()); securitycontextholder.getcontext().setauthentication(authentication); //添加 end return token; } @postmapping("haha") public string haha(){ userdetails userdetails = (userdetails) org.springframework.security.core.context.securitycontextholder.getcontext().getauthentication().getprincipal(); return "haha:"+userdetails.getusername()+","+userdetails.getpassword(); } }
然后登陆,然后访问/haha,崩了,发现userdetails里面没数据。说明这会上下文环境中我们主体不存在。
为什么会这样呢?
securitycontextpersistencefilter 一次请求,filter链结束之后 会清除掉context里面的东西。所说以,主体数据生命周期是一次请求。
源码如下:
public void dofilter(servletrequest req, servletresponse res, filterchain chain) throws ioexception, servletexception { ...假装有一堆代码... try { } finally { securitycontext contextafterchainexecution = securitycontextholder .getcontext(); // crucial removal of securitycontextholder contents - do this before anything // else. securitycontextholder.clearcontext(); repo.savecontext(contextafterchainexecution, holder.getrequest(), holder.getresponse()); request.removeattribute(filter_applied); } }
关键就是finally里面 securitycontextholder.clearcontext(); 这句话。这才体现了那句,把维护信息的事扔给了客户端,你不请求,我也不知道你有啥。
体验小结
配置起来感觉还可以吧,使用jwt方式,生成token.由于上下文环境的生命周期是一次请求,所以在不请求的情况下,服务端不清楚用户有那些权限,真正实现了客户端维护安全信息,所以项目中也没有登出接口,因为没必要。即使前端退出了,你有token,依然可以通过postman请求接口(token没有过期)。不同于shiro可以把信息维护在服务端,要是登出,clear主体信息,访问接口就需要在登录。不过security这样也有好处,可以实现单点登陆了,也方便做分布式。(只要你不同子系统中验证那一套逻辑相同,或者在分布式的情况下有单独的验证系统)。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。