直接使用桌面钉钉客户端登录系统:
代码案例:
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_ID="dinxxxxxxxxxxxx";private static final String CLIENT_SECRET="dinxxxxxxxxxxxx-dinxxxxxxxxxxxx";private static final String REDIRECT_URI="http://xxxx.cn:28088/login/oauth2/code/dingtalk";private static final String SCOPE="openid";// 仅登录,不申请 corpid,避免选择企业 private static final String SYSTEM_USERNAME="admin";private static final String SYSTEM_PASSWORD="123456";//========================private static final String SESSION_USER="demo.login.user";private static final long STATE_TTL_SECONDS=600;private final SecureRandom random=new SecureRandom();private final RestClient dingTalk=RestClient.create("https://api.dingtalk.com");@GetMapping(value="/", produces=MediaType.TEXT_HTML_VALUE)public ResponseEntity<String>loginPage(HttpSession session, @RequestParam(required=false)String error){if(session.getAttribute(SESSION_USER)!=null){returnredirect("/system");}returnhtml(LOGIN_HTML.formatted(error==null ?"":"<div class='error'>"+ escape(error)+"</div>", escape(SYSTEM_USERNAME), escape(SYSTEM_PASSWORD)));}@PostMapping("/login/account")public ResponseEntity<Void>accountLogin(@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 ResponseEntity<Void>dingTalkLogin(){URI uri=UriComponentsBuilder.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", produces=MediaType.TEXT_HTML_VALUE)public ResponseEntity<String>dingTalkCallback(@RequestParam(required=false)String authCode, @RequestParam(required=false)String state, @RequestParam(required=false)String error, HttpSession session){if(error!=null)returnerrorPage("未完成钉钉授权", error);if(!validState(state))returnerrorPage("登录请求已失效","请返回登录页重新登录");if(authCode==null||authCode.isBlank())returnerrorPage("缺少授权码","请重新登录");try{Token token=dingTalk.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(token==null||token.accessToken()==null){returnerrorPage("钉钉登录失败","钉钉没有返回 Access Token");}DingUser user=dingTalk.get().uri("/v1.0/contact/users/me").header("x-acs-dingtalk-access-token", token.accessToken()).retrieve().body(DingUser.class);if(user==null)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", produces=MediaType.TEXT_HTML_VALUE)public ResponseEntity<String>system(HttpSession session){LoginUser user=(LoginUser)session.getAttribute(SESSION_USER);if(user==null)returnredirect("/");String avatar=user.avatarUrl()==null ?"<div class='avatar fallback'>"+ escape(user.name().substring(0,1))+"</div>":"<img class='avatar' src='"+ escape(user.avatarUrl())+"' alt='头像'>";returnhtml(SYSTEM_HTML.formatted(avatar, escape(user.name()), escape(user.loginType())));}@GetMapping("/logout")public ResponseEntity<Void>logout(HttpSession session){session.invalidate();returnredirect("/");}private StringcreateState(){byte[]bytes=new byte[24];random.nextBytes(bytes);String payload=Instant.now().getEpochSecond()+"."+ Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);returnpayload +"."+ sign(payload);}private boolean validState(String state){try{int lastDot=state.lastIndexOf('.');String payload=state.substring(0, lastDot);byte[]expected=Base64.getUrlDecoder().decode(sign(payload));byte[]actual=Base64.getUrlDecoder().decode(state.substring(lastDot +1));long issuedAt=Long.parseLong(payload.substring(0, payload.indexOf('.')));long age=Instant.now().getEpochSecond()- issuedAt;returnMessageDigest.isEqual(expected, actual)&&age>=-30&&age<=STATE_TTL_SECONDS;}catch(Exception ex){returnfalse;}}private String sign(String value){try{Mac mac=Mac.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 ResponseEntity<String>errorPage(String title, String message){returnhtml(ERROR_HTML.formatted(escape(title), escape(message)));}private static<T>ResponseEntity<T>redirect(String location){returnResponseEntity.status(302).header(HttpHeaders.LOCATION, location).build();}private static ResponseEntity<String>html(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(value==null)return"";returnvalue.replace("&","&").replace("<","<").replace(">",">").replace("\"",""").replace("'","'");}private record LoginUser(String name, String avatarUrl, String loginType){}private record TokenRequest(String clientId, String clientSecret, String code, String grantType){}@JsonIgnoreProperties(ignoreUnknown=true)private record Token(String accessToken){}@JsonIgnoreProperties(ignoreUnknown=true)private record DingUser(String nick, String avatarUrl){}private static final String STYLE="""<style>*{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 html><htmllang='zh-CN'><head><metacharset='UTF-8'><metaname='viewport'content='width=device-width,initial-scale=1'><title>登录 · Hello Ding</title>%s</head><body><mainclass='shell'><sectionclass='brand'><b>HELLO DING</b><div><h1>欢迎回来</h1><p>登录你的系统,继续处理今天的工作。</p></div><small>安全登录 · 单文件 Demo</small></section><sectionclass='panel'><divclass='content'><h2>账号登录</h2><pclass='sub'>请输入系统账号和密码</p>%s<formclass='form'method='post'action='/login/account'><label>账号</label><inputname='username'autocomplete='username'required autofocus><label>密码</label><inputname='password'type='password'autocomplete='current-password'required><buttonclass='btn'>登录系统</button></form><divclass='demo'><span>Demo 账号</span><code>%s / %s</code></div><divclass='or'>或</div><aclass='ding'href='/login/dingtalk'><i>➤</i><span><b>使用钉钉登录</b><small>已登录桌面钉钉时可快速确认</small></span></a></div></section></main></body></html>""".formatted(STYLE,"%s","%s","%s");private static final String SYSTEM_HTML="""<!doctype html><htmllang='zh-CN'><head><metacharset='UTF-8'><metaname='viewport'content='width=device-width,initial-scale=1'><title>我的系统</title>%s</head><body><mainclass='card'><divclass='row'>%s<div><h2>欢迎回来,%s</h2><pclass='muted'>认证方式:%s</p></div></div><aclass='btn'href='/logout'>退出登录</a></main></body></html>""".formatted(STYLE,"%s","%s","%s");private static final String ERROR_HTML="""<!doctype html><htmllang='zh-CN'><head><metacharset='UTF-8'><metaname='viewport'content='width=device-width,initial-scale=1'><title>登录失败</title>%s</head><body><mainclass='card error-card'><h2>%s</h2><pclass='muted'>%s</p><aclass='btn'href='/'>返回登录页</a></main></body></html>""".formatted(STYLE,"%s","%s");}