SpringCloud微服务实战——搭建企业级开发框架(四十一):集成JustAuth实现通用第三方登录功能【二】

举报
全栈程序猿 发表于 2023/07/21 11:11:44 2023/07/21
【摘要】 接上文:SpringCloud微服务实战——搭建企业级开发框架(四十一):集成JustAuth实现通用第三方登录功能【一】 4. 登录后注册或绑定用户  实现了第三方登录功能,我们自己的系统也需要做相应的用户匹配,通过OAuth2协议我们可以了解到,单点登录成功后可以获取第三方系统的用户信息,当然,具体获取到第三方用户的哪些信息是由第三方系统决定的。所以目前大多数系统平台再第三方登录成功之...

接上文:SpringCloud微服务实战——搭建企业级开发框架(四十一):集成JustAuth实现通用第三方登录功能【一】

4. 登录后注册或绑定用户

  实现了第三方登录功能,我们自己的系统也需要做相应的用户匹配,通过OAuth2协议我们可以了解到,单点登录成功后可以获取第三方系统的用户信息,当然,具体获取到第三方用户的哪些信息是由第三方系统决定的。所以目前大多数系统平台再第三方登录成功之后,都会显示用户注册或绑定页面,将第三方用户和自有系统平台用户进行绑定。那么在下一次第三方登录成功之后,就会自动匹配到自有系统的用户,进一步的获取到该用户在自有系统的权限、菜单等。

JustAuth官方提供的账户整合流程图:

JustAuth整合现有用户系统

  我们通常的第三方登录业务流程是点击登录,获取到第三方授权时,会去查询自有系统数据是否有匹配的用户,如果有,则自动登录到后台,如果没有,则跳转到账号绑定或者注册页面,进行账户绑定或者注册。我们将此业务流程放到gitegg-oauth微服务中去实现,新建SocialController类:

/**
 * 第三方登录
 * @author GitEgg
 */
