diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/config/SaSsoConfig.java b/sa-token-core/src/main/java/cn/dev33/satoken/config/SaSsoConfig.java index 219d0c4d..c6992683 100644 --- a/sa-token-core/src/main/java/cn/dev33/satoken/config/SaSsoConfig.java +++ b/sa-token-core/src/main/java/cn/dev33/satoken/config/SaSsoConfig.java @@ -25,23 +25,21 @@ public class SaSsoConfig { public String secretkey; /** - * SSO-Server端授权地址 + * SSO-Server端 单点登录地址 */ - public String serverUrl; + public String authUrl; /** - * @return SSO-Server端授权地址 + * SSO-Server端 Ticket校验地址 [模式三专用配置] */ - public String getServerUrl() { - return serverUrl; - } + public String checkTicketUrl; /** - * @param serverUrl SSO-Server端授权地址 + * SSO-Server端 单点注销地址 [模式三专用配置] */ - public void setServerUrl(String serverUrl) { - this.serverUrl = serverUrl; - } + public String sloUrl; + + /** * @return Ticket有效期 (单位: 秒) @@ -91,13 +89,54 @@ public class SaSsoConfig { return this; } + /** + * @return SSO-Server端 单点登录地址 + */ + public String getAuthUrl() { + return authUrl; + } + + /** + * @param authUrl SSO-Server端 单点登录地址 + */ + public void setAuthUrl(String authUrl) { + this.authUrl = authUrl; + } + + /** + * @return SSO-Server端Ticket校验地址 + */ + public String getCheckTicketUrl() { + return checkTicketUrl; + } + + /** + * @param checkTicketUrl SSO-Server端Ticket校验地址 + */ + public void setCheckTicketUrl(String checkTicketUrl) { + this.checkTicketUrl = checkTicketUrl; + } + + /** + * @return SSO-Server端单点注销地址 + */ + public String getSloUrl() { + return sloUrl; + } + + /** + * @param sloUrl SSO-Server端单点注销地址 + */ + public void setSloUrl(String sloUrl) { + this.sloUrl = sloUrl; + } + @Override public String toString() { return "SaSsoConfig [ticketTimeout=" + ticketTimeout + ", allowUrl=" + allowUrl + ", secretkey=" + secretkey - + ", serverUrl=" + serverUrl + "]"; + + ", authUrl=" + authUrl + ", checkTicketUrl=" + checkTicketUrl + ", sloUrl=" + sloUrl + "]"; } - /** * 以数组形式写入允许的授权回调地址 diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoConsts.java b/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoConsts.java index aeac0479..53225407 100644 --- a/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoConsts.java +++ b/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoConsts.java @@ -15,5 +15,17 @@ public class SaSsoConsts { /** back参数名称 */ public static final String BACK_NAME = "back"; + + /** loginId参数名称 */ + public static final String LOGIN_ID_NAME = "loginId"; + + /** secretkey参数名称 */ + public static final String SECRETKEY = "secretkey"; + + /** Client端单点注销时-回调URL 参数名称 */ + public static final String SLO_CALLBACK_NAME = "sloCallback"; + + /** Client端单点注销回调URL的Set集合,存储在Session中使用的key */ + public static final String SLO_CALLBACK_SET_KEY = "SLO_CALLBACK_SET_KEY_"; } diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoInterface.java b/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoInterface.java index f83cb192..bd15f250 100644 --- a/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoInterface.java +++ b/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoInterface.java @@ -1,10 +1,14 @@ package cn.dev33.satoken.sso; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import cn.dev33.satoken.SaManager; +import cn.dev33.satoken.config.SaSsoConfig; import cn.dev33.satoken.exception.SaTokenException; +import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.util.SaFoxUtil; /** @@ -41,14 +45,14 @@ public interface SaSsoInterface { } /** - * 根据 账号id & 重定向地址,构建[SSO-Client端-重定向地址] + * 构建URL:Server端向Client下放ticke的地址 * @param loginId 账号id - * @param redirect 重定向地址 - * @return [SSO-Client端-重定向地址] + * @param redirect Client端提供的重定向地址 + * @return see note */ public default String buildRedirectUrl(Object loginId, String redirect) { - // 校验授权地址 - checkAuthUrl(redirect); + // 校验重定向地址 + checkRedirectUrl(redirect); // 删掉旧ticket String oldTicket = SaManager.getSaTokenDao().get(splicingKeyIdToTicket(loginId)); @@ -61,7 +65,7 @@ public interface SaSsoInterface { // 构建 授权重定向地址 redirect = encodeBackParam(redirect); - String redirectUrl = SaFoxUtil.joinParam(redirect, SaSsoConsts.TICKET_NAME + "=" + ticket); + String redirectUrl = SaFoxUtil.joinParam(redirect, SaSsoConsts.TICKET_NAME, ticket); return redirectUrl; } @@ -87,10 +91,23 @@ public interface SaSsoInterface { } /** - * 校验url合法性 - * @param url 地址 + * 校验ticket码,获取账号id,如果ticket可以有效,则立刻删除 + * @param ticket Ticket码 + * @return 账号id */ - public default void checkAuthUrl(String url) { + public default Object checkTicket(String ticket) { + Object loginId = getLoginId(ticket); + if(loginId != null) { + deleteTicket(ticket); + } + return loginId; + } + + /** + * 校验重定向url合法性 + * @param url 下放ticket的url地址 + */ + public default void checkRedirectUrl(String url) { // 1、是否是一个有效的url if(SaFoxUtil.isUrl(url) == false) { @@ -122,15 +139,15 @@ public interface SaSsoInterface { */ public default String buildServerAuthUrl(String clientLoginUrl, String back) { // 服务端认证地址 - String serverUrl = SaManager.getConfig().getSso().getServerUrl(); + String serverUrl = SaManager.getConfig().getSso().getAuthUrl(); // 对back地址编码 back = (back == null ? "" : back); back = SaFoxUtil.encodeUrl(back); - // 拼接最终地址,格式:serverAuthUrl = http://xxx.com?redirectUrl=xxx.com?back=xxx.com - clientLoginUrl = SaFoxUtil.joinParam(clientLoginUrl, SaSsoConsts.BACK_NAME + "=" + back); - String serverAuthUrl = SaFoxUtil.joinParam(serverUrl, SaSsoConsts.REDIRECT_NAME + "=" + clientLoginUrl); + // 拼接最终地址,格式示例:serverAuthUrl = http://xxx.com?redirectUrl=xxx.com?back=xxx.com + clientLoginUrl = SaFoxUtil.joinParam(clientLoginUrl, SaSsoConsts.BACK_NAME, back); + String serverAuthUrl = SaFoxUtil.joinParam(serverUrl, SaSsoConsts.REDIRECT_NAME, clientLoginUrl); // 返回 return serverAuthUrl; @@ -171,6 +188,101 @@ public interface SaSsoInterface { return SaFoxUtil.getRandomString(64); } + + // ------------------- SSO 模式三 ------------------- + + /** + * 校验secretkey秘钥是否有效 + * @param secretkey 秘钥 + */ + public default void checkSecretkey(String secretkey) { + if(secretkey == null || secretkey.isEmpty() || secretkey.equals(SaManager.getConfig().getSso().getSecretkey()) == false) { + throw new SaTokenException("无效秘钥:" + secretkey); + } + } + + /** + * 构建URL:校验ticket的URL + * @param ticket ticket码 + * @param sloCallbackUrl 单点注销时的回调URL + * @return 构建完毕的URL + */ + public default String buildCheckTicketUrl(String ticket, String sloCallbackUrl) { + String url = SaManager.getConfig().getSso().getCheckTicketUrl(); + // 拼接ticket参数 + url = SaFoxUtil.joinParam(url, SaSsoConsts.TICKET_NAME, ticket); + // 拼接单点注销时的回调URL + if(sloCallbackUrl != null) { + url = SaFoxUtil.joinParam(url, SaSsoConsts.SLO_CALLBACK_NAME, sloCallbackUrl); + } + // 返回 + return url; + } + + /** + * 为指定账号id注册单点注销回调URL + * @param loginId 账号id + * @param sloCallbackUrl 单点注销时的回调URL + */ + public default void registerSloCallbackUrl(Object loginId, String sloCallbackUrl) { + if(loginId == null || sloCallbackUrl == null || sloCallbackUrl.isEmpty()) { + return; + } + Set urlSet = StpUtil.getSessionByLoginId(loginId).get(SaSsoConsts.SLO_CALLBACK_SET_KEY, ()-> new HashSet()); + urlSet.add(sloCallbackUrl); + StpUtil.getSessionByLoginId(loginId).set(SaSsoConsts.SLO_CALLBACK_SET_KEY, urlSet); + } + + /** + * 循环调用Client端单点注销回调 + * @param loginId 账号id + * @param fun 调用方法 + */ + public default void forEachSloUrl(Object loginId, CallSloUrlFunction fun) { + String secretkey = SaManager.getConfig().getSso().getSecretkey(); + Set urlSet = StpUtil.getSessionByLoginId(loginId).get(SaSsoConsts.SLO_CALLBACK_SET_KEY, + () -> new HashSet()); + + for (String url : urlSet) { + // 拼接:login参数、秘钥参数 + url = SaFoxUtil.joinParam(url, SaSsoConsts.LOGIN_ID_NAME, loginId); + url = SaFoxUtil.joinParam(url, SaSsoConsts.SECRETKEY, secretkey); + // 调用 + fun.run(url); + } + } + + /** + * 构建URL:单点注销URL + * @param loginId 要注销的账号id + * @return 单点注销URL + */ + public default String buildSloUrl(Object loginId) { + SaSsoConfig ssoConfig = SaManager.getConfig().getSso(); + String url = ssoConfig.getSloUrl(); + url = SaFoxUtil.joinParam(url, SaSsoConsts.LOGIN_ID_NAME, loginId); + url = SaFoxUtil.joinParam(url, SaSsoConsts.SECRETKEY, ssoConfig.getSecretkey()); + return url; + } + + /** + * 指定账号单点注销 + * @param secretkey 校验秘钥 + * @param loginId 指定账号 + * @param fun 调用方法 + */ + public default void singleLogout(String secretkey, Object loginId, CallSloUrlFunction fun) { + // step.1 校验秘钥 + checkSecretkey(secretkey); + + // step.2 遍历通知Client端注销会话 + forEachSloUrl(loginId, fun); + + // step.3 Server端注销 + StpUtil.logoutByLoginId(loginId); + } + + // ------------------- 返回相应key ------------------- @@ -193,4 +305,14 @@ public interface SaSsoInterface { } + @FunctionalInterface + static interface CallSloUrlFunction{ + /** + * 调用function + * @param url 注销回调URL + */ + public void run(String url); + } + + } diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoUtil.java b/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoUtil.java index 26172a9c..e8ccac00 100644 --- a/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoUtil.java +++ b/sa-token-core/src/main/java/cn/dev33/satoken/sso/SaSsoUtil.java @@ -1,5 +1,7 @@ package cn.dev33.satoken.sso; +import cn.dev33.satoken.sso.SaSsoInterface.CallSloUrlFunction; + /** * Sa-Token-SSO 单点登录工具类 * @author kong @@ -30,10 +32,10 @@ public class SaSsoUtil { } /** - * 根据 账号id & 重定向地址,构建[SSO-Client端-重定向地址] + * 构建URL:Server端向Client下放ticke的地址 * @param loginId 账号id - * @param redirect 重定向地址 - * @return [SSO-Client端-重定向地址] + * @param redirect Client端提供的重定向地址 + * @return see note */ public static String buildRedirectUrl(Object loginId, String redirect) { return saSsoInterface.buildRedirectUrl(loginId, redirect); @@ -56,13 +58,22 @@ public class SaSsoUtil { public static T getLoginId(String ticket, Class cs) { return saSsoInterface.getLoginId(ticket, cs); } + + /** + * 校验ticket码,获取账号id,如果ticket可以有效,则立刻删除 + * @param ticket Ticket码 + * @return 账号id + */ + public static Object checkTicket(String ticket) { + return saSsoInterface.checkTicket(ticket); + } /** - * 校验url合法性 - * @param url 地址 + * 校验重定向url合法性 + * @param url 下放ticket的url地址 */ public static void checkAuthUrl(String url) { - saSsoInterface.checkAuthUrl(url); + saSsoInterface.checkRedirectUrl(url); } /** @@ -74,5 +85,63 @@ public class SaSsoUtil { public static String buildServerAuthUrl(String clientLoginUrl, String back) { return saSsoInterface.buildServerAuthUrl(clientLoginUrl, back); } + + + // ------------------- SSO 模式三 ------------------- + + /** + * 校验secretkey秘钥是否有效 + * @param secretkey 秘钥 + */ + public static void checkSecretkey(String secretkey) { + saSsoInterface.checkSecretkey(secretkey); + } + + /** + * 构建URL:校验ticket的URL + * @param ticket ticket码 + * @param sloCallbackUrl 单点注销时的回调URL (如果不需要单点注销功能,此值可以填null) + * @return 构建完毕的URL + */ + public static String buildCheckTicketUrl(String ticket, String sloCallbackUrl) { + return saSsoInterface.buildCheckTicketUrl(ticket, sloCallbackUrl); + } + + /** + * 为指定账号id注册单点注销回调URL + * @param loginId 账号id + * @param sloCallbackUrl 单点注销时的回调URL + */ + public static void registerSloCallbackUrl(Object loginId, String sloCallbackUrl) { + saSsoInterface.registerSloCallbackUrl(loginId, sloCallbackUrl); + } + + /** + * 循环调用Client端单点注销回调 + * @param loginId 账号id + * @param fun 调用方法 + */ + public static void forEachSloUrl(Object loginId, CallSloUrlFunction fun) { + saSsoInterface.forEachSloUrl(loginId, fun); + } + + /** + * 构建URL:单点注销URL + * @param loginId 要注销的账号id + * @return 单点注销URL + */ + public static String buildSloUrl(Object loginId) { + return saSsoInterface.buildSloUrl(loginId); + } + + /** + * 指定账号单点注销 + * @param secretkey 校验秘钥 + * @param loginId 指定账号 + * @param fun 调用方法 + */ + public static void singleLogout(String secretkey, Object loginId, CallSloUrlFunction fun) { + saSsoInterface.singleLogout(secretkey, loginId, fun); + } } diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/util/SaFoxUtil.java b/sa-token-core/src/main/java/cn/dev33/satoken/util/SaFoxUtil.java index ccd03e7d..94dc03c7 100644 --- a/sa-token-core/src/main/java/cn/dev33/satoken/util/SaFoxUtil.java +++ b/sa-token-core/src/main/java/cn/dev33/satoken/util/SaFoxUtil.java @@ -226,6 +226,21 @@ public class SaFoxUtil { // 正常情况下, 代码不可能执行到此 return url; } + + /** + * 在url上拼接上kv参数并返回 + * @param url url + * @param key 参数名称 + * @param value 参数值 + * @return 拼接后的url字符串 + */ + public static String joinParam(String url, String key, Object value) { + // 如果参数为空, 直接返回 + if(isEmpty(url) || isEmpty(key) || isEmpty(String.valueOf(value))) { + return url; + } + return joinParam(url, key + "=" + value); + } /** * 将数组的所有元素使用逗号拼接在一起 diff --git a/sa-token-demo/sa-token-demo-springboot/src/main/java/com/pj/test/StressTestController.java b/sa-token-demo/sa-token-demo-springboot/src/main/java/com/pj/test/StressTestController.java index dcef7384..5c0b3ce1 100644 --- a/sa-token-demo/sa-token-demo-springboot/src/main/java/com/pj/test/StressTestController.java +++ b/sa-token-demo/sa-token-demo-springboot/src/main/java/com/pj/test/StressTestController.java @@ -20,7 +20,6 @@ import cn.dev33.satoken.stp.StpUtil; @RequestMapping("/s-test/") public class StressTestController { - // 测试 浏览器访问: http://localhost:8081/s-test/login // 测试前,请先将 is-read-cookie 配置为 false @RequestMapping("login") @@ -59,8 +58,4 @@ public class StressTestController { return AjaxJson.getSuccess(); } - - - - } diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/sso/GlobalException.java b/sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/sso/GlobalException.java deleted file mode 100644 index 52ab707a..00000000 --- a/sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/sso/GlobalException.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.pj.sso; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseBody; - -import com.pj.util.AjaxJson; - -/** - * 全局异常处理 - */ -@ControllerAdvice // 可指定包前缀,比如:(basePackages = "com.pj.admin") -public class GlobalException { - - // 全局异常拦截(拦截项目中的所有异常) - @ResponseBody - @ExceptionHandler - public AjaxJson handlerException(Exception e, HttpServletRequest request, HttpServletResponse response) - throws Exception { - - // 打印堆栈,以供调试 - System.out.println("全局异常---------------"); - e.printStackTrace(); - - // 不同异常返回不同状态码 - AjaxJson aj = AjaxJson.getError(e.getMessage()); - - // 返回给前端 - return aj; - - // 输出到客户端 -// response.setContentType("application/json; charset=utf-8"); // http说明,我要返回JSON对象 -// response.getWriter().print(new ObjectMapper().writeValueAsString(aj)); - } - -} diff --git a/sa-token-demo/sa-token-demo-sso-client/.gitignore b/sa-token-demo/sa-token-demo-sso1/.gitignore similarity index 100% rename from sa-token-demo/sa-token-demo-sso-client/.gitignore rename to sa-token-demo/sa-token-demo-sso1/.gitignore diff --git a/sa-token-demo/sa-token-demo-sso1/pom.xml b/sa-token-demo/sa-token-demo-sso1/pom.xml new file mode 100644 index 00000000..a50e7d8f --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso1/pom.xml @@ -0,0 +1,40 @@ + + 4.0.0 + cn.dev33 + sa-token-demo-sso1 + 0.0.1-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-parent + 2.0.0.RELEASE + + + + + + 1.20.0 + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + cn.dev33 + sa-token-spring-boot-starter + ${sa-token-version} + + + + + + \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso1/src/main/java/com/pj/SaSsoApplication.java b/sa-token-demo/sa-token-demo-sso1/src/main/java/com/pj/SaSsoApplication.java new file mode 100644 index 00000000..c80e20a4 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso1/src/main/java/com/pj/SaSsoApplication.java @@ -0,0 +1,16 @@ +package com.pj; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import cn.dev33.satoken.SaManager; + +@SpringBootApplication +public class SaSsoApplication { + + public static void main(String[] args) { + SpringApplication.run(SaSsoApplication.class, args); + System.out.println("\n启动成功:Sa-Token配置如下:" + SaManager.getConfig()); + } + +} \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-springboot/src/main/java/com/pj/test/SSOController.java b/sa-token-demo/sa-token-demo-sso1/src/main/java/com/pj/sso/SsoController.java similarity index 94% rename from sa-token-demo/sa-token-demo-springboot/src/main/java/com/pj/test/SSOController.java rename to sa-token-demo/sa-token-demo-sso1/src/main/java/com/pj/sso/SsoController.java index a723c7d6..d5e59d57 100644 --- a/sa-token-demo/sa-token-demo-springboot/src/main/java/com/pj/test/SSOController.java +++ b/sa-token-demo/sa-token-demo-sso1/src/main/java/com/pj/sso/SsoController.java @@ -1,4 +1,4 @@ -package com.pj.test; +package com.pj.sso; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -14,7 +14,7 @@ import cn.dev33.satoken.stp.StpUtil; */ @RestController @RequestMapping("/sso/") -public class SSOController { +public class SsoController { // 测试:进行登录 @RequestMapping("doLogin") diff --git a/sa-token-demo/sa-token-demo-sso-client/src/main/java/com/pj/util/AjaxJson.java b/sa-token-demo/sa-token-demo-sso1/src/main/java/com/pj/util/AjaxJson.java similarity index 100% rename from sa-token-demo/sa-token-demo-sso-client/src/main/java/com/pj/util/AjaxJson.java rename to sa-token-demo/sa-token-demo-sso1/src/main/java/com/pj/util/AjaxJson.java diff --git a/sa-token-demo/sa-token-demo-sso1/src/main/resources/application.yml b/sa-token-demo/sa-token-demo-sso1/src/main/resources/application.yml new file mode 100644 index 00000000..95704ba0 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso1/src/main/resources/application.yml @@ -0,0 +1,9 @@ +# 端口 +server: + port: 8081 + +spring: + sa-token: + # 写入Cookie时显式指定的作用域, 用于单点登录二级域名共享Cookie + cookie-domain: stp.com + \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso-server/.gitignore b/sa-token-demo/sa-token-demo-sso2-client/.gitignore similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/.gitignore rename to sa-token-demo/sa-token-demo-sso2-client/.gitignore diff --git a/sa-token-demo/sa-token-demo-sso-client/pom.xml b/sa-token-demo/sa-token-demo-sso2-client/pom.xml similarity index 96% rename from sa-token-demo/sa-token-demo-sso-client/pom.xml rename to sa-token-demo/sa-token-demo-sso2-client/pom.xml index bf44231e..89dc1230 100644 --- a/sa-token-demo/sa-token-demo-sso-client/pom.xml +++ b/sa-token-demo/sa-token-demo-sso2-client/pom.xml @@ -3,7 +3,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 cn.dev33 - sa-token-demo-sso-client + sa-token-demo-sso2-client 0.0.1-SNAPSHOT @@ -53,7 +53,7 @@ sa-token-alone-redis 1.20.0 - + diff --git a/sa-token-demo/sa-token-demo-sso-client/src/main/java/com/pj/SaSsoClientApplication.java b/sa-token-demo/sa-token-demo-sso2-client/src/main/java/com/pj/SaSsoClientApplication.java similarity index 85% rename from sa-token-demo/sa-token-demo-sso-client/src/main/java/com/pj/SaSsoClientApplication.java rename to sa-token-demo/sa-token-demo-sso2-client/src/main/java/com/pj/SaSsoClientApplication.java index 744ebcf9..cbafaa0e 100644 --- a/sa-token-demo/sa-token-demo-sso-client/src/main/java/com/pj/SaSsoClientApplication.java +++ b/sa-token-demo/sa-token-demo-sso2-client/src/main/java/com/pj/SaSsoClientApplication.java @@ -3,11 +3,6 @@ package com.pj; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -/** - * Sa-Token整合SpringBoot 示例 - * @author kong - * - */ @SpringBootApplication public class SaSsoClientApplication { diff --git a/sa-token-demo/sa-token-demo-sso-client/src/main/java/com/pj/sso/SsoClientController.java b/sa-token-demo/sa-token-demo-sso2-client/src/main/java/com/pj/sso/SsoClientController.java similarity index 73% rename from sa-token-demo/sa-token-demo-sso-client/src/main/java/com/pj/sso/SsoClientController.java rename to sa-token-demo/sa-token-demo-sso2-client/src/main/java/com/pj/sso/SsoClientController.java index ca5823bb..63a887c0 100644 --- a/sa-token-demo/sa-token-demo-sso-client/src/main/java/com/pj/sso/SsoClientController.java +++ b/sa-token-demo/sa-token-demo-sso2-client/src/main/java/com/pj/sso/SsoClientController.java @@ -20,38 +20,40 @@ public class SsoClientController { public String index() { String str = "

Sa-Token SSO-Client 应用端

" + "

当前会话是否登录:" + StpUtil.isLogin() + "

" + - "

登录

"; + "

登录

"; return str; } // SSO-Client端:登录地址 @RequestMapping("ssoLogin") - public Object login(String back, String ticket) { + public Object ssoLogin(String back, String ticket) { // 如果当前Client端已经登录,则无需访问SSO认证中心,可以直接返回 if(StpUtil.isLogin()) { return new ModelAndView("redirect:" + back); } /* * 接下来两种情况: - * ticket有值,说明此请求从SSO认证中心重定向而来,需要根据ticket进行登录 * ticket无值,说明此请求是Client端访问,需要重定向至SSO认证中心 + * ticket有值,说明此请求从SSO认证中心重定向而来,需要根据ticket进行登录 */ - if(ticket != null) { - Object loginId = SaSsoUtil.getLoginId(ticket); + if(ticket == null) { + String serverAuthUrl = SaSsoUtil.buildServerAuthUrl(SaHolder.getRequest().getUrl(), back); + return new ModelAndView("redirect:" + serverAuthUrl); + } else { + Object loginId = checkTicket(ticket); if(loginId != null ) { - // 如果ticket是有效的 (可以获取到值),需要就此登录 且清除此ticket + // loginId有值,说明ticket有效 StpUtil.login(loginId); - SaSsoUtil.deleteTicket(ticket); - // 最后重定向回back地址 return new ModelAndView("redirect:" + back); } // 此处向客户端提示ticket无效即可,不要重定向到SSO认证中心,否则容易引起无限重定向 return "ticket无效: " + ticket; } - - // 重定向至 SSO-Server端 认证地址 - String serverAuthUrl = SaSsoUtil.buildServerAuthUrl(SaHolder.getRequest().getUrl(), back); - return new ModelAndView("redirect:" + serverAuthUrl); } + // SSO-Client端:校验ticket,获取账号id + private Object checkTicket(String ticket) { + return SaSsoUtil.checkTicket(ticket); + } + } diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/util/AjaxJson.java b/sa-token-demo/sa-token-demo-sso2-client/src/main/java/com/pj/util/AjaxJson.java similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/util/AjaxJson.java rename to sa-token-demo/sa-token-demo-sso2-client/src/main/java/com/pj/util/AjaxJson.java diff --git a/sa-token-demo/sa-token-demo-sso-client/src/main/resources/application.yml b/sa-token-demo/sa-token-demo-sso2-client/src/main/resources/application.yml similarity index 85% rename from sa-token-demo/sa-token-demo-sso-client/src/main/resources/application.yml rename to sa-token-demo/sa-token-demo-sso2-client/src/main/resources/application.yml index cd2b1e80..3273d6cd 100644 --- a/sa-token-demo/sa-token-demo-sso-client/src/main/resources/application.yml +++ b/sa-token-demo/sa-token-demo-sso2-client/src/main/resources/application.yml @@ -13,10 +13,8 @@ spring: token-style: uuid # SSO-相关配置 sso: - # SSO-Server端授权地址 - server-url: http://sa-sso-server.com:9000/ssoAuth - # 接口调用秘钥(模式三才会用到此参数) - # secret-key: + # SSO-Server端 单点登录地址 + auth-url: http://sa-sso-server.com:9000/ssoAuth # 配置Sa-Token单独使用的Redis连接 (此处需要和SSO-Server端连接同一个Redis) alone-redis: diff --git a/sa-token-demo/sa-token-demo-sso2-server/.gitignore b/sa-token-demo/sa-token-demo-sso2-server/.gitignore new file mode 100644 index 00000000..99a6e767 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso2-server/.gitignore @@ -0,0 +1,12 @@ +target/ + +node_modules/ +bin/ +.settings/ +unpackage/ +.classpath +.project + +.idea/ + +.factorypath \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso-server/pom.xml b/sa-token-demo/sa-token-demo-sso2-server/pom.xml similarity index 97% rename from sa-token-demo/sa-token-demo-sso-server/pom.xml rename to sa-token-demo/sa-token-demo-sso2-server/pom.xml index 70475797..f519efb8 100644 --- a/sa-token-demo/sa-token-demo-sso-server/pom.xml +++ b/sa-token-demo/sa-token-demo-sso2-server/pom.xml @@ -3,7 +3,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 cn.dev33 - sa-token-demo-sso-server + sa-token-demo-sso2-server 0.0.1-SNAPSHOT diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/SaSsoServerApplication.java b/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/SaSsoServerApplication.java similarity index 85% rename from sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/SaSsoServerApplication.java rename to sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/SaSsoServerApplication.java index e019d237..6b57b574 100644 --- a/sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/SaSsoServerApplication.java +++ b/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/SaSsoServerApplication.java @@ -3,11 +3,6 @@ package com.pj; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -/** - * Sa-Token整合SpringBoot 示例 - * @author kong - * - */ @SpringBootApplication public class SaSsoServerApplication { diff --git a/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/sso/GlobalException.java b/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/sso/GlobalException.java new file mode 100644 index 00000000..1cf22b15 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/sso/GlobalException.java @@ -0,0 +1,21 @@ +package com.pj.sso; + +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.pj.util.AjaxJson; + +/** + * 全局异常处理 + */ +@RestControllerAdvice +public class GlobalException { + + // 全局异常拦截(拦截项目中的所有异常) + @ExceptionHandler + public AjaxJson handlerException(Exception e) { + e.printStackTrace(); + return AjaxJson.getError(e.getMessage()); + } + +} diff --git a/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/sso/SsoServerController.java b/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/sso/SsoServerController.java new file mode 100644 index 00000000..071caeb1 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/sso/SsoServerController.java @@ -0,0 +1,49 @@ +package com.pj.sso; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.ModelAndView; + +import com.pj.util.AjaxJson; + +import cn.dev33.satoken.sso.SaSsoUtil; +import cn.dev33.satoken.stp.StpUtil; + +/** + * Sa-Token-SSO Server端 Controller + * @author kong + * + */ +@RestController +public class SsoServerController { + + // SSO-Server端:授权地址,跳转到登录页面 + @RequestMapping("ssoAuth") + public Object ssoAuth(String redirect) { + /* + * 此处两种情况分开处理: + * 1、如果在SSO认证中心尚未登录,则先去登登录 + * 2、如果在SSO认证中心尚已登录,则开始对redirect地址下放ticket引导授权 + */ + // 情况1:尚未登录 + if(StpUtil.isLogin() == false) { +// return "当前会话在SSO-Server端尚未登录,请先访问 doLogin登录 进行登录之后,刷新页面开始授权"; + return new ModelAndView("sa-login.html"); + } + // 情况2:已经登录,开始构建授权重定向地址,下放ticket + String redirectUrl = SaSsoUtil.buildRedirectUrl(StpUtil.getLoginId(), redirect); + return new ModelAndView("redirect:" + redirectUrl); + } + + // SSO-Server端:登录接口 + @RequestMapping("doLogin") + public AjaxJson doLogin(String name, String pwd) { + // 此处仅做模拟登录,真实环境应该查询数据进行登录 + if("sa".equals(name) && "123456".equals(pwd)) { + StpUtil.login(10001); + return AjaxJson.getSuccess("登录成功!"); + } + return AjaxJson.getError("登录失败!"); + } + +} diff --git a/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/util/AjaxJson.java b/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/util/AjaxJson.java new file mode 100644 index 00000000..768d0578 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso2-server/src/main/java/com/pj/util/AjaxJson.java @@ -0,0 +1,162 @@ +package com.pj.util; + +import java.io.Serializable; +import java.util.List; + + +/** + * ajax请求返回Json格式数据的封装 + */ +public class AjaxJson implements Serializable{ + + private static final long serialVersionUID = 1L; // 序列化版本号 + + public static final int CODE_SUCCESS = 200; // 成功状态码 + public static final int CODE_ERROR = 500; // 错误状态码 + public static final int CODE_WARNING = 501; // 警告状态码 + public static final int CODE_NOT_JUR = 403; // 无权限状态码 + public static final int CODE_NOT_LOGIN = 401; // 未登录状态码 + public static final int CODE_INVALID_REQUEST = 400; // 无效请求状态码 + + public int code; // 状态码 + public String msg; // 描述信息 + public Object data; // 携带对象 + public Long dataCount; // 数据总数,用于分页 + + /** + * 返回code + * @return + */ + public int getCode() { + return this.code; + } + + /** + * 给msg赋值,连缀风格 + */ + public AjaxJson setMsg(String msg) { + this.msg = msg; + return this; + } + public String getMsg() { + return this.msg; + } + + /** + * 给data赋值,连缀风格 + */ + public AjaxJson setData(Object data) { + this.data = data; + return this; + } + + /** + * 将data还原为指定类型并返回 + */ + @SuppressWarnings("unchecked") + public T getData(Class cs) { + return (T) data; + } + + // ============================ 构建 ================================== + + public AjaxJson(int code, String msg, Object data, Long dataCount) { + this.code = code; + this.msg = msg; + this.data = data; + this.dataCount = dataCount; + } + + // 返回成功 + public static AjaxJson getSuccess() { + return new AjaxJson(CODE_SUCCESS, "ok", null, null); + } + public static AjaxJson getSuccess(String msg) { + return new AjaxJson(CODE_SUCCESS, msg, null, null); + } + public static AjaxJson getSuccess(String msg, Object data) { + return new AjaxJson(CODE_SUCCESS, msg, data, null); + } + public static AjaxJson getSuccessData(Object data) { + return new AjaxJson(CODE_SUCCESS, "ok", data, null); + } + public static AjaxJson getSuccessArray(Object... data) { + return new AjaxJson(CODE_SUCCESS, "ok", data, null); + } + + // 返回失败 + public static AjaxJson getError() { + return new AjaxJson(CODE_ERROR, "error", null, null); + } + public static AjaxJson getError(String msg) { + return new AjaxJson(CODE_ERROR, msg, null, null); + } + + // 返回警告 + public static AjaxJson getWarning() { + return new AjaxJson(CODE_ERROR, "warning", null, null); + } + public static AjaxJson getWarning(String msg) { + return new AjaxJson(CODE_WARNING, msg, null, null); + } + + // 返回未登录 + public static AjaxJson getNotLogin() { + return new AjaxJson(CODE_NOT_LOGIN, "未登录,请登录后再次访问", null, null); + } + + // 返回没有权限的 + public static AjaxJson getNotJur(String msg) { + return new AjaxJson(CODE_NOT_JUR, msg, null, null); + } + + // 返回一个自定义状态码的 + public static AjaxJson get(int code, String msg){ + return new AjaxJson(code, msg, null, null); + } + + // 返回分页和数据的 + public static AjaxJson getPageData(Long dataCount, Object data){ + return new AjaxJson(CODE_SUCCESS, "ok", data, dataCount); + } + + // 返回,根据受影响行数的(大于0=ok,小于0=error) + public static AjaxJson getByLine(int line){ + if(line > 0){ + return getSuccess("ok", line); + } + return getError("error").setData(line); + } + + // 返回,根据布尔值来确定最终结果的 (true=ok,false=error) + public static AjaxJson getByBoolean(boolean b){ + return b ? getSuccess("ok") : getError("error"); + } + + /* (non-Javadoc) + * @see java.lang.Object#toString() + */ + @SuppressWarnings("rawtypes") + @Override + public String toString() { + String data_string = null; + if(data == null){ + + } else if(data instanceof List){ + data_string = "List(length=" + ((List)data).size() + ")"; + } else { + data_string = data.toString(); + } + return "{" + + "\"code\": " + this.getCode() + + ", \"msg\": \"" + this.getMsg() + "\"" + + ", \"data\": " + data_string + + ", \"dataCount\": " + dataCount + + "}"; + } + + + + + +} diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/application.yml b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/application.yml similarity index 88% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/application.yml rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/application.yml index da617ca0..847d4cba 100644 --- a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/application.yml +++ b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/application.yml @@ -7,12 +7,10 @@ spring: sa-token: # SSO-相关配置 sso: - # Ticket有效期 (单位: 秒),默认三分钟 + # Ticket有效期 (单位: 秒),默认五分钟 ticket-timeout: 300 # 所有允许的授权回调地址 (此处为了方便测试配置为*,线上生产环境一定要配置为详细地地址) allow-url: http://sa-sso-client1.com:9001/ssoLogin, http://sa-sso-client2.com:9001/ssoLogin, http://sa-sso-client3.com:9001/ssoLogin - # 接口调用秘钥(模式三才会用到此参数) - # secret-key: # Redis配置 redis: diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/jquery.min.js b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/jquery.min.js similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/jquery.min.js rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/jquery.min.js diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/layer.js b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/layer.js similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/layer.js rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/layer.js diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/mobile/layer.js b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/mobile/layer.js similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/mobile/layer.js rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/mobile/layer.js diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/mobile/need/layer.css b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/mobile/need/layer.css similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/mobile/need/layer.css rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/mobile/need/layer.css diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/icon-ext.png b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/icon-ext.png similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/icon-ext.png rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/icon-ext.png diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/icon.png b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/icon.png similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/icon.png rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/icon.png diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/layer.css b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/layer.css similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/layer.css rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/layer.css diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/loading-0.gif b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/loading-0.gif similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/loading-0.gif rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/loading-0.gif diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/loading-1.gif b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/loading-1.gif similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/loading-1.gif rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/loading-1.gif diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/loading-2.gif b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/loading-2.gif similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/layer/theme/default/loading-2.gif rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/layer/theme/default/loading-2.gif diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/login.css b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/login.css similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/login.css rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/login.css diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/login.js b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/login.js similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/static/sa-res/login.js rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/static/sa-res/login.js diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/resources/templates/sa-login.html b/sa-token-demo/sa-token-demo-sso2-server/src/main/resources/templates/sa-login.html similarity index 100% rename from sa-token-demo/sa-token-demo-sso-server/src/main/resources/templates/sa-login.html rename to sa-token-demo/sa-token-demo-sso2-server/src/main/resources/templates/sa-login.html diff --git a/sa-token-demo/sa-token-demo-sso3-client/.gitignore b/sa-token-demo/sa-token-demo-sso3-client/.gitignore new file mode 100644 index 00000000..99a6e767 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-client/.gitignore @@ -0,0 +1,12 @@ +target/ + +node_modules/ +bin/ +.settings/ +unpackage/ +.classpath +.project + +.idea/ + +.factorypath \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-client/pom.xml b/sa-token-demo/sa-token-demo-sso3-client/pom.xml new file mode 100644 index 00000000..5d90d620 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-client/pom.xml @@ -0,0 +1,61 @@ + + 4.0.0 + cn.dev33 + sa-token-demo-sso3-client + 0.0.1-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-parent + 2.0.0.RELEASE + + + + + + 1.20.0 + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + cn.dev33 + sa-token-spring-boot-starter + ${sa-token-version} + + + + + cn.dev33 + sa-token-dao-redis-jackson + ${sa-token-version} + + + + + org.apache.commons + commons-pool2 + + + + + com.ejlchina + okhttps + 3.1.1 + + + + + + + \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/SaSsoClientApplication.java b/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/SaSsoClientApplication.java new file mode 100644 index 00000000..cbafaa0e --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/SaSsoClientApplication.java @@ -0,0 +1,14 @@ +package com.pj; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SaSsoClientApplication { + + public static void main(String[] args) { + SpringApplication.run(SaSsoClientApplication.class, args); + System.out.println("\nSa-Token-SSO Client端启动成功"); + } + +} \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/sso/SsoClientController.java b/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/sso/SsoClientController.java new file mode 100644 index 00000000..0dea88d4 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/sso/SsoClientController.java @@ -0,0 +1,71 @@ +package com.pj.sso; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.ModelAndView; + +import com.ejlchina.okhttps.OkHttps; + +import cn.dev33.satoken.context.SaHolder; +import cn.dev33.satoken.sso.SaSsoUtil; +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.util.SaFoxUtil; + +/** + * Sa-Token-SSO Client端 Controller + * @author kong + */ +@RestController +public class SsoClientController { + + // SSO-Client端:首页 + @RequestMapping("/") + public String index() { + String str = "

