springsecurity基于token的认证方式
前言
上一篇博客简析了一下spring security oauth中生成accesstoken的源码,目的就是为了方便我们将原有的表单登录,短信登录以及社交登录的认证方法,都改造成基于accesstoken的认证方式
基于token的表单登录
在简析了spring security oauth的源码之后,我们发现,其实有些源码我们并不能用,至少,tokenendpoint这个组件,我们就没法用,因为这个组件只会响应/oauth/token
的请求,而且spring security oauth会根据oauth协议中常用的4种授权模式去生成令牌,而我们这里是自定义的登录,自然用不上oauth协议中的授权模式,因此我们改造自定义的登录,只能借鉴其令牌生成方式。
如果有印象,在前几篇博客中总结过自定义登录成功处理的方式,无论前面登录逻辑如何认证,我们只需要在认证成功之后,自定义生成accesstoken 即可,因此我们只需要重新处理我们自定义登录成功的处理方式即可。
那么如何处理,依旧是一个问题,这就回到了上一篇博客中的内容,构造accesstoken需要oauth2request和authentication,其中authentication是登录成功后的认证详情信息,在登录成功处理器中,会有相关参数传递进来。oauth2request由clientdeatails和tokenrequest组成,这在上一篇博客中我们已经总结过了,clientdetails根据传递参数中的clientid和clientsecret等client配置信息组成,tokenrequest则由请求中其他参数实例化而成,具体如下图所示
相关改造代码如下
/** * autor:liman * createtime:2021/7/10 * comment: 自定义登录成功处理器 */ @component("selfauthenticationsuccesshandler") @slf4j public class selfauthenticationsuccesshandler extends simpleurlauthenticationsuccesshandler { @autowired private securityproperties securityproperties; @autowired private objectmapper objectmapper; @autowired private clientdetailsservice clientdetailsservice; @autowired private authorizationservertokenservices authenticationservertokenservices; @override public void onauthenticationsuccess(httpservletrequest request, httpservletresponse response , authentication authentication) throws ioexception, servletexception { log.info("自定义登录成功的处理器"); string header = request.getheader("authorization"); if (header == null || !header.startswith("basic ")) { throw new unapprovedclientauthenticationexception("请求头中没有client相关的信息"); } string[] tokens = extractanddecodeheader(header, request); assert tokens.length == 2; string clientid = tokens[0]; string clientsecret = tokens[1]; //得到clientdeatils信息 clientdetails clientdetails = clientdetailsservice.loadclientbyclientid(clientid);//得到clientdetails信息 if (null == clientdetails) { throw new unapprovedclientauthenticationexception("clientid对应的信息不存在" + clientid); } else if (!stringutils.equals(clientsecret, clientdetails.getclientsecret())) { throw new unapprovedclientauthenticationexception("clientsecret信息不匹配" + clientsecret); } //构建自己的tokenrequest,由于这里不能使用oauth2中的四种授权模式,因此这里第四个参数设置为"customer" //同理,第一个参数主要用于组装并生成authentication,而这里的authentication已经通过参数传递进来,因此可以直接赋一个空的map tokenrequest tokenrequest = new tokenrequest(maputils.empty_map, clientid, clientdetails.getscope(), "customer"); //构建oauth2request oauth2request oauth2request = tokenrequest.createoauth2request(clientdetails); //构建 oauth2authentication oauth2authentication oauth2authentication = new oauth2authentication(oauth2request, authentication); //生成accesstoken,这里依旧使用的是spring security oauth中默认的defaulttokenservice oauth2accesstoken accesstoken = authenticationservertokenservices.createaccesstoken(oauth2authentication); response.setcontenttype("application/json;charset=utf-8"); response.getwriter().write(objectmapper.writevalueasstring(accesstoken));//将authentication作为json写到前端 } /** * decodes the header into a username and password. * * @throws badcredentialsexception if the basic header is not present or is not valid * base64 */ //todo:解码请求头中的base64编码的 appid和appsecret private string[] extractanddecodeheader(string header, httpservletrequest request) throws ioexception { //格式:basic+空格+base64加密的appid和appsecret,所以这里substring(6) byte[] base64token = header.substring(6).getbytes("utf-8"); byte[] decoded; try { decoded = base64.decode(base64token); } catch (illegalargumentexception e) { throw new badcredentialsexception( "failed to decode basic authentication token"); } string token = new string(decoded, "utf-8"); int delim = token.indexof(":"); if (delim == -1) { throw new badcredentialsexception("invalid basic authentication token"); } return new string[]{token.substring(0, delim), token.substring(delim + 1)}; } }
基于token的短信验证码登录
之前提到过,由于基于token的认证交互,其实不一定会有session会话的概念,如果我们的验证码依旧存于session中,则并不能正常校验,因此在基于token的短信验证码登录的重构中,我们唯一要做的,就是将验证码存于redis等缓存中间件中,验证码的key值为deviceid。
方案比较简单,这里只贴出redis操作验证码的方法
/** * 基于redis的验证码存取器,避免由于没有session导致无法存取验证码的问题 */ @component public class redisvalidatecoderepository implements validatecoderepository { @autowired private redistemplate<object, object> redistemplate; /* * (non-javadoc) */ @override public void save(servletwebrequest request, validatecode code, validatecodetype type) { redistemplate.opsforvalue().set(buildkey(request, type), code, 30, timeunit.minutes); } /* * (non-javadoc) */ @override public validatecode get(servletwebrequest request, validatecodetype type) { object value = redistemplate.opsforvalue().get(buildkey(request, type)); if (value == null) { return null; } return (validatecode) value; } /* * (non-javadoc) * */ @override public void remove(servletwebrequest request, validatecodetype type) { redistemplate.delete(buildkey(request, type)); } /** * @param request * @param type * @return */ private string buildkey(servletwebrequest request, validatecodetype type) { string deviceid = request.getheader("deviceid"); if (stringutils.isblank(deviceid)) { throw new validatecodeexception("请在请求头中携带deviceid参数"); } return "code:" + type.tostring().tolowercase() + ":" + deviceid; } }
基于token的社交登录
在调通微信社交登录之后,再进行总结,只是需要明确的是,这里分为两种情况,一种是简化模式,一种是标准的oauth2授权模式(这两种的区别,在qq登录和微信登录流程中有详细的体现)。
简化的oauth的授权改造
简化的oauth模式,oauth协议简化的认证模式,与标准最大的不同,其实就是在获取授权码的时候,顺带将openid(第三方用户id)和accesstoken(获取用户信息的令牌),在这种前后端彻底分离的架构中,前三步前端可以通过服务提供商的sdk完成openid和accesstoken的获取。但是并不能根据openid作为我们自己登录系统凭证,因此我们需要提供一个根据openid进行登录的方式这个与之前短信登录方式大同小异
1、openidauthenticationtoken
/** * autor:liman * createtime:2021/8/4 * comment:openidauthenticationtoken */ public class openidauthenticationtoken extends abstractauthenticationtoken { private static final long serialversionuid = springsecuritycoreversion.serial_version_uid; private final object principal; private string providerid; /** openid,和providerid作为principal */ public openidauthenticationtoken(string openid, string providerid) { super(null); this.principal = openid; this.providerid = providerid; setauthenticated(false); } /** * this constructor should only be used by <code>authenticationmanager</code> or * <code>authenticationprovider</code> implementations that are satisfied with * producing a trusted (i.e. {@link #isauthenticated()} = <code>true</code>) * authentication token. * * @param principal * @param credentials * @param authorities */ public openidauthenticationtoken(object principal, collection<? extends grantedauthority> authorities) { super(authorities); this.principal = principal; super.setauthenticated(true); // must use super, as we override } public object getcredentials() { return null; } public object getprincipal() { return this.principal; } public string getproviderid() { return providerid; } public void setauthenticated(boolean isauthenticated) throws illegalargumentexception { if (isauthenticated) { throw new illegalargumentexception( "cannot set this token to trusted - use constructor which takes a grantedauthority list instead"); } super.setauthenticated(false); } @override public void erasecredentials() { super.erasecredentials(); } }
2、openidauthenticationfilter
/** * autor:liman * createtime:2021/8/4 * comment:基于openid登录的过滤器 */ @slf4j public class openidauthenticationfilter extends abstractauthenticationprocessingfilter { private string openidparameter = "openid"; private string provideridparameter = "providerid"; private boolean postonly = true; public openidauthenticationfilter() { super(new antpathrequestmatcher("/authentication/openid", "post")); } public authentication attemptauthentication(httpservletrequest request, httpservletresponse response) throws authenticationexception { if (postonly && !request.getmethod().equals("post")) { throw new authenticationserviceexception("authentication method not supported: " + request.getmethod()); } //获取请求中的openid和providerid string openid = obtainopenid(request); string providerid = obtainproviderid(request); if (openid == null) { openid = ""; } if (providerid == null) { providerid = ""; } openid = openid.trim(); providerid = providerid.trim(); //构造openidauthenticationtoken openidauthenticationtoken authrequest = new openidauthenticationtoken(openid, providerid); // allow subclasses to set the "details" property setdetails(request, authrequest); //交给authenticationmanager进行认证 return this.getauthenticationmanager().authenticate(authrequest); } /** * 获取openid */ protected string obtainopenid(httpservletrequest request) { return request.getparameter(openidparameter); } /** * 获取提供商id */ protected string obtainproviderid(httpservletrequest request) { return request.getparameter(provideridparameter); } protected void setdetails(httpservletrequest request, openidauthenticationtoken authrequest) { authrequest.setdetails(authenticationdetailssource.builddetails(request)); } public void setopenidparameter(string openidparameter) { assert.hastext(openidparameter, "username parameter must not be empty or null"); this.openidparameter = openidparameter; } public void setpostonly(boolean postonly) { this.postonly = postonly; } public final string getopenidparameter() { return openidparameter; } public string getprovideridparameter() { return provideridparameter; } public void setprovideridparameter(string provideridparameter) { this.provideridparameter = provideridparameter; } }
3、openidauthenticationprovider
/** * */ package com.learn.springsecurity.app.social.openid; /** * @author zhailiang * */ public class openidauthenticationprovider implements authenticationprovider { private socialuserdetailsservice userdetailsservice; private usersconnectionrepository usersconnectionrepository; /* * (non-javadoc) * * @see org.springframework.security.authentication.authenticationprovider# * authenticate(org.springframework.security.core.authentication) */ @override public authentication authenticate(authentication authentication) throws authenticationexception { openidauthenticationtoken authenticationtoken = (openidauthenticationtoken) authentication; set<string> provideruserids = new hashset<>(); provideruserids.add((string) authenticationtoken.getprincipal()); //之前社交登录中介绍的usersconnectionrepository,从user_connection表中根据providerid和openid查询用户id set<string> userids = usersconnectionrepository.finduseridsconnectedto(authenticationtoken.getproviderid(), provideruserids); if(collectionutils.isempty(userids) || userids.size() != 1) { throw new internalauthenticationserviceexception("无法获取用户信息"); } //获取到userid了 string userid = userids.iterator().next(); //利用userdetailsservice根据userid查询用户信息 userdetails user = userdetailsservice.loaduserbyuserid(userid); if (user == null) { throw new internalauthenticationserviceexception("无法获取用户信息"); } openidauthenticationtoken authenticationresult = new openidauthenticationtoken(user, user.getauthorities()); authenticationresult.setdetails(authenticationtoken.getdetails()); return authenticationresult; } /* * (non-javadoc) * * @see org.springframework.security.authentication.authenticationprovider# * supports(java.lang.class) */ @override public boolean supports(class<?> authentication) { return openidauthenticationtoken.class.isassignablefrom(authentication); } public socialuserdetailsservice getuserdetailsservice() { return userdetailsservice; } public void setuserdetailsservice(socialuserdetailsservice userdetailsservice) { this.userdetailsservice = userdetailsservice; } public usersconnectionrepository getusersconnectionrepository() { return usersconnectionrepository; } public void setusersconnectionrepository(usersconnectionrepository usersconnectionrepository) { this.usersconnectionrepository = usersconnectionrepository; } }
4、配置类
/** * @author zhailiang * */ @component public class openidauthenticationsecurityconfig extends securityconfigureradapter<defaultsecurityfilterchain, httpsecurity> { @autowired private authenticationsuccesshandler selfauthenticationsuccesshandler; @autowired private authenticationfailurehandler selfauthenticationfailurehandler; @autowired private socialuserdetailsservice userdetailsservice; @autowired private usersconnectionrepository usersconnectionrepository; @override public void configure(httpsecurity http) throws exception { openidauthenticationfilter openidauthenticationfilter = new openidauthenticationfilter(); openidauthenticationfilter.setauthenticationmanager(http.getsharedobject(authenticationmanager.class)); openidauthenticationfilter.setauthenticationsuccesshandler(selfauthenticationsuccesshandler); openidauthenticationfilter.setauthenticationfailurehandler(selfauthenticationfailurehandler); openidauthenticationprovider openidauthenticationprovider = new openidauthenticationprovider(); openidauthenticationprovider.setuserdetailsservice(userdetailsservice); openidauthenticationprovider.setusersconnectionrepository(usersconnectionrepository); http.authenticationprovider(openidauthenticationprovider) .addfilterafter(openidauthenticationfilter, usernamepasswordauthenticationfilter.class); } }
测试结果
标准的oauth授权改造
标准的oauth模式
针对标准的授权模式,我们并不需要做多少改动,因为在社交登录那一节中我们已经做了相关开发,只是需要说明的是,只是在spring-social的过滤器——socialauthenticationfilter
中,在正常社交登录流程完成之后会默认跳转到某个页面,而这个并不适用于前后端分离的项目,因此要针对这个问题定制化解决。这需要回到之前socialauthenticationfilter
加入到认证过滤器链上的代码。之前我们说过社交登录的过滤器链不需要我们手动配置,只需要初始化springsocialconfiguer的时候,会自动加入到社交登录的认证过滤器链上
@configuration @enablesocial public class socialconfig extends socialconfigureradapter { @bean public springsocialconfigurer selfsocialsecurityconfig(){ springsocialconfigurer selfspringsocialconfig = new springsocialconfigurer(); return selfspringsocialconfig; } }
我们只需要改变socialauthenticationfilter
的默认处理即可,因此我们给他加一个后置处理器,但是这个后置处理器是在springsocialconfigurer的postprocess函数中进行处理
/** * autor:liman * createtime:2021/7/15 * comment:自定义的springsocial配置类 */ public class selfspringsocialconfig extends springsocialconfigurer { private string processfilterurl; @autowired(required = false) private connectionsignup connectionsignup; @autowired(required = false) private socialauthenticationfilterpostprocessor socialauthenticationfilterpostprocessor; public selfspringsocialconfig(string processfilterurl) { this.processfilterurl = processfilterurl; } @override protected <t> t postprocess(t object) { socialauthenticationfilter socialauthenticationfilter = (socialauthenticationfilter) super.postprocess(object); socialauthenticationfilter.setfilterprocessesurl(processfilterurl); if(null!=socialauthenticationfilterpostprocessor){ socialauthenticationfilterpostprocessor.process(socialauthenticationfilter); } return (t) socialauthenticationfilter; } public connectionsignup getconnectionsignup() { return connectionsignup; } public void setconnectionsignup(connectionsignup connectionsignup) { this.connectionsignup = connectionsignup; } public socialauthenticationfilterpostprocessor getsocialauthenticationfilterpostprocessor() { return socialauthenticationfilterpostprocessor; } public void setsocialauthenticationfilterpostprocessor(socialauthenticationfilterpostprocessor socialauthenticationfilterpostprocessor) { this.socialauthenticationfilterpostprocessor = socialauthenticationfilterpostprocessor; } } //将我们自定义的 springsocialconfigurer交给spring托管 @configuration @enablesocial public class socialconfig extends socialconfigureradapter { @bean public springsocialconfigurer selfsocialsecurityconfig(){ string processfilterurl = securityproperties.getsocial().getprocessfilterurl(); selfspringsocialconfig selfspringsocialconfig = new selfspringsocialconfig(processfilterurl); //指定第三方用户信息认证不存在的注册页 selfspringsocialconfig.signupurl(securityproperties.getbrowser().getsiguuppage()); selfspringsocialconfig.setconnectionsignup(connectionsignup); selfspringsocialconfig.setsocialauthenticationfilterpostprocessor(socialauthenticationfilterpostprocessor); return selfspringsocialconfig; } }
我们自定义的过滤器后置处理器如下
/** * autor:liman * createtime:2021/8/7 * comment:app社交登录认证后置处理器 */ @component public class appsocialauthenticationfilterpostprocessor implements socialauthenticationfilterpostprocessor { @autowired private authenticationsuccesshandler selfauthenticationsuccesshandler; @override public void process(socialauthenticationfilter socialauthenticationfilter) { socialauthenticationfilter.setauthenticationsuccesshandler(selfauthenticationsuccesshandler); } }
关于用户的绑定
这里需要总结一下之前的社交登录中用户注册绑定的操作。
之前的社交登录绑定用户
在之前的社交登录中,如果spring social发现用户是第一次登录,则会跳转到相关的页面,这个页面我们其实也可以自己定义并配置
@configuration @enablesocial public class socialconfig extends socialconfigureradapter { @bean public springsocialconfigurer selfsocialsecurityconfig(){ string processfilterurl = securityproperties.getsocial().getprocessfilterurl(); selfspringsocialconfig selfspringsocialconfig = new selfspringsocialconfig(processfilterurl); //指定第三方用户信息认证不存在的注册页 selfspringsocialconfig.signupurl(securityproperties.getbrowser().getsiguuppage()); selfspringsocialconfig.setconnectionsignup(connectionsignup); selfspringsocialconfig.setsocialauthenticationfilterpostprocessor(socialauthenticationfilterpostprocessor); return selfspringsocialconfig; } @bean public providersigninutils providersigninutils(connectionfactorylocator connectionfactorylocator){ return new providersigninutils(connectionfactorylocator, getusersconnectionrepository(connectionfactorylocator)); } }
我们配置的代码中,可以自定义页面路径,我们自定义页面如下(一个简单的登录绑定页面)
<!doctype html> <html> <head> <meta charset="utf-8"> <title>登录</title> </head> <body> <h2>demo注册页</h2> <form action="user/regist" method="post"> <table> <tr> <td>用户名:</td> <td><input type="text" name="username"></td> </tr> <tr> <td>密码:</td> <td><input type="password" name="password"></td> </tr> <tr> <td colspan="2"> <button type="submit" name="type" value="regist">注册</button> <button type="submit" name="type" value="binding">绑定</button> </td> </tr> </table> </form> </body> </html>
在用户第一次跳转到这个页面的用户选择注册,或者绑定,都会请求/user/register接口,这个接口借助providersigninutils完成会话中的用户数据更新
@autowired private providersigninutils providersigninutils; @postmapping("/register") public void userregister(@requestbody user user, httpservletrequest request) { //利用providersigninutils,将注册之后的用户信息,关联到会话中 providersigninutils.dopostsignup(user.getid(),new servletwebrequest(request)); }
在跳转之前,spring social已经帮我们将用户信息存入会话(在socialauthenticationfilter
中可以看到相关代码)
//以下代码位于:org.springframework.social.security.socialauthenticationfilter#doauthentication private authentication doauthentication(socialauthenticationservice<?> authservice, httpservletrequest request, socialauthenticationtoken token) { try { if (!authservice.getconnectioncardinality().isauthenticatepossible()) return null; token.setdetails(authenticationdetailssource.builddetails(request)); authentication success = getauthenticationmanager().authenticate(token); assert.isinstanceof(socialuserdetails.class, success.getprincipal(), "unexpected principle type"); updateconnections(authservice, token, success); return success; } catch (badcredentialsexception e) { // connection unknown, register new user? if (signupurl != null) { //这里就是将社交用户信息存入会话 // store connectiondata in session and redirect to register page sessionstrategy.setattribute(new servletwebrequest(request), providersigninattempt.session_attribute, new providersigninattempt(token.getconnection())); throw new socialauthenticationredirectexception(buildsignupurl(request)); } throw e; } }
但是基于前后端分离,且并没有会话对象交互的系统,这种方式并不适用,因为并不存在会话,如何处理,需要用其他方案,其实我们可以在验证码登录的改造中受到启发,将用户数据存入会话即可,我们自定义实现一个providersigninutils
将用户信息存入redis即可。
自定义providersignutils
1、将第三方用户数据存入redis的工具类
/** * autor:liman * createtime:2021/8/7 * comment:app端用户信息存入redis的工具类 */ @component public class appsignuputils { public static final string social_redis_user_prefix = "self:security:social:connectiondata"; @autowired private redistemplate<object, object> redistemplate; @autowired private usersconnectionrepository usersconnectionrepository; @autowired private connectionfactorylocator connectionfactorylocator; public void saveconnectiondata(webrequest webrequest, connectiondata connectiondata) { redistemplate.opsforvalue().set(getkey(webrequest), connectiondata, 10, timeunit.minutes); } /** * 将用户与数据库中的信息进行绑定 * @param request * @param userid */ public void dopostsignup(webrequest request,string userid){ string key = getkey(request); if(!redistemplate.haskey(key)){ throw new runtimeexception("无法找到缓存的用户社交账号信息"); } connectiondata connectiondata = (connectiondata) redistemplate.opsforvalue().get(key); //根据connectiondata实例化创建一个connection connection<?> connection = connectionfactorylocator.getconnectionfactory(connectiondata.getproviderid()) .createconnection(connectiondata); //将数据库中的用户与redis中的用户信息关联 usersconnectionrepository.createconnectionrepository(userid).addconnection(connection); } /** * 获取设备id作为key * * @param webrequest * @return */ public string getkey(webrequest webrequest) { string deviceid = webrequest.getheader("deviceid"); if (stringutils.isblank(deviceid)) { throw new runtimeexception("设备id不能为空"); } return social_redis_user_prefix + deviceid; } }
2、复写掉原来的配置类
为了避免对原有代码的侵入性处理,这里我们需要自定义一个实现beanpostprocessor
接口的类
/** * autor:liman * createtime:2021/8/7 * comment:由于app端的社交用户绑定,不能采用跳转,也不能操作会话,需要用自定义的providersignuputils工具类 * 因此需要定义一个后置处理器,针对springsocialconfigurer进行一些后置处理 */ @component public class appspringsocialconfigurerpostprocessor implements beanpostprocessor { @override public object postprocessbeforeinitialization(object bean, string beanname) throws beansexception { return null; } @override public object postprocessafterinitialization(object bean, string beanname) throws beansexception { if(stringutils.equals(beanname,"selfsocialsecurityconfig")){ selfspringsocialconfig configurer = (selfspringsocialconfig) bean; //复写掉原有的selfspringsocialconfig的signupurl configurer.signupurl("/app/social/signup"); return configurer; } return bean; } }
针对上述的请求路径,我们也要写一个对应路径的controller处理方法
@restcontroller @slf4j public class appsecuritycontroller { @autowired private providersigninutils providersigninutils; @autowired private appsignuputils appsignuputils; @getmapping("/app/social/signup") @responsestatus(httpstatus.unauthorized) public baseresponse getsocialuserinfo(httpservletrequest request){ baseresponse result = new baseresponse(statuscode.success); log.info("【app模式】开始获取会话中的第三方用户信息"); //先从其中拿出数据,毕竟这个时候还没有完全跳转,下一个会话,就没有该数据了 connection<?> connectionfromsession = providersigninutils.getconnectionfromsession(new servletwebrequest(request)); socialuserinfo socialuserinfo = new socialuserinfo(); socialuserinfo.setproviderid(connectionfromsession.getkey().getproviderid()); socialuserinfo.setprovideruserid(connectionfromsession.getkey().getprovideruserid()); socialuserinfo.setnickname(connectionfromsession.getdisplayname()); socialuserinfo.setheadimg(connectionfromsession.getimageurl()); //转存到自己的工具类中 appsignuputils.saveconnectiondata(new servletwebrequest(request),connectionfromsession.createdata()); result.setdata(socialuserinfo); return result; } }
对于用户注册的接口也需要做调整
@postmapping("/register") public void userregister(@requestbody user user, httpservletrequest request) { //如果是浏览器的应用利用providersigninutils,将注册之后的用户信息,关联到会话中 providersigninutils.dopostsignup(user.getid(),new servletwebrequest(request)); //如果是app的应用,则利用appsignuputils 将注册之后的用户信息,关联到会话中 appsignuputils.dopostsignup(new servletwebrequest(request),user.getid()); }
总结
总结了基于token认证的三种登录方式,最为复杂的为社交登录方式
到此这篇关于springsecurity基于token的认证方式的文章就介绍到这了,更多相关springsecurity token认证内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!