若依整合釘釘掃碼登入

2022-06-14 21:00:28

準備

釘釘檔案地址:https://open.dingtalk.com/document/orgapp-server/scan-qr-code-to-log-on-to-third-party-websites

這個是歷史版本的檔案,最新版本的測試不穩定,經常出現系統繁忙。

按照釘釘檔案做好前期準備,這裡只說明若依框架程式碼的調整。

前端

頁面

修改登陸頁面src/views/login.vue,增加釘釘登入按鈕


<el-form-item style="width:100%;">
    <el-button size="medium" type="primary" style="width:100%;" @click.native.prevent="ddLogin">
        <span>掃碼登入</span>
    </el-button>
</el-form-item>


    ddLogin() {
      window.location.href = "https://oapi.dingtalk.com/connect/qrconnect?appid=your appid&response_type=code&scope=snsapi_login&state=STATE&redirect_uri=your redirect_uri"
    }

新建頁面src/views/sso.vue

<script>

export default {
    data() {
        return {
            code: '',
            state: ''
        }
    },
    created() {
        const { params, query } = this.$route
        this.code = query.code
        this.state = query.state
        // 釘釘
        this.$store.dispatch("SSO", { code: this.code, state: this.state }).then(() => {
            this.$router.push({ path: this.redirect || "/" }).catch(() => { });
        }).catch(() => {
            this.$message.error("系統異常,請稍後再試!");
        });
    },
    render: function (h) {
        return h() // avoid warning message
    }
}
</script>

修改路由檔案src/router/index.js增加路由

// 公共路由
export const constantRoutes = [
  .....
  {
    path: '/sso',
    component: () => import('@/views/sso'),
    hidden: true
  },
   ..... 
]

修改src/permission.js,設定白名單

const whiteList = ['/login', '/auth-redirect', '/bind', '/register','/sso']

介面

新建src/api/sso.js

import request from '@/utils/request'

// 登入方法
export function sso(code,state) {
  const data = {
    code,
    state
  }
  return request({
    url: '/sso',
    headers: {
      isToken: false
    },
    method: 'post',
    data: data
  })
}

修改src/store/modules/user.js actions增加釘釘登入

//SSO
SSO({ commit }, info) { 
    const code = info.code
    const state = info.state
    return new Promise((resolve, reject) => {
        sso(code, state).then(res => {
            console.log('user.js sso')
            console.log(res)
            setToken(res.token)
            commit('SET_TOKEN', res.token)
            resolve()
        }).catch(error => {
            reject(error)
        })
    })
}

後端

因為我對專案目錄結構進行了調整,這裡就不說明放在那個包下,大家根據自己情況使用即可。

Controller

新建SSOController

@RestController
public class SSOController {

    @Resource
    private ISsoService ssoService;
    /**
     * 登入方法
     *
     * @param ssoBody 登入資訊
     * @return 結果
     */
    @PostMapping("/sso")
    public AjaxResult sso(@RequestBody SSOBody ssoBody) throws ApiException {
        AjaxResult ajax = AjaxResult.success();
        // 生成令牌
        String token = ssoService.login(ssoBody.getCode(), ssoBody.getState(), ssoBody.getType());
        ajax.put(Constants.TOKEN, token);
        return ajax;
    }
}

新建SSOBody


/**
 * sso登入物件
 */
public class SSOBody {
    /**
     * 編碼
     */
    private String code;

    /**
     * 狀態碼 可以用來判斷是哪個系統
     */
    private String state;


    /**
     * 登入系統 後面可以換成列舉
     */
    private String type;

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getState() {
        return state;
    }
    public void setState(String state) {
        this.state = state;
    }

    public String getType() {
        return type;
    }

    public void setType(String type) {
        this.type = type;
    }
}

Service

新建ISsoService

public interface ISsoService {

    /**
     * sso登入
     * @param code 登入碼
     * @param state 狀態碼
     * @param type 登入系統 後面可以換成列舉
     * @return token
     */
    public String login(String code,String state,String type) throws ApiException;

    /**
     * 根據手機號獲取使用者
     * @param phonenumber 手機號
     * @return 使用者
     */
    public UserDetails loadUserByPhonenumber(String phonenumber);
}

新建SsoServiceImpl

登入方法對應釘釘介面檔案:https://open.dingtalk.com/document/orgapp-server/use-dingtalk-account-to-log-on-to-third-party-websites

@Service
public class SsoServiceImpl implements ISsoService {

    private static final Logger log = LoggerFactory.getLogger(SsoServiceImpl.class);

    @Autowired
    private ISysUserService userService;

    @Autowired
    private SysPermissionService permissionService;

    @Resource
    private AuthenticationManager authenticationManager;

    @Resource
    private TokenService tokenService;