@Slf4j
@RestController
@RequestMapping("/social")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class SocialController {
    
    private final GitEggAuthRequestFactory factory;
    
    private final IJustAuthFeign justAuthFeign;
    
    private final IUserFeign userFeign;
    
    private final ISmsFeign smsFeign;
    
    @Value("${system.secret-key}")
    private String secretKey;
    
    @Value("${system.secret-key-salt}")
    private String secretKeySalt;
    
    private final RedisTemplate redisTemplate;
    
    /**
     * 密码最大尝试次数
     */
    @Value("${system.maxTryTimes}")
    private int maxTryTimes;
    
    /**
     * 锁定时间,单位 秒
     */
    @Value("${system.maxTryTimes}")
    private long maxLockTime;
    
    /**
     * 第三方登录缓存时间,单位 秒
     */
    @Value("${system.socialLoginExpiration}")
    private long socialLoginExpiration;

    @GetMapping
    public List<String> list() {
        return factory.oauthList();
    }
    
    /**
     * 获取到对应类型的登录url
     * @param type
     * @return
     */
    @GetMapping("/login/{type}")
    public Result login(@PathVariable String type) {
        AuthRequest authRequest = factory.get(type);
        return Result.data(authRequest.authorize(AuthStateUtils.createState()));
    }
    
    /**
     * 保存或更新用户数据,并进行判断是否进行注册或绑定
     * @param type
     * @param callback
     * @return
     */
    @RequestMapping("/{type}/callback")
    public Result login(@PathVariable String type, AuthCallback callback) {
        AuthRequest authRequest = factory.get(type);
        AuthResponse response = authRequest.login(callback);
        if (response.ok())
        {
            AuthUser authUser = (AuthUser) response.getData();
            JustAuthSocialInfoDTO justAuthSocialInfoDTO = BeanCopierUtils.copyByClass(authUser, JustAuthSocialInfoDTO.class);
            BeanCopierUtils.copyByObject(authUser.getToken(), justAuthSocialInfoDTO);
            // 获取到第三方用户信息后,先进行保存或更新
            Result<Object> createResult = justAuthFeign.userCreateOrUpdate(justAuthSocialInfoDTO);
            if(createResult.isSuccess() && null != createResult.getData())
            {
                Long socialId = Long.parseLong((String)createResult.getData());
                // 判断此第三方用户是否被绑定到系统用户
                Result<Object> bindResult = justAuthFeign.userBindQuery(socialId);
                // 这里需要处理返回消息,前端需要根据返回是否已经绑定好的消息来判断
                // 将socialId进行加密返回
                DES des = new DES(Mode.CTS, Padding.PKCS5Padding, secretKey.getBytes(), secretKeySalt.getBytes());
                // 这里将source+uuid通过des加密作为key返回到前台
                String socialKey = authUser.getSource() + StrPool.UNDERLINE + authUser.getUuid();
                // 将socialKey放入缓存,默认有效期2个小时,如果2个小时未完成验证,那么操作失效,重新获取,在system:socialLoginExpiration配置
                redisTemplate.opsForValue().set(AuthConstant.SOCIAL_VALIDATION_PREFIX + socialKey, createResult.getData(), socialLoginExpiration,
                        TimeUnit.SECONDS);
                String desSocialKey = des.encryptHex(socialKey);
                bindResult.setData(desSocialKey);
                // 这里返回的成功是请求成功,里面放置的result是是否有绑定用户的成功
                return Result.data(bindResult);
            }
            return Result.error("获取第三方用户绑定信息失败");
        }
        else
        {
            throw new BusinessException(response.getMsg());
        }
    }
    
    /**
     * 绑定用户手机号
     * 这里不走手机号登录的流程,因为如果手机号不存在那么可以直接创建一个用户并进行绑定
     */
    @PostMapping("/bind/mobile")
    @ApiOperation(value = "绑定用户手机号")
    public Result<?> bindMobile(@Valid @RequestBody SocialBindMobileDTO socialBind) {
        Result<?> smsResult = smsFeign.checkSmsVerificationCode(socialBind.getSmsCode(), socialBind.getPhoneNumber(), socialBind.getCode());
        // 判断短信验证是否成功
        if (smsResult.isSuccess() && null != smsResult.getData() && (Boolean)smsResult.getData()) {
            // 解密前端传来的socialId
            DES des = new DES(Mode.CTS, Padding.PKCS5Padding, secretKey.getBytes(), secretKeySalt.getBytes());
            String desSocialKey = des.decryptStr(socialBind.getSocialKey());
    
            // 将socialKey放入缓存,默认有效期2个小时,如果2个小时未完成验证,那么操作失效,重新获取,在system:socialLoginExpiration配置
            String desSocialId = (String)redisTemplate.opsForValue().get(AuthConstant.SOCIAL_VALIDATION_PREFIX + desSocialKey);
            
            // 查询第三方用户信息
            Result<Object> justAuthInfoResult = justAuthFeign.querySocialInfo(Long.valueOf(desSocialId));
    
            if (null == justAuthInfoResult || !justAuthInfoResult.isSuccess() || null == justAuthInfoResult.getData())
            {
                throw new BusinessException("未查询到第三方用户信息,请返回到登录页重试");
            }
    
            JustAuthSocialInfoDTO justAuthSocialInfoDTO = BeanUtil.copyProperties(justAuthInfoResult.getData(), JustAuthSocialInfoDTO.class);
            
           // 查询用户是否存在,如果存在,那么直接调用绑定接口
           Result<Object> result = userFeign.queryUserByPhone(socialBind.getPhoneNumber());
           Long userId;
           // 判断返回信息
           if (null != result && result.isSuccess() && null != result.getData()) {
               GitEggUser gitEggUser = BeanUtil.copyProperties(result.getData(), GitEggUser.class);
               userId = gitEggUser.getId();
           }
           else
           {
               // 如果用户不存在,那么调用新建用户接口,并绑定
               UserAddDTO userAdd = new UserAddDTO();
               userAdd.setAccount(socialBind.getPhoneNumber());
               userAdd.setMobile(socialBind.getPhoneNumber());
               userAdd.setNickname(justAuthSocialInfoDTO.getNickname());
               userAdd.setPassword(StringUtils.isEmpty(justAuthSocialInfoDTO.getUnionId()) ? justAuthSocialInfoDTO.getUuid() : justAuthSocialInfoDTO.getUnionId());
               userAdd.setStatus(GitEggConstant.UserStatus.ENABLE);
               userAdd.setAvatar(justAuthSocialInfoDTO.getAvatar());
               userAdd.setEmail(justAuthSocialInfoDTO.getEmail());
               userAdd.setStreet(justAuthSocialInfoDTO.getLocation());
               userAdd.setComments(justAuthSocialInfoDTO.getRemark());
               Result<?> resultUserAdd = userFeign.userAdd(userAdd);
               if (null != resultUserAdd && resultUserAdd.isSuccess() && null != resultUserAdd.getData())
               {
                   userId = Long.parseLong((String) resultUserAdd.getData());
               }
               else
               {
                   // 如果添加失败,则返回失败信息
                   return resultUserAdd;
               }
           }
            // 执行绑定操作
            return justAuthFeign.userBind(Long.valueOf(desSocialId), userId);
        }
        return smsResult;
    }
    
    /**
     * 绑定账号
     * 这里只有绑定操作,没有创建用户操作
     */
    @PostMapping("/bind/account")
    @ApiOperation(value = "绑定用户账号")
    public Result<?> bindAccount(@Valid @RequestBody SocialBindAccountDTO socialBind) {
        // 查询用户是否存在,如果存在,那么直接调用绑定接口
        Result<?> result = userFeign.queryUserByAccount(socialBind.getUsername());
        // 判断返回信息
        if (null != result && result.isSuccess() && null != result.getData()) {
            
            GitEggUser gitEggUser = BeanUtil.copyProperties(result.getData(), GitEggUser.class);
            // 必须添加次数验证,和登录一样,超过最大验证次数那么直接锁定账户
            // 从Redis获取账号密码错误次数
            Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get();
            // 判断账号密码输入错误几次,如果输入错误多次,则锁定账号
            if(null != lockTimes && (int)lockTimes >= maxTryTimes){
                throw new BusinessException("密码尝试次数过多,请使用其他方式绑定");
            }
    
            PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
            String password = AuthConstant.BCRYPT + gitEggUser.getAccount() +  DigestUtils.md5DigestAsHex(socialBind.getPassword().getBytes());
            // 验证账号密码是否正确
            if ( passwordEncoder.matches(password, gitEggUser.getPassword()))
            {
                // 解密前端传来的socialId
                DES des = new DES(Mode.CTS, Padding.PKCS5Padding, secretKey.getBytes(), secretKeySalt.getBytes());
                String desSocialKey = des.decryptStr(socialBind.getSocialKey());
                // 将socialKey放入缓存,默认有效期2个小时,如果2个小时未完成验证,那么操作失效,重新获取,在system:socialLoginExpiration配置
                String desSocialId = (String)redisTemplate.opsForValue().get(AuthConstant.SOCIAL_VALIDATION_PREFIX + desSocialKey);
          
                // 执行绑定操作
                return justAuthFeign.userBind(Long.valueOf(desSocialId), gitEggUser.getId());
            }
            else
            {
                // 增加锁定次数
                redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).increment(GitEggConstant.Number.ONE);
                redisTemplate.expire(AuthConstant.LOCK_ACCOUNT_PREFIX +gitEggUser.getId(), maxLockTime , TimeUnit.SECONDS);
                throw new BusinessException("账号或密码错误");
            }
        }
        else
        {
            throw new BusinessException("账号不存在");
        }
    }

}
5. 所有的配置和绑定注册功能实现之后,我们还需要实现关键的一步,就是自定义实现OAuth2的第三方登录模式SocialTokenGranter,在第三方授权之后,通过此模式进行登录,自定义实现之后,记得t_oauth_client_details表需增加social授权。

