认证服务用于统一管理用户信息,用于解决各个服务各自维护一套用户体系导致用户信息无法统一的问题。依托于spring-security + OAuth2的框架设计,维护两套对外认证方式:
- 一:经典的OAuth2授权码模式,针对非内部项目,使用授权码模式保护用户信息安全性,获取用户信息也有响应的权限控制。
- 二:内部使用的认证方式,使用账号密码/手机号验证码登录,依托spring-security框架拓展实现。 本次主要介绍第二种内部使用认证方式,第一种方式已经在上一篇博客介绍。 本文涉及到spring-security框架的扩展知识,可能会一笔带过,各位看官可以在spring-security官网查看扩展点。
一、内部使用认证流程
可以发现,这里认证和外部接入的请求方式是相同的,差异点是登录直接使用认证服务提供的登录页面或接口,而无需用户授权页。
二、认证服务的内部编码设计
内部认证方式依托于spring-security框架扩展,目前扩展出手机号验证码登录。具体实现如下:
2.1、创建认证Filter节点
创建一个Filter节点用于拦截登录请求。
- 这里配置DelegatingAuthenticationConverte用于配置不同的Token类型转换器。
java
@Slf4j
public class UserAuthenticationFilter extends AbstractAuthenticationProcessingFilter implements Ordered {
public static final String DEFAULT_LOGIN_PATH = "/auth/login";
public static final String USER_AUTH_HEADER_KEY = "A_SPID"; // auth service permit id
private final String loginPath = DEFAULT_LOGIN_PATH;
@Setter @Getter private AuthenticationConverter authenticationConverter = new DelegatingAuthenticationConverter(Arrays.asList(new DefaultAuthenticationConverter()));
/**
... 中间省略构造器
**/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
/*
这里解析请求信息,构造Token,根据不同的Token类型执行不同的认证流程
*/
Authentication requestAuthentication = authenticationConverter.convert(request);
if(log.isDebugEnabled()) {
log.debug("[DEBUG]UserAuthenticationFilter -> requestAuthentication[{}]", requestAuthentication);
}
/*
开始认证
*/
return getAuthenticationManager().authenticate(requestAuthentication);
}
@Override
public int getOrder() {
return 0;
}
}
2.2、创建AuthenticationConverter转换器
AuthenticationConverter转换器用于将HttpServletRequest转换成Authentication对象,认证职责链会根据具体的Authentication实现来确定Support类,具体如下:
故我们拓展一下这个convert用于支持手机号登录,扩展代码如下:
java
public class MobileAuthenticationConverter extends AbstractAuthenticationConverter {
@Setter private String mobileKey = "mobile";
@Setter private String smsCodeKey = "smsCode";
private static final ParameterizedTypeReference<MobileLogin> STRING_OBJECT_MAP =
new ParameterizedTypeReference<MobileLogin>() {};
@Override
public Authentication convert(HttpServletRequest request) {
// 支持form
String mobile = findMobileValue(request);
String smsCode = findSmsCodeValue(request);
if(StringUtils.isBlank(mobile)) {
try {
// 支持json
MobileLogin mobileLogin = parsePostJson(request);
mobile = mobileLogin.getMobile();
smsCode = mobileLogin.getSmsCode();
} catch (IOException e) {
return null;
}
}
if(StringUtils.isBlank(mobile)) {
// 这里如果是空,则代表判断下一个Convert
return null;
}
return new MobileAuthenticationToken(mobile, smsCode);
}
public MobileLogin parsePostJson(HttpServletRequest request) throws IOException {
GenericHttpMessageConverter<Object> jsonHttpMessageConverter = HttpMessageConverters.getJsonMessageConverter();
MobileLogin mobileLogin = (MobileLogin) jsonHttpMessageConverter.read(STRING_OBJECT_MAP.getType(), null, new ServletServerHttpRequest(request));
return mobileLogin;
}
private String findMobileValue(HttpServletRequest request) {
return request.getParameter(mobileKey);
}
private String findSmsCodeValue(HttpServletRequest request) {
return request.getParameter(smsCodeKey);
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE - 10 * 10000;
}
}
2.3、创建AuthenticationProvider认证处理器
AuthenticationProvider会根据Convert转换的Authentication类型,确定是否要执行。这里扩展手机号的认证处理器,如下:
java
@Component
public class MobileAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider implements Ordered {
@Getter private final UserDetailsService userDetailsService;
@Getter private final PasswordEncoder passwordEncoder;
private final SmsCodeStoreService smsCodeStoreService;
private final Oauth2ApplicationUserRepository userRepository;
public MobileAuthenticationProvider(@Qualifier("mobileApplicationUserDetailsService") UserDetailsService userDetailsService, PasswordEncoder passwordEncoder, SmsCodeStoreService smsCodeStoreService, Oauth2ApplicationUserRepository userRepository) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
this.smsCodeStoreService = smsCodeStoreService;
this.userRepository = userRepository;
}
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
MobileAuthenticationToken mobileAuthenticationToken = (MobileAuthenticationToken) authentication;
if (mobileAuthenticationToken.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String mobile = mobileAuthenticationToken.getName();
String smsCode = mobileAuthenticationToken.getCredentials().toString();
if(!smsCodeStoreService.hasSmsCode(mobile)) {
this.logger.debug("短信息验证码未发送或者已过期!");
throw new BadCredentialsException("短信息验证码未发送或者已过期");
}
String storeSmsCode = smsCodeStoreService.getSmsCode(mobile);
if (StringUtils.isBlank(storeSmsCode) || !storeSmsCode.equals(smsCode)) {
this.logger.debug(format("短信息验证码验证失败!请求验证码:[%s], store验证码:[%s]", smsCode, storeSmsCode));
throw new BadCredentialsException("短信息验证码验证失败");
}
// 验证码过期
smsCodeStoreService.evictSmsCode(mobile);
}
@Override
protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (Objects.isNull(loadedUser)) {
loadedUser = helpRegister(username);
if(Objects.isNull(loadedUser)) {
throw new InternalAuthenticationServiceException(
"MobileUserDetailsService returned null, which is an interface contract violation");
}
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
private UserDetails helpRegister(String mobile) {
// check sms
if(!smsCodeStoreService.hasSmsCode(mobile)) {
throw new RegisterException("验证码不正确!");
}
String username = RandomUtils.generateCode();
String userUniqueCode = RandomUtils.generateCode();
Oauth2ApplicationUser newUser = new Oauth2ApplicationUser();
newUser.setMobile(mobile);
newUser.setUserName(username);
newUser.setUserUniqueCode(userUniqueCode);
Oauth2ApplicationUser saved = userRepository.save(newUser);
return this.getUserDetailsService().loadUserByUsername(saved.getMobile());
}
@Override
public boolean supports(Class<?> authentication) {
return MobileAuthenticationToken.class.isAssignableFrom(authentication);
}
@Override
public int getOrder() {
return ProviderOrdered.MOBILE_AUTHENTICATION_PROVIDER.getOrder();
}
}
在这里认证成功之后根据用户信息返回access_token,认证流程就完成了。
三、内部服务调用方式
提供过滤器为其他需要校验token的服务。 过滤器流程如下,流程固定,内部使用TokenCheckService用于扩展接口。
java
public class CheckFilter implements Filter {
private final String authHeaderKey;
private final TokenCheckService tokenCheckService;
public CheckFilter(String authHeaderKey, TokenCheckService tokenCheckService) {
this.authHeaderKey = authHeaderKey;
this.tokenCheckService = tokenCheckService;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest r = (HttpServletRequest) request;
String uri = r.getRequestURI();
tokenCheckService.printer("执行 CheckFilter uri:" + uri);
if(tokenCheckService.whiteList(uri)) {
chain.doFilter(request, response);
return ;
}
String token = findTokenFromRequestHeader(r);
if(!tokenCheckService.checkToken(token)) {
tokenCheckService.printer("token invalid, token -> " + token);
boolean continuing = tokenCheckService.invalidTokenHandler(token, request, response);
if(!continuing) {
return ;
}
}
try {
tokenCheckService.parseToken(token);
chain.doFilter(request, response);
} finally {
}
}
private String findTokenFromRequestHeader(HttpServletRequest request) {
String token = request.getHeader(authHeaderKey);
return token;
}
}
TokenCheckService中可配置获取用户信息后存入线程缓存用于后续接口中使用。
四、总结
至此,一个简单的认证服务内部逻辑就完成了,依托于spring-security框架可以快速的集成认证方式。 各位看客如果有问题或者建议可以评论区发表哦,我看到会即使回复的。