SpringCloud - Oauth2增加短信验证码验证登录

前言

目前的大多数网站或者APP都会采取短信验证码的方式帮助用户登陆系统授权。
我们现在就在Oauth里添加该功能的实现。
其实在一篇博客中,我们仿照Security自带的密码模式实现并添加自己的密码模式。
因此,我们可以仿照密码模式在上一篇的基础上继续实现自己的短信验证码授权。

Oauth2认证过程

1、在进行认证时,会先根据认证类型即前端传入的grant_type,从所有的TokenGranter中找到具体匹配的TokenGranter

代码如下:

@RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
	public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
	Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {//代码@1
		...省略
		OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);//代码@7
		if (token == null) {
			throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
		}

		return getResponse(token);
	}

2、在找到对应的TokenGranter之后,会调用getOAuth2Authentication方法进行认证

以用户名密码的实现类ResourceOwnerPasswordTokenGranter

  • 这个方法显示通过传入的tokenRequest获得到 username 和 password参数
  • 通过username 和 password 实例化出一个 UsernamePasswordAuthenticationToken 对象,继承自AuthenticationToken,用来存放主要的信息,后面我们可以创建一个属于自己短信验证码的主体类 SmsCodeAuthenticationToken
  • 将请求中的所有参数,赋予detail
    ((AbstractAuthenticationToken)userAuth).setDetails(parameters);
    里面主要存放这用来验证客户端的信息等。
  • 然后最重要的一步 userAuth = this.authenticationManager.authenticate(userAuth); 这里是认证的具体校验实现
  ...
  protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
        Map<String, String> parameters = new LinkedHashMap(tokenRequest.getRequestParameters());
        String username = (String)parameters.get("username");
        String password = (String)parameters.get("password");
        parameters.remove("password");
        Authentication userAuth = new UsernamePasswordAuthenticationToken(username, password);
        ((AbstractAuthenticationToken)userAuth).setDetails(parameters);

        Authentication userAuth;
        try {
            userAuth = this.authenticationManager.authenticate(userAuth);
        } catch (AccountStatusException var8) {
            throw new InvalidGrantException(var8.getMessage());
        } catch (BadCredentialsException var9) {
            throw new InvalidGrantException(var9.getMessage());
        }
			...省略
    }

3、关于 this.authenticationManager.authenticate(userAuth) 的实现

我们知道,AuthenticationManager是oauth2中的认证管理器,我们查看其缺省实现 ProviderManager:
其中 List providers中包含了所有的认证服务。
那么他又是怎么知道该用哪个服务去处理我们的请求的呢?

    private static final Log logger = LogFactory.getLog(ProviderManager.class);
    private AuthenticationEventPublisher eventPublisher;
    private List<AuthenticationProvider> providers;
    protected MessageSourceAccessor messages;
    private AuthenticationManager parent;
    private boolean eraseCredentialsAfterAuthentication;

查看ProviderManager中的authenticate方法:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Class<? extends Authentication> toTest = authentication.getClass();
        AuthenticationException lastException = null;
        AuthenticationException parentException = null;
        Authentication result = null;
        Authentication parentResult = null;
        int currentPosition = 0;
        int size = this.providers.size();
        Iterator var9 = this.getProviders().iterator();

        while(var9.hasNext()) {
            AuthenticationProvider provider = (AuthenticationProvider)var9.next();
            if (provider.supports(toTest)) {
                if (logger.isTraceEnabled()) {
                    Log var10000 = logger;
                    String var10002 = provider.getClass().getSimpleName();
                    ++currentPosition;
                    var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
                }

                try {
                    result = provider.authenticate(authentication);
                    if (result != null) {
                        this.copyDetails(authentication, result);
                        break;
                    }
                } catch (InternalAuthenticationServiceException | AccountStatusException var14) {
                    this.prepareException(var14, authentication);
                    throw var14;
                } catch (AuthenticationException var15) {
                    lastException = var15;
                }
            }
        }

        if (result == null && this.parent != null) {
            try {
                parentResult = this.parent.authenticate(authentication);
                result = parentResult;
            } catch (ProviderNotFoundException var12) {
            } catch (AuthenticationException var13) {
                parentException = var13;
                lastException = var13;
            }
        }

        if (result != null) {
            if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
                ((CredentialsContainer)result).eraseCredentials();
            }

            if (parentResult == null) {
                this.eventPublisher.publishAuthenticationSuccess(result);
            }

            return result;
        } else {
            if (lastException == null) {
                lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
            }

            if (parentException == null) {
                this.prepareException((AuthenticationException)lastException, authentication);
            }

            throw lastException;
        }
    }