SocialTokenGranter.java

/**
 * 第三方登录模式
 * @author GitEgg
 */
public class SocialTokenGranter extends AbstractTokenGranter {

    private static final String GRANT_TYPE = "social";

    private final AuthenticationManager authenticationManager;

    private UserDetailsService userDetailsService;
    
    private IJustAuthFeign justAuthFeign;

    private RedisTemplate redisTemplate;

    private String captchaType;
    
    private String secretKey;
    
    private String secretKeySalt;

    public SocialTokenGranter(AuthenticationManager authenticationManager,
                              AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService,
                              OAuth2RequestFactory requestFactory, RedisTemplate redisTemplate, IJustAuthFeign justAuthFeign,
                              UserDetailsService userDetailsService, String captchaType, String secretKey, String secretKeySalt) {
        this(authenticationManager, tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
        this.redisTemplate = redisTemplate;
        this.captchaType = captchaType;
        this.secretKey = secretKey;
        this.secretKeySalt = secretKeySalt;
        this.justAuthFeign = justAuthFeign;
        this.userDetailsService = userDetailsService;
    }

    protected SocialTokenGranter(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 socialKey = parameters.get(TokenConstant.SOCIAL_KEY);
        // Protect from downstream leaks of password
        parameters.remove(TokenConstant.SOCIAL_KEY);
    
        // 校验socialId
        String socialId;
        try {
            // 将socialId进行加密返回
            DES des = new DES(Mode.CTS, Padding.PKCS5Padding, secretKey.getBytes(), secretKeySalt.getBytes());
            String desSocialKey = des.decryptStr(socialKey);
            // 获取缓存中的key
            socialId = (String) redisTemplate.opsForValue().get(AuthConstant.SOCIAL_VALIDATION_PREFIX + desSocialKey);
        }
        catch (Exception e)
        {
            throw new InvalidGrantException("第三方登录验证已失效,请返回登录页重新操作");
        }
        
        if (StringUtils.isEmpty(socialId))
        {
            throw new InvalidGrantException("第三方登录验证已失效,请返回登录页重新操作");
        }
    
        // 校验userId
        String userId;
        try {
            Result<Object> socialResult = justAuthFeign.userBindQuery(Long.parseLong(socialId));
            if (null == socialResult || StringUtils.isEmpty(socialResult.getData())) {
                throw new InvalidGrantException("操作失败,请返回登录页重新操作");
            }
            userId = (String) socialResult.getData();
        }
        catch (Exception e)
        {
            throw new InvalidGrantException("操作失败,请返回登录页重新操作");
        }
    
        if (StringUtils.isEmpty(userId))
        {
            throw new InvalidGrantException("操作失败,请返回登录页重新操作");
        }
        
        // 这里是通过用户id查询用户信息
        UserDetails userDetails = this.userDetailsService.loadUserByUsername(userId);

        Authentication userAuth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        ((AbstractAuthenticationToken)userAuth).setDetails(parameters);

        OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);
        return new OAuth2Authentication(storedOAuth2Request, userAuth);
    }

}
6. 后台处理完成之后,前端VUE也需要做回调处理

  因为是前后端分离的项目,我们这里需要将第三方回调接口配置在vue页面,前端页面根据账户信息判断是直接登录还是进行绑定或者注册等操作。新建SocialCallback.vue用于处理前端第三方登录授权后的回调操作。
