现在开发个应用登录比以前麻烦的多。产品经理说用户名密码登录、短信登录都得弄上,如果搞个小程序连小程序登录也得安排上,差不多就是我全都要。
多种登录途径达到一个效果确实不太容易,今天胖哥在Spring Security中实现了这三种登录你全都要的效果,爽的飞起,还不点个赞先。
大致原理
虽然不需要知道原理,但是还是要满足一下需要知道原理的同学。不过这里不会太深入,只会说核心的部分。更多的相关知识可以去看胖哥的Spring Security干货教程。
登录的几大组件
在Spring Security中我们需要实现登录认证就需要实现AbstractAuthenticationProcessingFilter
;还需要一个处理具体登录逻辑的AuthenticationProvider
;而每个AuthenticationProvider
又对应一种Authentication
。执行流程如下:
登录的基本流程
原理呢大概就是这样子的,接下来的工作就是按照上面封装每种登录的逻辑了。
ChannelUserDetailsService
在整个Spring Security体系中只允许有一个UserDetailsService
注入Spring IoC,所以我扩展了这个接口:
publicinterfaceChannelUserDetailsServiceextendsUserDetailsService{/***验证码登录**@paramphonethephone*@returntheuserdetails*/UserDetailsloadByPhone(Stringphone);/***openid登录**@paramopenIdtheopenid*@returntheuserdetails*/UserDetailsloadByOpenId(StringopenId);}
这样三种登录都能使用一个UserDetailsService
了,当然如果你的登录渠道更多你可以增加更多的实现。
验证码登录
关于验证码登录以前有专门的文章来讲解登录流程和实现细节这里就不再赘述了,有兴趣可以去看相关的文章。这里提一句验证码登录的URI为/login/captcha
,这是一个比较关键的细节后面有关于它的更多运用。开发中我们需要实现上面的loadByPhone
,另外还需要实现验证码的校验服务逻辑:
publicinterfaceCaptchaService{/***Sendcaptchacodestring.**@paramphonethephone*@returntheboolean*/booleansendCaptchaCode(Stringphone);/***根据手机号去缓存中获取验证码同{@codecaptcha}进行对比,对比成功从缓存中主动清除验证码**@paramphone手机号*@paramcaptcha前端传递的验证码*@returntheboolean*/booleanverifyCaptchaCode(Stringphone,Stringcaptcha);}
微信小程序登录
微信小程序登录这里需要重点说一下.首先前端会传递一个clientId
和jsCode
, 我们比较陌生的是clientId
的目的是为了标识小程序的配置appid
和secret
,这样我们可以同时适配多个小程序。这里我设计了一个获取小程序客户端的函数式接口:
@FunctionalInterfacepublicinterfaceMiniAppClientService{/***Getminiappclient.**@return{@linkMiniAppClient}*@seeMiniAppClient#getAppId()*@seeMiniAppClient#getSecret()*/MiniAppClientget(StringclientId);}
然后就可以请求微信服务器的登录接口code2session
了,拿到openid
后注册或者登录(实现loadByOpenId
),同时还要缓存sessionKey
用来加解密使用:
/***缓存sessionKey,这里只实现put ,get可以根据cachekey规则去实现获取。**@*@since1.0.8.RELEASE*/publicinterfaceMiniAppSessionKeyCache{/***Put.**@paramcacheKeythecachekey*@paramsessionKeythesessionkey*/voidput(StringcacheKey,StringsessionKey);}
对应的AuthenticationProvider
实现
packagecn.felord.security.autoconfigure.miniapp;importcn.felord.security.autoconfigure.ChannelUserDetailsService;importcom.fasterxml.jackson.databind.node.ObjectNode;importorg.springframework.context.MessageSource;importorg.springframework.context.MessageSourceAware;importorg.springframework.context.support.MessageSourceAccessor;importorg.springframework.http.RequestEntity;importorg.springframework.http.ResponseEntity;importorg.springframework.security.authentication.AuthenticationProvider;importorg.springframework.security.authentication.BadCredentialsException;importorg.springframework.security.core.Authentication;importorg.springframework.security.core.AuthenticationException;importorg.springframework.security.core.GrantedAuthority;importorg.springframework.security.core.SpringSecurityMessageSource;importorg.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;importorg.springframework.security.core.authority.mapping.NullAuthoritiesMapper;importorg.springframework.security.core.userdetails.UserDetails;importorg.springframework.util.Assert;importorg.springframework.util.LinkedMultiValueMap;importorg.springframework.util.MultiValueMap;importorg.springframework.web.client.RestOperations;importorg.springframework.web.client.RestTemplate;importorg.springframework.web.util.UriComponentsBuilder;.URI;importjava.util.Collection;importjava.util.Objects;/***Miniappauthenticationprovider.**@*@since1.0.8.RELEASE*/publicclassMiniAppAuthenticationProviderimplementsAuthenticationProvider,MessageSourceAware{privatestaticfinalStringENDPOINT="https://api./sns/jscode2session";privatefinalGrantedAuthoritiesMapperauthoritiesMapper=newNullAuthoritiesMapper();privatefinalMiniAppClientServiceminiAppClientService;privatefinalChannelUserDetailsServicechannelUserDetailsService;privatefinalMiniAppSessionKeyCacheminiAppSessionKeyCache;privatefinalRestOperationsrestOperations;privateMessageSourceAccessormessages=SpringSecurityMessageSource.getAccessor();/***InstantiatesanewCaptchaauthenticationprovider.*appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code**@paramminiAppClientServicetheminiappclientsupplier*@paramchannelUserDetailsServicethechanneluserdetailsservice*@paramminiAppSessionKeyCachetheminiappsessionkeycache*/publicMiniAppAuthenticationProvider(MiniAppClientServiceminiAppClientService,ChannelUserDetailsServicechannelUserDetailsService,MiniAppSessionKeyCacheminiAppSessionKeyCache){this.miniAppClientService=miniAppClientService;this.channelUserDetailsService=channelUserDetailsService;this.miniAppSessionKeyCache=miniAppSessionKeyCache;this.restOperations=newRestTemplate();}@OverridepublicAuthenticationauthenticate(Authenticationauthentication)throwsAuthenticationException{Assert.isInstanceOf(MiniAppAuthenticationToken.class,authentication,()->messages.getMessage("MiniAppAuthenticationProvider.onlySupports","OnlyMiniAppAuthenticationTokenissupported"));MiniAppAuthenticationTokenunAuthenticationToken=(MiniAppAuthenticationToken)authentication;StringclientId=unAuthenticationToken.getName();StringjsCode=(String)unAuthenticationToken.getCredentials();ObjectNoderesponse=this.getResponse(miniAppClientService.get(clientId),jsCode);StringopenId=response.get("openid").asText();StringsessionKey=response.get("session_key").asText();UserDetailsuserDetails=channelUserDetailsService.loadByOpenId(openId);Stringusername=userDetails.getUsername();miniAppSessionKeyCache.put(username,sessionKey);returncreateSuccessAuthentication(authentication,userDetails);}@Overridepublicbooleansupports(Class<?>authentication){returnMiniAppAuthenticationToken.class.isAssignableFrom(authentication);}@OverridepublicvoidsetMessageSource(MessageSourcemessageSource){this.messages=newMessageSourceAccessor(messageSource);}/***认证成功将非授信凭据转为授信凭据.*封装用户信息角色信息。**@paramauthenticationtheauthentication*@paramusertheuser*@returntheauthentication*/protectedAuthenticationcreateSuccessAuthentication(Authenticationauthentication,UserDetailsuser){Collection<?extendsGrantedAuthority>authorities=authoritiesMapper.mapAuthorities(user.getAuthorities());MiniAppAuthenticationTokenauthenticationToken=newMiniAppAuthenticationToken(user,null,authorities);authenticationToken.setDetails(authentication.getDetails());returnauthenticationToken;}/***请求微信服务器登录接口code2session*@paramminiAppClientminiAppClient*@paramjsCodejsCode*@returnObjectNode*/privateObjectNodegetResponse(MiniAppClientminiAppClient,StringjsCode){MultiValueMap<String,String>queryParams=newLinkedMultiValueMap<>();queryParams.add("appid",miniAppClient.getAppId());queryParams.add("secret",miniAppClient.getSecret());queryParams.add("js_code",jsCode);queryParams.add("grant_type","authorization_code");URIuri=UriComponentsBuilder.fromHttpUrl(ENDPOINT).queryParams(queryParams).build().toUri();ResponseEntity<ObjectNode>response=restOperations.exchange(RequestEntity.get(uri).build(),ObjectNode.class);ObjectNodebody=response.getBody();if(Objects.isNull(body)){thrownewBadCredentialsException("miniappresponseisnull");}//openidsession_keyunioniderrcodeerrmsgfinalintdefaultVal=-2;if(body.get("errcode").asInt(defaultVal)!=0){thrownewBadCredentialsException(body.get("errmsg").asText("unknownerror"));}returnbody;}}
❝
AbstractAuthenticationProcessingFilter
实现参考文末源码,没有什么特色。
登录渠道聚合
最终验证码登录为:
POST/login/captcha?phone=182****0032&captcha=596001HTTP/1.1Host:localhost:8085
小程序登录为:
POST/login/miniapp?clientId=wx12342&code=asdfasdfasdfasdfsdHTTP/1.1Host:localhost:8085
但是我们要配置两套过滤器,要能配置一个聚合过滤器就完美了,我观察了一下它们的URI,如果能解析出验证码登录为captcha
、小程序为miniapp
就能根据对应的标识路由到对应的过滤器处理了。事实上是可以的:
RequestMatchermatcher=newAntPathRequestMatcher("/login/{channal}","POST");Stringchannel=LOGIN_REQUEST_MATCHER.matcher(request).getVariables().get("channel");
为此我增强了AbstractAuthenticationProcessingFilter
,让它能够获取渠道:
publicabstractclassAbstractChannelAuthenticationProcessingFilterextendsAbstractAuthenticationProcessingFilter{protectedAbstractChannelAuthenticationProcessingFilter(RequestMatcherrequiresAuthenticationRequestMatcher){super(requiresAuthenticationRequestMatcher);}/***用来获取登录渠道标识**@returnthestring*/protectedabstractStringchannel();}
验证码和小程序的过滤器只需要实现这个接口即可,小程序的就这样实现:
/***ThetypeMiniappauthenticationfilter.**@*@since1.0.8.RELEASE*/publicclassMiniAppAuthenticationFilterextendsAbstractChannelAuthenticationProcessingFilter{/***TheconstantCHANNEL_ID.*/privatestaticfinalStringCHANNEL_ID="miniapp";privatestaticfinalStringSPRING_SECURITY_FORM_MINI_CLIENT_KEY="clientId";/***TheconstantSPRING_SECURITY_FORM_PHONE_KEY.*/privatestaticfinalStringSPRING_SECURITY_FORM_JS_CODE_KEY="jsCode";/***InstantiatesanewCaptchaauthenticationfilter.*/publicMiniAppAuthenticationFilter(){super(newAntPathRequestMatcher("/login/"+CHANNEL_ID,"POST"));}@OverridepublicAuthenticationattemptAuthentication(HttpServletRequestrequest,HttpServletResponseresponse)throwsAuthenticationException{if(!request.getMethod().equals(HttpMethod.POST.name())){thrownewAuthenticationServiceException("Authenticationmethodnotsupported:"+request.getMethod());}StringclientId=obtainClientId(request);StringjsCode=obtainJsCode(request);MiniAppAuthenticationTokenauthRequest=newMiniAppAuthenticationToken(clientId,jsCode);//Allowsubclassestosetthe"details"propertysetDetails(request,authRequest);returnthis.getAuthenticationManager().authenticate(authRequest);}@OverridepublicStringchannel(){returnCHANNEL_ID;}protectedStringobtainClientId(HttpServletRequestrequest){StringclientId=request.getParameter(SPRING_SECURITY_FORM_MINI_CLIENT_KEY);if(!StringUtils.hasText(clientId)){thrownewIllegalArgumentException("clientIdisrequired");}returnclientId.trim();}/***ObtainJSCODE.**@paramrequesttherequest*@returnthestring*/protectedStringobtainJsCode(HttpServletRequestrequest){StringjsCode=request.getParameter(SPRING_SECURITY_FORM_JS_CODE_KEY);if(!StringUtils.hasText(jsCode)){thrownewIllegalArgumentException("js_codeisrequired");}returnjsCode.trim();}/***Setsdetails.**@paramrequesttherequest*@paramauthRequesttheauthrequest*/protectedvoidsetDetails(HttpServletRequestrequest,MiniAppAuthenticationTokenauthRequest){authRequest.setDetails(authenticationDetailsSource.buildDetails(request));}}
这样我们的聚合过滤器就产生了:
publicclassChannelAuthenticationFilterextendsAbstractAuthenticationProcessingFilter{privatestaticfinalStringCHANNEL_URI_VARIABLE_NAME="channel";privatestaticfinalRequestMatcherLOGIN_REQUEST_MATCHER=newAntPathRequestMatcher("/login/{"+CHANNEL_URI_VARIABLE_NAME+"}","POST");privatefinalList<?extendsAbstractChannelAuthenticationProcessingFilter>channelFilters;publicChannelAuthenticationFilter(List<?extendsAbstractChannelAuthenticationProcessingFilter>channelFilters){super(LOGIN_REQUEST_MATCHER);this.channelFilters=CollectionUtils.isEmpty(channelFilters)?Collections.emptyList():channelFilters;}@OverridepublicAuthenticationattemptAuthentication(HttpServletRequestrequest,HttpServletResponseresponse)throwsAuthenticationException,IOException,ServletException{Stringchannel=LOGIN_REQUEST_MATCHER.matcher(request).getVariables().get(CHANNEL_URI_VARIABLE_NAME);for(AbstractChannelAuthenticationProcessingFilterchannelFilter:channelFilters){StringrawChannel=channelFilter.channel();if(Objects.equals(channel,rawChannel)){returnchannelFilter.attemptAuthentication(request,response);}}thrownewProviderNotFoundException("NoSuitableProvider");}}
然后注入Spring IoC:
@Configuration(proxyBeanMethods=false)publicclassChannelAuthenticationConfiguration{/***短信验证码登录过滤器**@paramchannelUserDetailsServicethechanneluserdetailsservice*@paramcaptchaServicethecaptchaservice*@paramjwtTokenGeneratorthejwttokengenerator*@returnthecaptchaauthenticationprovider*/@Bean@ConditionalOnBean({ChannelUserDetailsService.class,CaptchaService.class,JwtTokenGenerator.class})CaptchaAuthenticationFiltercaptchaAuthenticationFilter(ChannelUserDetailsServicechannelUserDetailsService,CaptchaServicecaptchaService,JwtTokenGeneratorjwtTokenGenerator){CaptchaAuthenticationProvidercaptchaAuthenticationProvider=newCaptchaAuthenticationProvider(channelUserDetailsService,captchaService);CaptchaAuthenticationFiltercaptchaAuthenticationFilter=newCaptchaAuthenticationFilter();ProviderManagerproviderManager=newProviderManager(Collections.singletonList(captchaAuthenticationProvider));captchaAuthenticationFilter.setAuthenticationManager(providerManager);captchaAuthenticationFilter.setAuthenticationSuccessHandler(newLoginAuthenticationSuccessHandler(jwtTokenGenerator));SimpleAuthenticationEntryPointauthenticationEntryPoint=newSimpleAuthenticationEntryPoint();captchaAuthenticationFilter.setAuthenticationFailureHandler(newAuthenticationEntryPointFailureHandler(authenticationEntryPoint));returncaptchaAuthenticationFilter;}/***小程序登录过滤器**@paramminiAppClientServicetheminiappclientservice*@paramchannelUserDetailsServicethechanneluserdetailsservice*@paramjwtTokenGeneratorthejwttokengenerator*@returntheminiappauthenticationfilter*/@Bean@ConditionalOnBean({ChannelUserDetailsService.class,MiniAppClientService.class,MiniAppSessionKeyCache.class,JwtTokenGenerator.class})MiniAppAuthenticationFilterminiAppAuthenticationFilter(MiniAppClientServiceminiAppClientService,ChannelUserDetailsServicechannelUserDetailsService,MiniAppSessionKeyCacheminiAppSessionKeyCache,JwtTokenGeneratorjwtTokenGenerator){MiniAppAuthenticationFilterminiAppAuthenticationFilter=newMiniAppAuthenticationFilter();MiniAppAuthenticationProviderminiAppAuthenticationProvider=newMiniAppAuthenticationProvider(miniAppClientService,channelUserDetailsService,miniAppSessionKeyCache);ProviderManagerproviderManager=newProviderManager(Collections.singletonList(miniAppAuthenticationProvider));miniAppAuthenticationFilter.setAuthenticationManager(providerManager);miniAppAuthenticationFilter.setAuthenticationSuccessHandler(newLoginAuthenticationSuccessHandler(jwtTokenGenerator));SimpleAuthenticationEntryPointauthenticationEntryPoint=newSimpleAuthenticationEntryPoint();miniAppAuthenticationFilter.setAuthenticationFailureHandler(newAuthenticationEntryPointFailureHandler(authenticationEntryPoint));returnminiAppAuthenticationFilter;}/***Channelauthenticationfilterchannelauthenticationfilter.**@paramchannelFiltersthechannelfilters*@returnthechannelauthenticationfilter*/@BeanpublicChannelAuthenticationFilterchannelAuthenticationFilter(List<?extendsAbstractChannelAuthenticationProcessingFilter>channelFilters){returnnewChannelAuthenticationFilter(channelFilters);}}
❝
看上去好像还有优化空间。
我们只需要把它配置到HttpSecurity
就可以了实现三种登录了。
ChannelAuthenticationFilterchannelAuthenticationFilter=getChannelAuthenticationFilterBeanOrNull(applicationContext);if(Objects.nonNull(channelAuthenticationFilter)){httpSecurity.addFilterBefore(channelAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);}
总结
今天用Spring Security实现了比较实用的多端登录,其中的很多知识点都是以往积累的,而且是借鉴了Spring框架源码的思路。完整代码已经开源,请关注:码农小胖哥回复channellogin
获取生产级别源码。
往期推荐
重装IDEA再也不愁了,一招搞定同步个人配置!
用办公电脑存不雅视频,结果被告了...
小米宣布发放价值15.3亿元的股票!人均39万,最小授予者仅24岁
为什么catch了异常,但事务还是回滚了?
骚操作!阿里云直接买的关键词来抢生意?