可以看到这一行代码:if (provider.supports(toTest))
即表示如果服务支持 toTest 这个类型: Class<? extends Authentication> toTest = authentication.getClass();
那么它就会调用这个服务的认证方法:result = provider.authenticate(authentication);
supports 方法是接口AuthenticationProvider中的一个方法:

public interface AuthenticationProvider {
    Authentication authenticate(Authentication var1) throws AuthenticationException;
    boolean supports(Class<?> var1);
}

我们打开一个实现类,以AbstractUserDetailsAuthenticationProvider抽象类为例
这个方法的实现表示这个服务支持 UsernamePasswordAuthenticationToken 这个类型的Token
当传入的Token类型是UsernamePasswordAuthenticationToken
并且在服务列表中循环到这个服务的具体实现服务时,就会触发该实现的认证方法
因此我们不仅要创建一个属于自己独一无二的Token类,而且我们还要让我们自己的服务支持这个类

public boolean supports(Class<?> authentication) {
    return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}

实现

这里所有的实现都是都是模仿用户名密码的所有代码去实现的

1、创建我们的SmsCodeAuthenticationToken,这里我们的主体信息就只有手机号

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 520L;

    //账号主体信息,手机号码
    private final Object principal;

    //构建未授权的 SmsCodeAuthenticationToken
    public SmsCodeAuthenticationToken(String mobile) {
        super(null);
        this.principal = mobile;
        setAuthenticated(false);
    }

    //构建已经授权的 SmsCodeAuthenticationToke
    public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities){
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) {
        if(isAuthenticated){
            throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }else{
            super.setAuthenticated(false);
        }
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }
}

2、实现我们的认证服务提供者