SocialCallback.vue

<template>
  <div>
  </div>
</template>
<script>
import { socialLoginCallback } from '@/api/login'
import { mapActions } from 'vuex'
export default {
  name: 'SocialCallback',
  created () {
    this.$loading.show({ tip: '登录中......' })
    const query = this.$route.query
    const socialType = this.$route.params.socialType
    this.socialCallback(socialType, query)
  },
  methods: {
    ...mapActions(['Login']),
    getUrlKey: function (name) {
       // eslint-disable-next-line no-sparse-arrays
       return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(window.opener.location.href) || [, ''])[1].replace(/\+/g, '%20')) || null
    },
    socialCallback (socialType, parameter) {
      const that = this
      socialLoginCallback(socialType, parameter).then(res => {
        that.$loading.hide()
        const bindResult = res.data
        if (bindResult && bindResult !== '') {
          if (bindResult.success && bindResult.data) {
            // 授权后发现已绑定,那么直接调用第三方登录
            this.socialLogin(bindResult.data)
          } else if (bindResult.code === 601) {
            // 授权后没有绑定则跳转到绑定界面
            that.$router.push({ name: 'socialBind', query: { redirect: this.getUrlKey('redirect'), key: bindResult.data } })
          } else if (bindResult.code === 602) {
            // 该账号已绑定多个账号,请联系系统管理员,或者到个人中心解绑
            this.$notification['error']({
              message: '错误',
              description: ((res.response || {}).data || {}).message || '该账号已绑定多个账号,请联系系统管理员,或者到个人中心解绑',
              duration: 4
            })
          } else {
            // 提示获取第三方登录失败
            this.$notification['error']({
              message: '错误',
              description: '第三方登录失败,请稍后再试',
              duration: 4
            })
          }
        } else {
          // 提示获取第三方登录失败
            this.$notification['error']({
              message: '错误',
              description: '第三方登录失败,请稍后再试',
              duration: 4
            })
        }
      })
    },
    // 第三方登录后回调
    socialLogin (key) {
      const { Login } = this
      // 执行登录操作
      const loginParams = {
        grant_type: 'social',
        social_key: key
      }
      this.$loading.show({ tip: '登录中......' })
      Login(loginParams)
        .then((res) => this.loginSuccess(res))
        .catch(err => this.loginError(err))
        .finally(() => {
           this.$loading.hide()
           if (this.getUrlKey('redirect')) {
              window.opener.location.href = window.opener.location.origin + this.getUrlKey('redirect')
           } else {
              window.opener.location.reload()
           }
           window.close()
       })
    },
    loginSuccess (res) {
      this.$notification['success']({
         message: '提示',
         description: '第三方登录成功',
         duration: 4
      })
    },
    loginError (err) {
      this.$notification['error']({
        message: '错误',
        description: ((err.response || {}).data || {}).message || '请求出现错误,请稍后再试',
        duration: 4
      })
    }
  }
}
</script>
<style>
</style>