    /**
     * sso登入
     *
     * @param code  登入碼
     * @param state 狀態碼
     * @param type  登入系統 後面可以換成列舉
     * @return token
     */
    @Override
    public String login(String code, String state, String type) throws ApiException {
        //if type = xxx ....如果多種驗證方式

        // 獲取access_token
        String access_token = AccessTokenUtil.getToken();

        // 通過臨時授權碼獲取授權使用者的個人資訊
        DefaultDingTalkClient client2 = new DefaultDingTalkClient("https://oapi.dingtalk.com/sns/getuserinfo_bycode");
        OapiSnsGetuserinfoBycodeRequest reqBycodeRequest = new OapiSnsGetuserinfoBycodeRequest();
        // 通過掃描二維條碼,跳轉指定的redirect_uri後,向url中追加的code臨時授權碼
        reqBycodeRequest.setTmpAuthCode(code);
        OapiSnsGetuserinfoBycodeResponse response = client.execute(reqBycodeRequest, "yourAppKey", "yourAppSecret");

        // 根據unionid獲取userid
        String unionid = bycodeResponse.getUserInfo().getUnionid();
        DingTalkClient clientDingTalkClient = new DefaultDingTalkClient("https://oapi.dingtalk.com/topapi/user/getbyunionid");
        OapiUserGetbyunionidRequest reqGetbyunionidRequest = new OapiUserGetbyunionidRequest();
        reqGetbyunionidRequest.setUnionid(unionid);
        OapiUserGetbyunionidResponse oapiUserGetbyunionidResponse = clientDingTalkClient.execute(reqGetbyunionidRequest, access_token);

        // 根據userId獲取使用者資訊
        String userid = oapiUserGetbyunionidResponse.getResult().getUserid();
        DingTalkClient clientDingTalkClient2 = new DefaultDingTalkClient("https://oapi.dingtalk.com/topapi/v2/user/get");
        OapiV2UserGetRequest reqGetRequest = new OapiV2UserGetRequest();
        reqGetRequest.setUserid(userid);
        reqGetRequest.setLanguage("zh_CN");
        OapiV2UserGetResponse rspGetResponse = clientDingTalkClient2.execute(reqGetRequest, access_token);

        // 使用者驗證
        Authentication authentication = null;
        authentication = authenticationManager.authenticate(new DingDingAuthenticationToken(rspGetResponse.getResult().getMobile()));

        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(loginUser.getUsername(), Constants.LOGIN_SUCCESS, MessageUtils.message("user.dd.login.success")));

        recordLoginInfo(loginUser.getUserId());
        // 生成token
        return tokenService.createToken(loginUser);
    }

    /**
     * 根據手機號獲取使用者
     *
     * @param phonenumber 手機號
     * @return 使用者
     */
    @Override
    public UserDetails loadUserByPhonenumber(String phonenumber) {
        {
            SysUser user = userService.selectUserByPhonenumber(phonenumber);
            if (StringUtils.isNull(user)) {
                log.info("sso登入使用者:{} 不存在.", phonenumber);
                throw new ServiceException("登入使用者不存在");
            }

            return createLoginUser(user);
        }

    }

    public UserDetails createLoginUser(SysUser user) {
        return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
    }

    /**
     * 記錄登入資訊
     *
     * @param userId 使用者ID
     */
    public void recordLoginInfo(Long userId) {
        SysUser sysUser = new SysUser();
        sysUser.setUserId(userId);
        sysUser.setLoginIp(IpUtils.getIpAddr(ServletUtils.getRequest()));
        sysUser.setLoginDate(DateUtils.getNowDate());
        userService.updateUserProfile(sysUser);
    }
}

修改使用者登入

修改ISysUserService增加通過手機號搜尋使用者方法

    /**
     * 通過手機號查詢使用者
     *
     * @param phonenumber 使用者名稱
     * @return 使用者物件資訊
     */
    public SysUser selectUserByPhonenumber(String phonenumber);

實現


    /**
     * 通過手機號查詢使用者
     *
     * @param phonenumber 使用者名稱
     * @return 使用者物件資訊
     */
    @Override
    public SysUser selectUserByPhonenumber(String phonenumber) {
        return userMapper.selectUserByPhonenumber(phonenumber);
    }

Mapper

    /**
     * 通過手機號查詢使用者
     *
     * @param phonenumber 使用者名稱
     * @return 使用者物件資訊
     */
    public SysUser selectUserByPhonenumber(String phonenumber);
	<select id="selectUserByPhonenumber" parameterType="String"  resultMap="SysUserResult">
		<include refid="selectUserVo"/>
		where u.phonenumber = #{phonenumber}
	</select>

重點Spring Security

這裡參考文章為:https://blog.csdn.net/dnf9906/article/details/113571941

SecurityConfig設定(framework/config/SecurityConfig.java)