Sa-Token SSO-Client 应用端

" + + "

当前会话是否登录:" + StpUtil.isLogin() + "

" + + "

登录" + + " 注销

"; + return str; + } + + // SSO-Client端:登录地址 + @RequestMapping("ssoLogin") + public Object ssoLogin(String back, String ticket) { + // 如果当前Client端已经登录,则无需访问SSO认证中心,可以直接返回 + if(StpUtil.isLogin()) { + return new ModelAndView("redirect:" + back); + } + /* + * 接下来两种情况: + * ticket无值,说明此请求是Client端访问,需要重定向至SSO认证中心 + * ticket有值,说明此请求从SSO认证中心重定向而来,需要根据ticket进行登录 + */ + if(ticket == null) { + String serverAuthUrl = SaSsoUtil.buildServerAuthUrl(SaHolder.getRequest().getUrl(), back); + return new ModelAndView("redirect:" + serverAuthUrl); + } else { + Object loginId = checkTicket(ticket); + if(loginId != null ) { + // loginId有值,说明ticket有效 + StpUtil.login(loginId); + return new ModelAndView("redirect:" + back); + } + // 此处向客户端提示ticket无效即可,不要重定向到SSO认证中心,否则容易引起无限重定向 + return "ticket无效: " + ticket; + } + } + + // SSO-Client端:校验ticket码,获取对应的账号id + private Object checkTicket(String ticket) { + // 构建单点注销的回调URL(不需要单点注销时此值可填null ) + String sloCallback = SaHolder.getRequest().getUrl().replace("/ssoLogin", "/sloCallback"); + + // 使用OkHttps请求SSO-Server端,校验ticket + String checkUrl = SaSsoUtil.buildCheckTicketUrl(ticket, sloCallback); + String loginId = OkHttps.sync(checkUrl).get().getBody().toString(); + + // 判断返回值是否为有效账号Id + return (SaFoxUtil.isEmpty(loginId) ? null : loginId); + } + +} diff --git a/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/sso/SsoClientLogoutController.java b/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/sso/SsoClientLogoutController.java new file mode 100644 index 00000000..d733cf7f --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/sso/SsoClientLogoutController.java @@ -0,0 +1,50 @@ +package com.pj.sso; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ejlchina.okhttps.OkHttps; +import com.pj.util.AjaxJson; + +import cn.dev33.satoken.sso.SaSsoUtil; +import cn.dev33.satoken.stp.StpUtil; + +/** + * Sa-Token-SSO Client端 单点注销 Controller + * @author kong + */ +@RestController +public class SsoClientLogoutController { + + // SSO-Client端:单端注销 (其它Client端会话不受影响) + @RequestMapping("logout") + public AjaxJson logout() { + StpUtil.logout(); + return AjaxJson.getSuccess(); + } + + // SSO-Client端:单点注销 (所有端一起下线) + @RequestMapping("ssoLogout") + public AjaxJson ssoLogout() { + // 如果未登录,则无需注销 + if(StpUtil.isLogin() == false) { + return AjaxJson.getSuccess(); + } + // 调用SSO-Server认证中心API + String url = SaSsoUtil.buildSloUrl(StpUtil.getLoginId()); + String res = OkHttps.sync(url).get().getBody().toString(); + if(res.equals("ok")) { + return AjaxJson.getSuccess("单点注销成功"); + } + return AjaxJson.getError("单点注销失败"); + } + + // 单点注销的回调 + @RequestMapping("sloCallback") + public String sloCallback(String loginId, String secretkey) { + SaSsoUtil.checkSecretkey(secretkey); + StpUtil.logoutByLoginId(loginId); + return "ok"; + } + +} diff --git a/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/util/AjaxJson.java b/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/util/AjaxJson.java new file mode 100644 index 00000000..768d0578 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-client/src/main/java/com/pj/util/AjaxJson.java @@ -0,0 +1,162 @@ +package com.pj.util; + +import java.io.Serializable; +import java.util.List; + + +/** + * ajax请求返回Json格式数据的封装 + */ +public class AjaxJson implements Serializable{ + + private static final long serialVersionUID = 1L; // 序列化版本号 + + public static final int CODE_SUCCESS = 200; // 成功状态码 + public static final int CODE_ERROR = 500; // 错误状态码 + public static final int CODE_WARNING = 501; // 警告状态码 + public static final int CODE_NOT_JUR = 403; // 无权限状态码 + public static final int CODE_NOT_LOGIN = 401; // 未登录状态码 + public static final int CODE_INVALID_REQUEST = 400; // 无效请求状态码 + + public int code; // 状态码 + public String msg; // 描述信息 + public Object data; // 携带对象 + public Long dataCount; // 数据总数,用于分页 + + /** + * 返回code + * @return + */ + public int getCode() { + return this.code; + } + + /** + * 给msg赋值,连缀风格 + */ + public AjaxJson setMsg(String msg) { + this.msg = msg; + return this; + } + public String getMsg() { + return this.msg; + } + + /** + * 给data赋值,连缀风格 + */ + public AjaxJson setData(Object data) { + this.data = data; + return this; + } + + /** + * 将data还原为指定类型并返回 + */ + @SuppressWarnings("unchecked") + public T getData(Class cs) { + return (T) data; + } + + // ============================ 构建 ================================== + + public AjaxJson(int code, String msg, Object data, Long dataCount) { + this.code = code; + this.msg = msg; + this.data = data; + this.dataCount = dataCount; + } + + // 返回成功 + public static AjaxJson getSuccess() { + return new AjaxJson(CODE_SUCCESS, "ok", null, null); + } + public static AjaxJson getSuccess(String msg) { + return new AjaxJson(CODE_SUCCESS, msg, null, null); + } + public static AjaxJson getSuccess(String msg, Object data) { + return new AjaxJson(CODE_SUCCESS, msg, data, null); + } + public static AjaxJson getSuccessData(Object data) { + return new AjaxJson(CODE_SUCCESS, "ok", data, null); + } + public static AjaxJson getSuccessArray(Object... data) { + return new AjaxJson(CODE_SUCCESS, "ok", data, null); + } + + // 返回失败 + public static AjaxJson getError() { + return new AjaxJson(CODE_ERROR, "error", null, null); + } + public static AjaxJson getError(String msg) { + return new AjaxJson(CODE_ERROR, msg, null, null); + } + + // 返回警告 + public static AjaxJson getWarning() { + return new AjaxJson(CODE_ERROR, "warning", null, null); + } + public static AjaxJson getWarning(String msg) { + return new AjaxJson(CODE_WARNING, msg, null, null); + } + + // 返回未登录 + public static AjaxJson getNotLogin() { + return new AjaxJson(CODE_NOT_LOGIN, "未登录,请登录后再次访问", null, null); + } + + // 返回没有权限的 + public static AjaxJson getNotJur(String msg) { + return new AjaxJson(CODE_NOT_JUR, msg, null, null); + } + + // 返回一个自定义状态码的 + public static AjaxJson get(int code, String msg){ + return new AjaxJson(code, msg, null, null); + } + + // 返回分页和数据的 + public static AjaxJson getPageData(Long dataCount, Object data){ + return new AjaxJson(CODE_SUCCESS, "ok", data, dataCount); + } + + // 返回,根据受影响行数的(大于0=ok,小于0=error) + public static AjaxJson getByLine(int line){ + if(line > 0){ + return getSuccess("ok", line); + } + return getError("error").setData(line); + } + + // 返回,根据布尔值来确定最终结果的 (true=ok,false=error) + public static AjaxJson getByBoolean(boolean b){ + return b ? getSuccess("ok") : getError("error"); + } + + /* (non-Javadoc) + * @see java.lang.Object#toString() + */ + @SuppressWarnings("rawtypes") + @Override + public String toString() { + String data_string = null; + if(data == null){ + + } else if(data instanceof List){ + data_string = "List(length=" + ((List)data).size() + ")"; + } else { + data_string = data.toString(); + } + return "{" + + "\"code\": " + this.getCode() + + ", \"msg\": \"" + this.getMsg() + "\"" + + ", \"data\": " + data_string + + ", \"dataCount\": " + dataCount + + "}"; + } + + + + + +} diff --git a/sa-token-demo/sa-token-demo-sso3-client/src/main/resources/application.yml b/sa-token-demo/sa-token-demo-sso3-client/src/main/resources/application.yml new file mode 100644 index 00000000..adf2534a --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-client/src/main/resources/application.yml @@ -0,0 +1,51 @@ +# 端口 +server: + port: 9001 + +spring: + # sa-token配置 + sa-token: + # Token名称 + token-name: satoken + # Token有效期 + timeout: 2592000 + # Token风格 + token-style: uuid + # SSO-相关配置 + sso: + # SSO-Server端 单点登录地址 + auth-url: http://sa-sso-server.com:9000/ssoAuth + # SSO-Server端 ticket校验地址 + check-ticket-url: http://sa-sso-server.com:9000/checkTicket + # SSO-Server端 单点注销地址 + slo-url: http://sa-sso-server.com:9000/ssoLogout + # 接口调用秘钥(用于SSO模式三的单点注销功能) + secretkey: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor + + # 配置Sa-Token单独使用的Redis连接 (此处需要和SSO-Server端连接同一个Redis) + redis: + # Redis数据库索引 + database: 6 + # Redis服务器地址 + host: 127.0.0.1 + # Redis服务器连接端口 + port: 6379 + # Redis服务器连接密码(默认为空) + password: + # 连接超时时间(毫秒) + timeout: 10ms + lettuce: + pool: + # 连接池最大连接数 + max-active: 200 + # 连接池最大阻塞等待时间(使用负值表示没有限制) + max-wait: -1ms + # 连接池中的最大空闲连接 + max-idle: 10 + # 连接池中的最小空闲连接 + min-idle: 0 + + + + + \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-server/.gitignore b/sa-token-demo/sa-token-demo-sso3-server/.gitignore new file mode 100644 index 00000000..99a6e767 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/.gitignore @@ -0,0 +1,12 @@ +target/ + +node_modules/ +bin/ +.settings/ +unpackage/ +.classpath +.project + +.idea/ + +.factorypath \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-server/pom.xml b/sa-token-demo/sa-token-demo-sso3-server/pom.xml new file mode 100644 index 00000000..84123fd5 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/pom.xml @@ -0,0 +1,66 @@ + + 4.0.0 + cn.dev33 + sa-token-demo-sso3-server + 0.0.1-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-parent + 2.0.0.RELEASE + + + + + + 1.20.0 + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + cn.dev33 + sa-token-spring-boot-starter + ${sa-token-version} + + + + + cn.dev33 + sa-token-dao-redis-jackson + ${sa-token-version} + + + + + org.apache.commons + commons-pool2 + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + + com.ejlchina + okhttps + 3.1.1 + + + + + + \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/SaSsoServerApplication.java b/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/SaSsoServerApplication.java new file mode 100644 index 00000000..0e07f76d --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/SaSsoServerApplication.java @@ -0,0 +1,14 @@ +package com.pj; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SaSsoServerApplication { + + public static void main(String[] args) { + SpringApplication.run(SaSsoServerApplication.class, args); + System.out.println("\nSa-Token-SSO 认证中心启动成功"); + } + +} \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/sso/GlobalException.java b/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/sso/GlobalException.java new file mode 100644 index 00000000..036e2a56 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/sso/GlobalException.java @@ -0,0 +1,21 @@ +package com.pj.sso; + +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.pj.util.AjaxJson; + +/** + * 全局异常处理 + */ +@RestControllerAdvice +public class GlobalException { + + // 全局异常拦截(拦截项目中的所有异常) + @ExceptionHandler + public AjaxJson handlerException(Exception e){ + e.printStackTrace(); + return AjaxJson.getError(e.getMessage()); + } + +} diff --git a/sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/sso/SsoServerController.java b/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/sso/SsoServerController.java similarity index 72% rename from sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/sso/SsoServerController.java rename to sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/sso/SsoServerController.java index 672eea38..1a1da438 100644 --- a/sa-token-demo/sa-token-demo-sso-server/src/main/java/com/pj/sso/SsoServerController.java +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/sso/SsoServerController.java @@ -27,10 +27,6 @@ public class SsoServerController { */ // 情况1:尚未登录 if(StpUtil.isLogin() == false) { -// String msg = "当前会话在SSO-Server端尚未登录,请先访问" -// + " doLogin登录 " -// + "进行登录之后,刷新页面开始授权"; -// return msg; return new ModelAndView("sa-login.html"); } // 情况2:已经登录,开始构建授权重定向地址,下放ticket @@ -49,15 +45,17 @@ public class SsoServerController { return AjaxJson.getError("登录失败!"); } - // SSO-Server端:根据 Ticket 获取账号id - @RequestMapping("getLoginId") - public AjaxJson getLoginId(String ticket) { - Object loginId = SaSsoUtil.getLoginId(ticket); - if(loginId != null) { - SaSsoUtil.deleteTicket(ticket); - return AjaxJson.getSuccessData(loginId); - } - return AjaxJson.getError("无效ticket: " + ticket); + // SSO-Server端:校验ticket 获取账号id + @RequestMapping("checkTicket") + public Object checkTicket(String ticket, String sloCallback) { + // 校验ticket,获取对应的账号id + Object loginId = SaSsoUtil.checkTicket(ticket); + + // 注册此客户端的单点注销回调URL(不需要单点注销功能可删除此行代码) + SaSsoUtil.registerSloCallbackUrl(loginId, sloCallback); + + // 返回给Client端 + return loginId; } } diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/sso/SsoServerLogoutController.java b/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/sso/SsoServerLogoutController.java new file mode 100644 index 00000000..4116633a --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/sso/SsoServerLogoutController.java @@ -0,0 +1,28 @@ +package com.pj.sso; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ejlchina.okhttps.OkHttps; + +import cn.dev33.satoken.sso.SaSsoUtil; + +/** + * Sa-Token-SSO Server端 单点注销 Controller + * @author kong + */ +@RestController +public class SsoServerLogoutController { + + // SSO-Server端:单点注销 + @RequestMapping("ssoLogout") + public String ssoLogout(String loginId, String secretkey) { + + // 遍历通知Client端注销会话 (为了提高响应速度这里可将sync换为async) + SaSsoUtil.singleLogout(secretkey, loginId, url -> OkHttps.sync(url).get()); + + // 完成 + return "ok"; + } + +} diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/util/AjaxJson.java b/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/util/AjaxJson.java new file mode 100644 index 00000000..768d0578 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/java/com/pj/util/AjaxJson.java @@ -0,0 +1,162 @@ +package com.pj.util; + +import java.io.Serializable; +import java.util.List; + + +/** + * ajax请求返回Json格式数据的封装 + */ +public class AjaxJson implements Serializable{ + + private static final long serialVersionUID = 1L; // 序列化版本号 + + public static final int CODE_SUCCESS = 200; // 成功状态码 + public static final int CODE_ERROR = 500; // 错误状态码 + public static final int CODE_WARNING = 501; // 警告状态码 + public static final int CODE_NOT_JUR = 403; // 无权限状态码 + public static final int CODE_NOT_LOGIN = 401; // 未登录状态码 + public static final int CODE_INVALID_REQUEST = 400; // 无效请求状态码 + + public int code; // 状态码 + public String msg; // 描述信息 + public Object data; // 携带对象 + public Long dataCount; // 数据总数,用于分页 + + /** + * 返回code + * @return + */ + public int getCode() { + return this.code; + } + + /** + * 给msg赋值,连缀风格 + */ + public AjaxJson setMsg(String msg) { + this.msg = msg; + return this; + } + public String getMsg() { + return this.msg; + } + + /** + * 给data赋值,连缀风格 + */ + public AjaxJson setData(Object data) { + this.data = data; + return this; + } + + /** + * 将data还原为指定类型并返回 + */ + @SuppressWarnings("unchecked") + public T getData(Class cs) { + return (T) data; + } + + // ============================ 构建 ================================== + + public AjaxJson(int code, String msg, Object data, Long dataCount) { + this.code = code; + this.msg = msg; + this.data = data; + this.dataCount = dataCount; + } + + // 返回成功 + public static AjaxJson getSuccess() { + return new AjaxJson(CODE_SUCCESS, "ok", null, null); + } + public static AjaxJson getSuccess(String msg) { + return new AjaxJson(CODE_SUCCESS, msg, null, null); + } + public static AjaxJson getSuccess(String msg, Object data) { + return new AjaxJson(CODE_SUCCESS, msg, data, null); + } + public static AjaxJson getSuccessData(Object data) { + return new AjaxJson(CODE_SUCCESS, "ok", data, null); + } + public static AjaxJson getSuccessArray(Object... data) { + return new AjaxJson(CODE_SUCCESS, "ok", data, null); + } + + // 返回失败 + public static AjaxJson getError() { + return new AjaxJson(CODE_ERROR, "error", null, null); + } + public static AjaxJson getError(String msg) { + return new AjaxJson(CODE_ERROR, msg, null, null); + } + + // 返回警告 + public static AjaxJson getWarning() { + return new AjaxJson(CODE_ERROR, "warning", null, null); + } + public static AjaxJson getWarning(String msg) { + return new AjaxJson(CODE_WARNING, msg, null, null); + } + + // 返回未登录 + public static AjaxJson getNotLogin() { + return new AjaxJson(CODE_NOT_LOGIN, "未登录,请登录后再次访问", null, null); + } + + // 返回没有权限的 + public static AjaxJson getNotJur(String msg) { + return new AjaxJson(CODE_NOT_JUR, msg, null, null); + } + + // 返回一个自定义状态码的 + public static AjaxJson get(int code, String msg){ + return new AjaxJson(code, msg, null, null); + } + + // 返回分页和数据的 + public static AjaxJson getPageData(Long dataCount, Object data){ + return new AjaxJson(CODE_SUCCESS, "ok", data, dataCount); + } + + // 返回,根据受影响行数的(大于0=ok,小于0=error) + public static AjaxJson getByLine(int line){ + if(line > 0){ + return getSuccess("ok", line); + } + return getError("error").setData(line); + } + + // 返回,根据布尔值来确定最终结果的 (true=ok,false=error) + public static AjaxJson getByBoolean(boolean b){ + return b ? getSuccess("ok") : getError("error"); + } + + /* (non-Javadoc) + * @see java.lang.Object#toString() + */ + @SuppressWarnings("rawtypes") + @Override + public String toString() { + String data_string = null; + if(data == null){ + + } else if(data instanceof List){ + data_string = "List(length=" + ((List)data).size() + ")"; + } else { + data_string = data.toString(); + } + return "{" + + "\"code\": " + this.getCode() + + ", \"msg\": \"" + this.getMsg() + "\"" + + ", \"data\": " + data_string + + ", \"dataCount\": " + dataCount + + "}"; + } + + + + + +} diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/application.yml b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/application.yml new file mode 100644 index 00000000..25264a48 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/application.yml @@ -0,0 +1,44 @@ +# 端口 +server: + port: 9000 + +spring: + # Sa-Token配置 + sa-token: + # SSO-相关配置 + sso: + # Ticket有效期 (单位: 秒),默认五分钟 + ticket-timeout: 300 + # 所有允许的授权回调地址 + allow-url: http://sa-sso-client1.com:9001/ssoLogin, http://sa-sso-client2.com:9001/ssoLogin, http://sa-sso-client3.com:9001/ssoLogin + # 接口调用秘钥(用于SSO模式三的单点注销功能) + secretkey: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor + + # Redis配置 + redis: + # Redis数据库索引(默认为0) + database: 5 + # Redis服务器地址 + host: 127.0.0.1 + # Redis服务器连接端口 + port: 6379 + # Redis服务器连接密码(默认为空) + password: + # 连接超时时间(毫秒) + timeout: 10ms + lettuce: + pool: + # 连接池最大连接数 + max-active: 200 + # 连接池最大阻塞等待时间(使用负值表示没有限制) + max-wait: -1ms + # 连接池中的最大空闲连接 + max-idle: 10 + # 连接池中的最小空闲连接 + min-idle: 0 + + + + + + \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/jquery.min.js b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/jquery.min.js new file mode 100644 index 00000000..07c00cd2 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/jquery.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.4.1 | (c) JS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],E=C.document,r=Object.getPrototypeOf,s=t.slice,g=t.concat,u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.4.1",k=function(e,t){return new k.fn.init(e,t)},p=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;function d(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp($),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+$),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(m.childNodes),m.childNodes),t[m.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&((e?e.ownerDocument||e:m)!==C&&T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!A[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&U.test(t)){(s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=k),o=(l=h(t)).length;while(o--)l[o]="#"+s+" "+xe(l[o]);c=l.join(","),f=ee.test(t)&&ye(e.parentNode)||e}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){A(t,!0)}finally{s===k&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[k]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:m;return r!==C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),m!==C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=k,!C.getElementsByName||!C.getElementsByName(k).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+k+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+k+"+*").length||v.push(".#.+[+~]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",$)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e===C||e.ownerDocument===m&&y(m,e)?-1:t===C||t.ownerDocument===m&&y(m,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===C?-1:t===C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]===m?-1:s[r]===m?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==C&&T(e),d.matchesSelector&&E&&!A[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){A(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=p[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&p(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?k.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?k.grep(e,function(e){return e===n!==r}):"string"!=typeof n?k.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(k.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:L.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof k?t[0]:t,k.merge(this,k.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),D.test(r[1])&&k.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(k):k.makeArray(e,this)}).prototype=k.fn,q=k(E);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}k.fn.extend({has:function(e){var t=k(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?k.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;nx",y.noCloneChecked=!!me.cloneNode(!0).lastChild.defaultValue;var Te=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ee=/^([^.]*)(?:\.(.+)|)/;function ke(){return!0}function Se(){return!1}function Ne(e,t){return e===function(){try{return E.activeElement}catch(e){}}()==("focus"===t)}function Ae(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)Ae(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Se;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return k().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=k.guid++)),e.each(function(){k.event.add(this,t,i,r,n)})}function De(e,i,o){o?(Q.set(e,i,!1),k.event.add(e,i,{namespace:!1,handler:function(e){var t,n,r=Q.get(this,i);if(1&e.isTrigger&&this[i]){if(r.length)(k.event.special[i]||{}).delegateType&&e.stopPropagation();else if(r=s.call(arguments),Q.set(this,i,r),t=o(this,i),this[i](),r!==(n=Q.get(this,i))||t?Q.set(this,i,!1):n={},r!==n)return e.stopImmediatePropagation(),e.preventDefault(),n.value}else r.length&&(Q.set(this,i,{value:k.event.trigger(k.extend(r[0],k.Event.prototype),r.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===Q.get(e,i)&&k.event.add(e,i,ke)}k.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.get(t);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&k.find.matchesSelector(ie,i),n.guid||(n.guid=k.guid++),(u=v.events)||(u=v.events={}),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof k&&k.event.triggered!==e.type?k.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(R)||[""]).length;while(l--)d=g=(s=Ee.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=k.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=k.event.special[d]||{},c=k.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&k.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),k.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.hasData(e)&&Q.get(e);if(v&&(u=v.events)){l=(t=(t||"").match(R)||[""]).length;while(l--)if(d=g=(s=Ee.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){f=k.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||k.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)k.event.remove(e,d+t[l],n,r,!0);k.isEmptyObject(u)&&Q.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=k.event.fix(e),u=new Array(arguments.length),l=(Q.get(this,"events")||{})[s.type]||[],c=k.event.special[s.type]||{};for(u[0]=s,t=1;t\x20\t\r\n\f]*)[^>]*)\/>/gi,qe=/\s*$/g;function Oe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&k(e).children("tbody")[0]||e}function Pe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Re(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Me(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(Q.hasData(e)&&(o=Q.access(e),a=Q.set(t,o),l=o.events))for(i in delete a.handle,a.events={},l)for(n=0,r=l[i].length;n")},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=oe(e);if(!(y.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||k.isXMLDoc(e)))for(a=ve(c),r=0,i=(o=ve(e)).length;r").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Vt,Gt=[],Yt=/(=)\?(?=&|$)|\?\?/;k.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Gt.pop()||k.expando+"_"+kt++;return this[e]=!0,e}}),k.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Yt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Yt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Yt,"$1"+r):!1!==e.jsonp&&(e.url+=(St.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||k.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?k(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Gt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Vt=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Vt.childNodes.length),k.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=D.exec(e))?[t.createElement(i[1])]:(i=we([e],t,o),o&&o.length&&k(o).remove(),k.merge([],i.childNodes)));var r,i,o},k.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(k.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},k.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){k.fn[t]=function(e){return this.on(t,e)}}),k.expr.pseudos.animated=function(t){return k.grep(k.timers,function(e){return t===e.elem}).length},k.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=k.css(e,"position"),c=k(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=k.css(e,"top"),u=k.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,k.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},k.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){k.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===k.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===k.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=k(e).offset()).top+=k.css(e,"borderTopWidth",!0),i.left+=k.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-k.css(r,"marginTop",!0),left:t.left-i.left-k.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===k.css(e,"position"))e=e.offsetParent;return e||ie})}}),k.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;k.fn[t]=function(e){return _(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),k.each(["top","left"],function(e,n){k.cssHooks[n]=ze(y.pixelPosition,function(e,t){if(t)return t=_e(e,n),$e.test(t)?k(e).position()[n]+"px":t})}),k.each({Height:"height",Width:"width"},function(a,s){k.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){k.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return _(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?k.css(e,t,i):k.style(e,t,n,i)},s,n?e:void 0,n)}})}),k.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){k.fn[n]=function(e,t){return 00;n--)if("interactive"===t[n].readyState){e=t[n].src;break}return e||t[i].src}();return e.substring(0,e.lastIndexOf("/")+1)}(),config:{},end:{},minIndex:0,minLeft:[],btn:["确定","取消"],type:["dialog","page","iframe","loading","tips"],getStyle:function(t,i){var n=t.currentStyle?t.currentStyle:e.getComputedStyle(t,null);return n[n.getPropertyValue?"getPropertyValue":"getAttribute"](i)},link:function(t,i,n){if(r.path){var a=document.getElementsByTagName("head")[0],s=document.createElement("link");"string"==typeof i&&(n=i);var l=(n||t).replace(/\.|\//g,""),f="layuicss-"+l,c=0;s.rel="stylesheet",s.href=r.path+t,s.id=f,document.getElementById(f)||a.appendChild(s),"function"==typeof i&&!function u(){return++c>80?e.console&&console.error("layer.css: Invalid"):void(1989===parseInt(o.getStyle(document.getElementById(f),"width"))?i():setTimeout(u,100))}()}}},r={v:"3.1.1",ie:function(){var t=navigator.userAgent.toLowerCase();return!!(e.ActiveXObject||"ActiveXObject"in e)&&((t.match(/msie\s(\d+)/)||[])[1]||"11")}(),index:e.layer&&e.layer.v?1e5:0,path:o.getPath,config:function(e,t){return e=e||{},r.cache=o.config=i.extend({},o.config,e),r.path=o.config.path||r.path,"string"==typeof e.extend&&(e.extend=[e.extend]),o.config.path&&r.ready(),e.extend?(a?layui.addcss("modules/layer/"+e.extend):o.link("theme/"+e.extend),this):this},ready:function(e){var t="layer",i="",n=(a?"modules/layer/":"theme/")+"default/layer.css?v="+r.v+i;return a?layui.addcss(n,e,t):o.link(n,e,t),this},alert:function(e,t,n){var a="function"==typeof t;return a&&(n=t),r.open(i.extend({content:e,yes:n},a?{}:t))},confirm:function(e,t,n,a){var s="function"==typeof t;return s&&(a=n,n=t),r.open(i.extend({content:e,btn:o.btn,yes:n,btn2:a},s?{}:t))},msg:function(e,n,a){var s="function"==typeof n,f=o.config.skin,c=(f?f+" "+f+"-msg":"")||"layui-layer-msg",u=l.anim.length-1;return s&&(a=n),r.open(i.extend({content:e,time:3e3,shade:!1,skin:c,title:!1,closeBtn:!1,btn:!1,resize:!1,end:a},s&&!o.config.skin?{skin:c+" layui-layer-hui",anim:u}:function(){return n=n||{},(n.icon===-1||n.icon===t&&!o.config.skin)&&(n.skin=c+" "+(n.skin||"layui-layer-hui")),n}()))},load:function(e,t){return r.open(i.extend({type:3,icon:e||0,resize:!1,shade:.01},t))},tips:function(e,t,n){return r.open(i.extend({type:4,content:[e,t],closeBtn:!1,time:3e3,shade:!1,resize:!1,fixed:!1,maxWidth:210},n))}},s=function(e){var t=this;t.index=++r.index,t.config=i.extend({},t.config,o.config,e),document.body?t.creat():setTimeout(function(){t.creat()},30)};s.pt=s.prototype;var l=["layui-layer",".layui-layer-title",".layui-layer-main",".layui-layer-dialog","layui-layer-iframe","layui-layer-content","layui-layer-btn","layui-layer-close"];l.anim=["layer-anim-00","layer-anim-01","layer-anim-02","layer-anim-03","layer-anim-04","layer-anim-05","layer-anim-06"],s.pt.config={type:0,shade:.3,fixed:!0,move:l[1],title:"信息",offset:"auto",area:"auto",closeBtn:1,time:0,zIndex:19891014,maxWidth:360,anim:0,isOutAnim:!0,icon:-1,moveType:1,resize:!0,scrollbar:!0,tips:2},s.pt.vessel=function(e,t){var n=this,a=n.index,r=n.config,s=r.zIndex+a,f="object"==typeof r.title,c=r.maxmin&&(1===r.type||2===r.type),u=r.title?'
'+(f?r.title[0]:r.title)+"
":"";return r.zIndex=s,t([r.shade?'
':"",'
'+(e&&2!=r.type?"":u)+'
'+(0==r.type&&r.icon!==-1?'':"")+(1==r.type&&e?"":r.content||"")+'
'+function(){var e=c?'':"";return r.closeBtn&&(e+=''),e}()+""+(r.btn?function(){var e="";"string"==typeof r.btn&&(r.btn=[r.btn]);for(var t=0,i=r.btn.length;t'+r.btn[t]+"";return'
'+e+"
"}():"")+(r.resize?'':"")+"
"],u,i('
')),n},s.pt.creat=function(){var e=this,t=e.config,a=e.index,s=t.content,f="object"==typeof s,c=i("body");if(!t.id||!i("#"+t.id)[0]){switch("string"==typeof t.area&&(t.area="auto"===t.area?["",""]:[t.area,""]),t.shift&&(t.anim=t.shift),6==r.ie&&(t.fixed=!1),t.type){case 0:t.btn="btn"in t?t.btn:o.btn[0],r.closeAll("dialog");break;case 2:var s=t.content=f?t.content:[t.content||"http://layer.layui.com","auto"];t.content='';break;case 3:delete t.title,delete t.closeBtn,t.icon===-1&&0===t.icon,r.closeAll("loading");break;case 4:f||(t.content=[t.content,"body"]),t.follow=t.content[1],t.content=t.content[0]+'',delete t.title,t.tips="object"==typeof t.tips?t.tips:[t.tips,!0],t.tipsMore||r.closeAll("tips")}if(e.vessel(f,function(n,r,u){c.append(n[0]),f?function(){2==t.type||4==t.type?function(){i("body").append(n[1])}():function(){s.parents("."+l[0])[0]||(s.data("display",s.css("display")).show().addClass("layui-layer-wrap").wrap(n[1]),i("#"+l[0]+a).find("."+l[5]).before(r))}()}():c.append(n[1]),i(".layui-layer-move")[0]||c.append(o.moveElem=u),e.layero=i("#"+l[0]+a),t.scrollbar||l.html.css("overflow","hidden").attr("layer-full",a)}).auto(a),i("#layui-layer-shade"+e.index).css({"background-color":t.shade[1]||"#000",opacity:t.shade[0]||t.shade}),2==t.type&&6==r.ie&&e.layero.find("iframe").attr("src",s[0]),4==t.type?e.tips():e.offset(),t.fixed&&n.on("resize",function(){e.offset(),(/^\d+%$/.test(t.area[0])||/^\d+%$/.test(t.area[1]))&&e.auto(a),4==t.type&&e.tips()}),t.time<=0||setTimeout(function(){r.close(e.index)},t.time),e.move().callback(),l.anim[t.anim]){var u="layer-anim "+l.anim[t.anim];e.layero.addClass(u).one("webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend",function(){i(this).removeClass(u)})}t.isOutAnim&&e.layero.data("isOutAnim",!0)}},s.pt.auto=function(e){var t=this,a=t.config,o=i("#"+l[0]+e);""===a.area[0]&&a.maxWidth>0&&(r.ie&&r.ie<8&&a.btn&&o.width(o.innerWidth()),o.outerWidth()>a.maxWidth&&o.width(a.maxWidth));var s=[o.innerWidth(),o.innerHeight()],f=o.find(l[1]).outerHeight()||0,c=o.find("."+l[6]).outerHeight()||0,u=function(e){e=o.find(e),e.height(s[1]-f-c-2*(0|parseFloat(e.css("padding-top"))))};switch(a.type){case 2:u("iframe");break;default:""===a.area[1]?a.maxHeight>0&&o.outerHeight()>a.maxHeight?(s[1]=a.maxHeight,u("."+l[5])):a.fixed&&s[1]>=n.height()&&(s[1]=n.height(),u("."+l[5])):u("."+l[5])}return t},s.pt.offset=function(){var e=this,t=e.config,i=e.layero,a=[i.outerWidth(),i.outerHeight()],o="object"==typeof t.offset;e.offsetTop=(n.height()-a[1])/2,e.offsetLeft=(n.width()-a[0])/2,o?(e.offsetTop=t.offset[0],e.offsetLeft=t.offset[1]||e.offsetLeft):"auto"!==t.offset&&("t"===t.offset?e.offsetTop=0:"r"===t.offset?e.offsetLeft=n.width()-a[0]:"b"===t.offset?e.offsetTop=n.height()-a[1]:"l"===t.offset?e.offsetLeft=0:"lt"===t.offset?(e.offsetTop=0,e.offsetLeft=0):"lb"===t.offset?(e.offsetTop=n.height()-a[1],e.offsetLeft=0):"rt"===t.offset?(e.offsetTop=0,e.offsetLeft=n.width()-a[0]):"rb"===t.offset?(e.offsetTop=n.height()-a[1],e.offsetLeft=n.width()-a[0]):e.offsetTop=t.offset),t.fixed||(e.offsetTop=/%$/.test(e.offsetTop)?n.height()*parseFloat(e.offsetTop)/100:parseFloat(e.offsetTop),e.offsetLeft=/%$/.test(e.offsetLeft)?n.width()*parseFloat(e.offsetLeft)/100:parseFloat(e.offsetLeft),e.offsetTop+=n.scrollTop(),e.offsetLeft+=n.scrollLeft()),i.attr("minLeft")&&(e.offsetTop=n.height()-(i.find(l[1]).outerHeight()||0),e.offsetLeft=i.css("left")),i.css({top:e.offsetTop,left:e.offsetLeft})},s.pt.tips=function(){var e=this,t=e.config,a=e.layero,o=[a.outerWidth(),a.outerHeight()],r=i(t.follow);r[0]||(r=i("body"));var s={width:r.outerWidth(),height:r.outerHeight(),top:r.offset().top,left:r.offset().left},f=a.find(".layui-layer-TipsG"),c=t.tips[0];t.tips[1]||f.remove(),s.autoLeft=function(){s.left+o[0]-n.width()>0?(s.tipLeft=s.left+s.width-o[0],f.css({right:12,left:"auto"})):s.tipLeft=s.left},s.where=[function(){s.autoLeft(),s.tipTop=s.top-o[1]-10,f.removeClass("layui-layer-TipsB").addClass("layui-layer-TipsT").css("border-right-color",t.tips[1])},function(){s.tipLeft=s.left+s.width+10,s.tipTop=s.top,f.removeClass("layui-layer-TipsL").addClass("layui-layer-TipsR").css("border-bottom-color",t.tips[1])},function(){s.autoLeft(),s.tipTop=s.top+s.height+10,f.removeClass("layui-layer-TipsT").addClass("layui-layer-TipsB").css("border-right-color",t.tips[1])},function(){s.tipLeft=s.left-o[0]-10,s.tipTop=s.top,f.removeClass("layui-layer-TipsR").addClass("layui-layer-TipsL").css("border-bottom-color",t.tips[1])}],s.where[c-1](),1===c?s.top-(n.scrollTop()+o[1]+16)<0&&s.where[2]():2===c?n.width()-(s.left+s.width+o[0]+16)>0||s.where[3]():3===c?s.top-n.scrollTop()+s.height+o[1]+16-n.height()>0&&s.where[0]():4===c&&o[0]+16-s.left>0&&s.where[1](),a.find("."+l[5]).css({"background-color":t.tips[1],"padding-right":t.closeBtn?"30px":""}),a.css({left:s.tipLeft-(t.fixed?n.scrollLeft():0),top:s.tipTop-(t.fixed?n.scrollTop():0)})},s.pt.move=function(){var e=this,t=e.config,a=i(document),s=e.layero,l=s.find(t.move),f=s.find(".layui-layer-resize"),c={};return t.move&&l.css("cursor","move"),l.on("mousedown",function(e){e.preventDefault(),t.move&&(c.moveStart=!0,c.offset=[e.clientX-parseFloat(s.css("left")),e.clientY-parseFloat(s.css("top"))],o.moveElem.css("cursor","move").show())}),f.on("mousedown",function(e){e.preventDefault(),c.resizeStart=!0,c.offset=[e.clientX,e.clientY],c.area=[s.outerWidth(),s.outerHeight()],o.moveElem.css("cursor","se-resize").show()}),a.on("mousemove",function(i){if(c.moveStart){var a=i.clientX-c.offset[0],o=i.clientY-c.offset[1],l="fixed"===s.css("position");if(i.preventDefault(),c.stX=l?0:n.scrollLeft(),c.stY=l?0:n.scrollTop(),!t.moveOut){var f=n.width()-s.outerWidth()+c.stX,u=n.height()-s.outerHeight()+c.stY;af&&(a=f),ou&&(o=u)}s.css({left:a,top:o})}if(t.resize&&c.resizeStart){var a=i.clientX-c.offset[0],o=i.clientY-c.offset[1];i.preventDefault(),r.style(e.index,{width:c.area[0]+a,height:c.area[1]+o}),c.isResize=!0,t.resizing&&t.resizing(s)}}).on("mouseup",function(e){c.moveStart&&(delete c.moveStart,o.moveElem.hide(),t.moveEnd&&t.moveEnd(s)),c.resizeStart&&(delete c.resizeStart,o.moveElem.hide())}),e},s.pt.callback=function(){function e(){var e=a.cancel&&a.cancel(t.index,n);e===!1||r.close(t.index)}var t=this,n=t.layero,a=t.config;t.openLayer(),a.success&&(2==a.type?n.find("iframe").on("load",function(){a.success(n,t.index)}):a.success(n,t.index)),6==r.ie&&t.IE6(n),n.find("."+l[6]).children("a").on("click",function(){var e=i(this).index();if(0===e)a.yes?a.yes(t.index,n):a.btn1?a.btn1(t.index,n):r.close(t.index);else{var o=a["btn"+(e+1)]&&a["btn"+(e+1)](t.index,n);o===!1||r.close(t.index)}}),n.find("."+l[7]).on("click",e),a.shadeClose&&i("#layui-layer-shade"+t.index).on("click",function(){r.close(t.index)}),n.find(".layui-layer-min").on("click",function(){var e=a.min&&a.min(n);e===!1||r.min(t.index,a)}),n.find(".layui-layer-max").on("click",function(){i(this).hasClass("layui-layer-maxmin")?(r.restore(t.index),a.restore&&a.restore(n)):(r.full(t.index,a),setTimeout(function(){a.full&&a.full(n)},100))}),a.end&&(o.end[t.index]=a.end)},o.reselect=function(){i.each(i("select"),function(e,t){var n=i(this);n.parents("."+l[0])[0]||1==n.attr("layer")&&i("."+l[0]).length<1&&n.removeAttr("layer").show(),n=null})},s.pt.IE6=function(e){i("select").each(function(e,t){var n=i(this);n.parents("."+l[0])[0]||"none"===n.css("display")||n.attr({layer:"1"}).hide(),n=null})},s.pt.openLayer=function(){var e=this;r.zIndex=e.config.zIndex,r.setTop=function(e){var t=function(){r.zIndex++,e.css("z-index",r.zIndex+1)};return r.zIndex=parseInt(e[0].style.zIndex),e.on("mousedown",t),r.zIndex}},o.record=function(e){var t=[e.width(),e.height(),e.position().top,e.position().left+parseFloat(e.css("margin-left"))];e.find(".layui-layer-max").addClass("layui-layer-maxmin"),e.attr({area:t})},o.rescollbar=function(e){l.html.attr("layer-full")==e&&(l.html[0].style.removeProperty?l.html[0].style.removeProperty("overflow"):l.html[0].style.removeAttribute("overflow"),l.html.removeAttr("layer-full"))},e.layer=r,r.getChildFrame=function(e,t){return t=t||i("."+l[4]).attr("times"),i("#"+l[0]+t).find("iframe").contents().find(e)},r.getFrameIndex=function(e){return i("#"+e).parents("."+l[4]).attr("times")},r.iframeAuto=function(e){if(e){var t=r.getChildFrame("html",e).outerHeight(),n=i("#"+l[0]+e),a=n.find(l[1]).outerHeight()||0,o=n.find("."+l[6]).outerHeight()||0;n.css({height:t+a+o}),n.find("iframe").css({height:t})}},r.iframeSrc=function(e,t){i("#"+l[0]+e).find("iframe").attr("src",t)},r.style=function(e,t,n){var a=i("#"+l[0]+e),r=a.find(".layui-layer-content"),s=a.attr("type"),f=a.find(l[1]).outerHeight()||0,c=a.find("."+l[6]).outerHeight()||0;a.attr("minLeft");s!==o.type[3]&&s!==o.type[4]&&(n||(parseFloat(t.width)<=260&&(t.width=260),parseFloat(t.height)-f-c<=64&&(t.height=64+f+c)),a.css(t),c=a.find("."+l[6]).outerHeight(),s===o.type[2]?a.find("iframe").css({height:parseFloat(t.height)-f-c}):r.css({height:parseFloat(t.height)-f-c-parseFloat(r.css("padding-top"))-parseFloat(r.css("padding-bottom"))}))},r.min=function(e,t){var a=i("#"+l[0]+e),s=a.find(l[1]).outerHeight()||0,f=a.attr("minLeft")||181*o.minIndex+"px",c=a.css("position");o.record(a),o.minLeft[0]&&(f=o.minLeft[0],o.minLeft.shift()),a.attr("position",c),r.style(e,{width:180,height:s,left:f,top:n.height()-s,position:"fixed",overflow:"hidden"},!0),a.find(".layui-layer-min").hide(),"page"===a.attr("type")&&a.find(l[4]).hide(),o.rescollbar(e),a.attr("minLeft")||o.minIndex++,a.attr("minLeft",f)},r.restore=function(e){var t=i("#"+l[0]+e),n=t.attr("area").split(",");t.attr("type");r.style(e,{width:parseFloat(n[0]),height:parseFloat(n[1]),top:parseFloat(n[2]),left:parseFloat(n[3]),position:t.attr("position"),overflow:"visible"},!0),t.find(".layui-layer-max").removeClass("layui-layer-maxmin"),t.find(".layui-layer-min").show(),"page"===t.attr("type")&&t.find(l[4]).show(),o.rescollbar(e)},r.full=function(e){var t,a=i("#"+l[0]+e);o.record(a),l.html.attr("layer-full")||l.html.css("overflow","hidden").attr("layer-full",e),clearTimeout(t),t=setTimeout(function(){var t="fixed"===a.css("position");r.style(e,{top:t?0:n.scrollTop(),left:t?0:n.scrollLeft(),width:n.width(),height:n.height()},!0),a.find(".layui-layer-min").hide()},100)},r.title=function(e,t){var n=i("#"+l[0]+(t||r.index)).find(l[1]);n.html(e)},r.close=function(e){var t=i("#"+l[0]+e),n=t.attr("type"),a="layer-anim-close";if(t[0]){var s="layui-layer-wrap",f=function(){if(n===o.type[1]&&"object"===t.attr("conType")){t.children(":not(."+l[5]+")").remove();for(var a=t.find("."+s),r=0;r<2;r++)a.unwrap();a.css("display",a.data("display")).removeClass(s)}else{if(n===o.type[2])try{var f=i("#"+l[4]+e)[0];f.contentWindow.document.write(""),f.contentWindow.close(),t.find("."+l[5])[0].removeChild(f)}catch(c){}t[0].innerHTML="",t.remove()}"function"==typeof o.end[e]&&o.end[e](),delete o.end[e]};t.data("isOutAnim")&&t.addClass("layer-anim "+a),i("#layui-layer-moves, #layui-layer-shade"+e).remove(),6==r.ie&&o.reselect(),o.rescollbar(e),t.attr("minLeft")&&(o.minIndex--,o.minLeft.push(t.attr("minLeft"))),r.ie&&r.ie<10||!t.data("isOutAnim")?f():setTimeout(function(){f()},200)}},r.closeAll=function(e){i.each(i("."+l[0]),function(){var t=i(this),n=e?t.attr("type")===e:1;n&&r.close(t.attr("times")),n=null})};var f=r.cache||{},c=function(e){return f.skin?" "+f.skin+" "+f.skin+"-"+e:""};r.prompt=function(e,t){var a="";if(e=e||{},"function"==typeof e&&(t=e),e.area){var o=e.area;a='style="width: '+o[0]+"; height: "+o[1]+';"',delete e.area}var s,l=2==e.formType?'":function(){return''}(),f=e.success;return delete e.success,r.open(i.extend({type:1,btn:["确定","取消"],content:l,skin:"layui-layer-prompt"+c("prompt"),maxWidth:n.width(),success:function(e){s=e.find(".layui-layer-input"),s.focus(),"function"==typeof f&&f(e)},resize:!1,yes:function(i){var n=s.val();""===n?s.focus():n.length>(e.maxlength||500)?r.tips("最多输入"+(e.maxlength||500)+"个字数",s,{tips:1}):t&&t(n,i,s)}},e))},r.tab=function(e){e=e||{};var t=e.tab||{},n="layui-this",a=e.success;return delete e.success,r.open(i.extend({type:1,skin:"layui-layer-tab"+c("tab"),resize:!1,title:function(){var e=t.length,i=1,a="";if(e>0)for(a=''+t[0].title+"";i"+t[i].title+"";return a}(),content:'
    '+function(){var e=t.length,i=1,a="";if(e>0)for(a='
  • '+(t[0].content||"no content")+"
  • ";i'+(t[i].content||"no content")+"";return a}()+"