@Slf4j
@Configuration
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    private UsersDao usersDao;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        SmsCodeAuthenticationToken smsCodeAuthenticationToken = (SmsCodeAuthenticationToken) authentication;

        String mobile = (String) smsCodeAuthenticationToken.getPrincipal();

        usersDao = SpringContextUtils.getBean(UsersDao.class);

        //查询手机号的详细信息
        QueryWrapper<Users> usersQueryWrapper =new QueryWrapper<>();
        usersQueryWrapper.eq("phone_no",mobile).eq("delete_flag",0);

        Users user = usersDao.selectOne(usersQueryWrapper);
        if(null!=user){
            //校验手机收到的验证码和rediss中的验证码是否一致
            checkSmsCode(mobile);
            //授权通过
            UserDetails userDetails = buildUserDetails(user);
            return new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());
        }else{
            throw new BadCredentialsException("该手机号未注册或未绑定账号!");
        }
    }

    /**
     * 构建用户认证信息
     * @param user 用户对象
     * @return UserDetails
     */
    private UserDetails buildUserDetails(Users user) {
        return new org.springframework.security.core.userdetails.User(
                user.getUserName(),
                user.getPassword(),
                AuthorityUtils.createAuthorityList("ADMIN")) ;
    }

    /**
     * 校验手机号与验证码的绑定关系是否正确
     * 在调用短信验证码认之前我们需要先生成验证码,接口需要自己实现
     * Redis的存储风格按照自己的习惯,能够保证唯一即可
     * 然后根据手机号信息去Redis中查询对应的验证码是否正确
     * @param mobile 手机号码
     */
    private void checkSmsCode(String mobile) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        //获取验证码
        String smsCode = request.getParameter("smsCode");
        //从redis中获取只当key的值
        String smsStr = JedisUtils.getObject("sms"+mobile);
        if(StringUtils.isEmpty(smsCode) || !smsCode.equals(smsStr)){
            throw new BadCredentialsException("验证码错误!");
        }
    }

    /**
     * ProviderManager 选择具体Provider时根据此方法判断
     * 判断 authentication 是不是 SmsCodeAuthenticationToken 的子类或子接口
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

3、将我们的服务提供者添加到认证管理器中

我们需要重写 WebSecurityConfigurerAdapter 中的configure(AuthenticationManagerBuilder auth)方法
加入我们的短信验证码服务

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

   	...省略
   	
    @Bean
    public SmsCodeAuthenticationProvider providers(){
        SmsCodeAuthenticationProvider provider =new SmsCodeAuthenticationProvider();
        return provider;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                //添加自定义认证
                .authenticationProvider(providers())
                .userDetailsService(userDetailsService())
                .passwordEncoder(new BCryptPasswordEncoder());
    }
}

4、添加我们的短信验证码授权实现

我们的授权类型是 sms_code,
除了修改以下代码,其他基本和密码模式的实现相同
因为我们需要的时短信类型的Token从而触发短信验证码的认证服务

 	 Map<String, String> parameters = new LinkedHashMap(tokenRequest.getRequestParameters());
     String mobile = parameters.get("mobile");

     Authentication userAuth = new SmsCodeAuthenticationToken(mobile);

     ((AbstractAuthenticationToken)userAuth).setDetails(parameters);

全部代码:

public class SmsCodeTokenGranter extends AbstractTokenGranter {

    private static final String GRANT_TYPE = "sms_code";

    private final AuthenticationManager authenticationManager;

    public SmsCodeTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) {
        this(authenticationManager, tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
    }

    protected SmsCodeTokenGranter(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 mobile = parameters.get("mobile");

        Authentication userAuth = new SmsCodeAuthenticationToken(mobile);

        ((AbstractAuthenticationToken)userAuth).setDetails(parameters);

        try {
            userAuth = this.authenticationManager.authenticate(userAuth);
        } catch (AccountStatusException ex) {
            throw new InvalidGrantException(ex.getMessage());
        } catch (BadCredentialsException ex) {
            throw new InvalidGrantException(ex.getMessage());
        }
        
        if (userAuth != null && userAuth.isAuthenticated()) {
            OAuth2Request storedOAuth2Request = this.getRequestFactory().createOAuth2Request(client, tokenRequest);
            return new OAuth2Authentication(storedOAuth2Request, userAuth);
        } else {
            throw new InvalidGrantException("Could not authenticate mobile: " + mobile);
        }
    }
}

5、配置我们的自定义授权模式,接着上一篇,我们添加上短信验证模式

@Configuration
public class TokenGranterConfig {
    ...其他省略
    private List<TokenGranter> getDefaultTokenGranters() {
        AuthorizationServerTokenServices tokenServices = tokenServices();
        AuthorizationCodeServices authorizationCodeServices = authorizationCodeServices();
        OAuth2RequestFactory requestFactory = requestFactory();

        List<TokenGranter> tokenGranters = new ArrayList();
        //授权码模式
        tokenGranters.add(new AuthorizationCodeTokenGranter(tokenServices, authorizationCodeServices, clientDetailsService, requestFactory));
        //refresh模式
        tokenGranters.add(new RefreshTokenGranter(tokenServices, clientDetailsService, requestFactory));
        //简化模式
        ImplicitTokenGranter implicit = new ImplicitTokenGranter(tokenServices, clientDetailsService, requestFactory);
        tokenGranters.add(implicit);
        //客户端模式
        tokenGranters.add(new ClientCredentialsTokenGranter(tokenServices, clientDetailsService, requestFactory));

        if (authenticationManager != null) {
            //之前我们自定义的密码模式
            tokenGranters.add(new CustomResourceOwnerPasswordTokenGranter(authenticationManager, tokenServices, clientDetailsService, requestFactory));
            //短信验证码模式
            tokenGranters.add(new SmsCodeTokenGranter(authenticationManager, tokenServices, clientDetailsService, requestFactory));
        }

        return tokenGranters;
    }
}

6、最后,修改Oauth2的认证服务器Endpoints配置为我们自定义的授权模式

其实在上一篇我们已经修改了

@Configuration
@EnableAuthorizationServer
@EnableConfigurationProperties(RedisProperties.class)
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    ...省略
    @Autowired
    private TokenGranter tokenGranter;
		
    /**
     * 认证服务器Endpoints配置
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        //自定义授权模式
        endpoints.tokenGranter(tokenGranter);
    }

}

关于Redis反序列化失败

可以参考这篇博文

https://blog.csdn.net/CSDN877425287/article/details/120663052

资源服务器在解析 Redis 中的认证信息时,找不到自定义的 Token
在我项目中的情景是:

用户认证是通过 auth-biz/auth-service 项目实现的,在该项目中创建了 SmsCodeAuthenticationToken 类,
当我们使用短信验证码申请授权之后,通过 auth-biz/auth-serviceRedis 中生成了包含 SmsCodeAuthenticationToken 的认证信息。
在这里插入图片描述
后来由于项目添加了 gateway 服务 ,除特殊配置的接口路径之外,如 “oauth/*” 等,其他请求都将先通过 gateway 服务进行权限验证。

cloud-gateway 项目目录结构,创建时缺少 SmsCodeAuthenticationToken 这个类
权限验证放行的接口
在这里插入图片描述
而在 gateway 中进行解析时找不到 SmsCodeAuthenticationToken 这个类,导致反序列化失败,因此我们只要将 SmsCodeAuthenticationToken 这个类复制到 gateway 项目中即可(注意类的路径要保持一致)。
在这里插入图片描述

最后

这样我们的短信验证码模式就已经实现了,
在请求认证时,相对于用户名密码认证:
请求参数中的 grant_type 需要传 sms_code
请求参数中的 username 和 password 我们已经不需要了
我们需要替换为 mobile 和 smsCode

  • 6
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Mingvvv

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值