
直接使用桌面钉钉客户端登录系统代码案例importcom.fasterxml.jackson.annotation.JsonIgnoreProperties;importjakarta.servlet.http.HttpSession;importorg.springframework.http.HttpHeaders;importorg.springframework.http.MediaType;importorg.springframework.http.ResponseEntity;importorg.springframework.web.bind.annotation.*;importorg.springframework.web.client.RestClient;importorg.springframework.web.client.RestClientResponseException;importorg.springframework.web.util.UriComponentsBuilder;importjavax.crypto.Mac;importjavax.crypto.spec.SecretKeySpec;importjava.net.URI;importjava.nio.charset.StandardCharsets;importjava.security.MessageDigest;importjava.security.SecureRandom;importjava.time.Instant;importjava.util.Base64;/** * 单文件钉钉登录 Demo复制到任意 Spring Boot Web 项目修改下方常量即可。 * 生产环境请把密钥和系统账号移到环境变量/数据库并接入 Spring Security。 * 后台应用配好登录回调地址重定向URL回调域名 REDIRECT_URI */ RestController public class DingTalkLoginController{//只需修改这里private static final String CLIENT_IDdinxxxxxxxxxxxx;private static final String CLIENT_SECRETdinxxxxxxxxxxxx-dinxxxxxxxxxxxx;private static final String REDIRECT_URIhttp://xxxx.cn:28088/login/oauth2/code/dingtalk;private static final String SCOPEopenid;// 仅登录不申请 corpid避免选择企业 private static final String SYSTEM_USERNAMEadmin;private static final String SYSTEM_PASSWORD123456;//private static final String SESSION_USERdemo.login.user;private static final long STATE_TTL_SECONDS600;private final SecureRandom randomnew SecureRandom();private final RestClient dingTalkRestClient.create(https://api.dingtalk.com);GetMapping(value/, producesMediaType.TEXT_HTML_VALUE)public ResponseEntityStringloginPage(HttpSession session, RequestParam(requiredfalse)String error){if(session.getAttribute(SESSION_USER)!null){returnredirect(/system);}returnhtml(LOGIN_HTML.formatted(errornull ?:div classerror escape(error)/div, escape(SYSTEM_USERNAME), escape(SYSTEM_PASSWORD)));}PostMapping(/login/account)public ResponseEntityVoidaccountLogin(RequestParam String username, RequestParam String password, HttpSession session){if(!SYSTEM_USERNAME.equals(username)||!SYSTEM_PASSWORD.equals(password)){returnredirect(/?error encode(账号或密码错误));}session.setAttribute(SESSION_USER, new LoginUser(username, null,系统账号));session.setMaxInactiveInterval(30*60);returnredirect(/system);}GetMapping(/login/dingtalk)public ResponseEntityVoiddingTalkLogin(){URI uriUriComponentsBuilder.fromUriString(https://login.dingtalk.com/oauth2/auth).queryParam(client_id, CLIENT_ID).queryParam(redirect_uri, REDIRECT_URI).queryParam(response_type,code).queryParam(prompt,consent).queryParam(scope, SCOPE).queryParam(state, createState()).build().encode().toUri();returnredirect(uri.toString());}GetMapping(value/login/oauth2/code/dingtalk, producesMediaType.TEXT_HTML_VALUE)public ResponseEntityStringdingTalkCallback(RequestParam(requiredfalse)String authCode, RequestParam(requiredfalse)String state, RequestParam(requiredfalse)String error, HttpSession session){if(error!null)returnerrorPage(未完成钉钉授权, error);if(!validState(state))returnerrorPage(登录请求已失效,请返回登录页重新登录);if(authCodenull||authCode.isBlank())returnerrorPage(缺少授权码,请重新登录);try{Token tokendingTalk.post().uri(/v1.0/oauth2/userAccessToken).contentType(MediaType.APPLICATION_JSON).body(new TokenRequest(CLIENT_ID, CLIENT_SECRET, authCode,authorization_code)).retrieve().body(Token.class);if(tokennull||token.accessToken()null){returnerrorPage(钉钉登录失败,钉钉没有返回 Access Token);}DingUser userdingTalk.get().uri(/v1.0/contact/users/me).header(x-acs-dingtalk-access-token, token.accessToken()).retrieve().body(DingUser.class);if(usernull)returnerrorPage(钉钉登录失败,钉钉没有返回用户信息);session.setAttribute(SESSION_USER, new LoginUser(user.nick(), user.avatarUrl(),钉钉 OAuth));session.setMaxInactiveInterval(30*60);returnredirect(/system);}catch(RestClientResponseException ex){returnerrorPage(钉钉登录失败,接口返回 HTTP ex.getStatusCode().value()请检查应用权限);}}GetMapping(value/system, producesMediaType.TEXT_HTML_VALUE)public ResponseEntityStringsystem(HttpSession session){LoginUser user(LoginUser)session.getAttribute(SESSION_USER);if(usernull)returnredirect(/);String avataruser.avatarUrl()null ?div classavatar fallback escape(user.name().substring(0,1))/div:img classavatar src escape(user.avatarUrl()) alt头像;returnhtml(SYSTEM_HTML.formatted(avatar, escape(user.name()), escape(user.loginType())));}GetMapping(/logout)public ResponseEntityVoidlogout(HttpSession session){session.invalidate();returnredirect(/);}private StringcreateState(){byte[]bytesnew byte[24];random.nextBytes(bytes);String payloadInstant.now().getEpochSecond(). Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);returnpayload . sign(payload);}private boolean validState(String state){try{int lastDotstate.lastIndexOf(.);String payloadstate.substring(0, lastDot);byte[]expectedBase64.getUrlDecoder().decode(sign(payload));byte[]actualBase64.getUrlDecoder().decode(state.substring(lastDot 1));long issuedAtLong.parseLong(payload.substring(0, payload.indexOf(.)));long ageInstant.now().getEpochSecond()- issuedAt;returnMessageDigest.isEqual(expected, actual)age-30ageSTATE_TTL_SECONDS;}catch(Exception ex){returnfalse;}}private String sign(String value){try{Mac macMac.getInstance(HmacSHA256);mac.init(new SecretKeySpec(CLIENT_SECRET.getBytes(StandardCharsets.UTF_8),HmacSHA256));returnBase64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException(ex);}}private ResponseEntityStringerrorPage(String title, String message){returnhtml(ERROR_HTML.formatted(escape(title), escape(message)));}private staticTResponseEntityTredirect(String location){returnResponseEntity.status(302).header(HttpHeaders.LOCATION, location).build();}private static ResponseEntityStringhtml(String body){returnResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(body);}private static String encode(String value){returnjava.net.URLEncoder.encode(value, StandardCharsets.UTF_8);}private static String escape(String value){if(valuenull)return;returnvalue.replace(,amp;).replace(,lt;).replace(,gt;).replace(\,quot;).replace(,#39;);}private record LoginUser(String name, String avatarUrl, String loginType){}private record TokenRequest(String clientId, String clientSecret, String code, String grantType){}JsonIgnoreProperties(ignoreUnknowntrue)private record Token(String accessToken){}JsonIgnoreProperties(ignoreUnknowntrue)private record DingUser(String nick, String avatarUrl){}private static final String STYLEstyle*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;font-family:Inter,Microsoft YaHei,sans-serif;color:#17233d;background:#eef3fa}.shell{width:min(960px,100%);min-height:610px;display:grid;grid-template-columns:42% 58%;overflow:hidden;border-radius:24px;background:#fff;box-shadow:0 28px 80px #1f417029}.brand{display:flex;flex-direction:column;justify-content:space-between;padding:52px;color:#fff;background:linear-gradient(145deg,#075de7,#168bff)}.brand h1{font-size:40px}.brand p{color:#ffffffcc}.panel{display:grid;place-items:center;padding:50px}.content{width:min(390px,100%)}h2{font-size:30px;margin:0 0 8px}.sub,.muted{color:#758198}.form{display:flex;flex-direction:column}.form label{margin:15px 0 8px;font-weight:700;font-size:14px}.form input{padding:14px;border:1px solid #d8dee9;border-radius:10px;font-size:15px;outline:none}.form input:focus{border-color:#1677ff;box-shadow:0 0 0 3px #1677ff1f}.btn{display:block;width:100%;margin-top:24px;padding:14px;border:0;border-radius:10px;color:#fff;background:#1677ff;text-align:center;text-decoration:none;font-size:16px;font-weight:700;cursor:pointer}.demo{display:flex;justify-content:space-between;margin-top:14px;padding:11px;border-radius:8px;background:#f6f8fb;font-size:13px}.or{display:flex;align-items:center;gap:12px;margin:24px 0;color:#98a2b3}.or:before,.or:after{content:;flex:1;height:1px;background:#e4e7ec}.ding{display:flex;align-items:center;gap:13px;padding:14px;border:1px solid #d8e5fb;border-radius:12px;color:#17233d;text-decoration:none}.ding i{width:40px;height:40px;display:grid;place-items:center;border-radius:10px;color:#fff;background:#1677ff;font-style:normal}.ding small{display:block;margin-top:4px;color:#8792a5}.error{margin:18px 0;padding:12px;border-radius:8px;color:#a61b1b;background:#fff0f0}.card{width:min(680px,100%);padding:44px;border-radius:20px;background:#fff;box-shadow:0 18px 55px #1837691f}.avatar{width:76px;height:76px;border-radius:50%;object-fit:cover}.fallback{display:grid;place-items:center;color:#fff;background:#1677ff;font-size:28px}.row{display:flex;align-items:center;gap:18px}.error-card{text-align:center}media(max-width:720px){.shell{grid-template-columns:1fr;min-height:auto}.brand{display:none}.panel{padding:38px 24px}}/style.replace(%,%%);private static final String LOGIN_HTML!doctype htmlhtmllangzh-CNheadmetacharsetUTF-8metanameviewportcontentwidthdevice-width,initial-scale1title登录 · Hello Ding/title%s/headbodymainclassshellsectionclassbrandbHELLO DING/bdivh1欢迎回来/h1p登录你的系统继续处理今天的工作。/p/divsmall安全登录 · 单文件 Demo/small/sectionsectionclasspaneldivclasscontenth2账号登录/h2pclasssub请输入系统账号和密码/p%sformclassformmethodpostaction/login/accountlabel账号/labelinputnameusernameautocompleteusernamerequired autofocuslabel密码/labelinputnamepasswordtypepasswordautocompletecurrent-passwordrequiredbuttonclassbtn登录系统/button/formdivclassdemospanDemo 账号/spancode%s / %s/code/divdivclassor或/divaclassdinghref/login/dingtalki➤/ispanb使用钉钉登录/bsmall已登录桌面钉钉时可快速确认/small/span/a/div/section/main/body/html.formatted(STYLE,%s,%s,%s);private static final String SYSTEM_HTML!doctype htmlhtmllangzh-CNheadmetacharsetUTF-8metanameviewportcontentwidthdevice-width,initial-scale1title我的系统/title%s/headbodymainclasscarddivclassrow%sdivh2欢迎回来%s/h2pclassmuted认证方式%s/p/div/divaclassbtnhref/logout退出登录/a/main/body/html.formatted(STYLE,%s,%s,%s);private static final String ERROR_HTML!doctype htmlhtmllangzh-CNheadmetacharsetUTF-8metanameviewportcontentwidthdevice-width,initial-scale1title登录失败/title%s/headbodymainclasscard error-cardh2%s/h2pclassmuted%s/paclassbtnhref/返回登录页/a/main/body/html.formatted(STYLE,%s,%s);}