",success:function(t){var o=t.find(".layui-layer-title").children(),r=t.find(".layui-layer-tabmain").children();o.on("mousedown",function(t){t.stopPropagation?t.stopPropagation():t.cancelBubble=!0;var a=i(this),o=a.index();a.addClass(n).siblings().removeClass(n),r.eq(o).show().siblings().hide(),"function"==typeof e.change&&e.change(o)}),"function"==typeof a&&a(t)}},e))},r.photos=function(t,n,a){function o(e,t,i){var n=new Image;return n.src=e,n.complete?t(n):(n.onload=function(){n.onload=null,t(n)},void(n.onerror=function(e){n.onerror=null,i(e)}))}var s={};if(t=t||{},t.photos){var l=t.photos.constructor===Object,f=l?t.photos:{},u=f.data||[],d=f.start||0;s.imgIndex=(0|d)+1,t.img=t.img||"img";var y=t.success;if(delete t.success,l){if(0===u.length)return r.msg("没有图片")}else{var p=i(t.photos),h=function(){u=[],p.find(t.img).each(function(e){var t=i(this);t.attr("layer-index",e),u.push({alt:t.attr("alt"),pid:t.attr("layer-pid"),src:t.attr("layer-src")||t.attr("src"),thumb:t.attr("src")})})};if(h(),0===u.length)return;if(n||p.on("click",t.img,function(){var e=i(this),n=e.attr("layer-index");r.photos(i.extend(t,{photos:{start:n,data:u,tab:t.tab},full:t.full}),!0),h()}),!n)return}s.imgprev=function(e){s.imgIndex--,s.imgIndex<1&&(s.imgIndex=u.length),s.tabimg(e)},s.imgnext=function(e,t){s.imgIndex++,s.imgIndex>u.length&&(s.imgIndex=1,t)||s.tabimg(e)},s.keyup=function(e){if(!s.end){var t=e.keyCode;e.preventDefault(),37===t?s.imgprev(!0):39===t?s.imgnext(!0):27===t&&r.close(s.index)}},s.tabimg=function(e){if(!(u.length<=1))return f.start=s.imgIndex-1,r.close(s.index),r.photos(t,!0,e)},s.event=function(){s.bigimg.hover(function(){s.imgsee.show()},function(){s.imgsee.hide()}),s.bigimg.find(".layui-layer-imgprev").on("click",function(e){e.preventDefault(),s.imgprev()}),s.bigimg.find(".layui-layer-imgnext").on("click",function(e){e.preventDefault(),s.imgnext()}),i(document).on("keyup",s.keyup)},s.loadi=r.load(1,{shade:!("shade"in t)&&.9,scrollbar:!1}),o(u[d].src,function(n){r.close(s.loadi),s.index=r.open(i.extend({type:1,id:"layui-layer-photos",area:function(){var a=[n.width,n.height],o=[i(e).width()-100,i(e).height()-100];if(!t.full&&(a[0]>o[0]||a[1]>o[1])){var r=[a[0]/o[0],a[1]/o[1]];r[0]>r[1]?(a[0]=a[0]/r[0],a[1]=a[1]/r[0]):r[0]'+(u[d].alt||
'+(u.length>1?'':"")+'
'+(u[d].alt||"")+""+s.imgIndex+"/"+u.length+"
",success:function(e,i){s.bigimg=e.find(".layui-layer-phimg"),s.imgsee=e.find(".layui-layer-imguide,.layui-layer-imgbar"),s.event(e),t.tab&&t.tab(u[d],e),"function"==typeof y&&y(e)},end:function(){s.end=!0,i(document).off("keyup",s.keyup)}},t))},function(){r.close(s.loadi),r.msg("当前图片地址异常
是否继续查看下一张?",{time:3e4,btn:["下一张","不看了"],yes:function(){u.length>1&&s.imgnext(!0,!0)}})})}},o.run=function(t){i=t,n=i(e),l.html=i("html"),r.open=function(e){var t=new s(e);return t.index}},e.layui&&layui.define?(r.ready(),layui.define("jquery",function(t){r.path=layui.cache.dir,o.run(layui.$),e.layer=r,t("layer",r)})):"function"==typeof define&&define.amd?define(["jquery"],function(){return o.run(e.jQuery),r}):function(){o.run(e.jQuery),r.ready()}()}(window); \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/mobile/layer.js b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/mobile/layer.js new file mode 100644 index 00000000..f9cf6931 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/mobile/layer.js @@ -0,0 +1,2 @@ +/*! layer mobile-v2.0.0 Web弹层组件 MIT License http://layer.layui.com/mobile By 贤心 */ + ;!function(e){"use strict";var t=document,n="querySelectorAll",i="getElementsByClassName",a=function(e){return t[n](e)},s={type:0,shade:!0,shadeClose:!0,fixed:!0,anim:"scale"},l={extend:function(e){var t=JSON.parse(JSON.stringify(s));for(var n in e)t[n]=e[n];return t},timer:{},end:{}};l.touch=function(e,t){e.addEventListener("click",function(e){t.call(this,e)},!1)};var r=0,o=["layui-m-layer"],c=function(e){var t=this;t.config=l.extend(e),t.view()};c.prototype.view=function(){var e=this,n=e.config,s=t.createElement("div");e.id=s.id=o[0]+r,s.setAttribute("class",o[0]+" "+o[0]+(n.type||0)),s.setAttribute("index",r);var l=function(){var e="object"==typeof n.title;return n.title?'