/**
 * spring security設定
 * 
 * @author jelly
 */
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
    /**
     * 自定義使用者認證邏輯
     */
    @Autowired
    private UserDetailsService userDetailsService;
    /** 釘釘 認證器*/
    @Autowired
    private DingDingAuthenticationProvider dingDingAuthenticationProvider;
    
    /**
     * 認證失敗處理類
     */
    @Autowired
    private AuthenticationEntryPointImpl unauthorizedHandler;

    /**
     * 退出處理類
     */
    @Autowired
    private LogoutSuccessHandlerImpl logoutSuccessHandler;

    /**
     * token認證過濾器
     */
    @Autowired
    private JwtAuthenticationTokenFilter authenticationTokenFilter;
    
    /**
     * 跨域過濾器
     */
    @Autowired
    private CorsFilter corsFilter;
    
    /**
     * 解決 無法直接注入 AuthenticationManager
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception
    {
        return super.authenticationManagerBean();
    }

    /**
     * anyRequest          |   匹配所有請求路徑
     * access              |   SpringEl表示式結果為true時可以存取
     * anonymous           |   匿名可以存取
     * denyAll             |   使用者不能存取
     * fullyAuthenticated  |   使用者完全認證可以存取(非remember-me下自動登入)
     * hasAnyAuthority     |   如果有引數,參數列示許可權,則其中任何一個許可權可以存取
     * hasAnyRole          |   如果有引數,參數列示角色,則其中任何一個角色可以存取
     * hasAuthority        |   如果有引數,參數列示許可權,則其許可權可以存取
     * hasIpAddress        |   如果有引數,參數列示IP地址,如果使用者IP和引數匹配,則可以存取
     * hasRole             |   如果有引數,參數列示角色,則其角色可以存取
     * permitAll           |   使用者可以任意存取
     * rememberMe          |   允許通過remember-me登入的使用者存取
     * authenticated       |   使用者登入後可存取
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception
    {
        httpSecurity
                // CSRF禁用,因為不使用session
                .csrf().disable()
                // 認證失敗處理類
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                // 基於token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 過濾請求
                .authorizeRequests()
                // 使用 permitAll() 方法所有人都能存取,包括帶上 token 存取
                // 使用 anonymous() 所有人都能存取,但是帶上 token 存取後會報錯
                // 對於登入login 註冊register 驗證碼captchaImage 允許匿名存取
                .antMatchers("/login", "/register", "/captchaImage").anonymous()
                .antMatchers("/sso").anonymous()
                .antMatchers(
                        HttpMethod.GET,
                        "/",
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js",
                        "/profile/**"
                ).permitAll()
                .antMatchers("/swagger-ui.html").anonymous()
                .antMatchers("/swagger-resources/**").anonymous()
                .antMatchers("/webjars/**").anonymous()
                .antMatchers("/*/api-docs").anonymous()
                .antMatchers("/druid/**").anonymous()
                // 除上面外的所有請求全部需要鑑權認證
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
        // 新增JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 新增CORS filter
        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
    }

    /**
     * 強雜湊雜湊加密實現
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder()
    {
        return new BCryptPasswordEncoder();
    }

    /**
     * 身份認證介面
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception
    {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
        // 新增 釘釘
        auth.authenticationProvider(dingDingAuthenticationProvider);
    }
}


主要修改了兩個地方

.antMatchers("/sso").anonymous()
  // 新增 釘釘
auth.authenticationProvider(dingDingAuthenticationProvider);

新建Provider

新建DingDingAuthenticationProvider



/**
 * 釘釘登入
 */
@Component
public class DingDingAuthenticationProvider implements AuthenticationProvider {

    /** 釘釘登入驗證服務 */
    @Autowired
    private ISsoService ssoService;

    /**
     * 進行認證
     *
     * @param authentication
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        long time = System.currentTimeMillis();
        System.out.println("釘釘登入驗證");

        String phone = authentication.getName();
        // String rawCode = authentication.getCredentials().toString();

        // 1.根據手機號獲取使用者資訊
        UserDetails userDetails = ssoService.loadUserByPhonenumber(phone);
        if (Objects.isNull(userDetails)) {
            throw new BadCredentialsException("釘釘當前使用者未關聯到系統使用者");
        }
        // 3、返回經過認證的Authentication
        DingDingAuthenticationToken result = new DingDingAuthenticationToken(userDetails, Collections.emptyList());
        result.setDetails(authentication.getDetails());
        System.out.println("釘釘登入驗證完成");
        return result;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        boolean res = DingDingAuthenticationToken.class.isAssignableFrom(authentication);
        System.out.println("釘釘進行登入驗證 res:"+ res);
        return res;
    }
}

新建DingDingAuthenticationToken


public class DingDingAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    // 手機號
    private final Object principal;
    /**
     * 此建構函式用來初始化未授信憑據.
     *
     * @param principal
     */
    public DingDingAuthenticationToken(Object principal) {
        super(null);
        System.out.println("DingDingAuthenticationToken1"+principal.toString());
        this.principal = principal;
        setAuthenticated(false);
    }
    /**
     * 此建構函式用來初始化授信憑據.
     *
     * @param principal
     */
    public DingDingAuthenticationToken(Object principal,Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        System.out.println("DingDingAuthenticationToken2"+principal.toString());
        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) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException(
                    "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }
        super.setAuthenticated(false);
    }

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

測試

資料庫給某個使用者賦值釘釘手機號,掃碼登入。