二、登录和绑定测试

JustAuth官方提供了详细的第三方登录的使用指南,按照其介绍,到需要的第三方网站申请,然后进行配置即可,这里只展示GitHub的登录测试步骤。
1、按照官方提供的注册申请步骤,获取到GitHub的client-id和client-secret并配置回调地址redirect-uri

image.png

  • Nacos配置
      client-id: 59ced49784f3cebfb208
      client-secret: xxxxxxxxxxxxxxxxxxxxxxxx
      redirect-uri: http://192.168.0.2:8000/social/github/callback
      ignore-check-state: false
  • 或者使用多租户系统配置 ,每个租户仅允许有一个主配置

image.png

image.png

2、登录页添加Github登录链接

      <div class="user-login-other">
        <span>{{ $t('user.login.sign-in-with') }}</span>
        <a @click="openSocialLogin('wechat_open')">
          <a-icon class="item-icon"
                  type="wechat"></a-icon>
        </a>
        <a @click="openSocialLogin('qq')">
          <a-icon class="item-icon"
                  type="qq"></a-icon>
        </a>
        <a @click="openSocialLogin('github')">
          <a-icon class="item-icon"
                  type="github"></a-icon>
        </a>
        <a @click="openSocialLogin('dingtalk')">
          <a-icon class="item-icon"
                  type="dingding"></a-icon>
        </a>
        <a class="register"
           @click="openRegister"
        >{{ $t('user.login.signup') }}
        </a>
      </div>

登录页

3、点击登录,如果此时GitHub账号没有登录过,则跳转到绑定或者注册账号界面

image.png

4、输入手机号+验证码或者账号+密码,即可进入到登录前的页面。使用手机号+验证码的模式,如果系统不存在账号,可以直接注册新账号并登录。
5、JustAuth支持的第三方登录列表,只需到相应第三方登录申请即可,下面图片取自JustAuth官网:

image.png

GitEgg-Cloud是一款基于SpringCloud整合搭建的企业级微服务应用开发框架,开源项目地址:

Gitee: https://gitee.com/wmz1930/GitEgg

GitHub: https://github.com/wmz1930/GitEgg

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。