附源码|基础登录、验证码登录、小程序登录…… 我全都要
现在开发个应用登录比以前麻烦的多。产品经理说用户名密码登录、短信登录都得弄上,如果搞个小程序连小程序登录也得安排上,差不多就是我全都要。
多种登录途径达到一个效果确实不太容易,今天胖哥在Spring Security中实现了这三种登录你全都要的效果,爽的飞起,还不点个赞先。
大致原理
虽然不需要知道原理,但是还是要满足一下需要知道原理的同学。不过这里不会太深入,只会说核心的部分。更多的相关知识可以去看胖哥的Spring Security干货教程。
登录的几大组件
在Spring Security中我们需要实现登录认证就需要实现AbstractAuthenticationProcessingFilter
;还需要一个处理具体登录逻辑的AuthenticationProvider
;而每个AuthenticationProvider
又对应一种Authentication
。执行流程如下:
原理呢大概就是这样子的,接下来的工作就是按照上面封装每种登录的逻辑了。
ChannelUserDetailsService
在整个Spring Security体系中只允许有一个UserDetailsService
注入Spring IoC,所以我扩展了这个接口:
public interface ChannelUserDetailsService extends UserDetailsService {
/**
* 验证码登录
*
* @param phone the phone
* @return the user details
*/
UserDetails loadByPhone(String phone);
/**
* openid登录
*
* @param openId the open id
* @return the user details
*/
UserDetails loadByOpenId(String openId);
}
这样三种登录都能使用一个UserDetailsService
了,当然如果你的登录渠道更多你可以增加更多的实现。
验证码登录
关于验证码登录以前有专门的文章来讲解登录流程和实现细节这里就不再赘述了,有兴趣可以去看相关的文章。这里提一句验证码登录的URI为/login/captcha
,这是一个比较关键的细节后面有关于它的更多运用。开发中我们需要实现上面的loadByPhone
,另外还需要实现验证码的校验服务逻辑:
public interface CaptchaService {
/**
* Send captcha code string.
*
* @param phone the phone
* @return the boolean
*/
boolean sendCaptchaCode(String phone);
/**
* 根据手机号去缓存中获取验证码同{@code captcha}进行对比,对比成功从缓存中主动清除验证码
*
* @param phone 手机号
* @param captcha 前端传递的验证码
* @return the boolean
*/
boolean verifyCaptchaCode(String phone,String captcha);
}
微信小程序登录
微信小程序登录这里需要重点说一下.首先前端会传递一个clientId
和jsCode
, 我们比较陌生的是clientId
的目的是为了标识小程序的配置appid
和secret
,这样我们可以同时适配多个小程序。这里我设计了一个获取小程序客户端的函数式接口:
@FunctionalInterface
public interface MiniAppClientService {
/**
* Get mini app client.
*
* @return {@link MiniAppClient}
* @see MiniAppClient#getAppId()
* @see MiniAppClient#getSecret()
*/
MiniAppClient get(String clientId);
}
然后就可以请求微信服务器的登录接口code2session
了,拿到openid
后注册或者登录(实现loadByOpenId
),同时还要缓存sessionKey
用来加解密使用:
/**
* 缓存sessionKey,这里只实现put ,get可以根据cachekey规则去实现获取。
*
* @author felord.cn
* @since 1.0.8.RELEASE
*/
public interface MiniAppSessionKeyCache {
/**
* Put.
*
* @param cacheKey the cache key
* @param sessionKey the session key
*/
void put(String cacheKey,String sessionKey);
}
对应的AuthenticationProvider
实现
package cn.felord.security.autoconfigure.miniapp;
import cn.felord.security.autoconfigure.ChannelUserDetailsService;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.util.Collection;
import java.util.Objects;
/**
* Miniapp authentication provider.
*
* @author felord.cn
* @since 1.0.8.RELEASE
*/
public class MiniAppAuthenticationProvider implements AuthenticationProvider, MessageSourceAware {
private static final String ENDPOINT = "https://api.weixin.qq.com/sns/jscode2session";
private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
private final MiniAppClientService miniAppClientService;
private final ChannelUserDetailsService channelUserDetailsService;
private final MiniAppSessionKeyCache miniAppSessionKeyCache;
private final RestOperations restOperations;
private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
/**
* Instantiates a new Captcha authentication provider.
* appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code
*
* @param miniAppClientService the mini app client supplier
* @param channelUserDetailsService the channel user details service
* @param miniAppSessionKeyCache the mini app session key cache
*/
public MiniAppAuthenticationProvider(MiniAppClientService miniAppClientService, ChannelUserDetailsService channelUserDetailsService, MiniAppSessionKeyCache miniAppSessionKeyCache) {
this.miniAppClientService = miniAppClientService;
this.channelUserDetailsService = channelUserDetailsService;
this.miniAppSessionKeyCache = miniAppSessionKeyCache;
this.restOperations = new RestTemplate();
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(MiniAppAuthenticationToken.class, authentication,
() -> messages.getMessage(
"MiniAppAuthenticationProvider.onlySupports",
"Only MiniAppAuthenticationToken is supported"));
MiniAppAuthenticationToken unAuthenticationToken = (MiniAppAuthenticationToken) authentication;
String clientId = unAuthenticationToken.getName();
String jsCode = (String) unAuthenticationToken.getCredentials();
ObjectNode response = this.getResponse(miniAppClientService.get(clientId), jsCode);
String openId = response.get("openid").asText();
String sessionKey = response.get("session_key").asText();
UserDetails userDetails = channelUserDetailsService.loadByOpenId(openId);
String username = userDetails.getUsername();
miniAppSessionKeyCache.put(username,sessionKey);
return createSuccessAuthentication(authentication, userDetails);
}
@Override
public boolean supports(Class<?> authentication) {
return MiniAppAuthenticationToken.class.isAssignableFrom(authentication);
}
@Override
public void setMessageSource(MessageSource messageSource) {
this.messages = new MessageSourceAccessor(messageSource);
}
/**
* 认证成功将非授信凭据转为授信凭据.
* 封装用户信息 角色信息。
*
* @param authentication the authentication
* @param user the user
* @return the authentication
*/
protected Authentication createSuccessAuthentication(Authentication authentication, UserDetails user) {
Collection<? extends GrantedAuthority> authorities = authoritiesMapper.mapAuthorities(user.getAuthorities());
MiniAppAuthenticationToken authenticationToken = new MiniAppAuthenticationToken(user, null, authorities);
authenticationToken.setDetails(authentication.getDetails());
return authenticationToken;
}
/**
* 请求微信服务器登录接口 code2session
* @param miniAppClient miniAppClient
* @param jsCode jsCode
* @return ObjectNode
*/
private ObjectNode getResponse(MiniAppClient miniAppClient, String jsCode) {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("appid", miniAppClient.getAppId());
queryParams.add("secret", miniAppClient.getSecret());
queryParams.add("js_code", jsCode);
queryParams.add("grant_type", "authorization_code");
URI uri = UriComponentsBuilder.fromHttpUrl(ENDPOINT)
.queryParams(queryParams)
.build()
.toUri();
ResponseEntity<ObjectNode> response = restOperations.exchange(RequestEntity.get(uri).build(), ObjectNode.class);
ObjectNode body = response.getBody();
if (Objects.isNull(body)) {
throw new BadCredentialsException("miniapp response is null");
}
// openid session_key unionid errcode errmsg
final int defaultVal = -2;
if (body.get("errcode").asInt(defaultVal) != 0) {
throw new BadCredentialsException(body.get("errmsg").asText("unknown error"));
}
return body;
}
}
❝
AbstractAuthenticationProcessingFilter
实现参考文末源码,没有什么特色。
登录渠道聚合
最终验证码登录为:
POST /login/captcha?phone=182****0032&captcha=596001 HTTP/1.1
Host: localhost:8085
小程序登录为:
POST /login/miniapp?clientId=wx12342&code=asdfasdfasdfasdfsd HTTP/1.1
Host: localhost:8085
但是我们要配置两套过滤器,要能配置一个聚合过滤器就完美了,我观察了一下它们的URI,如果能解析出验证码登录为captcha
、小程序为miniapp
就能根据对应的标识路由到对应的过滤器处理了。事实上是可以的:
RequestMatcher matcher = new AntPathRequestMatcher("/login/{channal}", "POST");
String channel = LOGIN_REQUEST_MATCHER.matcher(request)
.getVariables()
.get("channel");
为此我增强了AbstractAuthenticationProcessingFilter
,让它能够获取渠道:
public abstract class AbstractChannelAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
protected AbstractChannelAuthenticationProcessingFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
super(requiresAuthenticationRequestMatcher);
}
/**
* 用来获取登录渠道标识
*
* @return the string
*/
protected abstract String channel();
}
验证码和小程序的过滤器只需要实现这个接口即可,小程序的就这样实现:
/**
* The type Mini app authentication filter.
*
* @author felord.cn
* @since 1.0.8.RELEASE
*/
public class MiniAppAuthenticationFilter extends AbstractChannelAuthenticationProcessingFilter {
/**
* The constant CHANNEL_ID.
*/
private static final String CHANNEL_ID = "miniapp";
private static final String SPRING_SECURITY_FORM_MINI_CLIENT_KEY = "clientId";
/**
* The constant SPRING_SECURITY_FORM_PHONE_KEY.
*/
private static final String SPRING_SECURITY_FORM_JS_CODE_KEY = "jsCode";
/**
* Instantiates a new Captcha authentication filter.
*/
public MiniAppAuthenticationFilter() {
super(new AntPathRequestMatcher("/login/" + CHANNEL_ID, "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals(HttpMethod.POST.name())) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String clientId = obtainClientId(request);
String jsCode = obtainJsCode(request);
MiniAppAuthenticationToken authRequest = new MiniAppAuthenticationToken(clientId,
jsCode);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
@Override
public String channel() {
return CHANNEL_ID;
}
protected String obtainClientId(HttpServletRequest request) {
String clientId = request.getParameter(SPRING_SECURITY_FORM_MINI_CLIENT_KEY);
if (!StringUtils.hasText(clientId)) {
throw new IllegalArgumentException("clientId is required");
}
return clientId.trim();
}
/**
* Obtain JS CODE.
*
* @param request the request
* @return the string
*/
protected String obtainJsCode(HttpServletRequest request) {
String jsCode = request.getParameter(SPRING_SECURITY_FORM_JS_CODE_KEY);
if (!StringUtils.hasText(jsCode)) {
throw new IllegalArgumentException("js_code is required");
}
return jsCode.trim();
}
/**
* Sets details.
*
* @param request the request
* @param authRequest the auth request
*/
protected void setDetails(HttpServletRequest request,
MiniAppAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
}
这样我们的聚合过滤器就产生了:
public class ChannelAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String CHANNEL_URI_VARIABLE_NAME = "channel";
private static final RequestMatcher LOGIN_REQUEST_MATCHER = new AntPathRequestMatcher("/login/{" + CHANNEL_URI_VARIABLE_NAME + "}", "POST");
private final List<? extends AbstractChannelAuthenticationProcessingFilter> channelFilters;
public ChannelAuthenticationFilter(List<? extends AbstractChannelAuthenticationProcessingFilter> channelFilters) {
super(LOGIN_REQUEST_MATCHER);
this.channelFilters = CollectionUtils.isEmpty(channelFilters) ? Collections.emptyList() : channelFilters;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
String channel = LOGIN_REQUEST_MATCHER.matcher(request)
.getVariables()
.get(CHANNEL_URI_VARIABLE_NAME);
for (AbstractChannelAuthenticationProcessingFilter channelFilter : channelFilters) {
String rawChannel = channelFilter.channel();
if (Objects.equals(channel, rawChannel)) {
return channelFilter.attemptAuthentication(request, response);
}
}
throw new ProviderNotFoundException("No Suitable Provider");
}
}
然后注入Spring IoC:
@Configuration(proxyBeanMethods = false)
public class ChannelAuthenticationConfiguration {
/**
* 短信验证码登录过滤器
*
* @param channelUserDetailsService the channel user details service
* @param captchaService the captcha service
* @param jwtTokenGenerator the jwt token generator
* @return the captcha authentication provider
*/
@Bean
@ConditionalOnBean({ChannelUserDetailsService.class, CaptchaService.class, JwtTokenGenerator.class})
CaptchaAuthenticationFilter captchaAuthenticationFilter(ChannelUserDetailsService channelUserDetailsService,
CaptchaService captchaService,
JwtTokenGenerator jwtTokenGenerator) {
CaptchaAuthenticationProvider captchaAuthenticationProvider = new CaptchaAuthenticationProvider(channelUserDetailsService, captchaService);
CaptchaAuthenticationFilter captchaAuthenticationFilter = new CaptchaAuthenticationFilter();
ProviderManager providerManager = new ProviderManager(Collections.singletonList(captchaAuthenticationProvider));
captchaAuthenticationFilter.setAuthenticationManager(providerManager);
captchaAuthenticationFilter.setAuthenticationSuccessHandler(new LoginAuthenticationSuccessHandler(jwtTokenGenerator));
SimpleAuthenticationEntryPoint authenticationEntryPoint = new SimpleAuthenticationEntryPoint();
captchaAuthenticationFilter.setAuthenticationFailureHandler(new AuthenticationEntryPointFailureHandler(authenticationEntryPoint));
return captchaAuthenticationFilter;
}
/**
* 小程序登录过滤器
*
* @param miniAppClientService the mini app client service
* @param channelUserDetailsService the channel user details service
* @param jwtTokenGenerator the jwt token generator
* @return the mini app authentication filter
*/
@Bean
@ConditionalOnBean({ChannelUserDetailsService.class, MiniAppClientService.class,MiniAppSessionKeyCache.class, JwtTokenGenerator.class})
MiniAppAuthenticationFilter miniAppAuthenticationFilter(MiniAppClientService miniAppClientService,
ChannelUserDetailsService channelUserDetailsService,
MiniAppSessionKeyCache miniAppSessionKeyCache,
JwtTokenGenerator jwtTokenGenerator) {
MiniAppAuthenticationFilter miniAppAuthenticationFilter = new MiniAppAuthenticationFilter();
MiniAppAuthenticationProvider miniAppAuthenticationProvider = new MiniAppAuthenticationProvider(miniAppClientService, channelUserDetailsService,miniAppSessionKeyCache);
ProviderManager providerManager = new ProviderManager(Collections.singletonList(miniAppAuthenticationProvider));
miniAppAuthenticationFilter.setAuthenticationManager(providerManager);
miniAppAuthenticationFilter.setAuthenticationSuccessHandler(new LoginAuthenticationSuccessHandler(jwtTokenGenerator));
SimpleAuthenticationEntryPoint authenticationEntryPoint = new SimpleAuthenticationEntryPoint();
miniAppAuthenticationFilter.setAuthenticationFailureHandler(new AuthenticationEntryPointFailureHandler(authenticationEntryPoint));
return miniAppAuthenticationFilter;
}
/**
* Channel authentication filter channel authentication filter.
*
* @param channelFilters the channel filters
* @return the channel authentication filter
*/
@Bean
public ChannelAuthenticationFilter channelAuthenticationFilter(List<? extends AbstractChannelAuthenticationProcessingFilter> channelFilters) {
return new ChannelAuthenticationFilter(channelFilters);
}
}
❝看上去好像还有优化空间。
我们只需要把它配置到HttpSecurity
就可以了实现三种登录了。
ChannelAuthenticationFilter channelAuthenticationFilter = getChannelAuthenticationFilterBeanOrNull(applicationContext);
if (Objects.nonNull(channelAuthenticationFilter)) {
httpSecurity.addFilterBefore(channelAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
总结
今天用Spring Security实现了比较实用的多端登录,其中的很多知识点都是以往积累的,而且是借鉴了Spring框架源码的思路。完整代码已经开源,请关注:码农小胖哥 回复channellogin
获取生产级别源码。
2021-06-21
2021-06-18