'+(e?n.title[0]:n.title)+"

":""}(),c=function(){"string"==typeof n.btn&&(n.btn=[n.btn]);var e,t=(n.btn||[]).length;return 0!==t&&n.btn?(e=''+n.btn[0]+"",2===t&&(e=''+n.btn[1]+""+e),'
'+e+"
"):""}();if(n.fixed||(n.top=n.hasOwnProperty("top")?n.top:100,n.style=n.style||"",n.style+=" top:"+(t.body.scrollTop+n.top)+"px"),2===n.type&&(n.content='

'+(n.content||"")+"

"),n.skin&&(n.anim="up"),"msg"===n.skin&&(n.shade=!1),s.innerHTML=(n.shade?"
':"")+'
"+l+'
'+n.content+"
"+c+"
",!n.type||2===n.type){var d=t[i](o[0]+n.type),y=d.length;y>=1&&layer.close(d[0].getAttribute("index"))}document.body.appendChild(s);var u=e.elem=a("#"+e.id)[0];n.success&&n.success(u),e.index=r++,e.action(n,u)},c.prototype.action=function(e,t){var n=this;e.time&&(l.timer[n.index]=setTimeout(function(){layer.close(n.index)},1e3*e.time));var a=function(){var t=this.getAttribute("type");0==t?(e.no&&e.no(),layer.close(n.index)):e.yes?e.yes(n.index):layer.close(n.index)};if(e.btn)for(var s=t[i]("layui-m-layerbtn")[0].children,r=s.length,o=0;odiv{line-height:22px;padding-top:7px;margin-bottom:20px;font-size:14px}.layui-m-layerbtn{display:box;display:-moz-box;display:-webkit-box;width:100%;height:50px;line-height:50px;font-size:0;border-top:1px solid #D0D0D0;background-color:#F2F2F2}.layui-m-layerbtn span{display:block;-moz-box-flex:1;box-flex:1;-webkit-box-flex:1;font-size:14px;cursor:pointer}.layui-m-layerbtn span[yes]{color:#40AFFE}.layui-m-layerbtn span[no]{border-right:1px solid #D0D0D0;border-radius:0 0 0 5px}.layui-m-layerbtn span:active{background-color:#F6F6F6}.layui-m-layerend{position:absolute;right:7px;top:10px;width:30px;height:30px;border:0;font-weight:400;background:0 0;cursor:pointer;-webkit-appearance:none;font-size:30px}.layui-m-layerend::after,.layui-m-layerend::before{position:absolute;left:5px;top:15px;content:'';width:18px;height:1px;background-color:#999;transform:rotate(45deg);-webkit-transform:rotate(45deg);border-radius:3px}.layui-m-layerend::after{transform:rotate(-45deg);-webkit-transform:rotate(-45deg)}body .layui-m-layer .layui-m-layer-footer{position:fixed;width:95%;max-width:100%;margin:0 auto;left:0;right:0;bottom:10px;background:0 0}.layui-m-layer-footer .layui-m-layercont{padding:20px;border-radius:5px 5px 0 0;background-color:rgba(255,255,255,.8)}.layui-m-layer-footer .layui-m-layerbtn{display:block;height:auto;background:0 0;border-top:none}.layui-m-layer-footer .layui-m-layerbtn span{background-color:rgba(255,255,255,.8)}.layui-m-layer-footer .layui-m-layerbtn span[no]{color:#FD482C;border-top:1px solid #c2c2c2;border-radius:0 0 5px 5px}.layui-m-layer-footer .layui-m-layerbtn span[yes]{margin-top:10px;border-radius:5px}body .layui-m-layer .layui-m-layer-msg{width:auto;max-width:90%;margin:0 auto;bottom:-150px;background-color:rgba(0,0,0,.7);color:#fff}.layui-m-layer-msg .layui-m-layercont{padding:10px 20px} \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/icon-ext.png b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/icon-ext.png new file mode 100644 index 00000000..bbbb669b Binary files /dev/null and b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/icon-ext.png differ diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/icon.png b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/icon.png new file mode 100644 index 00000000..3e17da8b Binary files /dev/null and b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/icon.png differ diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/layer.css b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/layer.css new file mode 100644 index 00000000..820b4a99 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/layer.css @@ -0,0 +1 @@ +.layui-layer-imgbar,.layui-layer-imgtit a,.layui-layer-tab .layui-layer-title span,.layui-layer-title{text-overflow:ellipsis;white-space:nowrap}html #layuicss-layer{display:none;position:absolute;width:1989px}.layui-layer,.layui-layer-shade{position:fixed;_position:absolute;pointer-events:auto}.layui-layer-shade{top:0;left:0;width:100%;height:100%;_height:expression(document.body.offsetHeight+"px")}.layui-layer{-webkit-overflow-scrolling:touch;top:150px;left:0;margin:0;padding:0;background-color:#fff;-webkit-background-clip:content;border-radius:2px;box-shadow:1px 1px 50px rgba(0,0,0,.3)}.layui-layer-close{position:absolute}.layui-layer-content{position:relative}.layui-layer-border{border:1px solid #B2B2B2;border:1px solid rgba(0,0,0,.1);box-shadow:1px 1px 5px rgba(0,0,0,.2)}.layui-layer-load{background:url(loading-1.gif) center center no-repeat #eee}.layui-layer-ico{background:url(icon.png) no-repeat}.layui-layer-btn a,.layui-layer-dialog .layui-layer-ico,.layui-layer-setwin a{display:inline-block;*display:inline;*zoom:1;vertical-align:top}.layui-layer-move{display:none;position:fixed;*position:absolute;left:0;top:0;width:100%;height:100%;cursor:move;opacity:0;filter:alpha(opacity=0);background-color:#fff;z-index:2147483647}.layui-layer-resize{position:absolute;width:15px;height:15px;right:0;bottom:0;cursor:se-resize}.layer-anim{-webkit-animation-fill-mode:both;animation-fill-mode:both;-webkit-animation-duration:.3s;animation-duration:.3s}@-webkit-keyframes layer-bounceIn{0%{opacity:0;-webkit-transform:scale(.5);transform:scale(.5)}100%{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@keyframes layer-bounceIn{0%{opacity:0;-webkit-transform:scale(.5);-ms-transform:scale(.5);transform:scale(.5)}100%{opacity:1;-webkit-transform:scale(1);-ms-transform:scale(1);transform:scale(1)}}.layer-anim-00{-webkit-animation-name:layer-bounceIn;animation-name:layer-bounceIn}@-webkit-keyframes layer-zoomInDown{0%{opacity:0;-webkit-transform:scale(.1) translateY(-2000px);transform:scale(.1) translateY(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateY(60px);transform:scale(.475) translateY(60px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}@keyframes layer-zoomInDown{0%{opacity:0;-webkit-transform:scale(.1) translateY(-2000px);-ms-transform:scale(.1) translateY(-2000px);transform:scale(.1) translateY(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateY(60px);-ms-transform:scale(.475) translateY(60px);transform:scale(.475) translateY(60px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}.layer-anim-01{-webkit-animation-name:layer-zoomInDown;animation-name:layer-zoomInDown}@-webkit-keyframes layer-fadeInUpBig{0%{opacity:0;-webkit-transform:translateY(2000px);transform:translateY(2000px)}100%{opacity:1;-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes layer-fadeInUpBig{0%{opacity:0;-webkit-transform:translateY(2000px);-ms-transform:translateY(2000px);transform:translateY(2000px)}100%{opacity:1;-webkit-transform:translateY(0);-ms-transform:translateY(0);transform:translateY(0)}}.layer-anim-02{-webkit-animation-name:layer-fadeInUpBig;animation-name:layer-fadeInUpBig}@-webkit-keyframes layer-zoomInLeft{0%{opacity:0;-webkit-transform:scale(.1) translateX(-2000px);transform:scale(.1) translateX(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateX(48px);transform:scale(.475) translateX(48px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}@keyframes layer-zoomInLeft{0%{opacity:0;-webkit-transform:scale(.1) translateX(-2000px);-ms-transform:scale(.1) translateX(-2000px);transform:scale(.1) translateX(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateX(48px);-ms-transform:scale(.475) translateX(48px);transform:scale(.475) translateX(48px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}.layer-anim-03{-webkit-animation-name:layer-zoomInLeft;animation-name:layer-zoomInLeft}@-webkit-keyframes layer-rollIn{0%{opacity:0;-webkit-transform:translateX(-100%) rotate(-120deg);transform:translateX(-100%) rotate(-120deg)}100%{opacity:1;-webkit-transform:translateX(0) rotate(0);transform:translateX(0) rotate(0)}}@keyframes layer-rollIn{0%{opacity:0;-webkit-transform:translateX(-100%) rotate(-120deg);-ms-transform:translateX(-100%) rotate(-120deg);transform:translateX(-100%) rotate(-120deg)}100%{opacity:1;-webkit-transform:translateX(0) rotate(0);-ms-transform:translateX(0) rotate(0);transform:translateX(0) rotate(0)}}.layer-anim-04{-webkit-animation-name:layer-rollIn;animation-name:layer-rollIn}@keyframes layer-fadeIn{0%{opacity:0}100%{opacity:1}}.layer-anim-05{-webkit-animation-name:layer-fadeIn;animation-name:layer-fadeIn}@-webkit-keyframes layer-shake{0%,100%{-webkit-transform:translateX(0);transform:translateX(0)}10%,30%,50%,70%,90%{-webkit-transform:translateX(-10px);transform:translateX(-10px)}20%,40%,60%,80%{-webkit-transform:translateX(10px);transform:translateX(10px)}}@keyframes layer-shake{0%,100%{-webkit-transform:translateX(0);-ms-transform:translateX(0);transform:translateX(0)}10%,30%,50%,70%,90%{-webkit-transform:translateX(-10px);-ms-transform:translateX(-10px);transform:translateX(-10px)}20%,40%,60%,80%{-webkit-transform:translateX(10px);-ms-transform:translateX(10px);transform:translateX(10px)}}.layer-anim-06{-webkit-animation-name:layer-shake;animation-name:layer-shake}@-webkit-keyframes fadeIn{0%{opacity:0}100%{opacity:1}}.layui-layer-title{padding:0 80px 0 20px;height:42px;line-height:42px;border-bottom:1px solid #eee;font-size:14px;color:#333;overflow:hidden;background-color:#F8F8F8;border-radius:2px 2px 0 0}.layui-layer-setwin{position:absolute;right:15px;*right:0;top:15px;font-size:0;line-height:initial}.layui-layer-setwin a{position:relative;width:16px;height:16px;margin-left:10px;font-size:12px;_overflow:hidden}.layui-layer-setwin .layui-layer-min cite{position:absolute;width:14px;height:2px;left:0;top:50%;margin-top:-1px;background-color:#2E2D3C;cursor:pointer;_overflow:hidden}.layui-layer-setwin .layui-layer-min:hover cite{background-color:#2D93CA}.layui-layer-setwin .layui-layer-max{background-position:-32px -40px}.layui-layer-setwin .layui-layer-max:hover{background-position:-16px -40px}.layui-layer-setwin .layui-layer-maxmin{background-position:-65px -40px}.layui-layer-setwin .layui-layer-maxmin:hover{background-position:-49px -40px}.layui-layer-setwin .layui-layer-close1{background-position:1px -40px;cursor:pointer}.layui-layer-setwin .layui-layer-close1:hover{opacity:.7}.layui-layer-setwin .layui-layer-close2{position:absolute;right:-28px;top:-28px;width:30px;height:30px;margin-left:0;background-position:-149px -31px;*right:-18px;_display:none}.layui-layer-setwin .layui-layer-close2:hover{background-position:-180px -31px}.layui-layer-btn{text-align:right;padding:0 15px 12px;pointer-events:auto;user-select:none;-webkit-user-select:none}.layui-layer-btn a{height:28px;line-height:28px;margin:5px 5px 0;padding:0 15px;border:1px solid #dedede;background-color:#fff;color:#333;border-radius:2px;font-weight:400;cursor:pointer;text-decoration:none}.layui-layer-btn a:hover{opacity:.9;text-decoration:none}.layui-layer-btn a:active{opacity:.8}.layui-layer-btn .layui-layer-btn0{border-color:#1E9FFF;background-color:#1E9FFF;color:#fff}.layui-layer-btn-l{text-align:left}.layui-layer-btn-c{text-align:center}.layui-layer-dialog{min-width:260px}.layui-layer-dialog .layui-layer-content{position:relative;padding:20px;line-height:24px;word-break:break-all;overflow:hidden;font-size:14px;overflow-x:hidden;overflow-y:auto}.layui-layer-dialog .layui-layer-content .layui-layer-ico{position:absolute;top:16px;left:15px;_left:-40px;width:30px;height:30px}.layui-layer-ico1{background-position:-30px 0}.layui-layer-ico2{background-position:-60px 0}.layui-layer-ico3{background-position:-90px 0}.layui-layer-ico4{background-position:-120px 0}.layui-layer-ico5{background-position:-150px 0}.layui-layer-ico6{background-position:-180px 0}.layui-layer-rim{border:6px solid #8D8D8D;border:6px solid rgba(0,0,0,.3);border-radius:5px;box-shadow:none}.layui-layer-msg{min-width:180px;border:1px solid #D3D4D3;box-shadow:none}.layui-layer-hui{min-width:100px;background-color:#000;filter:alpha(opacity=60);background-color:rgba(0,0,0,.6);color:#fff;border:none}.layui-layer-hui .layui-layer-content{padding:12px 25px;text-align:center}.layui-layer-dialog .layui-layer-padding{padding:20px 20px 20px 55px;text-align:left}.layui-layer-page .layui-layer-content{position:relative;overflow:auto}.layui-layer-iframe .layui-layer-btn,.layui-layer-page .layui-layer-btn{padding-top:10px}.layui-layer-nobg{background:0 0}.layui-layer-iframe iframe{display:block;width:100%}.layui-layer-loading{border-radius:100%;background:0 0;box-shadow:none;border:none}.layui-layer-loading .layui-layer-content{width:60px;height:24px;background:url(loading-0.gif) no-repeat}.layui-layer-loading .layui-layer-loading1{width:37px;height:37px;background:url(loading-1.gif) no-repeat}.layui-layer-ico16,.layui-layer-loading .layui-layer-loading2{width:32px;height:32px;background:url(loading-2.gif) no-repeat}.layui-layer-tips{background:0 0;box-shadow:none;border:none}.layui-layer-tips .layui-layer-content{position:relative;line-height:22px;min-width:12px;padding:8px 15px;font-size:12px;_float:left;border-radius:2px;box-shadow:1px 1px 3px rgba(0,0,0,.2);background-color:#000;color:#fff}.layui-layer-tips .layui-layer-close{right:-2px;top:-1px}.layui-layer-tips i.layui-layer-TipsG{position:absolute;width:0;height:0;border-width:8px;border-color:transparent;border-style:dashed;*overflow:hidden}.layui-layer-tips i.layui-layer-TipsB,.layui-layer-tips i.layui-layer-TipsT{left:5px;border-right-style:solid;border-right-color:#000}.layui-layer-tips i.layui-layer-TipsT{bottom:-8px}.layui-layer-tips i.layui-layer-TipsB{top:-8px}.layui-layer-tips i.layui-layer-TipsL,.layui-layer-tips i.layui-layer-TipsR{top:5px;border-bottom-style:solid;border-bottom-color:#000}.layui-layer-tips i.layui-layer-TipsR{left:-8px}.layui-layer-tips i.layui-layer-TipsL{right:-8px}.layui-layer-lan[type=dialog]{min-width:280px}.layui-layer-lan .layui-layer-title{background:#4476A7;color:#fff;border:none}.layui-layer-lan .layui-layer-btn{padding:5px 10px 10px;text-align:right;border-top:1px solid #E9E7E7}.layui-layer-lan .layui-layer-btn a{background:#fff;border-color:#E9E7E7;color:#333}.layui-layer-lan .layui-layer-btn .layui-layer-btn1{background:#C9C5C5}.layui-layer-molv .layui-layer-title{background:#009f95;color:#fff;border:none}.layui-layer-molv .layui-layer-btn a{background:#009f95;border-color:#009f95}.layui-layer-molv .layui-layer-btn .layui-layer-btn1{background:#92B8B1}.layui-layer-iconext{background:url(icon-ext.png) no-repeat}.layui-layer-prompt .layui-layer-input{display:block;width:230px;height:36px;margin:0 auto;line-height:30px;padding-left:10px;border:1px solid #e6e6e6;color:#333}.layui-layer-prompt textarea.layui-layer-input{width:300px;height:100px;line-height:20px;padding:6px 10px}.layui-layer-prompt .layui-layer-content{padding:20px}.layui-layer-prompt .layui-layer-btn{padding-top:0}.layui-layer-tab{box-shadow:1px 1px 50px rgba(0,0,0,.4)}.layui-layer-tab .layui-layer-title{padding-left:0;overflow:visible}.layui-layer-tab .layui-layer-title span{position:relative;float:left;min-width:80px;max-width:260px;padding:0 20px;text-align:center;overflow:hidden;cursor:pointer}.layui-layer-tab .layui-layer-title span.layui-this{height:43px;border-left:1px solid #eee;border-right:1px solid #eee;background-color:#fff;z-index:10}.layui-layer-tab .layui-layer-title span:first-child{border-left:none}.layui-layer-tabmain{line-height:24px;clear:both}.layui-layer-tabmain .layui-layer-tabli{display:none}.layui-layer-tabmain .layui-layer-tabli.layui-this{display:block}.layui-layer-photos{-webkit-animation-duration:.8s;animation-duration:.8s}.layui-layer-photos .layui-layer-content{overflow:hidden;text-align:center}.layui-layer-photos .layui-layer-phimg img{position:relative;width:100%;display:inline-block;*display:inline;*zoom:1;vertical-align:top}.layui-layer-imgbar,.layui-layer-imguide{display:none}.layui-layer-imgnext,.layui-layer-imgprev{position:absolute;top:50%;width:27px;_width:44px;height:44px;margin-top:-22px;outline:0;blr:expression(this.onFocus=this.blur())}.layui-layer-imgprev{left:10px;background-position:-5px -5px;_background-position:-70px -5px}.layui-layer-imgprev:hover{background-position:-33px -5px;_background-position:-120px -5px}.layui-layer-imgnext{right:10px;_right:8px;background-position:-5px -50px;_background-position:-70px -50px}.layui-layer-imgnext:hover{background-position:-33px -50px;_background-position:-120px -50px}.layui-layer-imgbar{position:absolute;left:0;bottom:0;width:100%;height:32px;line-height:32px;background-color:rgba(0,0,0,.8);background-color:#000\9;filter:Alpha(opacity=80);color:#fff;overflow:hidden;font-size:0}.layui-layer-imgtit *{display:inline-block;*display:inline;*zoom:1;vertical-align:top;font-size:12px}.layui-layer-imgtit a{max-width:65%;overflow:hidden;color:#fff}.layui-layer-imgtit a:hover{color:#fff;text-decoration:underline}.layui-layer-imgtit em{padding-left:10px;font-style:normal}@-webkit-keyframes layer-bounceOut{100%{opacity:0;-webkit-transform:scale(.7);transform:scale(.7)}30%{-webkit-transform:scale(1.05);transform:scale(1.05)}0%{-webkit-transform:scale(1);transform:scale(1)}}@keyframes layer-bounceOut{100%{opacity:0;-webkit-transform:scale(.7);-ms-transform:scale(.7);transform:scale(.7)}30%{-webkit-transform:scale(1.05);-ms-transform:scale(1.05);transform:scale(1.05)}0%{-webkit-transform:scale(1);-ms-transform:scale(1);transform:scale(1)}}.layer-anim-close{-webkit-animation-name:layer-bounceOut;animation-name:layer-bounceOut;-webkit-animation-fill-mode:both;animation-fill-mode:both;-webkit-animation-duration:.2s;animation-duration:.2s}@media screen and (max-width:1100px){.layui-layer-iframe{overflow-y:auto;-webkit-overflow-scrolling:touch}} \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/loading-0.gif b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/loading-0.gif new file mode 100644 index 00000000..6f3c9539 Binary files /dev/null and b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/loading-0.gif differ diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/loading-1.gif b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/loading-1.gif new file mode 100644 index 00000000..db3a483e Binary files /dev/null and b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/loading-1.gif differ diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/loading-2.gif b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/loading-2.gif new file mode 100644 index 00000000..5bb90fd6 Binary files /dev/null and b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/layer/theme/default/loading-2.gif differ diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/login.css b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/login.css new file mode 100644 index 00000000..f831a49f --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/login.css @@ -0,0 +1,59 @@ +*{margin: 0; padding: 0;} +body{font-family: Helvetica Neue,Helvetica,PingFang SC,Tahoma,Arial,sans-serif;} +::-webkit-input-placeholder{color: #ccc;} + +/* 视图盒子 */ +.view-box{position: relative; width: 100vw; height: 100vh; overflow: hidden;} +/* 背景 EAEFF3 */ +.bg-1{height: 50%; background: linear-gradient(to bottom right, #0466c5, #3496F5);} +.bg-2{height: 50%; background-color: #EAEFF3;} + +/* 渐变背景 */ +/*.bg-1{ + background-size: 500%; + background-image: linear-gradient(125deg,#0466c5,#3496F5,#0466c5,#3496F5,#0466c5,#2496F5); + animation: bganimation 30s infinite; +} +@keyframes bganimation{ + 0%{background-position: 0% 50%;} + 50%{background-position: 100% 50%;} + 100%{background-position: 0% 50%;} +} */ +/* 背景 */ +.bg-1{background: #101C34;} +.bg-2{background: #101C34;} +/* .bg-1{height: 100%; background-image: url(./login-bg.png); background-size: 100% 100%;} */ + + +/* 内容盒子 */ +.content-box{position: absolute; width: 100vw; height: 100vh; top: 0px;} + +/* 登录盒子 */ +/* .login-box{width: 400px; height: 400px; position: absolute; left: calc(50% - 200px); top: calc(50% - 200px); max-width: 90%; } */ +.login-box{width: 400px; margin: auto; max-width: 90%; height: 100%;} +.login-box{display: flex; align-items: center; text-align: center;} + +/* 表单 */ +.from-box{flex: 1; padding: 20px 50px; background-color: #FFF;} +.from-box{border-radius: 1px; box-shadow: 1px 1px 20px #666;} +.from-title{margin-top: 20px; margin-bottom: 30px; text-align: center;} + +/* 输入框 */ +.from-item{border: 0px #000 solid; margin-bottom: 15px;} +.s-input{width: 100%; line-height: 32px; height: 32px; text-indent: 1em; outline: 0; border: 1px #ccc solid; border-radius: 3px; transition: all 0.2s;} +.s-input{font-size: 12px;} +.s-input:focus{border-color: #409eff} + +/* 登录按钮 */ +.s-btn{ text-indent: 0; cursor: pointer; background-color: #409EFF; border-color: #409EFF; color: #FFF;} +.s-btn:hover{background-color: #50aEFF;} + +/* 重置按钮 */ +.reset-box{text-align: left; font-size: 12px;} +.reset-box a{text-decoration: none;} +.reset-box a:hover{text-decoration: underline;} + +/* loading框样式 */ +.ajax-layer-load.layui-layer-dialog{min-width: 0px !important; background-color: rgba(0,0,0,0.85);} +.ajax-layer-load.layui-layer-dialog .layui-layer-content{padding: 10px 20px 10px 40px; color: #FFF;} +.ajax-layer-load.layui-layer-dialog .layui-layer-content .layui-layer-ico{width: 20px; height: 20px; background-size: 20px 20px; top: 12px; } \ No newline at end of file diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/login.js b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/login.js new file mode 100644 index 00000000..5e619d17 --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/static/sa-res/login.js @@ -0,0 +1,65 @@ +// sa +var sa = {}; + +// 打开loading +sa.loading = function(msg) { + layer.closeAll(); // 开始前先把所有弹窗关了 + return layer.msg(msg, {icon: 16, shade: 0.3, time: 1000 * 20, skin: 'ajax-layer-load' }); +}; + +// 隐藏loading +sa.hideLoading = function() { + layer.closeAll(); +}; + + +// ----------------------------------- 登录事件 ----------------------------------- + +$('.login-btn').click(function(){ + sa.loading("正在登录..."); + // 开始登录 + setTimeout(function() { + $.ajax({ + url: "doLogin", + type: "post", + data: { + name: $('[name=name]').val(), + pwd: $('[name=pwd]').val() + }, + dataType: 'json', + success: function(res){ + console.log('返回数据:', res); + sa.hideLoading(); + if(res.code == 200) { + layer.msg('登录成功', {anim: 0, icon: 6 }); + setTimeout(function() { + location.reload(); + }, 800) + } else { + layer.msg(res.msg, {anim: 6, icon: 2 }); + } + }, + error: function(xhr, type, errorThrown){ + sa.hideLoading(); + if(xhr.status == 0){ + return layer.alert('无法连接到服务器,请检查网络'); + } + return layer.alert("异常:" + JSON.stringify(xhr)); + } + }); + }, 400); +}); + +// 绑定回车事件 +$('[name=name],[name=pwd]').bind('keypress', function(event){ + if(event.keyCode == "13") { + $('.login-btn').click(); + } +}); + +// 输入框获取焦点 +$("[name=name]").focus(); + +// 打印信息 +var str = "This page is provided by Sa-Token, Please refer to: " + "http://sa-token.dev33.cn/"; +console.log(str); diff --git a/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/templates/sa-login.html b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/templates/sa-login.html new file mode 100644 index 00000000..82b50faa --- /dev/null +++ b/sa-token-demo/sa-token-demo-sso3-server/src/main/resources/templates/sa-login.html @@ -0,0 +1,45 @@ + + + + Sa-SSO-Server 认证中心-登录 + + + + + + +
+
+
+
+ +
+ +
+ This page is provided by Sa-Token-SSO +
+
+ + + + + + + + diff --git a/sa-token-doc/doc/_sidebar.md b/sa-token-doc/doc/_sidebar.md index 116916ca..9e6a4c54 100644 --- a/sa-token-doc/doc/_sidebar.md +++ b/sa-token-doc/doc/_sidebar.md @@ -33,7 +33,12 @@ - [集群、分布式](/senior/dcs) - [多账号验证](/use/many-account) - - [单点登录](/sso/readme) + +- **单点登录** + - [单点登录简述](/sso/readme) + - [SSO模式一 共享Cookie同步会话](/sso/sso-type1) + - [SSO模式二 URL重定向传播会话](/sso/sso-type2) + - [SSO模式三 Http请求获取会话](/sso/sso-type3) - **插件** - [AOP注解鉴权](/plugin/aop-at) diff --git a/sa-token-doc/doc/index.html b/sa-token-doc/doc/index.html index debc001f..42f7a71e 100644 --- a/sa-token-doc/doc/index.html +++ b/sa-token-doc/doc/index.html @@ -66,9 +66,10 @@ errorText: '错误', successText: '复制成功' }, + // sidebarDisplayLevel : 1 , // 设置侧边栏显示级别 // search: 'auto', // 搜索功能 alias: { - '/sso/_sidebar.md': '/sso/_sidebar.md', + // '/sso/_sidebar.md': '/sso/_sidebar.md', '/.*/_sidebar.md': '/_sidebar.md' }, // tab选项卡 @@ -125,6 +126,10 @@ + + + diff --git a/sa-token-doc/doc/sso/readme.md b/sa-token-doc/doc/sso/readme.md index 7abcf199..3e487afb 100644 --- a/sa-token-doc/doc/sso/readme.md +++ b/sa-token-doc/doc/sso/readme.md @@ -3,36 +3,37 @@ --- ### 什么是单点登录?解决什么问题? -举个场景:假设我们的系统被切割成N个部分:商城、论坛、直播、社交…… 如果用户每访问其中一个模块都要进行一次登录注册,那么用户将会疯掉, -为了不让用户疯掉,我们急需一套机制将这N个系统的授权进行共享,让用户在其中一个系统登录之后,便可以畅通无阻的访问其它系统 + +举个场景,假设我们的系统被切割为N个部分:商城、论坛、直播、社交…… 如果用户每访问一个模块都要登录一次,那么用户将会疯掉, +为了优化用户体验,我们急需一套机制将这N个系统的认证授权互通共享,让用户在一个系统登录之后,便可以畅通无阻的访问其它所有系统 单点登录——就是为了解决这个问题而生! -简而言之,单点登录可以做到:**`在多个系统中,用户只需登录一次,就可以访问所有系统。`** +简而言之,单点登录可以做到:**`在多个互相信任的系统中,用户只需登录一次,就可以访问所有系统。`** ### 架构选型 -对于单点登录,网上教程很多,但大多数讲述的都是CAS重定向机制,相对来讲,CAS模式较为复杂且并不是实现单点登录的唯一方式。 +对于单点登录,网上教程大多以CAS模式为主,其实对于不同的系统架构,实现单点登录的步骤也大为不同,Sa-Token由简入难将其划分为三种模式: -对于不同的系统架构来讲,实现单点登录的步骤也大为不同,Sa-Token由简入难将其划分为三种模式 + -| 系统架构 | 采用模式 | 简介 | 文档链接 | -| :-------- | :-------- | :-------- | :-------- | -| 前端同域 + 后端同 Redis | 模式一 | 共享Cookie + 子系统 [权限缓存与业务缓存分离] | [详情](/sso/sso-type1) | -| 前端不同域 + 后端同 Redis | 模式二 | URL重定向传播会话 | [详情]() | -| 前端不同域 + 后端 不同Redis | 模式三 | SSO认证中心开放接口校验Ticket | [详情]() | +| 系统架构 | 采用模式 | 简介 | 文档链接 | +| :-------- | :-------- | :-------- | :-------- | +| 前端同域 + 后端同 Redis | 模式一 | 共享Cookie同步会话 | [文档](/sso/sso-type1)、[示例](https://gitee.com/dromara/sa-token/blob/dev/sa-token-demo/sa-token-demo-sso1) | +| 前端不同域 + 后端同 Redis | 模式二 | URL重定向传播会话 | [文档](/sso/sso-type2)、[示例](https://gitee.com/dromara/sa-token/blob/dev/sa-token-demo/sa-token-demo-sso2-server) | +| 前端不同域 + 后端 不同Redis | 模式三 | Http请求获取会话 | [文档](/sso/sso-type3)、[示例](https://gitee.com/dromara/sa-token/blob/dev/sa-token-demo/sa-token-demo-sso3-server) | 1. 前端同域:就是指多个系统可以部署在同一个主域名之下,比如:`c1.domain.com`、`c2.domain.com`、`c3.domain.com` -2. 后端同Redis:就是指多个系统可以连接同一个Redis,或者其它的缓存数据中心。PS:这里并不需要把所有项目的数据都放在同一个Redis中,Sa-Token提供了 **[权限缓存与业务缓存分离]** 的解决方案,详情戳:[Alone独立Redis插件](http://sa-token.dev33.cn/doc/index.html#/plugin/alone-redis) -3. 如果既无法做到前端同域,也无法做到后端同Redis,那么只能走模式三,CAS模式(Sa-Token对CAS模式提供了完整的封装,你只需要按照示例从文档上复制几段代码便可以轻松集成) -4. 只有根据自己的系统架构合理选择,才可对症下药,事半功倍,否则只能是劳而无功,不得要领 +2. 后端同Redis:就是指多个系统可以连接同一个Redis,其它的缓存数据中心亦可。PS:这里并不需要把所有项目的数据都放在同一个Redis中,Sa-Token提供了 **`[权限缓存与业务缓存分离]`** 的解决方案,详情戳:[Alone独立Redis插件](http://sa-token.dev33.cn/doc/index.html#/plugin/alone-redis) +3. 如果既无法做到前端同域,也无法做到后端同Redis,那么只能走模式三,Http请求获取会话(Sa-Token对SSO提供了完整的封装,你只需要按照示例从文档上复制几段代码便可以轻松集成) +4. 技术选型一定要根据系统架构对症下药,切不可胡乱选择 ### Sa-Token-SSO 特性 1. API简单易用,文档介绍详细,且提供直接可用的集成示例 2. 支持三种模式,不论是否跨域、是否共享Redis,都可以完美解决 -3. 安全性高:内置域名校验、Ticket校验、秘钥校验等,杜绝`Ticket劫持`、`Token窃取`等常见攻击手段 (文档讲述攻击原理和防御手段) +3. 安全性高:内置域名校验、Ticket校验、秘钥校验等,杜绝`Ticket劫持`、`Token窃取`等常见攻击手段(文档讲述攻击原理和防御手段) 4. 不丢参数:笔者曾试验多个单点登录框架,均有参数丢失的情况,比如重定向之前是:`http://a.com?id=1&name=2`,登录成功之后就变成了:`http://a.com?id=1`,Sa-Token-SSO内有专门的算法保证了参数不丢失,登录成功之后原路返回页面 5. 无缝集成:由于Sa-Token本身就是一个权限认证框架,因此你可以只用一个框架同时解决`权限认证` + `单点登录`问题,让你不再到处搜索:xxx单点登录与xxx权限认证如何整合…… 6. 高可定制:Sa-Token-SSO模块对代码架构侵入性极低,结合Sa-Token本身的路由拦截特性,你可以非常轻松的定制化开发 diff --git a/sa-token-doc/doc/sso/sso-type1.md b/sa-token-doc/doc/sso/sso-type1.md index 1bc7c231..387137ad 100644 --- a/sa-token-doc/doc/sso/sso-type1.md +++ b/sa-token-doc/doc/sso/sso-type1.md @@ -1,12 +1,15 @@ # SSO模式一 共享Cookie同步会话 -如果我们的系统可以保证部署在同一个主域名之下,并且后端连接同一个Redis,那么便可以使用 `[共享Cookie同步会话]` 的方式做到单点登录 +如果我们的系统可以保证部署在同一个主域名之下,并且后端连接同一个Redis,那么便可以使用 **`[共享Cookie同步会话]`** 的方式做到单点登录 + +> Sa-Token整合同域单点登录非常简单,相比于正常的登录,你只需增加配置 `sa-token.cookie-domain=xxx.com` 指定一下Cookie写入时的父级域名即可
+> 整合示例在官方仓库的 `/sa-token-demo/sa-token-demo-sso1/`,如遇到难点可结合源码进行测试学习 --- -### 解决思路? +### 0、解决思路? -首先我们分析一下多个系统之间为什么无法同步登录状态? +首先我们分析一下多个系统之间,为什么无法同步登录状态? 1. 前端的`Token`无法在多个系统下共享 2. 后端的`Session`无法在多个系统间共享 @@ -18,16 +21,14 @@ 而共享Redis,并不需要我们把所有项目的数据都放在同一个Redis中,Sa-Token提供了 **[权限缓存与业务缓存分离]** 的解决方案,详情戳:[Alone独立Redis插件](/plugin/alone-redis) -> PS:这里建议不要用B项目去连接A项目的Redis,也不要A项目连接B项目的Redis,而是抽离出一个单独的 SSO-Redis,A项目和B项目一起来连接这个 SSO-Redis +> PS:这里建议不要用B项目去连接A项目的Redis,也不要A项目连接B项目的Redis,而是抽离出一个单独的 SSO-Redis,A 和 B 一起连接这个 SSO-Redis OK,所有理论就绪,下面开始实战 -### 集成步骤 -Sa-Token整合同域下的单点登录非常简单,相比于正常的登录,你只需要在配置文件中增加配置 `sa-token.cookie-domain=xxx.com` 来指定一下Cookie写入时指定的父级域名即可,详细步骤示例如下: +### 1、准备工作 -#### 1. 准备工作 首先修改hosts文件`(C:\WINDOWS\system32\drivers\etc\hosts)`,添加以下IP映射,方便我们进行测试: ``` text 127.0.0.1 s1.stp.com @@ -35,8 +36,8 @@ Sa-Token整合同域下的单点登录非常简单,相比于正常的登录, 127.0.0.1 s3.stp.com ``` -#### 2. 指定Cookie的作用域 -常规情况下,在`s1.stp.com`域名访问服务器,其Cookie也只能写入到`s1.stp.com`下,为了将Cookie写入到其父级域名`stp.com`下,我们需要在配置文件中新增配置: +### 2、指定Cookie的作用域 +在`s1.stp.com`访问服务器,其Cookie也只能写入到`s1.stp.com`下,为了将Cookie写入到其父级域名`stp.com`下,我们需要新增配置: ``` yml spring: sa-token: @@ -44,8 +45,8 @@ spring: cookie-domain: stp.com ``` -#### 3. 新增测试Controller -新建`SSOController.java`控制器,写入代码: +### 3、新增测试Controller +新建`SsoController.java`控制器,写入代码: ``` java /** * 测试: 同域单点登录 @@ -53,7 +54,7 @@ spring: */ @RestController @RequestMapping("/sso/") -public class SSOController { +public class SsoController { // 测试:进行登录 @RequestMapping("doLogin") @@ -74,45 +75,40 @@ public class SSOController { } ``` -#### 4、访问测试 +``` java +// 启动类 +@SpringBootApplication +public class SaSsoApplication { + public static void main(String[] args) { + SpringApplication.run(SaSsoApplication.class, args); + System.out.println("\n启动成功:Sa-Token配置如下:" + SaManager.getConfig()); + } +} +``` + + +### 4、访问测试 启动项目,依次访问: - [http://s1.stp.com:8081/sso/isLogin](http://s1.stp.com:8081/sso/isLogin) - [http://s2.stp.com:8081/sso/isLogin](http://s2.stp.com:8081/sso/isLogin) - [http://s3.stp.com:8081/sso/isLogin](http://s3.stp.com:8081/sso/isLogin) 均返回以下结果: -``` js -{ - "code": 200, - "msg": "是否登录: false", - "data": null -} -``` -现在访问任意节点的登录接口: -- [http://s1.stp.com:8081/sso/doLogin](http://s1.stp.com:8081/sso/doLogin) +![sso-type1-wd.png](https://oss.dev33.cn/sa-token/doc/sso/sso-type1-wd.png 's-w-sh') -``` js -{ - "code": 200, - "msg": "登录成功: 10001", - "data": null -} -``` +现在访问任意节点的登录接口:[http://s1.stp.com:8081/sso/doLogin](http://s1.stp.com:8081/sso/doLogin) + +![sso-type1-login.png](https://oss.dev33.cn/sa-token/doc/sso/sso-type1-login.png 's-w-sh') 然后再次刷新上面三个测试接口,均可以得到以下结果: -``` js -{ - "code": 200, - "msg": "是否登录: true", - "data": null -} -``` + +![sso-type1-yd.png](https://oss.dev33.cn/sa-token/doc/sso/sso-type1-yd.png 's-w-sh') 测试完毕 -### 跨域模式下的解决方案 +### 5、跨域模式下的解决方案 如上,我们使用极其简单的步骤实现了同域下的单点登录,聪明如你😏,马上想到了这种模式有着一个不小的限制: @@ -122,50 +118,6 @@ public class SSOController { 且往下看,[SSO模式二:URL重定向传播会话](/sso/sso-type2) - - - diff --git a/sa-token-doc/doc/sso/sso-type2.md b/sa-token-doc/doc/sso/sso-type2.md index db02f701..b7e343ca 100644 --- a/sa-token-doc/doc/sso/sso-type2.md +++ b/sa-token-doc/doc/sso/sso-type2.md @@ -1,11 +1,11 @@ # SSO模式二 URL重定向传播会话 -如果我们的系统部署在不同的域名之下,但是后端可以连接同一个Redis,那么便可以使用 [URL重定向传播会话] 的方式做到单点登录 +如果我们的系统部署在不同的域名之下,但是后端可以连接同一个Redis,那么便可以使用 **`[URL重定向传播会话]`** 的方式做到单点登录 ### 0、解题思路 -首先我们再次复习一下多个系统之间为什么无法同步登录状态? +首先我们再次复习一下多个系统之间,为什么无法同步登录状态? 1. 前端的`Token`无法在多个系统下共享 2. 后端的`Session`无法在多个系统间共享 @@ -25,12 +25,12 @@ 整个过程,除了第四步用户在SSO认证中心登录时会被打断,其余过程均是自动化的,当用户在另一个子系统再次点击`[登录]`按钮,由于此用户在SSO认证中心已有会话登录, 所以第四步也将自动化,也就是单点登录的最终目的 —— 一次登录,处处通行。 -下面我们按照步骤依次完成上述步骤 +下面我们按照步骤依次完成上述过程 ### 1、搭建SSO-Server认证中心 -> 搭建示例在官方仓库的 `/sa-token-demo/sa-token-demo-sso-server/`,如遇到难点可结合源码进行测试学习 +> 搭建示例在官方仓库的 `/sa-token-demo/sa-token-demo-sso2-server/`,如遇到难点可结合源码进行测试学习 ##### 1.1、创建SSO-Server端项目 创建一个SpringBoot项目 `sa-token-demo-sso-server`(不会的同学自行百度或参考仓库示例),添加pom依赖: @@ -116,12 +116,10 @@ spring: sa-token: # SSO-相关配置 sso: - # Ticket有效期 (单位: 秒),默认三分钟 + # Ticket有效期 (单位: 秒),默认五分钟 ticket-timeout: 300 # 所有允许的授权回调地址 (此处为了方便测试配置为*,线上生产环境一定要配置为详细地地址) allow-url: "*" - # 接口调用秘钥(模式三才会用到此参数) - # secret-key: # Redis配置 redis: @@ -134,7 +132,7 @@ spring: # Redis服务器连接密码(默认为空) password: ``` -注意点:`allow-url`为了方便测试配置为*,线上生产环境一定要配置为详细URL地址 +注意点:`allow-url`为了方便测试配置为*,线上生产环境一定要配置为详细URL地址 (详见下方“配置域名校验”) ##### 1.4、创建SSO-Server端启动类 ``` java @@ -147,12 +145,10 @@ public class SaSsoServerApplication { } ``` -启动此项目,用作**`SSO-Server认证中心`** - ### 2、搭建SSO-Client应用端 -> 搭建示例在官方仓库的 `/sa-token-demo/sa-token-demo-sso-client/`,如遇到难点可结合源码进行测试学习 +> 搭建示例在官方仓库的 `/sa-token-demo/sa-token-demo-sso2-client/`,如遇到难点可结合源码进行测试学习 ##### 2.1、创建SSO-Client端项目 创建一个SpringBoot项目 `sa-token-demo-sso-client`,添加pom依赖: @@ -206,38 +202,40 @@ public class SsoClientController { public String index() { String str = "

