在程序的认证过程中,除了常规的用户名和密码方式,也经常会出现手机号码+密码的方式;以下,以电话号码+验证码的方式讲述OAuth2认证方式的扩展。
项目结构
手机号码登陆最重要的几个类
代码部分
TokenGranter接口定义token获取方法:
package com.hngtsd.zxtk.oauth.config.sms;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.TokenRequest;
/**
* 默认的只有授权码类型,简化类型,密码类型,客户端类型。
* 这里需要新增一种电话号码+验证码的认证和生成访问授权码的TokenGranter。
* 接口TokenGranter定义了token获取方法
*/
public interface TokenGranter {
OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest);
}
CompositeTokenGranter实现TokenGranter:
package com.hngtsd.zxtk.oauth.config.sms;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.TokenRequest;
import java.util.ArrayList;
import java.util.List;
public class CompositeTokenGranter implements TokenGranter {
// TokenGranter 列表
private final List<TokenGranter> tokenGranters;
public CompositeTokenGranter(List<TokenGranter> tokenGranters) {
this.tokenGranters = new ArrayList<TokenGranter>(tokenGranters);
}
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
for (TokenGranter granter : tokenGranters) {
OAuth2AccessToken grant = granter.grant(grantType, tokenRequest);
if (grant!=null) {
return grant;
}
}
return null;
}
public void addTokenGranter(TokenGranter tokenGranter) {
if (tokenGranter == null) {
throw new IllegalArgumentException("Token granter is null");
}
tokenGranters.add(tokenGranter);
}
}
通过AuthenticationProvider接口的扩展来实现自定义认证方式:
package com.hngtsd.zxtk.oauth.config.sms;
import com.alibaba.fastjson.JSON;
import com.hngtsd.zxtk.common.utlis.CommonVo;
import com.hngtsd.zxtk.common.utlis.SmsCode;
import com.hngtsd.zxtk.oauth.service.MyUserDetailsService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* 在AuthenticationManager认证过程中,
* 是通过AuthenticationProvider接口的扩展来实现自定义认证方式的。
* 定义手机和验证码认证提供者PhoneAndVerificationCodeAuthenticationProvider
*/
public class PhoneAndVerificationCodeAuthenticationProvider implements AuthenticationProvider {
/**
* UserDetailsService
*/
private MyUserDetailsService UserDetailsService;
/**
* redis服务
*/
private StringRedisTemplate stringRedisTemplate;
public PhoneAndVerificationCodeAuthenticationProvider(MyUserDetailsService UserDetailsService,StringRedisTemplate redisTemplate) {
this.UserDetailsService = UserDetailsService;
this.stringRedisTemplate = redisTemplate;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
PhoneAndVerificationCodeAuthenticationToken phoneAndVerificationCodeAuthenticationToken = (PhoneAndVerificationCodeAuthenticationToken) authentication;
// TODO 验证授权码
Object verificationCodeObj;
String verificationCode = Objects.nonNull(verificationCodeObj = phoneAndVerificationCodeAuthenticationToken.getCredentials()) ?
verificationCodeObj.toString() : StringUtils.EMPTY;
Object phoneNumberObj;
String phoneNumber = Objects.nonNull(phoneNumberObj = phoneAndVerificationCodeAuthenticationToken.getPrincipal())
? phoneNumberObj.toString() : StringUtils.EMPTY;
// 验证用户
if (StringUtils.isBlank(phoneNumber)) {
throw new InternalAuthenticationServiceException("phone number is empty!");
}
// 根据电话号码获取用户
UserDetails userDetails = UserDetailsService.loadUserByUsername(phoneNumber);
if (Objects.isNull(userDetails)) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null");
}
/* //校验验证码
//验证的过期时间
Long expire = stringRedisTemplate.getExpire( CommonVo.SMS_CODE + phoneNumber, TimeUnit.SECONDS);
if ( expire <= 0 ) {
throw new InternalAuthenticationServiceException(
"The captcha has expired!");
}
SmsCode cmsCode = this.getCmsCode(phoneNumber);
if (Objects.isNull(cmsCode) && cmsCode == null) {
throw new InternalAuthenticationServiceException(
"The captcha is null");
}
//校验验证码
if (verificationCode == cmsCode.getCode()){
throw new InternalAuthenticationServiceException(
"The captcha is Bad!");
}
*/
// 封装需要认证的PhoneAndVerificationCodeAuthenticationToken对象
return new PhoneAndVerificationCodeAuthenticationToken(userDetails.getAuthorities(), phoneNumber, verificationCode);
}
@Override
public boolean supports(Class<?> authentication) {
return PhoneAndVerificationCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
//从redis查询验证码
public SmsCode getCmsCode(String phoneNumber) {
//从redis中取到令牌信息
String value = stringRedisTemplate.opsForValue().get(CommonVo.SMS_CODE + phoneNumber);
//转成对象
try {
SmsCode smsCode = JSON.parseObject(value, SmsCode.class);
return smsCode;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
在OAuth2认证开始认证时,会提前Authentication认证信息,然后交由AuthenticationManager认证
package com.hngtsd.zxtk.oauth.config.sms;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import javax.servlet.http.HttpSession;
import java.util.Collection;
/**
* 在OAuth2认证开始认证时,
* 会提前Authentication认证信息,
* 然后交由AuthenticationManager认证。
* 定义电话号码+验证码的Authentication认证信息
*/
public class PhoneAndVerificationCodeAuthenticationToken extends AbstractAuthenticationToken{
/**
* 手机号
*/
private final Object phoneNumber;
/**
* 验证码
*/
private final Object verificationCode;
public PhoneAndVerificationCodeAuthenticationToken(Object phoneNumber, Object verificationCode) {
super(null);
this.phoneNumber = phoneNumber;
this.verificationCode = verificationCode;
}
public PhoneAndVerificationCodeAuthenticationToken(Collection<? extends GrantedAuthority> authorities, Object phoneNumber, Object verificationCode) {
super(authorities);
this.phoneNumber = phoneNumber;
this.verificationCode = verificationCode;
// 认证已经通过
setAuthenticated(true);
}
/**
* 用户身份凭证(一般是密码或者验证码)
*/
@Override
public Object getCredentials() {
return verificationCode;
}
/**
* 身份标识(一般是姓名,手机号)
*/
@Override
public Object getPrincipal() {
return phoneNumber;
}
}
新增电话验证码类型,PhoneAndVerificationCodeTokenGranter:
package com.hngtsd.zxtk.oauth.config.sms;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AccountStatusException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
import org.springframework.security.oauth2.provider.*;
import org.springframework.security.oauth2.provider.token.AbstractTokenGranter;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 新增电话验证码类型,PhoneAndVerificationCodeTokenGranter,
* 参考密码类型ResourceOwnerPasswordTokenGranter的认证流程,
* 首先进行电话号码与验证码的认证,然后生成访问授权码
*/
public class PhoneAndVerificationCodeTokenGranter extends AbstractTokenGranter {
private static final String GRANT_TYPE = "cms_code";
private final AuthenticationManager authenticationManager;
public PhoneAndVerificationCodeTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices,
ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) {
this(authenticationManager, tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
}
@Autowired
protected PhoneAndVerificationCodeTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices,
ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, String grantType) {
super(tokenServices, clientDetailsService, requestFactory, grantType);
this.authenticationManager = authenticationManager;
}
@Override
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
Map<String, String> parameters = new LinkedHashMap<>(tokenRequest.getRequestParameters());
// 电话号码与验证码
String phoneNumber = parameters.get("mobile");
String verificationCode = parameters.get("cms_code");
Authentication userAuth = new PhoneAndVerificationCodeAuthenticationToken(phoneNumber, verificationCode);
((AbstractAuthenticationToken) userAuth).setDetails(parameters);
try {
// authenticationManager进行验证
userAuth = authenticationManager.authenticate(userAuth);
} catch (AccountStatusException ase) {
//covers expired, locked, disabled cases (mentioned in section 5.2, draft 31)
throw new InvalidGrantException(ase.getMessage());
} catch (BadCredentialsException e) {
// If the username/password are wrong the spec says we should send 400/invalid grant
throw new InvalidGrantException(e.getMessage());
}
if (userAuth == null || !userAuth.isAuthenticated()) {
throw new InvalidGrantException("Could not authenticate phone number: " + phoneNumber);
}
OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);
return new OAuth2Authentication(storedOAuth2Request, userAuth);
}
}
在认证授权类上添加上手机号登陆的配置
/**
* 初始化所有的TokenGranter
*/
private List<TokenGranter> getDefaultTokenGranters(AuthorizationServerEndpointsConfigurer endpoints) {
ClientDetailsService clientDetails = endpoints.getClientDetailsService();
AuthorizationServerTokenServices tokenServices = endpoints.getTokenServices();
AuthorizationCodeServices authorizationCodeServices = endpoints.getAuthorizationCodeServices();
OAuth2RequestFactory requestFactory = endpoints.getOAuth2RequestFactory();
List<TokenGranter> tokenGranters = new ArrayList<>();
tokenGranters.add(new AuthorizationCodeTokenGranter(tokenServices, authorizationCodeServices, clientDetails,
requestFactory));
tokenGranters.add(new RefreshTokenGranter(tokenServices, clientDetails, requestFactory));
ImplicitTokenGranter implicit = new ImplicitTokenGranter(tokenServices, clientDetails, requestFactory);
tokenGranters.add(implicit);
tokenGranters.add(new ClientCredentialsTokenGranter(tokenServices, clientDetails, requestFactory));
if (authenticationManager != null) {
tokenGranters.add(new ResourceOwnerPasswordTokenGranter(authenticationManager, tokenServices,
clientDetails, requestFactory));
}
return tokenGranters;
}
/**
* 配置令牌访问端点
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
/* . tokenStore(tokenStore)
.userDetailsService(userDetailsService)*/
.authenticationManager(authenticationManager) //密码模式需要
.authorizationCodeServices(authorizationCodeService) //授权码服务
.tokenServices(tokenServices()) //令牌服务
.allowedTokenEndpointRequestMethods(HttpMethod.POST, HttpMethod.GET);
// 初始化所有的TokenGranter,并且类型为CompositeTokenGranter
List<TokenGranter> defaultTokenGranters = this.getDefaultTokenGranters(endpoints);
defaultTokenGranters.add(new PhoneAndVerificationCodeTokenGranter(authenticationManager, endpoints.getTokenServices(),
endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory()));
endpoints.tokenGranter(new CompositeTokenGranter(defaultTokenGranters));
}
WebSecurityConfig前端安全配置类上配置手机号码和验证码认证:
/**
* 安全拦截机制
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 配置手机号码和验证码认证
http.authenticationProvider(new PhoneAndVerificationCodeAuthenticationProvider(userDetailsService,redisTemplate))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.csrf().disable()
.authorizeRequests()
.antMatchers("/**").permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin();
}
写一个测试发送验证码方法 我这里是将验证码放在redis中:
package com.hngtsd.zxtk.oauth.controller;
import com.alibaba.fastjson.JSON;
import com.hngtsd.zxtk.common.exception.ExceptionCast;
import com.hngtsd.zxtk.common.model.TbUser;
import com.hngtsd.zxtk.common.response.AuthCode;
import com.hngtsd.zxtk.common.response.CommonCode;
import com.hngtsd.zxtk.common.response.LoginSmsCode;
import com.hngtsd.zxtk.common.response.ResponseResult;
import com.hngtsd.zxtk.common.utlis.CommonVo;
import com.hngtsd.zxtk.common.utlis.SmsCode;
import com.hngtsd.zxtk.oauth.mapper.TbUserMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.RandomStringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpSession;
import javax.xml.ws.soap.Addressing;
import java.util.concurrent.TimeUnit;
/*
* @author Mr.wangfeng
* @date 2020/8/5 16:51
* @param 模拟发送短信验证码v才
* @return
*/
@Slf4j
@RestController
public class SmsCodeController {
@Autowired
TbUserMapper userMapper;
@Autowired
StringRedisTemplate stringRedisTemplate;
int CmsCodeValiditySeconds = 120;
/**
* 模拟发送短信
*
* @param mobile
* @param
*/
@GetMapping("/smscode")
public ResponseResult SmsCode(@RequestParam String mobile) {
TbUser tbUser = userMapper.selectByUsername(mobile);
if (tbUser == null) {
return new ResponseResult(AuthCode.AUTH_ACCOUNT_NOTEXISTS);
}
//生产随机的四位数字,过期时间为60s
SmsCode smsCode = new SmsCode(RandomStringUtils.randomNumeric(4), 60, mobile);
//TODO 调用短信服务提供商的接口发送短信
log.info(smsCode.getCode() + "-----------> " + mobile);
String codeJson = JSON.toJSONString(smsCode);
//将验证码放入redis中
boolean result = this.saveCmsCode(mobile,codeJson,CmsCodeValiditySeconds);
if (!result) {
ExceptionCast.cast(LoginSmsCode.AUTH_LOGIN_CMS_CODE_SAVEFAIL);
}
// session.setAttribute("sms_key", smsCode);
return new ResponseResult(LoginSmsCode.SMS_SUCCESS);
}
/**
* @param mobile 手机号码
* @param content 内容
* @param ttl 过期时间
* @return
*/
private boolean saveCmsCode(String mobile, String content, long ttl) {
String key = CommonVo.SMS_CODE + mobile;
stringRedisTemplate.boundValueOps(key).set(content, ttl, TimeUnit.SECONDS);
Long expire = stringRedisTemplate.getExpire(key, TimeUnit.SECONDS);
return expire > 0;
}
}
测试
一切准备就绪后我们就可以进行测试了,先调接口发送验证码:
上面校验验证码的步骤我已经注释了,随便输入验证码都可以成功拿到令牌。
检查redis有没有保存成功
验证码拿到后我就可以测试啦!
数据库表我已更新到码云上了
项目地址:https://gitee.com/wf109809/security-oauth2-jwt.git