Sa-Token SSO-Client 应用端

" + "

当前会话是否登录:" + StpUtil.isLogin() + "

" + - "

登录

"; + "

登录

"; return str; } // SSO-Client端:登录地址 @RequestMapping("ssoLogin") - public Object login(String back, String ticket) { + public Object ssoLogin(String back, String ticket) { // 如果当前Client端已经登录,则无需访问SSO认证中心,可以直接返回 if(StpUtil.isLogin()) { return new ModelAndView("redirect:" + back); } /* * 接下来两种情况: - * ticket有值,说明此请求从SSO认证中心重定向而来,需要根据ticket进行登录 * ticket无值,说明此请求是Client端访问,需要重定向至SSO认证中心 + * ticket有值,说明此请求从SSO认证中心重定向而来,需要根据ticket进行登录 */ - if(ticket != null) { - Object loginId = SaSsoUtil.getLoginId(ticket); + if(ticket == null) { + String serverAuthUrl = SaSsoUtil.buildServerAuthUrl(SaHolder.getRequest().getUrl(), back); + return new ModelAndView("redirect:" + serverAuthUrl); + } else { + Object loginId = checkTicket(ticket); if(loginId != null ) { - // 如果ticket是有效的 (可以获取到值),需要就此登录 且清除此ticket + // loginId有值,说明ticket有效 StpUtil.login(loginId); - SaSsoUtil.deleteTicket(ticket); - // 最后重定向回back地址 return new ModelAndView("redirect:" + back); } // 此处向客户端提示ticket无效即可,不要重定向到SSO认证中心,否则容易引起无限重定向 return "ticket无效: " + ticket; } - - // 重定向至 SSO-Server端 认证地址 - String serverAuthUrl = SaSsoUtil.buildServerAuthUrl(SaHolder.getRequest().getUrl(), back); - return new ModelAndView("redirect:" + serverAuthUrl); + } + + // SSO-Client端:校验ticket,获取账号id + private Object checkTicket(String ticket) { + return SaSsoUtil.checkTicket(ticket); } } @@ -255,8 +253,8 @@ spring: sa-token: # SSO-相关配置 sso: - # SSO-Server端授权地址 - server-url: http://sa-sso-server.com:9000/ssoAuth + # SSO-Server端 单点登录地址 + auth-url: http://sa-sso-server.com:9000/ssoAuth # 配置Sa-Token单独使用的Redis连接 (此处需要和SSO-Server端连接同一个Redis) alone-redis: @@ -377,7 +375,7 @@ public class SaSsoClientApplication { | 配置方式 | 举例 | 安全性 | 建议 | | :-------- | :-------- | :-------- | :-------- | -| 配置为* | `*` | | 禁止在生产环境下使用 | +| 配置为* | `*` | | **禁止在生产环境下使用** | | 配置到域名 | `http://sa-sso-client1.com/*` | | 不建议在生产环境下使用 | | 配置到详细地址| `http://sa-sso-client1.com:9001/ssoLogin` | | 可以在生产环境下使用 | @@ -389,6 +387,12 @@ Token作为长时间有效的会话凭证,在任何时候都不应该直接在 +### 6、跨Redis的单点登录 +以上流程解决了跨域模式下的单点登录,但是后端仍然采用了共享Redis来同步会话,如果我们的架构设计中Client端与Server端无法共享Redis,又该怎么完成单点登录? + +这就要采用模式三了,且往下看:[Http请求获取会话](/sso/sso-type3) + + diff --git a/sa-token-doc/doc/sso/sso-type3.md b/sa-token-doc/doc/sso/sso-type3.md index 4ec5f6be..cf993d02 100644 --- a/sa-token-doc/doc/sso/sso-type3.md +++ b/sa-token-doc/doc/sso/sso-type3.md @@ -1,2 +1,244 @@ -# SSO模式三 SSO认证中心开放接口校验Ticket +# SSO模式三 Http请求获取会话 + +如果既无法做到前端同域,也无法做到后端同Redis,那么可以使用模式三完成单点登录 + +> 阅读本篇之前请务必先熟读SSO模式二!因为模式三仅仅属于模式二的一个特殊场景,熟读模式二有助于您快速理解本章内容 + + +### 0、问题分析 +我们先来分析一下,当后端不使用共享Redis时,会对架构发生哪些影响 + +1. Client端 无法直连 Redis 校验 ticket,取出账号id +2. Client端 无法与 Server端 共用一套会话,需要自行维护子会话 +3. 由于不是一套会话,所以无法“一次注销,全端下线”,需要额外编写代码完成单点注销 + +所以模式三的主要目标:也就是在 模式二的基础上 解决上述 三个难题 + +> 模式三的Demo示例地址:
+> SSO-Server端: `/sa-token-demo/sa-token-demo-sso3-server/` [源码链接](https://gitee.com/dromara/sa-token/tree/dev/sa-token-demo/sa-token-demo-sso3-server)
+> SSO-Client端: `/sa-token-demo/sa-token-demo-sso3-client/` [源码链接](https://gitee.com/dromara/sa-token/tree/dev/sa-token-demo/sa-token-demo-sso3-client)
+> 如遇难点可参考示例 + + +### 1、SSO-Server认证中心开放ticket校验接口 +既然Client端无法直连Redis校验ticket,那就在Server端开放ticket校验接口,然后Client端通过http请求获取数据 + +##### 1.1、添加依赖 +首先在Server端和Client端均添加以下依赖(如果不需要单点注销功能则Server端可不引入) +``` xml + + + com.ejlchina + okhttps + 3.1.1 + +``` +> OkHttps是一个轻量级http请求工具,详情参考:[OkHttps](https://gitee.com/ejlchina-zhxu/okhttps) + +##### 1.2、认证中心开放接口 +在SSO-Server端的`SsoServerController`中,新增以下接口: +``` java +// SSO-Server端:校验ticket 获取账号id +@RequestMapping("checkTicket") +public Object checkTicket(String ticket, String sloCallback) { + // 校验ticket,获取对应的账号id + Object loginId = SaSsoUtil.checkTicket(ticket); + + // 注册此客户端的单点注销回调URL(不需要单点注销功能可删除此行代码) + SaSsoUtil.registerSloCallbackUrl(loginId, sloCallback); + + // 返回给Client端 + return loginId; +} +``` +此接口的作用是让Client端通过http请求校验ticket,获取对应的账号id + +##### 1.3、Client端新增配置 +``` yml +spring: + sa-token: + sso: + # SSO-Server端 ticket校验地址 + check-ticket-url: http://sa-sso-server.com:9000/checkTicket +``` + +##### 1.4、修改校验ticket的逻辑 +在模式二的`SsoClientController`中,校验ticket的方法是: +``` java +// SSO-Client端:校验ticket,获取账号id +private Object checkTicket(String ticket) { + return SaSsoUtil.checkTicket(ticket); +} +``` +不能直连Redis后,上述方法也将无效,我们把它改为以下方式: +``` java +// SSO-Client端:校验ticket码,获取对应的账号id +private Object checkTicket(String ticket) { + // 构建单点注销的回调URL(不需要单点注销时此值可填null ) + String sloCallback = SaHolder.getRequest().getUrl().replace("/ssoLogin", "/sloCallback"); + + // 使用OkHttps请求SSO-Server端,校验ticket + String checkUrl = SaSsoUtil.buildCheckTicketUrl(ticket, sloCallback); + String loginId = OkHttps.sync(checkUrl).get().getBody().toString(); + + // 判断返回值是否为有效账号Id + return (SaFoxUtil.isEmpty(loginId) ? null : loginId); +} +``` + +##### 1.5 启动项目测试 +启动SSO-Server、SSO-Client,访问测试:[http://sa-sso-client1.com:9001/](http://sa-sso-client1.com:9001/) +> 注:如果已测试运行模式二,可先将Redis中的数据清空,以防旧数据对测试造成干扰 + + +### 2、无刷单点注销 + +有了单点登录就必然要有单点注销,网上给出的大多数解决方案是将注销请求重定向至SSO-Server中心,逐个通知Client端下线 + +在某些场景下,页面的跳转可能造成不太好的用户体验,Sa-Token-SSO 允许你以 `REST API` 的形式构建接口,做到页面无刷新单点注销 + +1. Client端校验ticket的时候将注销回调地址发送到Server端 +2. Server端将注销回调地址存储到Set集合 +3. Client端向Server端发送单点注销请求 +4. Server端遍历Set集合,逐个通知Client端下线 +5. Server端注销下线 +6. 单点注销完成 + +##### 2.1、SSO-Server认证中心增加单点注销接口 +新建 `SsoServerLogoutController` 增加以下代码 +``` java +/** + * Sa-Token-SSO Server端 单点注销 Controller + */ +@RestController +public class SsoServerLogoutController { + + // SSO-Server端:单点注销 + @RequestMapping("ssoLogout") + public String ssoLogout(String loginId, String secretkey) { + + // 遍历通知Client端注销会话 (为了提高响应速度这里可将sync换为async) + SaSsoUtil.singleLogout(secretkey, loginId, url -> OkHttps.sync(url).get()); + + // 完成 + return "ok"; + } + +} +``` + +并在 `application.yml` 下配置API调用秘钥 +``` yml +spring: + sa-token: + sso: + # API调用秘钥(用于SSO模式三的单点注销功能) + secretkey: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor +``` + +##### 2.2、SSO-Client端增加注销接口 +新建 `SsoClientLogoutController` 增加以下代码 +``` java +/** + * Sa-Token-SSO Client端 单点注销 Controller + * @author kong + */ +@RestController +public class SsoClientLogoutController { + + // SSO-Client端:单端注销 (其它Client端会话不受影响) + @RequestMapping("logout") + public AjaxJson logout() { + StpUtil.logout(); + return AjaxJson.getSuccess(); + } + + // SSO-Client端:单点注销 (所有端一起下线) + @RequestMapping("ssoLogout") + public AjaxJson ssoLogout() { + // 如果未登录,则无需注销 + if(StpUtil.isLogin() == false) { + return AjaxJson.getSuccess(); + } + // 调用SSO-Server认证中心API + String url = SaSsoUtil.buildSloUrl(StpUtil.getLoginId()); + String res = OkHttps.sync(url).get().getBody().toString(); + if(res.equals("ok")) { + return AjaxJson.getSuccess("单点注销成功"); + } + return AjaxJson.getError("单点注销失败"); + } + + // 单点注销的回调 + @RequestMapping("sloCallback") + public String sloCallback(String loginId, String secretkey) { + SaSsoUtil.checkSecretkey(secretkey); + StpUtil.logoutByLoginId(loginId); + return "ok"; + } + +} +``` + +并在 `application.yml` 增加配置: API调用秘钥 和 单点注销接口URL +``` yml +spring: + sa-token: + sso: + # SSO-Server端 单点注销地址 + slo-url: http://sa-sso-server.com:9000/ssoLogout + # 接口调用秘钥(用于SSO模式三的单点注销功能) + secretkey: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor +``` + +##### 2.3 更改Client端首页代码 +为了方便测试,我们更改一下Client端中`SsoClientController`类的`index`方法代码 +``` java +// SSO-Client端:首页 +@RequestMapping("/") +public String index() { + String str = "

Sa-Token SSO-Client 应用端

" + + "

当前会话是否登录:" + StpUtil.isLogin() + "

" + + "

登录" + + " 注销

"; + return str; +} +``` +PS:相比于模式二,增加了单点注销的按钮 + + +##### 2.4 启动测试 +启动SSO-Server、SSO-Client,访问测试:[http://sa-sso-client1.com:9001/](http://sa-sso-client1.com:9001/), +我们主要的测试点在于 `单点注销`,正常登陆即可 + +![sso-type3-client-index.png](https://oss.dev33.cn/sa-token/doc/sso/sso-type3-client-index.png 's-w-sh') + +点击 **`[注销]`** 按钮,即可单点注销成功 + +![sso-type3-slo.png](https://oss.dev33.cn/sa-token/doc/sso/sso-type3-slo.png 's-w-sh') + +![sso-type3-slo-index.png](https://oss.dev33.cn/sa-token/doc/sso/sso-type3-slo-index.png 's-w-sh') + +PS:这里我们为了方便演示,使用的是超链接跳页面的形式,正式项目中使用Ajax调用接口即可做到无刷单点登录退出 + +例如我们使用 [APIPost接口测试工具](https://www.apipost.cn/) 可以做到同样的效果: + +![sso-slo-apipost.png](https://oss.dev33.cn/sa-token/doc/sso/sso-slo-apipost.png 's-w-sh') + +测试完毕! + + + + +### 3、后记 +当我们熟读三种模式的单点登录之后,其实不难发现:所谓单点登录,其本质就是多个系统之间的会话共享 + +当我们理解这一点之后,三种模式的工作原理也浮出水面: + +- 模式一:采用共享Cookie来做到前端Token的共享,从而达到后端的Session会话共享 +- 模式二:采用URL重定向,以ticket码为授权中介,做到多个系统间的会话传播 +- 模式三:采用Http请求主动查询会话,做到Client端与Server端的会话同步 + + +