diff --git a/README.md b/README.md index ddd45dbc11c03e3384add3d5bc4bfa55e75bdabb..58bd5c00289a92d73ec88e17035f6fd6e348927b 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,30 @@ # spring-oauth-client
- spring-oauth-client depend on spring-oauth-server, - it is the oauth2 client demos. -
-
- 注意 从 1.1 版本开始支持 spring-oauth-server config分支 (旧版本的spring-oauth-server 测试请使用 1.0 分支) + spring-oauth-client depend on spring-oauth-server or MyOIDC, + it is the oauth2 client demo project.
+ +注意 + +- 从 1.1 版本开始支持 spring-oauth-server config分支 (旧版本的spring-oauth-server 测试请使用 1.0 分支) +- 从2.x版本开始支持 OAuth2.1 协议中的各功能 (对应 spring-oauth-server 的 v3.0.0 及以上版本) +
项目用Maven管理 -使用的技术与版本号 +## 主要技术与版本号 +
    -
  1. JDK (1.7.0_40)
  2. -
  3. Spring (4.1.6.RELEASE)
  4. -
  5. Spring MVC (4.1.6.RELEASE)
  6. -
  7. HttpClient (4.3.5)
  8. +
  9. Java (openjdk 17)
  10. +
  11. SpringBoot (3.1.2)
  12. +
  13. thymeleaf (3.1.1.RELEASE)
  14. +
  15. HttpClient (4.5.14)
  16. json-lib (2.4)
  17. -
  18. Log4j (1.2.14)
  19. +
  20. logback (1.4.8)
前端使用的技术与版本号
    @@ -29,121 +33,123 @@

-

- OAuth服务端项目请访问 spring-oauth-server -

-

- 在线测试地址 https://andaily.com/spring-oauth-client/ -

+## 在线测试 + + OAuth服务端项目请访问 spring-oauth-server + +
+ + 在线测试地址 https://andaily.com/spring-oauth-client/ (v1.x版本) +
-
- 如何使用? -
- 前提: 在使用之前必须保证 spring-oauth-server 项目已正常运行. -
    -
  1. - 项目是Maven管理的, 需要本地安装maven(开发用的maven版本号为3.1.0) -
  2. -
  3. - 下载(或clone)项目到本地 -
  4. -
  5. - 修改spring-oauth-client.properties(位于src/main/resources目录)中的配置信息(主要包括与spring-oauth-server的连接地址) -
  6. -
  7. - 将本地项目导入到IDE(如Intellij IDEA)中,配置Tomcat(或类似的servlet运行服务器), 并启动Tomcat(默认端口为8080) ,通过浏览器访问即可. -
    - 注意将项目的 contextPath(根路径) 设置为 'spring-oauth-client'. -
    - 所有的操作说明都在页面上体现. -
    - 另: 也可通过maven package命令将项目编译为war文件(spring-oauth-client.war), - 将war放在Tomcat中并启动(注意: 这种方式需要将spring-oauth-client.properties加入到classpath中并正确配置) -
  8. -
  9. -

    - 若在Android中使用, 可查看示例代码 AndroidClientTest.java(位于 src/master/src /test /java /com/andaily/springoauth/client/目录). - 里面包括获取 access_token 与 调用API的示例. -

    -
  10. -
-
+ +## 如何使用? + +前提: 在使用之前必须保证 spring-oauth-server 项目已正常运行. +
    +
  1. + 项目是Maven管理的, 需要本地安装maven(开发用的maven版本号为3.6.0) +
  2. +
  3. + 下载(或clone)项目到本地 +
  4. +
  5. + 修改application.properties(位于src/main/resources目录)中的配置信息(主要包括与spring-oauth-server的连接地址) +
  6. +
  7. + 将本地项目导入到IDE(如Intellij IDEA)中, 直接运行启动类 SpringOAuthClientApplication.java, 通过浏览器访问即可(默认端口 8082). +
    + 所有的操作说明都在页面上体现. +
    + 另: 也可通过maven package命令将项目编译为jar文件(spring-oauth-client.jar), 然后通过java -jar命令运行. +
  8. +
  9. +

    + 若在Android或移动设备中使用, 可查看示例代码 AndroidClientTest.java(位于 src/master/src /test /java /com/andaily/springoauth/client/目录). + 里面包括获取 access_token 与 调用API的示例. +

    +
  10. +

+ +## 实现思路 + +

+ spring-oauth-client 的实现没有使用开源项目 spring-security-oauth2 中提供的代码与配置, 如:<oauth:client + id="oauth2ClientFilter" /> +

- 实现思路 -

- spring-oauth-client 的实现没有使用开源项目 spring-security-oauth2 中提供的代码与配置, 如:<oauth:client - id="oauth2ClientFilter" /> -

-

- 而是按照Oauth2协议支持的5类grant_type依次去实现. -
-

    -
  1. authorization_code -- 授权码模式(即先登录获取code,再获取token)
  2. -
  3. password -- 密码模式(将用户名,密码传过去,直接获取token)
  4. -
  5. client_credentials -- 客户端模式(无用户,用户向客户端注册,然后客户端以自己的名义向'服务端'获取资源)
  6. -
  7. implicit -- 简化模式(在redirect_uri 的Hash传递token; Auth客户端运行在浏览器中,如JS,Flash)
  8. -
  9. refresh_token -- 刷新access_token
  10. -
- -

+ 而是按照OAuth2协议支持的各类grant_type依次去实现. +
+ 详见博客 https://andaily.com/blog/?p=103 +
+

- 项目的开发管理使用开源项目 andaily-developer. + 项目的开发管理使用开源项目 andaily-developer.


-
- 项目日志 -
    -
  1. -

    2015-03-17 项目创建

    -
  2. -
  3. -

    2015-06-02 V-0.1版本发布

    -
  4. -
  5. -

    2015-11-16 添加在线测试, 访问地址 http://andaily.com/spring-oauth-client/

    -
  6. -
  7. -

    2018-04-16 V-1.0发布; 开始V-1.1,增加对OIDC协议支持

    -
  8. -
-
+## 项目日志 + +
    +
  1. +

    2015-03-17 项目创建

    +
  2. +
  3. +

    2015-06-02 V-0.1版本发布

    +
  4. +
  5. +

    2015-11-16 添加在线测试, 访问地址 http://andaily.com/spring-oauth-client/

    +
  6. +
  7. +

    2018-04-16 V-1.0发布; 开始V-1.1,增加对OIDC协议支持

    +
  8. +
  9. +

    2023-11-04 v2.0.0准备开发, 升级支持spring-oauth-server中 OAuth2.1与OIDC 1.0 协议

    +
  10. +
  11. +

    2023-11-09 v2.0.0发布

    +
  12. +
+
-
- 参考资源 -
- 以下是在开发与学习过程中参考的Oauth资源,总结下来方便学习回顾. - -
+ +## 参考资源 + +以下是在开发与学习过程中参考的Oauth资源,总结下来方便学习回顾. + +
-

- 与项目相关的技术文章请访问 https://andaily.com/blog/?cat=19 (不断更新与OAuth相关的文章) -

+ +## 周边相关 + +
+ 与项目相关的技术文章请访问 https://andaily.com/blog/?cat=19 (不断更新与OAuth/OIDC相关的文章) +

问答与讨论
与项目相关的,与OAuth相关的问题与回答,以及各类讨论请访问
- https://andaily.com/blog/?dwqa-question_category=oauth + https://andaily.com/blog/?dwqa-question_category=oauth 或提 issue


diff --git a/others/client_test.txt b/others/client_test.txt index 13cb45232558d99dbcdd75683e9bd22e4373a5e5..233716a4e7da3abe52e6d64ecddba356d44fe332 100644 --- a/others/client_test.txt +++ b/others/client_test.txt @@ -1,4 +1,5 @@ +适用于 v1.x版本 -- grant_type = 'client_credentials' diff --git a/pom.xml b/pom.xml index 3e4f0bf7cf539fa34028b50e13dcfd8e15450967..316ef49cf6bef2e1b0a8176f7a7d04d72276a679 100644 --- a/pom.xml +++ b/pom.xml @@ -1,255 +1,146 @@ - - - 4.0.0 - - com.andaily - spring-oauth-client - 1.1 - spring-oauth-client - war - Spring Oauth Client - - - UTF-8 - - 4.1.6.RELEASE - - false - - - - - shengzhao - monkeyk1987@gmail.com - - - - - - - spring-oauth-client - - - - org.apache.maven.plugins - maven-compiler-plugin - 2.3.2 - - utf-8 - 1.7 - 1.7 - - - - - maven-war-plugin - 2.6 - - */classes/spring-oauth-client.properties - */classes/spring-oauth-client.properties - - - - ${project.version} - - - ${project.name} - - - ${project.version} - - ${maven.build.timestamp} - - - - - - - - maven-surefire-plugin - 2.4 - - ${test.skip} - none - - **/*Test.java - - - - - - - - src/main/resources - - **/* - - - - - - - - - - src/test/resources - - **/* - - - - - - - - - javax.servlet - servlet-api - 2.4 - provided - - - javax.servlet.jsp - jsp-api - 2.1 - provided - - - - - opensymphony - sitemesh - 2.4 - - - - org.apache.httpcomponents - httpclient - 4.3.5 - - - commons-logging - commons-logging - - - - - - - - org.springframework - spring-aop - ${spring.version} - - - org.springframework - spring-beans - ${spring.version} - - - org.springframework - spring-context - ${spring.version} - - - org.springframework - spring-core - ${spring.version} - - - - - - - - org.springframework - spring-expression - ${spring.version} - - - org.springframework - spring-web - ${spring.version} - - - org.springframework - spring-webmvc - ${spring.version} - - - org.springframework - spring-context-support - ${spring.version} - - - - - log4j - log4j - 1.2.14 - compile - - - org.slf4j - slf4j-log4j12 - 1.7.5 - compile - - - - net.sf.json-lib - json-lib - 2.4 - jdk15 - - - commons-logging - commons-logging - - - - - - commons-lang - commons-lang - 2.6 - - - - - javax.servlet - jstl - 1.1.2 - - - taglibs - standard - 1.1.2 - compile - - - - - org.springframework - spring-test - ${spring.version} - test - - - org.testng - testng - 6.1.1 - test - - - junit - junit - - - - - - + + + 4.0.0 + + com.andaily + spring-oauth-client + 2.0.0 + jar + + ${project.artifactId} + Spring OAuth Client Demo + + + org.springframework.boot + spring-boot-starter-parent + 3.1.2 + + + + + UTF-8 + 17 + + false + + + + + shengzhao + monkeyk1987@gmail.com + + + + + + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + com.nimbusds + nimbus-jose-jwt + 9.31 + + + + org.apache.httpcomponents + httpclient + 4.5.14 + + + commons-logging + commons-logging + + + + + + + net.sf.json-lib + json-lib + 2.4 + jdk15 + + + commons-logging + commons-logging + + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.apache.logging.log4j + log4j-to-slf4j + + + + + + + ${project.artifactId} + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + maven-jar-plugin + + + false + + ${project.version} + spring-oauth-client + ${project.version} + https://monkeyk.com + CloudJac, Inc. + + + true + + + + + + + maven-surefire-plugin + + ${test.skip} + + **/*Test.java + + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/andaily/springoauth/SpringOAuthClientApplication.java b/src/main/java/com/andaily/springoauth/SpringOAuthClientApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..cf6be3449c81e38541c99fc5e3c9bdb6d7299330 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/SpringOAuthClientApplication.java @@ -0,0 +1,24 @@ +package com.andaily.springoauth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 2023/11/6 22:17 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +@SpringBootApplication +public class SpringOAuthClientApplication { + + /** + * 主函数入口,用于启动程序 + * + * @param args 命令行参数 + */ + public static void main(String[] args) { + SpringApplication.run(SpringOAuthClientApplication.class, args); + } + +} diff --git a/src/main/java/com/andaily/springoauth/config/MVCConfiguration.java b/src/main/java/com/andaily/springoauth/config/MVCConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..81c3ef2e788d5a8e568b972ead028919ac5400fe --- /dev/null +++ b/src/main/java/com/andaily/springoauth/config/MVCConfiguration.java @@ -0,0 +1,41 @@ +package com.andaily.springoauth.config; + + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 2023/11/6 + *

+ * Spring MVC 扩展配置 + *

+ * + * @author Shengzhao Li + * @since 2.0.0 + */ +@Configuration +public class MVCConfiguration implements WebMvcConfigurer { + + +// /** +// * 扩展拦截器 +// */ +// @Override +// public void addInterceptors(InterceptorRegistry registry) { +// +// WebMvcConfigurer.super.addInterceptors(registry); +// } + + +// /** +// * 解决乱码问题 +// * For UTF-8 +// */ +// @Override +// public void configureMessageConverters(List> converters) { +// WebMvcConfigurer.super.configureMessageConverters(converters); +// converters.add(new StringHttpMessageConverter(StandardCharsets.UTF_8)); +// } + + +} diff --git a/src/main/java/com/andaily/springoauth/infrastructure/JwtBearerUtils.java b/src/main/java/com/andaily/springoauth/infrastructure/JwtBearerUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..099b73a84853d9352758c856fdfc9420f71a55af --- /dev/null +++ b/src/main/java/com/andaily/springoauth/infrastructure/JwtBearerUtils.java @@ -0,0 +1,98 @@ +package com.andaily.springoauth.infrastructure; + +import com.nimbusds.jose.*; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jwt.JWTClaimsSet; + +import java.time.Instant; +import java.util.Date; + +/** + * 2023/11/9 15:41 + *

+ * jwt-bearer utils + * + * @author Shengzhao Li + * @since 2.0.0 + */ +public abstract class JwtBearerUtils { + + + private JwtBearerUtils() { + } + + + /** + * RS256 assertion 生成 + * + * @param clientId clientId + * @param audience 一般是授权服务器的url, 如: http://auth.server.com:8080 + * @param jwk JWK 根据 jwks 中配置的, 必须是 RSA算法 + */ + public static String generateRsAssertion(String clientId, String audience, JWK jwk) throws JOSEException { + + JWSSigner jwsSigner = new RSASSASigner(jwk.toRSAKey()); + JWSHeader header = new JWSHeader(JWSAlgorithm.RS256); + + return generateAssertion(clientId, audience, jwsSigner, header); + } + + /** + * ES256 assertion 生成 + * + * @param clientId clientId + * @param audience 一般是授权服务器的url, 如: http://auth.server.com:8080 + * @param jwk JWK 根据 jwks 中配置的, 必须是 ES算法 + */ + public static String generateEsAssertion(String clientId, String audience, JWK jwk) throws JOSEException { + + JWSSigner jwsSigner = new ECDSASigner(jwk.toECKey()); + JWSHeader header = new JWSHeader(JWSAlgorithm.ES256); + + return generateAssertion(clientId, audience, jwsSigner, header); + } + + /** + * MAC assertion 生成 + * 对称算法,不推荐 + * + * @param clientId clientId + * @param audience 一般是授权服务器的url, 如: http://auth.server.com:8080 + * @param macSecret Mac 加密 secret + */ + public static String generateMacAssertion(String clientId, String audience, String macSecret) throws JOSEException { + + JWSSigner jwsSigner = new MACSigner(macSecret); + JWSHeader header = new JWSHeader(JWSAlgorithm.HS256); + + return generateAssertion(clientId, audience, jwsSigner, header); + } + + + /** + * 具体生成 assertion + * + * @return assertion + * @throws JOSEException e + */ + private static String generateAssertion(String clientId, String audience, JWSSigner jwsSigner, JWSHeader header) throws JOSEException { + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(clientId) + .issuer(clientId) + .audience(audience) + // assertion有效时间, 5分钟 + .expirationTime(Date.from(Instant.now().plusSeconds(300L))) + .build(); + Payload payload = new Payload(claimsSet.toJSONObject()); + + JWSObject jwsObject = new JWSObject(header, payload); + //签名 + jwsObject.sign(jwsSigner); + return jwsObject.serialize(); + } + + +} diff --git a/src/main/java/com/andaily/springoauth/infrastructure/OAuth2Holder.java b/src/main/java/com/andaily/springoauth/infrastructure/OAuth2Holder.java new file mode 100644 index 0000000000000000000000000000000000000000..420de95d1c63ac841aaf2cdfea485550b1c8a5f3 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/infrastructure/OAuth2Holder.java @@ -0,0 +1,205 @@ +package com.andaily.springoauth.infrastructure; + +import com.andaily.springoauth.infrastructure.httpclient.HttpClientExecutor; +import net.sf.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +/** + * 2023/11/6 23:19 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +@Component +public class OAuth2Holder implements InitializingBean { + + private static final Logger LOG = LoggerFactory.getLogger(OAuth2Holder.class); + + + /** + * spring-oauth-server or myoidc-server host + */ + @Value("${oauth2.server.host:http://127.0.0.1:8080}") + private String oauth2ServerHost; + + /** + * spring-oauth-server or myoidc-server well-known url: .well-known/openid-configuration + */ + @Value("${oauth2.server.well-known.url:.well-known/openid-configuration}") + private String wellKnownUrl; + + + /** + * spring-oauth-server or myoidc-server token url: oauth2/token + */ + private static String tokenUrl; + + /** + * spring-oauth-server or myoidc-server authorize url: oauth2/authorize + */ + private static String authorizeUrl; + + /** + * spring-oauth-server or myoidc-server device authorize url: oauth2/device_authorization + */ + private static String deviceAuthorizeUrl; + + + /** + * spring-oauth-server or myoidc-server userinfo url: /userinfo + */ + private static String userinfoUrl; + + + /** + * spring-oauth-server or myoidc-server jwks url: /oauth2/jwks + */ + private static String jwksUrl; + + + /** + * spring-oauth-server or myoidc-server jwks url: /oauth2/revoke + */ + private static String revokeUrl; + + /** + * spring-oauth-server or myoidc-server jwks url: /oauth2/introspect + */ + private static String introspectUrl; + + /** + * spring-oauth-server or myoidc-server wellKnown json + */ + private static JSONObject wellKnownJson; + + /** + * spring-oauth-server or myoidc-server wellKnown full url + */ + private static String fullWellKnownUrl; + + /** + * spring-oauth-server or myoidc-server issuer + */ + private static String issuer; + + + public OAuth2Holder() { + } + + + public static String issuer() { + return issuer; + } + + public static String tokenUrl() { + return tokenUrl; + } + + public static String authorizeUrl() { + return authorizeUrl; + } + + public static String deviceAuthorizeUrl() { + return deviceAuthorizeUrl; + } + + public static String userinfoUrl() { + return userinfoUrl; + } + + public static String jwksUrl() { + return jwksUrl; + } + + public static String revokeUrl() { + return revokeUrl; + } + + public static String introspectUrl() { + return introspectUrl; + } + + public static JSONObject wellKnownJson() { + return wellKnownJson; + } + + public static String fullWellKnownUrl() { + return fullWellKnownUrl; + } + + @Override + public void afterPropertiesSet() throws Exception { + Assert.notNull(oauth2ServerHost, "oauth2.server.host is null"); + if (!oauth2ServerHost.endsWith("/")) { + oauth2ServerHost += "/"; + } + + //请求 wellknown url + fullWellKnownUrl = oauth2ServerHost + wellKnownUrl; + if (LOG.isDebugEnabled()) { + LOG.debug("Will request fullWellKnownUrl: {}", fullWellKnownUrl); + } + + HttpClientExecutor clientExecutor = new HttpClientExecutor(fullWellKnownUrl) + .maxConnectionSeconds(5); + try { + clientExecutor.executeWithException(response -> { + String respText = response.responseAsString(); + if (!response.isResponse200()) { + if (LOG.isErrorEnabled()) { + LOG.error("WellKnownUrl: {} is not response 200, respText: {}, need checking", fullWellKnownUrl, respText); + } + setDefaultUrls(); + } else { + JSONObject json = JSONObject.fromObject(respText); + tokenUrl = json.getString("token_endpoint"); + authorizeUrl = json.getString("authorization_endpoint"); + deviceAuthorizeUrl = json.getString("device_authorization_endpoint"); + + jwksUrl = json.getString("jwks_uri"); + revokeUrl = json.getString("revocation_endpoint"); + introspectUrl = json.getString("introspection_endpoint"); + + userinfoUrl = json.getString("userinfo_endpoint"); + issuer = json.getString("issuer"); + wellKnownJson = json; + } + }); + } catch (Exception e) { + if (LOG.isErrorEnabled()) { + LOG.error("WellKnownUrl: {} response", fullWellKnownUrl, e); + } + setDefaultUrls(); + } + + Assert.notNull(tokenUrl, "tokenUrl is null"); + Assert.notNull(authorizeUrl, "authorizeUrl is null"); + Assert.notNull(deviceAuthorizeUrl, "deviceAuthorizeUrl is null"); + + Assert.notNull(jwksUrl, "jwksUrl is null"); + Assert.notNull(revokeUrl, "revokeUrl is null"); + Assert.notNull(introspectUrl, "introspectUrl is null"); + + Assert.notNull(userinfoUrl, "userinfoUrl is null"); + + } + + private void setDefaultUrls() { + //use default + tokenUrl = oauth2ServerHost + "oauth2/token"; + authorizeUrl = oauth2ServerHost + "oauth2/authorize"; + deviceAuthorizeUrl = oauth2ServerHost + "oauth2/device_authorization"; + + jwksUrl = oauth2ServerHost + "oauth2/jwks"; + revokeUrl = oauth2ServerHost + "oauth2/revoke"; + introspectUrl = oauth2ServerHost + "oauth2/introspect"; + + userinfoUrl = oauth2ServerHost + "userinfo"; + issuer = oauth2ServerHost; + } +} diff --git a/src/main/java/com/andaily/springoauth/infrastructure/PKCEUtils.java b/src/main/java/com/andaily/springoauth/infrastructure/PKCEUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..f4a52bace686691551d749b028eac8edd315a5f3 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/infrastructure/PKCEUtils.java @@ -0,0 +1,57 @@ +package com.andaily.springoauth.infrastructure; + + +import org.apache.commons.lang.RandomStringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * 2023/11/8 22:45 + *

+ * PKCE tool: + * + * @author Shengzhao Li + * @since 2.0.0 + */ +public abstract class PKCEUtils { + + private static final String ALG = "SHA-256"; + + + private PKCEUtils() { + } + + /** + * 随机生成32的 code_verifier + * + * @return code_verifier + */ + public static String generateCodeVerifier() { + // 1. 随机生成code_verifier + String codeVerifierVal = RandomStringUtils.random(32, true, true); + //2. 对 code_verifier 进行base64 encode + return Base64.getEncoder().encodeToString(codeVerifierVal.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 根据指定的 code_verifier 计算 code_challenge + * + * @param codeVerifier code_verifier + * @return code_challenge + */ + public static String generateCodeChallenge(String codeVerifier) { + MessageDigest md; + try { + md = MessageDigest.getInstance(ALG); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("JDK not found alg: '" + ALG + "' ??", e); + } + byte[] digest = md.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + + +} diff --git a/src/main/java/com/andaily/springoauth/infrastructure/httpclient/HttpClientExecutor.java b/src/main/java/com/andaily/springoauth/infrastructure/httpclient/HttpClientExecutor.java index 67992b38728c21b725628b6579622e2f74d51eba..d414a0ac6f8624d072ade33a05bdb6860944c698 100644 --- a/src/main/java/com/andaily/springoauth/infrastructure/httpclient/HttpClientExecutor.java +++ b/src/main/java/com/andaily/springoauth/infrastructure/httpclient/HttpClientExecutor.java @@ -27,34 +27,50 @@ import java.util.*; */ public class HttpClientExecutor { - /* - * Available content Types - * */ + /** + * Available content Types + */ public static final List CONTENT_TYPES = Arrays.asList( - ContentType.TEXT_PLAIN, ContentType.TEXT_HTML, - ContentType.TEXT_XML, ContentType.APPLICATION_XML, - ContentType.APPLICATION_SVG_XML, ContentType.APPLICATION_XHTML_XML, + ContentType.TEXT_PLAIN, + ContentType.TEXT_HTML, + ContentType.TEXT_XML, + ContentType.APPLICATION_XML, + ContentType.APPLICATION_SVG_XML, + ContentType.APPLICATION_XHTML_XML, ContentType.APPLICATION_ATOM_XML, - ContentType.APPLICATION_JSON); + ContentType.APPLICATION_JSON + ); protected static final Logger LOGGER = LoggerFactory.getLogger(HttpClientExecutor.class); - //Convert mill seconds to second unit + /** + * Convert mill seconds to second unit + */ protected static final int MS_TO_S_UNIT = 1000; - //https prefix + /** + * https prefix + */ protected static final String HTTPS = "https"; protected static HttpsTrustManager httpsTrustManager = new HttpsTrustManager(); protected String url; - protected int maxConnectionSeconds = 0; + /** + * 默认超时 10秒 + */ + protected int maxConnectionSeconds = 10; protected String contentType; protected Map requestParams = new HashMap(); + /** + * @since 2.0.0 + */ + protected Map headers = new HashMap<>(); + public HttpClientExecutor(String url) { this.url = url; } @@ -72,6 +88,17 @@ public class HttpClientExecutor { return (T) this; } + + /** + * @since 2.0.0 + */ + @SuppressWarnings("unchecked") + public T addHeader(String key, String value) { + this.headers.put(key, value); + return (T) this; + } + + @SuppressWarnings("unchecked") public T contentType(String contentType) { this.contentType = contentType; @@ -90,9 +117,9 @@ public class HttpClientExecutor { } - /* - * Execute and handle exception by yourself - * */ + /** + * Execute and handle exception by yourself + */ public void executeWithException(HttpResponseHandler responseHandler) throws Exception { final CloseableHttpResponse response = sendRequest(); responseHandler.handleResponse(new MkkHttpResponse(response)); @@ -103,6 +130,7 @@ public class HttpClientExecutor { protected CloseableHttpResponse sendRequest() throws Exception { HttpUriRequest request = retrieveHttpRequest(); setContentType(request); + setHeaders(request); CloseableHttpClient client = retrieveHttpClient(); return client.execute(request); @@ -115,6 +143,16 @@ public class HttpClientExecutor { } } + /** + * @since 2.0.0 + */ + protected void setHeaders(HttpUriRequest request) { + for (String key : headers.keySet()) { + request.addHeader(key, headers.get(key)); + } + } + + protected CloseableHttpClient retrieveHttpClient() { final RequestConfig requestConfig = requestConfig(); if (isHttps()) { diff --git a/src/main/java/com/andaily/springoauth/infrastructure/repository/ClientDetailsRepository.java b/src/main/java/com/andaily/springoauth/infrastructure/repository/ClientDetailsRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..3a9d2d968602efdfac57997bfe5e1a74ad552626 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/infrastructure/repository/ClientDetailsRepository.java @@ -0,0 +1,28 @@ +package com.andaily.springoauth.infrastructure.repository; + +import com.andaily.springoauth.service.dto.ClientDetailsDto; + +/** + * 2023/11/7 15:24 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +public interface ClientDetailsRepository { + + /** + * 查找默认client + * + * @return ClientDetailsDto or null + */ + ClientDetailsDto findDefaultClientDetails(); + + /** + * 保存 + * + * @param clientDetailsDto ClientDetailsDto + * @return id or null + */ + String saveClientDetails(ClientDetailsDto clientDetailsDto); + +} diff --git a/src/main/java/com/andaily/springoauth/infrastructure/repository/ClientDetailsRepositoryFile.java b/src/main/java/com/andaily/springoauth/infrastructure/repository/ClientDetailsRepositoryFile.java new file mode 100644 index 0000000000000000000000000000000000000000..2f94929bf00254e12f7cdbab64644a8b0efe67ed --- /dev/null +++ b/src/main/java/com/andaily/springoauth/infrastructure/repository/ClientDetailsRepositoryFile.java @@ -0,0 +1,81 @@ +package com.andaily.springoauth.infrastructure.repository; + +import com.andaily.springoauth.infrastructure.json.JsonUtils; +import com.andaily.springoauth.service.dto.ClientDetailsDto; +import net.sf.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Repository; +import org.springframework.util.FileCopyUtils; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * 2023/11/7 15:26 + *

+ * 默认存文件 + *

+ * TODO:此实现不能满足生产需要,请使用更安全的存储实现(如数据库) + * + * @author Shengzhao Li + * @since 2.0.0 + */ +@Repository +public class ClientDetailsRepositoryFile implements ClientDetailsRepository { + + private static final Logger LOG = LoggerFactory.getLogger(ClientDetailsRepositoryFile.class); + + /** + * 存储的文件名 + * 隐藏文件 .xxx + */ + public static final String CLIENT_DETAILS_FILE = ".client_details.json"; + + /** + * {@inheritDoc} + */ + @Override + public ClientDetailsDto findDefaultClientDetails() { + File jsonFile = getFile(); + if (!jsonFile.exists() || !jsonFile.canRead()) { + if (LOG.isWarnEnabled()) { + LOG.warn("Not exist jsonFile: {} or can not read", jsonFile); + } + return null; + } + if (LOG.isDebugEnabled()) { + LOG.debug("Read file full-path: {}", jsonFile.getAbsolutePath()); + } + String json; + try { + json = FileCopyUtils.copyToString(new FileReader(jsonFile, StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalStateException("Read error", e); + } + return JsonUtils.textToBean(new ClientDetailsDto(), json); + } + + private File getFile() { + return new File(CLIENT_DETAILS_FILE); + } + + /** + * {@inheritDoc} + */ + @Override + public String saveClientDetails(ClientDetailsDto clientDetailsDto) { + JSONObject jsonObject = JSONObject.fromObject(clientDetailsDto); + String json = jsonObject.toString(); + try { + File jsonFile = getFile(); + FileCopyUtils.copy(json, new FileWriter(jsonFile, StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalStateException("Write error", e); + } + return null; + } +} diff --git a/src/main/java/com/andaily/springoauth/service/OauthService.java b/src/main/java/com/andaily/springoauth/service/OauthService.java index 58020a988bebebe4815657e97dc0491c0b1bcb72..21fafc1d37b8159e7502e74ab989fc7161ec2a1f 100644 --- a/src/main/java/com/andaily/springoauth/service/OauthService.java +++ b/src/main/java/com/andaily/springoauth/service/OauthService.java @@ -13,11 +13,47 @@ public interface OauthService { AuthAccessTokenDto createAuthAccessTokenDto(AuthCallbackDto callbackDto); + /** + * @deprecated use loadUserinfoDto replaced from v2.0.0 + */ UserDto loadUnityUserDto(String accessToken); + /** + * @deprecated OAuth2.1中不支持 password grant type + */ AccessTokenDto retrievePasswordAccessTokenDto(AuthAccessTokenDto authAccessTokenDto); AccessTokenDto refreshAccessTokenDto(RefreshAccessTokenDto refreshAccessTokenDto); AccessTokenDto retrieveCredentialsAccessTokenDto(AuthAccessTokenDto authAccessTokenDto); + + /** + * load /userinfo + * + * @since 2.0.0 + */ + UserinfoDto loadUserinfoDto(String accessToken); + + /** + * save client details + * + * @since 2.0.0 + */ + String saveClientDetails(ClientDetailsDto clientDetailsDto); + + /** + * 加载保存的 client details + * 未保存返回 null + * + * @return ClientDetailsDto + * @since 2.0.0 + */ + ClientDetailsDto loadClientDetails(); + + /** + * 获取 device code, user code 等 + * + * @since 2.0.0 + */ + DeviceAuthorizationDto retrieveDeviceAuthorizationDto(AuthDeviceCodeDto deviceCodeDto); } \ No newline at end of file diff --git a/src/main/java/com/andaily/springoauth/service/dto/AbstractOauthDto.java b/src/main/java/com/andaily/springoauth/service/dto/AbstractOauthDto.java index b342c651471adfc955ae5efb17a1373989f1052f..39c3cffd4f12aa2fb27ea92f816fa7ef687178da 100644 --- a/src/main/java/com/andaily/springoauth/service/dto/AbstractOauthDto.java +++ b/src/main/java/com/andaily/springoauth/service/dto/AbstractOauthDto.java @@ -2,6 +2,7 @@ package com.andaily.springoauth.service.dto; import org.apache.commons.lang.StringUtils; +import java.io.Serial; import java.io.Serializable; /** @@ -10,6 +11,9 @@ import java.io.Serializable; public abstract class AbstractOauthDto implements Serializable { + @Serial + private static final long serialVersionUID = 2150565364012368729L; + //Error if have from oauth server protected String errorDescription; protected String error; diff --git a/src/main/java/com/andaily/springoauth/service/dto/AccessTokenDto.java b/src/main/java/com/andaily/springoauth/service/dto/AccessTokenDto.java index 35442dc82d6c9c6af286e2eb0215dfa14fa51d0e..a7fdbecd46a55b5f2d913f4a76f0fe78df007499 100644 --- a/src/main/java/com/andaily/springoauth/service/dto/AccessTokenDto.java +++ b/src/main/java/com/andaily/springoauth/service/dto/AccessTokenDto.java @@ -1,5 +1,7 @@ package com.andaily.springoauth.service.dto; +import java.io.Serial; + /** * 15-5-18 *

@@ -9,6 +11,9 @@ package com.andaily.springoauth.service.dto; */ public class AccessTokenDto extends AbstractOauthDto { + @Serial + private static final long serialVersionUID = 6933993017288255110L; + private String accessToken; private String tokenType; private String refreshToken; @@ -16,6 +21,10 @@ public class AccessTokenDto extends AbstractOauthDto { private int expiresIn; + /** + * @since 2.0.0 + */ + private String idToken; public AccessTokenDto() { } @@ -62,10 +71,20 @@ public class AccessTokenDto extends AbstractOauthDto { this.scope = scope; } + + public String getIdToken() { + return idToken; + } + + public void setIdToken(String idToken) { + this.idToken = idToken; + } + @Override public String toString() { final StringBuilder sb = new StringBuilder(); sb.append("{accessToken='").append(accessToken).append('\''); + sb.append("{idToken='").append(idToken).append('\''); sb.append(", tokenType='").append(tokenType).append('\''); sb.append(", refreshToken='").append(refreshToken).append('\''); sb.append(", scope='").append(scope).append('\''); diff --git a/src/main/java/com/andaily/springoauth/service/dto/AuthAccessTokenDto.java b/src/main/java/com/andaily/springoauth/service/dto/AuthAccessTokenDto.java index 234380e65d0d388dd121844dc1faae457d1ff90c..875132a552418989f0d8a4b5ec60cabdd5197b19 100644 --- a/src/main/java/com/andaily/springoauth/service/dto/AuthAccessTokenDto.java +++ b/src/main/java/com/andaily/springoauth/service/dto/AuthAccessTokenDto.java @@ -1,5 +1,8 @@ package com.andaily.springoauth.service.dto; +import org.apache.commons.lang.StringUtils; + +import java.io.Serial; import java.io.Serializable; import java.util.HashMap; import java.util.Map; @@ -7,13 +10,16 @@ import java.util.Map; /** * 15-5-18 *

- * http://localhost:8080/oauth/token?client_id=unity-client&client_secret=unity&grant_type=authorization_code&code=zLl170&redirect_uri=http%3a%2f%2flocalhost%3a8080%2funity%2fdashboard.htm + * http://localhost:8080/oauth2/token?client_id=unity-client&client_secret=unity&grant_type=authorization_code&code=zLl170&redirect_uri=http%3a%2f%2flocalhost%3a8080%2funity%2fdashboard.htm * * @author Shengzhao Li */ public class AuthAccessTokenDto implements Serializable { + @Serial + private static final long serialVersionUID = -4212912744864611167L; + private String accessTokenUri; private String clientId; @@ -27,13 +33,36 @@ public class AuthAccessTokenDto implements Serializable { private String redirectUri; private String scope; + + /** + * @deprecated Not yet used from v2.0.0 + */ private String username; + + /** + * @deprecated Not yet used from v2.0.0 + */ private String password; + /** + * PKCE flow + * + * @since 2.0.0 + */ + private String codeVerifier; public AuthAccessTokenDto() { } + + public String getCodeVerifier() { + return codeVerifier; + } + + public void setCodeVerifier(String codeVerifier) { + this.codeVerifier = codeVerifier; + } + public String getScope() { return scope; } @@ -112,9 +141,9 @@ public class AuthAccessTokenDto implements Serializable { return this; } - /* - * http://localhost:8080/oauth/token?client_id=unity-client&client_secret=unity&grant_type=authorization_code&code=zLl170&redirect_uri=http%3a%2f%2flocalhost%3a8080%2funity%2fdashboard.htm - * */ + /** + * http://localhost:8080/oauth2/token?client_id=unity-client&client_secret=unity&grant_type=authorization_code&code=zLl170&redirect_uri=http%3a%2f%2flocalhost%3a8080%2funity%2fdashboard.htm + */ public Map getAuthCodeParams() { Map map = new HashMap<>(); map.put("client_id", clientId); @@ -124,13 +153,20 @@ public class AuthAccessTokenDto implements Serializable { map.put("redirect_uri", redirectUri); map.put("code", code); + if (StringUtils.isNotBlank(codeVerifier)) { + map.put("code_verifier", codeVerifier); + } + return map; } - /* - * http://localhost:8080/spring-oauth-server/oauth/token?client_id=mobile-client&client_secret=mobile&grant_type=password&scope=read,write&username=mobile&password=mobile - * */ + /** + * http://localhost:8080/oauth2/token?client_id=mobile-client&client_secret=mobile&grant_type=password&scope=read,write&username=mobile&password=mobile + * + * @deprecated OAuth2.1中不再支持 password 授权方式 + */ + @Deprecated public Map getAccessTokenParams() { Map map = new HashMap<>(); map.put("client_id", clientId); @@ -144,8 +180,8 @@ public class AuthAccessTokenDto implements Serializable { return map; } - /* - * http://localhost:8080/spring-oauth-server/oauth/token?client_id=credentials-client&client_secret=credentials-secret&grant_type=client_credentials&scope=read,write + /** + * http://localhost:8080/oauth2/token?client_id=credentials-client&client_secret=credentials-secret&grant_type=client_credentials&scope=openid */ public Map getCredentialsParams() { Map map = new HashMap<>(); diff --git a/src/main/java/com/andaily/springoauth/service/dto/AuthDeviceCodeDto.java b/src/main/java/com/andaily/springoauth/service/dto/AuthDeviceCodeDto.java new file mode 100644 index 0000000000000000000000000000000000000000..acd54b19d68c3a5174e80deef85a6772a1551077 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/service/dto/AuthDeviceCodeDto.java @@ -0,0 +1,83 @@ +package com.andaily.springoauth.service.dto; + +import java.io.Serial; +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +/** + * 2023-11-09 + *

+ * http://localhost:8080/oauth2/device_authorization?client_id=xxxx&client_secret=xxx&scope=openid + * + * @author Shengzhao Li + * @since 2.0.0 + */ +public class AuthDeviceCodeDto implements Serializable { + + + @Serial + private static final long serialVersionUID = 3398358846980534154L; + + private String deviceAuthorizeUrl; + + private String clientId; + + private String clientSecret; + + + private String scope; + + + public AuthDeviceCodeDto() { + } + + public String getDeviceAuthorizeUrl() { + return deviceAuthorizeUrl; + } + + public void setDeviceAuthorizeUrl(String deviceAuthorizeUrl) { + this.deviceAuthorizeUrl = deviceAuthorizeUrl; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + + public String getClientId() { + return clientId; + } + + public AuthDeviceCodeDto setClientId(String clientId) { + this.clientId = clientId; + return this; + } + + public String getClientSecret() { + return clientSecret; + } + + public AuthDeviceCodeDto setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + + /** + * http://localhost:8080/oauth2/device_authorization?client_id=xxxx&client_secret=xxx&scope=openid + */ + public Map getAuthParams() { + Map map = new HashMap<>(); + map.put("client_id", clientId); + map.put("client_secret", clientSecret); + map.put("scope", scope); + + return map; + } + +} diff --git a/src/main/java/com/andaily/springoauth/service/dto/AuthorizationCodeDto.java b/src/main/java/com/andaily/springoauth/service/dto/AuthorizationCodeDto.java index 73a8696f405ff5d4b7e521bd0f6fcf5adf0712ec..80f968d53d764a29d8fb914906ad0065842b4b0c 100644 --- a/src/main/java/com/andaily/springoauth/service/dto/AuthorizationCodeDto.java +++ b/src/main/java/com/andaily/springoauth/service/dto/AuthorizationCodeDto.java @@ -1,8 +1,12 @@ package com.andaily.springoauth.service.dto; +import org.apache.commons.lang.StringUtils; + +import java.io.Serial; import java.io.Serializable; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; /** * @author Shengzhao Li @@ -10,6 +14,9 @@ import java.net.URLEncoder; public class AuthorizationCodeDto implements Serializable { + @Serial + private static final long serialVersionUID = -1351480384638774862L; + private String userAuthorizationUri; private String responseType; private String scope; @@ -17,10 +24,42 @@ public class AuthorizationCodeDto implements Serializable { private String redirectUri; private String state; + /** + * PKCE flow used + * + * @since 2.0.0 + */ + private String codeVerifier; + private String codeChallengeMethod; + private String codeChallenge; + public AuthorizationCodeDto() { } + public String getCodeChallengeMethod() { + return codeChallengeMethod; + } + + public void setCodeChallengeMethod(String codeChallengeMethod) { + this.codeChallengeMethod = codeChallengeMethod; + } + + public String getCodeChallenge() { + return codeChallenge; + } + + public void setCodeChallenge(String codeChallenge) { + this.codeChallenge = codeChallenge; + } + + public String getCodeVerifier() { + return codeVerifier; + } + + public void setCodeVerifier(String codeVerifier) { + this.codeVerifier = codeVerifier; + } public String getUserAuthorizationUri() { return userAuthorizationUri; @@ -70,11 +109,17 @@ public class AuthorizationCodeDto implements Serializable { this.state = state; } - /* - * http://localhost:8080/oauth/authorize?client_id=unity-client&redirect_uri=http%3a%2f%2flocalhost%3a8080%2funity%2fdashboard.htm&response_type=code&scope=read - * */ - public String getFullUri() throws UnsupportedEncodingException { - String redirect = URLEncoder.encode(redirectUri, "UTF-8"); - return String.format("%s?response_type=%s&scope=%s&client_id=%s&redirect_uri=%s&state=%s", userAuthorizationUri, responseType, scope, clientId, redirect, state); + /** + * http://localhost:8080/oauth2/authorize?client_id=unity-client&redirect_uri=http%3a%2f%2flocalhost%3a8080%2funity%2fdashboard.htm&response_type=code&scope=read + */ + public String getFullUri() { + String redirect = URLEncoder.encode(redirectUri, StandardCharsets.UTF_8); + if (StringUtils.isNotBlank(codeChallenge)) { + // v2.0.0 added PKCE request + return String.format("%s?response_type=%s&scope=%s&client_id=%s&redirect_uri=%s&state=%s&code_challenge_method=%s&code_challenge=%s", + userAuthorizationUri, responseType, scope, clientId, redirect, state, codeChallengeMethod, codeChallenge); + } else { + return String.format("%s?response_type=%s&scope=%s&client_id=%s&redirect_uri=%s&state=%s", userAuthorizationUri, responseType, scope, clientId, redirect, state); + } } } \ No newline at end of file diff --git a/src/main/java/com/andaily/springoauth/service/dto/ClientDetailsDto.java b/src/main/java/com/andaily/springoauth/service/dto/ClientDetailsDto.java new file mode 100644 index 0000000000000000000000000000000000000000..265246ba1334bda5aed8f7bc90646cc219af9954 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/service/dto/ClientDetailsDto.java @@ -0,0 +1,118 @@ +package com.andaily.springoauth.service.dto; + +import jakarta.validation.constraints.NotBlank; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 2023/11/7 15:00 + *

+ * client details data + * + * @author Shengzhao Li + * @since 2.0.0 + */ +public class ClientDetailsDto implements Serializable { + @Serial + private static final long serialVersionUID = 34710937786537773L; + + @NotBlank(message = "clientId is required") + private String clientId; + + @NotBlank(message = "clientSecret is required") + private String clientSecret; + + + /** + * OIDC scope 值, 多个由逗号分隔 + * 如: openid profile email + */ + @NotBlank(message = "scopes is required") + private String scopes = "openid"; + + /** + * 授权支持的 grant_type (OAuth2.1), 多个由逗号分隔 + * 如: authorization_code,refresh_token + */ + @NotBlank(message = "grantTypes is required") + private String authorizationGrantTypes = "authorization_code refresh_token"; + + /** + * OAuth2 认证后回调uri, 一般传递code, 多个由逗号分隔 + * The re-direct URI(s) established during registration (optional, comma separated). + */ + @NotBlank(message = "redirectUris is required") + private String redirectUris; + + /** + * 是否支持 PKCE + */ + private boolean supportPkce; + + + public ClientDetailsDto() { + } + + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public String getScopes() { + return scopes; + } + + public void setScopes(String scopes) { + this.scopes = scopes; + } + + public String getAuthorizationGrantTypes() { + return authorizationGrantTypes; + } + + public void setAuthorizationGrantTypes(String authorizationGrantTypes) { + this.authorizationGrantTypes = authorizationGrantTypes; + } + + public String getRedirectUris() { + return redirectUris; + } + + public void setRedirectUris(String redirectUris) { + this.redirectUris = redirectUris; + } + + public boolean isSupportPkce() { + return supportPkce; + } + + public void setSupportPkce(boolean supportPkce) { + this.supportPkce = supportPkce; + } + + + @Override + public String toString() { + return "{" + + "clientId='" + clientId + '\'' + + ", clientSecret=***" + + ", scopes='" + scopes + '\'' + + ", authorizationGrantTypes='" + authorizationGrantTypes + '\'' + + ", redirectUris='" + redirectUris + '\'' + + ", supportPkce=" + supportPkce + + '}'; + } +} diff --git a/src/main/java/com/andaily/springoauth/service/dto/DeviceAuthorizationDto.java b/src/main/java/com/andaily/springoauth/service/dto/DeviceAuthorizationDto.java new file mode 100644 index 0000000000000000000000000000000000000000..5bdf6482dd677dd01b8170e903bdad6b4e3a51f5 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/service/dto/DeviceAuthorizationDto.java @@ -0,0 +1,92 @@ +package com.andaily.springoauth.service.dto; + +import java.io.Serial; + +/** + * 2023-11-09 + *

+ *

{
+ *   "user_code": "GZDF-RJGM",
+ *   "device_code": "pRHa9EsrsQiHII3vNRUI-c1XKl7CjE5YaH8CYcES8T_IIX8ezpr_iaLsj0-ZatxzfUNwDbW3_Ej_5m4jy5U9VWB9-vIQUWzuL0L8ea1B3SV690sMUaaaFtVmlW0ZajYK",
+ *   "verification_uri_complete": "http://127.0.0.1:8080/oauth2/device_verification?user_code=GZDF-RJGM",
+ *   "verification_uri": "http://127.0.0.1:8080/oauth2/device_verification",
+ *   "expires_in": 300
+ * }
+ * + * @author Shengzhao Li + * @since 2.0.0 + */ +public class DeviceAuthorizationDto extends AbstractOauthDto { + + + @Serial + private static final long serialVersionUID = -7497017423107694265L; + + private String userCode; + + private String deviceCode; + + private String verificationUriComplete; + + private String verificationUri; + + private int expiresIn; + + + public DeviceAuthorizationDto() { + } + + + public int getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(int expiresIn) { + this.expiresIn = expiresIn; + } + + public String getUserCode() { + return userCode; + } + + public void setUserCode(String userCode) { + this.userCode = userCode; + } + + public String getDeviceCode() { + return deviceCode; + } + + public void setDeviceCode(String deviceCode) { + this.deviceCode = deviceCode; + } + + public String getVerificationUriComplete() { + return verificationUriComplete; + } + + public void setVerificationUriComplete(String verificationUriComplete) { + this.verificationUriComplete = verificationUriComplete; + } + + public String getVerificationUri() { + return verificationUri; + } + + public void setVerificationUri(String verificationUri) { + this.verificationUri = verificationUri; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append("{userCode='").append(userCode).append('\''); + sb.append(", deviceCode='").append(deviceCode).append('\''); + sb.append(", verificationUri='").append(verificationUri).append('\''); + sb.append(", expiresIn=").append(expiresIn); + sb.append(", errorDescription='").append(errorDescription).append('\''); + sb.append(", error='").append(error).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/src/main/java/com/andaily/springoauth/service/dto/UserDto.java b/src/main/java/com/andaily/springoauth/service/dto/UserDto.java index 8831cfd84f9057694ceb72cf4e28d76cdfa4e0e0..fbf225bdf42d22d209bf8092d37a124b46340329 100644 --- a/src/main/java/com/andaily/springoauth/service/dto/UserDto.java +++ b/src/main/java/com/andaily/springoauth/service/dto/UserDto.java @@ -1,5 +1,6 @@ package com.andaily.springoauth.service.dto; +import java.io.Serial; import java.util.ArrayList; import java.util.List; @@ -12,6 +13,9 @@ import java.util.List; public class UserDto extends AbstractOauthDto { + @Serial + private static final long serialVersionUID = -2054460930649083095L; + private boolean archived; private String email; private String uuid; diff --git a/src/main/java/com/andaily/springoauth/service/dto/UserinfoDto.java b/src/main/java/com/andaily/springoauth/service/dto/UserinfoDto.java new file mode 100644 index 0000000000000000000000000000000000000000..a6fd4fba85f10da941beba552a3e638c6e6881dd --- /dev/null +++ b/src/main/java/com/andaily/springoauth/service/dto/UserinfoDto.java @@ -0,0 +1,92 @@ +package com.andaily.springoauth.service.dto; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 2023/11/7 12:07 + *

+ * { + * "sub": "admin", + * "updated_at": "123456990", + * "nickname": "xxx" + * } + *

+ * 响应的数据信息可由 server端调整后在此添加或修改 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +public class UserinfoDto extends AbstractOauthDto { + @Serial + private static final long serialVersionUID = -8952118310258988557L; + + private String sub; + + private String nickname; + + private long updated_at; + + private String phone; + + private String email; + + private String address; + + public UserinfoDto() { + } + + public UserinfoDto(String error, String errorDescription) { + this.error = error; + this.errorDescription = errorDescription; + } + + + public String getSub() { + return sub; + } + + public void setSub(String sub) { + this.sub = sub; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public long getUpdated_at() { + return updated_at; + } + + public void setUpdated_at(long updated_at) { + this.updated_at = updated_at; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } +} diff --git a/src/main/java/com/andaily/springoauth/service/impl/DeviceAuthorizationResponseHandler.java b/src/main/java/com/andaily/springoauth/service/impl/DeviceAuthorizationResponseHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..ee03054baff1e5a3dfefda6831b97c72be6085f1 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/service/impl/DeviceAuthorizationResponseHandler.java @@ -0,0 +1,33 @@ +package com.andaily.springoauth.service.impl; + +import com.andaily.springoauth.infrastructure.httpclient.MkkHttpResponse; +import com.andaily.springoauth.service.dto.DeviceAuthorizationDto; + +/** + * 2023-11-09 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +public class DeviceAuthorizationResponseHandler extends AbstractResponseHandler { + + + private DeviceAuthorizationDto deviceAuthorizationDto; + + public DeviceAuthorizationResponseHandler() { + } + + @Override + public void handleResponse(MkkHttpResponse response) { + if (response.isResponse200()) { + this.deviceAuthorizationDto = responseToDto(response, new DeviceAuthorizationDto()); + } else { + this.deviceAuthorizationDto = responseToErrorDto(response, new DeviceAuthorizationDto()); + } + } + + + public DeviceAuthorizationDto getDeviceAuthorizationDto() { + return deviceAuthorizationDto; + } +} diff --git a/src/main/java/com/andaily/springoauth/service/impl/OauthServiceImpl.java b/src/main/java/com/andaily/springoauth/service/impl/OauthServiceImpl.java index 97605c718392a0aa5b69fc136f0697cc57bf78ff..0ea519531ad5960016dd9c56044a302c882e0702 100644 --- a/src/main/java/com/andaily/springoauth/service/impl/OauthServiceImpl.java +++ b/src/main/java/com/andaily/springoauth/service/impl/OauthServiceImpl.java @@ -1,13 +1,15 @@ package com.andaily.springoauth.service.impl; +import com.andaily.springoauth.infrastructure.OAuth2Holder; import com.andaily.springoauth.infrastructure.httpclient.HttpClientExecutor; import com.andaily.springoauth.infrastructure.httpclient.HttpClientPostExecutor; +import com.andaily.springoauth.infrastructure.repository.ClientDetailsRepository; import com.andaily.springoauth.service.OauthService; import com.andaily.springoauth.service.dto.*; import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.Map; @@ -23,11 +25,8 @@ public class OauthServiceImpl implements OauthService { private static final Logger LOG = LoggerFactory.getLogger(OauthServiceImpl.class); - @Value("#{properties['access-token-uri']}") - private String accessTokenUri; - - @Value("#{properties['unityUserInfoUri']}") - private String unityUserInfoUri; + @Autowired + private ClientDetailsRepository clientDetailsRepository; @Override @@ -41,7 +40,7 @@ public class OauthServiceImpl implements OauthService { @Override public AuthAccessTokenDto createAuthAccessTokenDto(AuthCallbackDto callbackDto) { return new AuthAccessTokenDto() - .setAccessTokenUri(accessTokenUri) + .setAccessTokenUri(OAuth2Holder.tokenUrl()) .setCode(callbackDto.getCode()); } @@ -52,13 +51,14 @@ public class OauthServiceImpl implements OauthService { if (StringUtils.isEmpty(accessToken)) { return new UserDto("Illegal 'access_token'", "'access_token' is empty"); } else { - HttpClientExecutor executor = new HttpClientExecutor(unityUserInfoUri); - executor.addRequestParam("access_token", accessToken); - - UserDtoResponseHandler responseHandler = new UserDtoResponseHandler(); - executor.execute(responseHandler); - - return responseHandler.getUserDto(); +// HttpClientExecutor executor = new HttpClientExecutor(unityUserInfoUri); +// executor.addRequestParam("access_token", accessToken); +// +// UserDtoResponseHandler responseHandler = new UserDtoResponseHandler(); +// executor.execute(responseHandler); +// +// return responseHandler.getUserDto(); + throw new UnsupportedOperationException("Not yet used from v2.0.0"); } } @@ -87,6 +87,66 @@ public class OauthServiceImpl implements OauthService { return loadAccessTokenDto(uri, authAccessTokenDto.getCredentialsParams()); } + /** + * {@inheritDoc} + */ + @Override + public UserinfoDto loadUserinfoDto(String accessToken) { + if (StringUtils.isEmpty(accessToken)) { + return new UserinfoDto("Illegal 'access_token'", "'access_token' is empty"); + } else { + HttpClientExecutor executor = new HttpClientExecutor(OAuth2Holder.userinfoUrl()); + executor.addHeader("Authorization", "Bearer " + accessToken); + + UserinfoDtoResponseHandler responseHandler = new UserinfoDtoResponseHandler(); + executor.execute(responseHandler); + + return responseHandler.getUserinfoDto(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public String saveClientDetails(ClientDetailsDto clientDetailsDto) { + return clientDetailsRepository.saveClientDetails(clientDetailsDto); + } + + + /** + * {@inheritDoc} + */ + @Override + public ClientDetailsDto loadClientDetails() { + ClientDetailsDto clientDetails = clientDetailsRepository.findDefaultClientDetails(); + if (clientDetails == null) { + //init empty client details + clientDetails = new ClientDetailsDto(); + } + return clientDetails; + } + + + /** + * {@inheritDoc} + */ + @Override + public DeviceAuthorizationDto retrieveDeviceAuthorizationDto(AuthDeviceCodeDto deviceCodeDto) { + Map params = deviceCodeDto.getAuthParams(); + String url = deviceCodeDto.getDeviceAuthorizeUrl(); + + HttpClientExecutor executor = new HttpClientPostExecutor(url); + for (String key : params.keySet()) { + executor.addRequestParam(key, params.get(key)); + } + + DeviceAuthorizationResponseHandler responseHandler = new DeviceAuthorizationResponseHandler(); + executor.execute(responseHandler); + + return responseHandler.getDeviceAuthorizationDto(); + } + private AccessTokenDto loadAccessTokenDto(String fullUri, Map params) { HttpClientExecutor executor = new HttpClientPostExecutor(fullUri); diff --git a/src/main/java/com/andaily/springoauth/service/impl/UserinfoDtoResponseHandler.java b/src/main/java/com/andaily/springoauth/service/impl/UserinfoDtoResponseHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..27583a707e95b05c40c72314c7e92fb035051135 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/service/impl/UserinfoDtoResponseHandler.java @@ -0,0 +1,42 @@ +package com.andaily.springoauth.service.impl; + +import com.andaily.springoauth.infrastructure.httpclient.MkkHttpResponse; +import com.andaily.springoauth.service.dto.UserinfoDto; + +/** + * 2023-11-07 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +public class UserinfoDtoResponseHandler extends AbstractResponseHandler { + + + private UserinfoDto userinfoDto; + + public UserinfoDtoResponseHandler() { + } + + /** + * Response is JSON or XML (failed) + * + * Error data: + * Invalid access token: 3420d0e0-ed77-45e1-8370-2b55af0a62e8invalid_token + * + * */ + @Override + public void handleResponse(MkkHttpResponse response) { + if (response.isResponse200()) { + this.userinfoDto = responseToDto(response, new UserinfoDto()); + } else { + this.userinfoDto = responseToErrorDto(response, new UserinfoDto()); + } + } + + + public UserinfoDto getUserinfoDto() { + return userinfoDto; + } + + +} diff --git a/src/main/java/com/andaily/springoauth/web/WebUtils.java b/src/main/java/com/andaily/springoauth/web/WebUtils.java index ead598467a627e26f7e53859b0e3cde77d5c6bfc..c8f4246c7ea423978ae57581bd195e05ef63ccf3 100644 --- a/src/main/java/com/andaily/springoauth/web/WebUtils.java +++ b/src/main/java/com/andaily/springoauth/web/WebUtils.java @@ -1,11 +1,13 @@ package com.andaily.springoauth.web; +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import net.sf.json.JSON; import org.apache.commons.lang.StringUtils; +import org.springframework.http.MediaType; + -import javax.servlet.ServletContext; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; @@ -14,22 +16,28 @@ import java.io.PrintWriter; */ public abstract class WebUtils { + /** + * session key for code_Verifier + * + * @since 2.0.0 + */ + private static final String CODE_VERIFIER_KEY = "code_Verifier"; private WebUtils() { } - /* - * Save state to ServletContext, key = value = state + /** + * Save state to ServletContext, key = value = state */ public static void saveState(HttpServletRequest request, String state) { final ServletContext servletContext = request.getSession().getServletContext(); servletContext.setAttribute(state, state); } - /* - * Validate state when callback from Oauth Server. - * If validation successful, will remove it from ServletContext. + /** + * Validate state when callback from Oauth Server. + * If validation successful, will remove it from ServletContext. */ public static boolean validateState(HttpServletRequest request, String state) { if (StringUtils.isEmpty(state)) { @@ -47,7 +55,7 @@ public abstract class WebUtils { public static void writeJson(HttpServletResponse response, JSON json) { - response.setContentType("application/json;charset=UTF-8"); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); try { PrintWriter writer = response.getWriter(); json.write(writer); @@ -58,4 +66,23 @@ public abstract class WebUtils { } + /** + * Save code_verifier to session, key = value = code_verifier + * + * @since 2.0.0 + */ + + public static void saveCodeVerifier(HttpServletRequest request, String codeVerifier) { + request.getSession().setAttribute(CODE_VERIFIER_KEY, codeVerifier); + } + + /** + * Get code_verifier from session + * + * @since 2.0.0 + */ + public static String getCodeVerifier(HttpServletRequest request) { + return (String) request.getSession().getAttribute(CODE_VERIFIER_KEY); + } + } \ No newline at end of file diff --git a/src/main/java/com/andaily/springoauth/web/controller/AuthorizationCodeController.java b/src/main/java/com/andaily/springoauth/web/controller/AuthorizationCodeController.java index 9d385bbe916226c6f1ed84a20582b16a52043159..0a06dad0f213ee181dbf704d37f6c86c88d9be25 100644 --- a/src/main/java/com/andaily/springoauth/web/controller/AuthorizationCodeController.java +++ b/src/main/java/com/andaily/springoauth/web/controller/AuthorizationCodeController.java @@ -1,11 +1,12 @@ package com.andaily.springoauth.web.controller; +import com.andaily.springoauth.infrastructure.OAuth2Holder; +import com.andaily.springoauth.infrastructure.PKCEUtils; import com.andaily.springoauth.service.OauthService; -import com.andaily.springoauth.service.dto.AccessTokenDto; -import com.andaily.springoauth.service.dto.AuthAccessTokenDto; -import com.andaily.springoauth.service.dto.AuthCallbackDto; -import com.andaily.springoauth.service.dto.AuthorizationCodeDto; +import com.andaily.springoauth.service.dto.*; import com.andaily.springoauth.web.WebUtils; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -14,8 +15,9 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + -import javax.servlet.http.HttpServletRequest; import java.util.UUID; /** @@ -30,75 +32,92 @@ public class AuthorizationCodeController { private static final Logger LOG = LoggerFactory.getLogger(AuthorizationCodeController.class); - @Value("#{properties['user-authorization-uri']}") - private String userAuthorizationUri; - - - @Value("#{properties['application-host']}") + @Value("${application-host:http://localhost:8082}") private String host; - @Value("#{properties['unityUserInfoUri']}") - private String unityUserInfoUri; - - @Autowired private OauthService oauthService; - /* - * Entrance: step-1 - * */ + /** + * Entrance: step-1 + */ @RequestMapping(value = "authorization_code", method = RequestMethod.GET) public String authorizationCode(Model model) { - model.addAttribute("userAuthorizationUri", userAuthorizationUri); - model.addAttribute("host", host); - model.addAttribute("unityUserInfoUri", unityUserInfoUri); + ClientDetailsDto clientDetailsDto = oauthService.loadClientDetails(); + model.addAttribute("clientDetails", clientDetailsDto); + if (clientDetailsDto.isSupportPkce()) { + //pkce + String codeVerifier = PKCEUtils.generateCodeVerifier(); + String codeChallenge = PKCEUtils.generateCodeChallenge(codeVerifier); + // codeVerifier 推荐存储在后端(如session中),前端不推荐存储 + model.addAttribute("codeVerifier", codeVerifier) + .addAttribute("codeChallenge", codeChallenge); + } else { + model.addAttribute("codeChallenge", ""); + } + + model.addAttribute("userAuthorizationUri", OAuth2Holder.authorizeUrl()); + model.addAttribute("host", host.endsWith("/") ? host : host + "/"); model.addAttribute("state", UUID.randomUUID().toString()); return "authorization_code"; } - /* - * Save state firstly - * Redirect to oauth-server login page: step-2 - * */ + /** + * Save state firstly + * Redirect to oauth-server login page: step-2 + */ @RequestMapping(value = "authorization_code", method = RequestMethod.POST) public String submitAuthorizationCode(AuthorizationCodeDto codeDto, HttpServletRequest request) throws Exception { //save stats firstly WebUtils.saveState(request, codeDto.getState()); + String codeVerifier = codeDto.getCodeVerifier(); + if (StringUtils.isNotBlank(codeVerifier)) { + WebUtils.saveCodeVerifier(request, codeVerifier); + } + final String fullUri = codeDto.getFullUri(); LOG.debug("Redirect to Oauth-Server URL: {}", fullUri); return "redirect:" + fullUri; } - /* - * Oauth callback (redirectUri): step-3 - * - * Handle 'code', go to 'access_token' ,validation oauth-server response data - * - * authorization_code_callback - * */ + /** + * Oauth callback (redirectUri): step-3 + *

+ * Handle 'code', go to 'access_token' ,validation oauth-server response data + *

+ * authorization_code_callback + */ @RequestMapping(value = "authorization_code_callback") - public String authorizationCodeCallback(AuthCallbackDto callbackDto, HttpServletRequest request, Model model) throws Exception { + public String authorizationCodeCallback(AuthCallbackDto callbackDto, HttpServletRequest request, Model model, RedirectAttributes redirectAttributes) throws Exception { if (callbackDto.error()) { //Server response error - model.addAttribute("message", callbackDto.getError_description()); - model.addAttribute("error", callbackDto.getError()); + redirectAttributes.addAttribute("message", callbackDto.getError_description()); + redirectAttributes.addAttribute("error", callbackDto.getError()); return "redirect:oauth_error"; } else if (correctState(callbackDto, request)) { //Go to retrieve access_token form AuthAccessTokenDto accessTokenDto = oauthService.createAuthAccessTokenDto(callbackDto); model.addAttribute("accessTokenDto", accessTokenDto); - model.addAttribute("host", host); + model.addAttribute("host", host.endsWith("/") ? host : host + "/"); + + ClientDetailsDto clientDetailsDto = oauthService.loadClientDetails(); + model.addAttribute("clientDetails", clientDetailsDto); + if (clientDetailsDto.isSupportPkce()) { + model.addAttribute("codeVerifier", WebUtils.getCodeVerifier(request)); + } else { + model.addAttribute("codeVerifier", ""); + } return "code_access_token"; } else { //illegal state - model.addAttribute("message", "Illegal \"state\": " + callbackDto.getState()); - model.addAttribute("error", "Invalid state"); + redirectAttributes.addAttribute("message", "Illegal \"state\": " + callbackDto.getState()); + redirectAttributes.addAttribute("error", "Invalid state"); return "redirect:oauth_error"; } @@ -113,7 +132,7 @@ public class AuthorizationCodeController { * @param tokenDto AuthAccessTokenDto * @param model Model * @return View - * @throws Exception + * @throws Exception e */ @RequestMapping(value = "code_access_token", method = RequestMethod.POST) public String codeAccessToken(AuthAccessTokenDto tokenDto, Model model) throws Exception { @@ -124,13 +143,13 @@ public class AuthorizationCodeController { return "oauth_error"; } else { model.addAttribute("accessTokenDto", accessTokenDto); - model.addAttribute("unityUserInfoUri", unityUserInfoUri); + model.addAttribute("userinfoUrl", OAuth2Holder.userinfoUrl()); return "access_token_result"; } } - /* + /** * Check the state is correct or not after redirect from Oauth Server. */ private boolean correctState(AuthCallbackDto callbackDto, HttpServletRequest request) { diff --git a/src/main/java/com/andaily/springoauth/web/controller/AuthorizationCodePkceController.java b/src/main/java/com/andaily/springoauth/web/controller/AuthorizationCodePkceController.java new file mode 100644 index 0000000000000000000000000000000000000000..5e0b2b8dbf44cb094eebc181aa6b0a69787cbf27 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/web/controller/AuthorizationCodePkceController.java @@ -0,0 +1,53 @@ +package com.andaily.springoauth.web.controller; + +import com.andaily.springoauth.service.OauthService; +import com.andaily.springoauth.service.dto.ClientDetailsDto; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Handle 'authorization_code' + PKCE type actions + * + * @author Shengzhao Li + * @since 2.0.0 + */ +@Controller +public class AuthorizationCodePkceController { + + + private static final Logger LOG = LoggerFactory.getLogger(AuthorizationCodePkceController.class); + + + @Value("${application-host:http://localhost:8082}") + private String host; + + + @Autowired + private OauthService oauthService; + + + /** + * check pkce support + */ + @GetMapping("authorization_code_pkce") + public String authorizationCodePkce(Model model) { + ClientDetailsDto clientDetailsDto = oauthService.loadClientDetails(); + + if (clientDetailsDto.isSupportPkce()) { + //与 authorization_code 流程一样 + return "redirect:authorization_code"; + } else { + //error + model.addAttribute("error", "PKCE is not supported"); + model.addAttribute("message", "ClientId: " + clientDetailsDto.getClientId() + " not supported PKCE"); + return "oauth_error"; + } + } + + +} \ No newline at end of file diff --git a/src/main/java/com/andaily/springoauth/web/controller/ClientCredentialsController.java b/src/main/java/com/andaily/springoauth/web/controller/ClientCredentialsController.java index 28bf6ba6687744f877d7418a259bafcd492c1370..c0410cfc780f58993deda01e36adb4b4615b1672 100644 --- a/src/main/java/com/andaily/springoauth/web/controller/ClientCredentialsController.java +++ b/src/main/java/com/andaily/springoauth/web/controller/ClientCredentialsController.java @@ -3,18 +3,18 @@ package com.andaily.springoauth.web.controller; import com.andaily.springoauth.service.OauthService; import com.andaily.springoauth.service.dto.AccessTokenDto; import com.andaily.springoauth.service.dto.AuthAccessTokenDto; +import com.andaily.springoauth.service.dto.ClientDetailsDto; +import jakarta.servlet.http.HttpServletResponse; import net.sf.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; -import javax.servlet.http.HttpServletResponse; - +import static com.andaily.springoauth.infrastructure.OAuth2Holder.tokenUrl; import static com.andaily.springoauth.web.WebUtils.writeJson; /** @@ -29,30 +29,32 @@ public class ClientCredentialsController { private static final Logger LOG = LoggerFactory.getLogger(ClientCredentialsController.class); - @Value("#{properties['access-token-uri']}") - private String accessTokenUri; - - - @Value("#{properties['unityUserInfoUri']}") - private String unityUserInfoUri; +// @Value("#{properties['access-token-uri']}") +// private String accessTokenUri; +// +// +// @Value("#{properties['unityUserInfoUri']}") +// private String unityUserInfoUri; @Autowired private OauthService oauthService; - /* + /** * Entrance: step-1 * */ @RequestMapping(value = "client_credentials", method = RequestMethod.GET) public String password(Model model) { - LOG.debug("Go to 'client_credentials' page, accessTokenUri = {}", accessTokenUri); - model.addAttribute("accessTokenUri", accessTokenUri); - model.addAttribute("unityUserInfoUri", unityUserInfoUri); + LOG.debug("Go to 'client_credentials' page, accessTokenUri = {}", tokenUrl()); + + ClientDetailsDto clientDetailsDto = oauthService.loadClientDetails(); + model.addAttribute("clientDetails", clientDetailsDto); + model.addAttribute("accessTokenUri", tokenUrl()); return "client_credentials"; } - /* + /** * Ajax call , get access_token * */ @RequestMapping(value = "credentials_access_token") diff --git a/src/main/java/com/andaily/springoauth/web/controller/DeviceCodeController.java b/src/main/java/com/andaily/springoauth/web/controller/DeviceCodeController.java new file mode 100644 index 0000000000000000000000000000000000000000..9fb65f431da1f2fad5eca467dad34d4604d4acb5 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/web/controller/DeviceCodeController.java @@ -0,0 +1,66 @@ +package com.andaily.springoauth.web.controller; + +import com.andaily.springoauth.infrastructure.OAuth2Holder; +import com.andaily.springoauth.service.OauthService; +import com.andaily.springoauth.service.dto.*; +import jakarta.servlet.http.HttpServletResponse; +import net.sf.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import static com.andaily.springoauth.web.WebUtils.writeJson; + +/** + * 2023/11/8 20:30 + *

+ * Handle 'device_code' type actions + * + * @author Shengzhao Li + * @since 2.0.0 + */ +@Controller +public class DeviceCodeController { + + + private static final Logger LOG = LoggerFactory.getLogger(DeviceCodeController.class); + + + @Value("${application-host:http://localhost:8082}") + private String host; + + + @Autowired + private OauthService oauthService; + + + /** + * Entrance: step-1 + */ + @GetMapping("device_code") + public String deviceCode(Model model) { + ClientDetailsDto clientDetailsDto = oauthService.loadClientDetails(); + model.addAttribute("clientDetails", clientDetailsDto); + + String host2 = host.endsWith("/") ? host : host + "/"; + model.addAttribute("host", host2); + model.addAttribute("deviceAuthorizeUrl", OAuth2Holder.deviceAuthorizeUrl()); + model.addAttribute("tokenUrl", OAuth2Holder.tokenUrl()); + return "device_code"; + } + + + /** + * Ajax call , get user_code, device_code, verification_uri + */ + @PostMapping("device_code") + public void getDeviceCode(@RequestBody AuthDeviceCodeDto deviceCodeDto, HttpServletResponse response) { + DeviceAuthorizationDto authDeviceCodeDto = oauthService.retrieveDeviceAuthorizationDto(deviceCodeDto); + writeJson(response, JSONObject.fromObject(authDeviceCodeDto)); + } + +} diff --git a/src/main/java/com/andaily/springoauth/web/controller/HomeController.java b/src/main/java/com/andaily/springoauth/web/controller/HomeController.java new file mode 100644 index 0000000000000000000000000000000000000000..bf5c12ce80666cf05f35609d9805dcdac4be74cf --- /dev/null +++ b/src/main/java/com/andaily/springoauth/web/controller/HomeController.java @@ -0,0 +1,60 @@ +package com.andaily.springoauth.web.controller; + +import com.andaily.springoauth.service.OauthService; +import com.andaily.springoauth.service.dto.ClientDetailsDto; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import static com.andaily.springoauth.infrastructure.OAuth2Holder.fullWellKnownUrl; + +/** + * 2023/11/7 11:13 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +@Controller +public class HomeController { + + + @Autowired + private OauthService oauthService; + + + @Value("${application-host:http://localhost:8082}") + private String host; + + + /** + * 首页 + * + * @return view + */ + @GetMapping("/") + public String index(Model model) { + ClientDetailsDto clientDetailsDto = oauthService.loadClientDetails(); + + String host2 = host.endsWith("/") ? host : host + "/"; + model.addAttribute("host", host2); + + //see AuthorizationCodeController#authorizationCodeCallback + String redirectUri = host2 + "authorization_code_callback"; + model.addAttribute("redirectUri", redirectUri); + // see JwtBearerJwksController.java + model.addAttribute("jwkUrl", host2 + "api/public/oauth2/jwt_bearer/demo_jwks"); + + //初始化 + if (StringUtils.isBlank(clientDetailsDto.getRedirectUris())) { + clientDetailsDto.setRedirectUris(redirectUri); + } + model.addAttribute("clientDetails", clientDetailsDto); + model.addAttribute("fullWellKnownUrl", fullWellKnownUrl()); + + return "index"; + } + +} diff --git a/src/main/java/com/andaily/springoauth/web/controller/ImplicitController.java b/src/main/java/com/andaily/springoauth/web/controller/ImplicitController.java index 1006237443e8a5133754b1b1a36a5a784dbe4f6b..0aa0bc96ec677dafa87a8a6945e978a2552237ab 100644 --- a/src/main/java/com/andaily/springoauth/web/controller/ImplicitController.java +++ b/src/main/java/com/andaily/springoauth/web/controller/ImplicitController.java @@ -7,10 +7,13 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; +import static com.andaily.springoauth.infrastructure.OAuth2Holder.authorizeUrl; + /** * Handle 'implicit' type actions * * @author Shengzhao Li + * @deprecated OAuth2.1 not yet supported */ @Controller public class ImplicitController { @@ -19,25 +22,25 @@ public class ImplicitController { private static final Logger LOG = LoggerFactory.getLogger(ImplicitController.class); - @Value("#{properties['user-authorization-uri']}") - private String userAuthorizationUri; +// @Value("#{properties['user-authorization-uri']}") +// private String userAuthorizationUri; - @Value("#{properties['unityUserInfoUri']}") - private String unityUserInfoUri; +// @Value("#{properties['unityUserInfoUri']}") +// private String unityUserInfoUri; - @Value("#{properties['application-host']}") + @Value("${application-host:http://localhost:8082}") private String host; - /* + /** * Entrance: step-1 * */ @RequestMapping(value = "implicit") public String password(Model model) { - LOG.debug("Go to 'implicit' page, userAuthorizationUri = {}", userAuthorizationUri); - model.addAttribute("userAuthorizationUri", userAuthorizationUri); - model.addAttribute("unityUserInfoUri", unityUserInfoUri); + LOG.debug("Go to 'implicit' page, userAuthorizationUri = {}", authorizeUrl()); + model.addAttribute("userAuthorizationUri", authorizeUrl()); +// model.addAttribute("unityUserInfoUri", unityUserInfoUri); model.addAttribute("host", host); return "implicit"; } diff --git a/src/main/java/com/andaily/springoauth/web/controller/JwtBearerController.java b/src/main/java/com/andaily/springoauth/web/controller/JwtBearerController.java new file mode 100644 index 0000000000000000000000000000000000000000..52f3a450e4a8b98fdd2f06f082cf7c4673233e75 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/web/controller/JwtBearerController.java @@ -0,0 +1,72 @@ +package com.andaily.springoauth.web.controller; + +import com.andaily.springoauth.infrastructure.JwtBearerUtils; +import com.andaily.springoauth.infrastructure.OAuth2Holder; +import com.andaily.springoauth.service.OauthService; +import com.andaily.springoauth.service.dto.AuthDeviceCodeDto; +import com.andaily.springoauth.service.dto.ClientDetailsDto; +import com.andaily.springoauth.service.dto.DeviceAuthorizationDto; +import com.nimbusds.jose.jwk.JWK; +import jakarta.servlet.http.HttpServletResponse; +import net.sf.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import static com.andaily.springoauth.infrastructure.OAuth2Holder.issuer; +import static com.andaily.springoauth.web.WebUtils.writeJson; +import static com.andaily.springoauth.web.controller.JwtBearerJwksController.RS256_KEY; + +/** + * 2023/11/9 15:30 + *

+ * Handle 'jwt-bearer' type actions + * + * @author Shengzhao Li + * @since 2.0.0 + */ +@Controller +public class JwtBearerController { + + + private static final Logger LOG = LoggerFactory.getLogger(JwtBearerController.class); + + + @Value("${application-host:http://localhost:8082}") + private String host; + + + @Autowired + private OauthService oauthService; + + + /** + * Entrance: step-1 + */ + @GetMapping("jwt_bearer") + public String jwtBearer(Model model) throws Exception { + ClientDetailsDto clientDetailsDto = oauthService.loadClientDetails(); + model.addAttribute("clientDetails", clientDetailsDto); + + String host2 = host.endsWith("/") ? host : host + "/"; + model.addAttribute("host", host2); + model.addAttribute("tokenUrl", OAuth2Holder.tokenUrl()); + // see JwtBearerJwksController.java + model.addAttribute("jwkUrl", host2 + "api/public/oauth2/jwt_bearer/demo_jwks"); + + // assertion + JWK rsJwk = JWK.parse(RS256_KEY); + String assertion = JwtBearerUtils.generateRsAssertion(clientDetailsDto.getClientId(), issuer(), rsJwk); + model.addAttribute("clientAssertion", assertion); + + return "jwt_bearer"; + } + + +} diff --git a/src/main/java/com/andaily/springoauth/web/controller/JwtBearerJwksController.java b/src/main/java/com/andaily/springoauth/web/controller/JwtBearerJwksController.java new file mode 100644 index 0000000000000000000000000000000000000000..0363022a5c49d581ee99bf3c70b9db6115420057 --- /dev/null +++ b/src/main/java/com/andaily/springoauth/web/controller/JwtBearerJwksController.java @@ -0,0 +1,57 @@ +package com.andaily.springoauth.web.controller; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Arrays; +import java.util.Map; + +/** + * 2023/11/8 16:24 + *

+ * grant_type=jwt-bearer 中的 jwkSetUrl 实现参考 + *

+ * + * @author Shengzhao Li + * @since 2.0.0 + */ +@RestController +public class JwtBearerJwksController { + + private static final Logger LOG = LoggerFactory.getLogger(JwtBearerJwksController.class); + + + /** + * RS256 公私钥对 + * 如何生成? 详见 JwksTest.java + */ + public static final String RS256_KEY = "{\"p\":\"-Y5ymP0tAtOmpksf6y1rT-CsGUyklercT0vY0fMbkUyZH8igxUr0ZjXVr3Yzhlh8sJ5y5-0IEpPw7L4v7_OmCC-7t_M-ntf2-36rqIrK7AMhGf4mle4pMQhBeIJN0n91wMxmNXMwto4L3MWZ8f6K1QH1cirj3_BQsA4XXEgMMKE\",\"kty\":\"RSA\",\"q\":\"_HUwOfykJSjDkisyAK3QaNDFxik3HLTr7m0kU3UNLc1KRaNTIwPYuLaskGE4Se6Idy8TLc7NuEB96VSd9LaGakrDPBwh9ZcN8uBJVA162TCA1RUJjwO4k33uxkVo8gvNQ5ooBnEdT-rMhrjZa3ko-vLR5KCQHs6Gq6SWLBalth8\",\"d\":\"D65_9R01rDFuXc6qJKlNo8-x52jBYtDJJSxFoXW3Znek3fwTX7Le10lNKHf0EEJixnmXumIivl4hFCCBvlc-KP6P_OZZmU9JzC-gezUFdOuhfouMJh6VpbO272nqIfOU8UZJEXCxMSvOqJs-grekSqWMdEZpFytlG6hxNGVEJcy619rPdKL-xUlIliK0M4BItOn24u0Awd4msHyOz9F5UamDa8dnnuRlCJSnqUxBhvMicxP-k4ZXqx_csiVJt5GSkBU2-68T4NYPsTBqUufXsPVbThcoHI6COdWv8dQ5ovNI6P02aEUYA0-QlGVC4mPCmxo81Q8ukK5UUOvjFP7cAQ\",\"e\":\"AQAB\",\"kid\":\"jwt-bearer-demo-rsa-kid1\",\"key_ops\":[\"encrypt\",\"verify\",\"deriveKey\",\"sign\",\"decrypt\"],\"qi\":\"glJKxfNKRauPqt-yQBuiF6XyfIxSSts0ZZJRyf4CAvlXmruWlZdd2IwY4V67SPBvoOHm1o32zI0clQabPt1ovHS1fMfPuy1L2ytQUL3yVSVddhkG9otadaPQW8kuc86wLdKwUjpBREQjwNeaTxkuoJVPcbXlNsayA6h17ljceBc\",\"dp\":\"lXGWcsN6Ru0UKRVn4d_rGYSDywq4rQZeNCZJi0C4S4TBVeVBUaSXQvYOJurz5AntcZ8RVI3_fZCWgE9MSbdwwApFsdy6rUjLIMQ0a9PhvQAKvJQT60kZ5cD54_60N9AYZgKBWpTGoSvjMqwqil5SKUjpARtqJtq0lxl5J8wFcME\",\"alg\":\"RS256\",\"dq\":\"CiaAEOTKiL_x1Q-ti_9xELXMLeJ8V8gicEytGDntlLjbUp91eUPvU8XsfEWcaMSRchFPeRkGhnD5XwdK7orkLqPg46rR5rjzE5_W8u0z0kWz-F1HLBvfMPbwQcKKrKiy0RQCpfeoUQ1Euen2u-58KlLXA5U9FjABlCci7pTehss\",\"n\":\"9hp17DWgdCzJBq8T0hyV5F99-7_NtJu01yL95jZ9UF7bErGdqBtfw6_X5NmI1zMwmsAiksARr5_X7Hr3Gg2EbadLPymYAoGpaIwOZV04hHr_pJmqxNOaQU89_CDz-fmOhRoizZgxKAfWGCW1VLrKMaU3h4gs-G2gT0xQPDpkuXDV7WxYViqfLPhP94Cnk-geCeJpkY9q9BFZGkqW9mYeb2Ut1owlgY-Rfz-RID5gqGjL_AS3DYvvNf9_4eI8v3ahqRKDUXccw_sntEwBs95zWbRXQXBHgIKNIKp4ITnsN7OPc66QlJSpzqSkeOx0fvnCJ5bIh4fViqqLtp0akdFZfw\"}"; + + + /** + * ES256 公私钥对 + */ + public static final String ES256_KEY = "{\"kty\":\"EC\",\"d\":\"J6ZIiWeVp4fTXAp5W2w9nw7lACkGaAjOAuLOlrzATDo\",\"crv\":\"P-256\",\"kid\":\"jwt-bearer-demo-ecc-kid\",\"key_ops\":[\"sign\",\"verify\",\"encrypt\",\"deriveKey\",\"decrypt\"],\"x\":\"fJ4RA2IawTPMIWx7bqlYTzrjM8Gl4YQMNRaX4isqeDI\",\"y\":\"sBeszsJArg2sdc1AdrxIyDIgDIVw84KWF27FsnkQenc\",\"alg\":\"ES256\"}"; + + + /** + * client 端提供的 jwks 参考实现; + * 返回 public-key + * + */ + @GetMapping("/api/public/oauth2/jwt_bearer/demo_jwks") + public Map jwks() throws Exception { + + JWK rsJwk = JWK.parse(RS256_KEY); + JWK esJwk = JWK.parse(ES256_KEY); + JWKSet jwkSet = new JWKSet(Arrays.asList(rsJwk, esJwk)); + + //注意:只返回 publicKey + return jwkSet.toJSONObject(true); + } + +} diff --git a/src/main/java/com/andaily/springoauth/web/controller/OauthController.java b/src/main/java/com/andaily/springoauth/web/controller/OauthController.java index 858bdc8b5ccd74de9184a582559f05cc4b3faeba..da8e1e41cb3be609249011e83faa2240c7deb70e 100644 --- a/src/main/java/com/andaily/springoauth/web/controller/OauthController.java +++ b/src/main/java/com/andaily/springoauth/web/controller/OauthController.java @@ -1,9 +1,15 @@ package com.andaily.springoauth.web.controller; +import com.andaily.springoauth.service.OauthService; +import com.andaily.springoauth.service.dto.ClientDetailsDto; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; /** @@ -18,10 +24,32 @@ public class OauthController { private static final Logger LOG = LoggerFactory.getLogger(OauthController.class); - /* - * Common handle oauth error , - * show the error message. - * */ + @Autowired + private OauthService oauthService; + + + /** + * 保存 client_details + * + * @since 2.0.0 + */ + @PostMapping("client_details") + public String saveClientDetails(@Validated ClientDetailsDto clientDetailsDto, BindingResult result) { + if (result.hasErrors()) { + if (LOG.isWarnEnabled()) { + LOG.warn("Save ClientDetailsDto errors: {}, need checking", result.getAllErrors()); + } + return "redirect:/"; + } + oauthService.saveClientDetails(clientDetailsDto); + return "redirect:/"; + } + + + /** + * Common handle oauth error , + * show the error message. + */ @RequestMapping("oauth_error") public String oauthError(String error, String message, Model model) { model.addAttribute("error", error); diff --git a/src/main/java/com/andaily/springoauth/web/controller/PasswordController.java b/src/main/java/com/andaily/springoauth/web/controller/PasswordController.java index 62beb0173be7aef89136df33906598f4cf766bb8..efaad11264e82b6d74030329e8b21166238d0b9a 100644 --- a/src/main/java/com/andaily/springoauth/web/controller/PasswordController.java +++ b/src/main/java/com/andaily/springoauth/web/controller/PasswordController.java @@ -2,16 +2,18 @@ package com.andaily.springoauth.web.controller; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; +import static com.andaily.springoauth.infrastructure.OAuth2Holder.tokenUrl; + /** * Handle 'password' type actions * * @author Shengzhao Li + * @deprecated OAuth2.1 not yet supported */ @Controller public class PasswordController { @@ -20,17 +22,17 @@ public class PasswordController { private static final Logger LOG = LoggerFactory.getLogger(PasswordController.class); - @Value("#{properties['access-token-uri']}") - private String accessTokenUri; +// @Value("#{properties['access-token-uri']}") +// private String accessTokenUri; - /* - * Entrance: step-1 - * */ + /** + * Entrance: step-1 + */ @RequestMapping(value = "password", method = RequestMethod.GET) public String password(Model model) { - LOG.debug("Go to 'password' page, accessTokenUri = {}", accessTokenUri); - model.addAttribute("accessTokenUri", accessTokenUri); + LOG.debug("Go to 'password' page, accessTokenUri = {}", tokenUrl()); + model.addAttribute("accessTokenUri", tokenUrl()); return "password"; } diff --git a/src/main/java/com/andaily/springoauth/web/controller/RefreshTokenController.java b/src/main/java/com/andaily/springoauth/web/controller/RefreshTokenController.java index cd1e9da8f18effe9dc8e9f375781e70c06f34585..616b49353e447515eb0bf03fde0e881fa22b5d86 100644 --- a/src/main/java/com/andaily/springoauth/web/controller/RefreshTokenController.java +++ b/src/main/java/com/andaily/springoauth/web/controller/RefreshTokenController.java @@ -3,19 +3,19 @@ package com.andaily.springoauth.web.controller; import com.andaily.springoauth.service.OauthService; import com.andaily.springoauth.service.dto.AccessTokenDto; import com.andaily.springoauth.service.dto.AuthAccessTokenDto; +import com.andaily.springoauth.service.dto.ClientDetailsDto; import com.andaily.springoauth.service.dto.RefreshAccessTokenDto; +import jakarta.servlet.http.HttpServletResponse; import net.sf.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; -import javax.servlet.http.HttpServletResponse; - +import static com.andaily.springoauth.infrastructure.OAuth2Holder.tokenUrl; import static com.andaily.springoauth.web.WebUtils.writeJson; /** @@ -34,22 +34,27 @@ public class RefreshTokenController { private OauthService oauthService; - @Value("#{properties['access-token-uri']}") - private String accessTokenUri; +// @Value("#{properties['access-token-uri']}") +// private String accessTokenUri; - /* + /** * Entrance: step-1 * */ @RequestMapping(value = "refresh_token", method = RequestMethod.GET) public String password(Model model) { - LOG.debug("Go to 'refresh_token' page, accessTokenUri = {}", accessTokenUri); - model.addAttribute("accessTokenUri", accessTokenUri); + LOG.debug("Go to 'refresh_token' page, accessTokenUri = {}", tokenUrl()); + + ClientDetailsDto clientDetailsDto = oauthService.loadClientDetails(); + model.addAttribute("clientDetails", clientDetailsDto); + model.addAttribute("accessTokenUri", tokenUrl()); return "refresh_token"; } - /* + /** * Ajax call , get access_token + * + * @deprecated OAuth2.1中不再支持 password * */ @RequestMapping(value = "password_access_token") public void getAccessToken(AuthAccessTokenDto authAccessTokenDto, HttpServletResponse response) { @@ -57,7 +62,7 @@ public class RefreshTokenController { writeJson(response, JSONObject.fromObject(accessTokenDto)); } - /* + /** * Ajax call , refresh access_token * */ @RequestMapping(value = "refresh_access_token") diff --git a/src/main/java/com/andaily/springoauth/web/controller/ResourcesController.java b/src/main/java/com/andaily/springoauth/web/controller/ResourcesController.java index 9bef545dd69db9c071e4602ae3f75d5d96b4f177..09e5b824133bf5bdb78d20413ca646f38057d327 100644 --- a/src/main/java/com/andaily/springoauth/web/controller/ResourcesController.java +++ b/src/main/java/com/andaily/springoauth/web/controller/ResourcesController.java @@ -2,6 +2,7 @@ package com.andaily.springoauth.web.controller; import com.andaily.springoauth.service.OauthService; import com.andaily.springoauth.service.dto.UserDto; +import com.andaily.springoauth.service.dto.UserinfoDto; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -9,6 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping; /** * Handle visit Oauth resources, must be have 'access_token' + * /userinfo * * @author Shengzhao Li */ @@ -19,10 +21,31 @@ public class ResourcesController { @Autowired private OauthService oauthService; + /** + * /userinfo resource API + * + * @since 2.0.0 + */ + @RequestMapping("userinfo") + public String userInfo(String access_token, Model model) { + UserinfoDto userDto = oauthService.loadUserinfoDto(access_token); - /* - * Visit unity role for get user information from oauth server - * */ + if (userDto.error()) { + //error + model.addAttribute("message", userDto.getErrorDescription()); + model.addAttribute("error", userDto.getError()); + return "redirect:oauth_error"; + } else { + model.addAttribute("userDto", userDto); + return "resources/userinfo"; + } + } + + /** + * Visit unity role for get user information from oauth server + * + * @deprecated use /userinfo replaced + */ @RequestMapping("unity_user_info") public String unityUserInfo(String access_token, Model model) { UserDto userDto = oauthService.loadUnityUserDto(access_token); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000000000000000000000000000000000000..d7b48f88f26bc68187f27190f808deb7200f5d53 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,22 @@ +# +spring.application.name=spring-oauth-client +# +server.port=8082 +# +# MVC +spring.thymeleaf.encoding=UTF-8 +spring.thymeleaf.cache=false +# +# spring-oauth-client application host +#Must be end with '/' +application-host=http://localhost:${server.port}/ +# +# spring-oauth-server or myoidc-server host; since 2.0.0 +oauth2.server.host=http://localhost:8080 +#Access token url +#access-token-uri=http://localhost:8080/oauth2/token +#Authorization url +#user-authorization-uri=http://localhost:8080/myoidc-server/oauth/authorize +#Oauth Server resource API +#unityUserInfoUri=http://localhost:8080/myoidc-server/unity/user_info +#mobileUserInfoUri=http://localhost:8080/myoidc-server/m/user_info diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000000000000000000000000000000000000..52eb4d9d4b4d79452e37346c4dc751782010a419 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,11 @@ +${AnsiColor.BRIGHT_BLACK} + _ _ _ + (_) | | | | + ___ _ __ _ __ _ _ __ __ _ ______ ___ __ _ _ _| |_| |__ ______ ___ ___ _ ____ _____ _ __ + / __| '_ \| '__| | '_ \ / _` |______/ _ \ / _` | | | | __| '_ \______/ __|/ _ \ '__\ \ / / _ \ '__| + \__ \ |_) | | | | | | | (_| | | (_) | (_| | |_| | |_| | | | \__ \ __/ | \ V / __/ | + |___/ .__/|_| |_|_| |_|\__, | \___/ \__,_|\__,_|\__|_| |_| |___/\___|_| \_/ \___|_| + | | __/ | + |_| |___/ +spring-oauth-client: ${application.formatted-version} +spring -boot: ${spring-boot.formatted-version} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000000000000000000000000000000000000..e444c0c95077039ff46f717fb6259f4f951873b8 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,47 @@ + + + ${spring.application.name} + + + + + + + + %d{yyyy-MM-dd HH:mm:ss} [%-5level] [%.80c{10}][%L] -%m%n + + + + + + true + + + logs/%d{yyyy-MM-dd}/soc-%i.log + 10MB + 15 + + + + + %d{yyyy-MM-dd HH:mm:ss} [%-5level] [%.80c{10}][%L] -%m%n + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/logging.properties b/src/main/resources/logging.properties deleted file mode 100644 index 0b089326695ecce6a4b6edbe307fa99b5ada01bf..0000000000000000000000000000000000000000 --- a/src/main/resources/logging.properties +++ /dev/null @@ -1,14 +0,0 @@ -handlers = org.apache.juli.FileHandler, java.util.logging.ConsoleHandler - -############################################################ -# Handler specific properties. -# Describes specific configuration info for Handlers. -# The configuration for Tomcat server -############################################################ - -org.apache.juli.FileHandler.level = FINE -org.apache.juli.FileHandler.directory = ${catalina.base}/logs -org.apache.juli.FileHandler.prefix = error-debug. - -java.util.logging.ConsoleHandler.level = FINE -java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter diff --git a/src/main/resources/spring-oauth-client.properties b/src/main/resources/spring-oauth-client.properties deleted file mode 100644 index ed0c50cf5cc7da5c458cdccb2441ee40c946f1e6..0000000000000000000000000000000000000000 --- a/src/main/resources/spring-oauth-client.properties +++ /dev/null @@ -1,14 +0,0 @@ - -#Access token url -access-token-uri=http://localhost:8080/myoidc-server/oauth/token - -#Authorization url -user-authorization-uri=http://localhost:8080/myoidc-server/oauth/authorize - -# spring-oauth-client application host -#Must be end with '/' -application-host=http://localhost:7777/spring-oauth-client/ - -#Oauth Server resource API -unityUserInfoUri=http://localhost:8080/myoidc-server/unity/user_info -mobileUserInfoUri=http://localhost:8080/myoidc-server/m/user_info diff --git a/src/main/resources/spring/context.xml b/src/main/resources/spring/context.xml deleted file mode 100644 index 97487bd1ea5787bf6eacea38cb8d487f0d0baf60..0000000000000000000000000000000000000000 --- a/src/main/resources/spring/context.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - classpath:spring-oauth-client.properties - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/resources/angular.min.js b/src/main/resources/static/angular.min.js similarity index 99% rename from src/main/webapp/resources/angular.min.js rename to src/main/resources/static/angular.min.js index 1ef70f54448a602d174b7e68f93c6bd65134cccc..ac033dc89bb07e48b89c99a681b3c8c768e9373b 100644 --- a/src/main/webapp/resources/angular.min.js +++ b/src/main/resources/static/angular.min.js @@ -1,178 +1,178 @@ -/* - AngularJS v1.1.5 - (c) 2010-2012 Google, Inc. http://angularjs.org - License: MIT -*/ -(function(M,T,p){'use strict';function lc(){var b=M.angular;M.angular=mc;return b}function Xa(b){return!b||typeof b.length!=="number"?!1:typeof b.hasOwnProperty!="function"&&typeof b.constructor!="function"?!0:b instanceof R||ga&&b instanceof ga||Ea.call(b)!=="[object Object]"||typeof b.callee==="function"}function n(b,a,c){var d;if(b)if(H(b))for(d in b)d!="prototype"&&d!="length"&&d!="name"&&b.hasOwnProperty(d)&&a.call(c,b[d],d);else if(b.forEach&&b.forEach!==n)b.forEach(a,c);else if(Xa(b))for(d= -0;d=0&&b.splice(c,1);return a}function V(b,a){if(sa(b)||b&&b.$evalAsync&&b.$watch)throw Error("Can't copy Window or Scope");if(a){if(b===a)throw Error("Can't copy equivalent objects or arrays");if(F(b))for(var c=a.length=0;c2?ka.call(arguments,2):[];return H(a)&&!(a instanceof RegExp)?c.length?function(){return arguments.length?a.apply(b,c.concat(ka.call(arguments,0))):a.apply(b,c)}:function(){return arguments.length?a.apply(b,arguments):a.call(b)}:a}function qc(b,a){var c=a;/^\$+/.test(b)?c=p:sa(a)?c="$WINDOW":a&&T===a?c="$DOCUMENT":a&&a.$evalAsync&& -a.$watch&&(c="$SCOPE");return c}function ha(b,a){return JSON.stringify(b,qc,a?" ":null)}function ub(b){return E(b)?JSON.parse(b):b}function ua(b){b&&b.length!==0?(b=I(""+b),b=!(b=="f"||b=="0"||b=="false"||b=="no"||b=="n"||b=="[]")):b=!1;return b}function va(b){b=w(b).clone();try{b.html("")}catch(a){}var c=w("

").append(b).html();try{return b[0].nodeType===3?I(c):c.match(/^(<[^>]+>)/)[1].replace(/^<([\w\-]+)/,function(a,b){return"<"+I(b)})}catch(d){return I(c)}}function vb(b){var a={},c,d;n((b|| -"").split("&"),function(b){b&&(c=b.split("="),d=decodeURIComponent(c[0]),a[d]=B(c[1])?decodeURIComponent(c[1]):!0)});return a}function wb(b){var a=[];n(b,function(b,d){a.push(wa(d,!0)+(b===!0?"":"="+wa(b,!0)))});return a.length?a.join("&"):""}function ab(b){return wa(b,!0).replace(/%26/gi,"&").replace(/%3D/gi,"=").replace(/%2B/gi,"+")}function wa(b,a){return encodeURIComponent(b).replace(/%40/gi,"@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,a?"%20":"+")}function rc(b, -a){function c(a){a&&d.push(a)}var d=[b],e,g,i=["ng:app","ng-app","x-ng-app","data-ng-app"],f=/\sng[:\-]app(:\s*([\w\d_]+);?)?\s/;n(i,function(a){i[a]=!0;c(T.getElementById(a));a=a.replace(":","\\:");b.querySelectorAll&&(n(b.querySelectorAll("."+a),c),n(b.querySelectorAll("."+a+"\\:"),c),n(b.querySelectorAll("["+a+"]"),c))});n(d,function(a){if(!e){var b=f.exec(" "+a.className+" ");b?(e=a,g=(b[2]||"").replace(/\s+/g,",")):n(a.attributes,function(b){if(!e&&i[b.name])e=a,g=b.value})}});e&&a(e,g?[g]:[])} -function xb(b,a){var c=function(){b=w(b);a=a||[];a.unshift(["$provide",function(a){a.value("$rootElement",b)}]);a.unshift("ng");var c=yb(a);c.invoke(["$rootScope","$rootElement","$compile","$injector","$animator",function(a,b,c,d,e){a.$apply(function(){b.data("$injector",d);c(b)(a)});e.enabled(!0)}]);return c},d=/^NG_DEFER_BOOTSTRAP!/;if(M&&!d.test(M.name))return c();M.name=M.name.replace(d,"");Ha.resumeBootstrap=function(b){n(b,function(b){a.push(b)});c()}}function bb(b,a){a=a||"_";return b.replace(sc, -function(b,d){return(d?a:"")+b.toLowerCase()})}function cb(b,a,c){if(!b)throw Error("Argument '"+(a||"?")+"' is "+(c||"required"));return b}function xa(b,a,c){c&&F(b)&&(b=b[b.length-1]);cb(H(b),a,"not a function, got "+(b&&typeof b=="object"?b.constructor.name||"Object":typeof b));return b}function tc(b){function a(a,b,e){return a[b]||(a[b]=e())}return a(a(b,"angular",Object),"module",function(){var b={};return function(d,e,g){e&&b.hasOwnProperty(d)&&(b[d]=null);return a(b,d,function(){function a(c, -d,e){return function(){b[e||"push"]([c,d,arguments]);return m}}if(!e)throw Error("No module: "+d);var b=[],c=[],j=a("$injector","invoke"),m={_invokeQueue:b,_runBlocks:c,requires:e,name:d,provider:a("$provide","provider"),factory:a("$provide","factory"),service:a("$provide","service"),value:a("$provide","value"),constant:a("$provide","constant","unshift"),animation:a("$animationProvider","register"),filter:a("$filterProvider","register"),controller:a("$controllerProvider","register"),directive:a("$compileProvider", -"directive"),config:j,run:function(a){c.push(a);return this}};g&&j(g);return m})}})}function Ia(b){return b.replace(uc,function(a,b,d,e){return e?d.toUpperCase():d}).replace(vc,"Moz$1")}function db(b,a){function c(){var e;for(var b=[this],c=a,i,f,h,j,m,k;b.length;){i=b.shift();f=0;for(h=i.length;f 
"+b;a.removeChild(a.firstChild);eb(this,a.childNodes);this.remove()}else eb(this,b)}function fb(b){return b.cloneNode(!0)}function ya(b){zb(b);for(var a=0,b=b.childNodes||[];a-1}function Cb(b,a){a&&n(a.split(" "),function(a){b.className=U((" "+b.className+" ").replace(/[\n\t]/g," ").replace(" "+U(a)+" "," "))})}function Db(b,a){a&&n(a.split(" "),function(a){if(!La(b,a))b.className=U(b.className+" "+U(a))})}function eb(b,a){if(a)for(var a=!a.nodeName&&B(a.length)&&!sa(a)?a:[a],c=0;c 4096 bytes)!")}else{if(h.cookie!==D){D=h.cookie;d=D.split("; ");G={};for(f=0;f0&&(a=unescape(e.substring(0,j)),G[a]===p&&(G[a]=unescape(e.substring(j+1))))}return G}};f.defer=function(a,b){var c;o++;c=k(function(){delete u[c];e(a)},b||0);u[c]=!0;return c};f.defer.cancel=function(a){return u[a]?(delete u[a],l(a),e(q),!0):!1}}function Ec(){this.$get= -["$window","$log","$sniffer","$document",function(b,a,c,d){return new Dc(b,d,a,c)}]}function Fc(){this.$get=function(){function b(b,d){function e(a){if(a!=k){if(l){if(l==a)l=a.n}else l=a;g(a.n,a.p);g(a,k);k=a;k.n=null}}function g(a,b){if(a!=b){if(a)a.p=b;if(b)b.n=a}}if(b in a)throw Error("cacheId "+b+" taken");var i=0,f=t({},d,{id:b}),h={},j=d&&d.capacity||Number.MAX_VALUE,m={},k=null,l=null;return a[b]={put:function(a,b){var c=m[a]||(m[a]={key:a});e(c);if(!C(b))return a in h||i++,h[a]=b,i>j&&this.remove(l.key), -b},get:function(a){var b=m[a];if(b)return e(b),h[a]},remove:function(a){var b=m[a];if(b){if(b==k)k=b.p;if(b==l)l=b.n;g(b.n,b.p);delete m[a];delete h[a];i--}},removeAll:function(){h={};i=0;m={};k=l=null},destroy:function(){m=f=h=null;delete a[b]},info:function(){return t({},f,{size:i})}}}var a={};b.info=function(){var b={};n(a,function(a,e){b[e]=a.info()});return b};b.get=function(b){return a[b]};return b}}function Gc(){this.$get=["$cacheFactory",function(b){return b("templates")}]}function Jb(b){var a= -{},c="Directive",d=/^\s*directive\:\s*([\d\w\-_]+)\s+(.*)$/,e=/(([\d\w\-_]+)(?:\:([^;]+))?;?)/,g="Template must have exactly one root element. was: ",i=/^\s*(https?|ftp|mailto|file):/;this.directive=function h(d,e){E(d)?(cb(e,"directive"),a.hasOwnProperty(d)||(a[d]=[],b.factory(d+c,["$injector","$exceptionHandler",function(b,c){var e=[];n(a[d],function(a){try{var g=b.invoke(a);if(H(g))g={compile:S(g)};else if(!g.compile&&g.link)g.compile=S(g.link);g.priority=g.priority||0;g.name=g.name||d;g.require= -g.require||g.controller&&g.name;g.restrict=g.restrict||"A";e.push(g)}catch(h){c(h)}});return e}])),a[d].push(e)):n(d,rb(h));return this};this.urlSanitizationWhitelist=function(a){return B(a)?(i=a,this):i};this.$get=["$injector","$interpolate","$exceptionHandler","$http","$templateCache","$parse","$controller","$rootScope","$document",function(b,j,m,k,l,u,o,z,r){function y(a,b,c){a instanceof w||(a=w(a));n(a,function(b,c){b.nodeType==3&&b.nodeValue.match(/\S+/)&&(a[c]=w(b).wrap("").parent()[0])}); -var d=W(a,b,a,c);return function(b,c){cb(b,"scope");for(var e=c?Ba.clone.call(a):a,j=0,g=e.length;js.priority)break;if(t=s.scope)O("isolated scope",K,s,J),L(t)&&(x(J,"ng-isolate-scope"),K=s),x(J,"ng-scope"),r=r||s;A=s.name;if(t=s.controller)q=q||{},O("'"+A+"' controller",q[A],s,J),q[A]=s;if(t=s.transclude)O("transclusion",G,s,J),G=s,l=s.priority,t=="element"?(Y=w(b),J=c.$$element=w(T.createComment(" "+A+": "+c[A]+" ")),b=J[0],ja(e,w(Y[0]),b),P=y(Y,d,l)):(Y=w(fb(b)).contents(), -J.html(""),P=y(Y,d));if(s.template)if(O("template",W,s,J),W=s,t=H(s.template)?s.template(J,c):s.template,t=Lb(t),s.replace){Y=w("
"+U(t)+"
").contents();b=Y[0];if(Y.length!=1||b.nodeType!==1)throw Error(g+t);ja(e,J,b);A={$attr:{}};a=a.concat(v(b,a.splice(B+1,a.length-(B+1)),A));D(c,A);C=a.length}else J.html(t);if(s.templateUrl)O("template",W,s,J),W=s,k=$(a.splice(B,a.length-B),k,J,c,e,s.replace,P),C=a.length;else if(s.compile)try{na=s.compile(J,c,P),H(na)?h(null,na):na&&h(na.pre,na.post)}catch(I){m(I, -va(J))}if(s.terminal)k.terminal=!0,l=Math.max(l,s.priority)}k.scope=r&&r.scope;k.transclude=G&&P;return k}function G(d,e,j,g){var l=!1;if(a.hasOwnProperty(e))for(var k,e=b.get(e+c),i=0,o=e.length;ik.priority)&&k.restrict.indexOf(j)!=-1)d.push(k),l=!0}catch(u){m(u)}return l}function D(a,b){var c=b.$attr,d=a.$attr,e=a.$$element;n(a,function(d,e){e.charAt(0)!="$"&&(b[e]&&(d+=(e==="style"?";":" ")+b[e]),a.$set(e,d,!0,c[e]))});n(b,function(b,j){j=="class"?(x(e,b),a["class"]= -(a["class"]?a["class"]+" ":"")+b):j=="style"?e.attr("style",e.attr("style")+";"+b):j.charAt(0)!="$"&&!a.hasOwnProperty(j)&&(a[j]=b,d[j]=c[j])})}function $(a,b,c,d,e,j,h){var i=[],o,m,u=c[0],z=a.shift(),r=t({},z,{controller:null,templateUrl:null,transclude:null,scope:null}),z=H(z.templateUrl)?z.templateUrl(c,d):z.templateUrl;c.html("");k.get(z,{cache:l}).success(function(l){var k,z,l=Lb(l);if(j){z=w("
"+U(l)+"
").contents();k=z[0];if(z.length!=1||k.nodeType!==1)throw Error(g+l);l={$attr:{}}; -ja(e,c,k);v(k,a,l);D(d,l)}else k=u,c.html(l);a.unshift(r);o=A(a,k,d,h);for(m=W(c[0].childNodes,h);i.length;){var ea=i.shift(),l=i.shift();z=i.shift();var x=i.shift(),y=k;l!==u&&(y=fb(k),ja(z,w(l),y));o(function(){b(m,ea,y,e,x)},ea,y,e,x)}i=null}).error(function(a,b,c,d){throw Error("Failed to load template: "+d.url);});return function(a,c,d,e,j){i?(i.push(c),i.push(d),i.push(e),i.push(j)):o(function(){b(m,c,d,e,j)},c,d,e,j)}}function K(a,b){return b.priority-a.priority}function O(a,b,c,d){if(b)throw Error("Multiple directives ["+ -b.name+", "+c.name+"] asking for "+a+" on: "+va(d));}function P(a,b){var c=j(b,!0);c&&a.push({priority:0,compile:S(function(a,b){var d=b.parent(),e=d.data("$binding")||[];e.push(c);x(d.data("$binding",e),"ng-binding");a.$watch(c,function(a){b[0].nodeValue=a})})})}function s(a,b,c,d){var e=j(c,!0);e&&b.push({priority:100,compile:S(function(a,b,c){b=c.$$observers||(c.$$observers={});if(e=j(c[d],!0))c[d]=e(a),(b[d]||(b[d]=[])).$$inter=!0,(c.$$observers&&c.$$observers[d].$$scope||a).$watch(e,function(a){c.$set(d, -a)})})})}function ja(a,b,c){var d=b[0],e=d.parentNode,j,g;if(a){j=0;for(g=a.length;j0){var e=O[0],f=e.text;if(f==a||f==b||f==c||f==d||!a&&!b&&!c&&!d)return e}return!1}function f(b, -c,d,f){return(b=i(b,c,d,f))?(a&&!b.json&&e("is not valid json",b),O.shift(),b):!1}function h(a){f(a)||e("is unexpected, expecting ["+a+"]",i())}function j(a,b){return t(function(c,d){return a(c,d,b)},{constant:b.constant})}function m(a,b,c){return t(function(d,e){return a(d,e)?b(d,e):c(d,e)},{constant:a.constant&&b.constant&&c.constant})}function k(a,b,c){return t(function(d,e){return b(d,e,a,c)},{constant:a.constant&&c.constant})}function l(){for(var a=[];;)if(O.length>0&&!i("}",")",";","]")&&a.push(w()), -!f(";"))return a.length==1?a[0]:function(b,c){for(var d,e=0;e","<=",">="))a=k(a,b.fn,x());return a}function n(){for(var a=v(),b;b=f("*","/","%");)a=k(a,b.fn,v());return a}function v(){var a;return f("+")?A():(a=f("-"))?k($,a.fn,v()):(a=f("!"))?j(a.fn,v()):A()}function A(){var a;if(f("("))a=w(),h(")");else if(f("["))a=G();else if(f("{"))a=D();else{var b=f();(a= -b.fn)||e("not a primary expression",b);if(b.json)a.constant=a.literal=!0}for(var c;b=f("(","[",".");)b.text==="("?(a=s(a,c),c=null):b.text==="["?(c=a,a=ma(a)):b.text==="."?(c=a,a=ja(a)):e("IMPOSSIBLE");return a}function G(){var a=[],b=!0;if(g().text!="]"){do{var c=P();a.push(c);c.constant||(b=!1)}while(f(","))}h("]");return t(function(b,c){for(var d=[],e=0;e1;d++){var e=a.shift(),g=b[e];g||(g={},b[e]=g);b=g}return b[a.shift()]=c}function ib(b,a,c){if(!a)return b;for(var a=a.split("."),d,e=b,g=a.length,i=0;ia)for(b in g++,e)e.hasOwnProperty(b)&&!f.hasOwnProperty(b)&&(x--,delete e[b])}else e!==f&&(e=f,g++);return g}, -function(){b(f,e,c)})},$digest:function(){var a,d,e,i,u=this.$$asyncQueue,o,z,r=b,n,x=[],p,v;g("$digest");do{z=!1;for(n=this;u.length;)try{n.$eval(u.shift())}catch(A){c(A)}do{if(i=n.$$watchers)for(o=i.length;o--;)try{if(a=i[o],(d=a.get(n))!==(e=a.last)&&!(a.eq?ia(d,e):typeof d=="number"&&typeof e=="number"&&isNaN(d)&&isNaN(e)))z=!0,a.last=a.eq?V(d):d,a.fn(d,e===f?d:e,n),r<5&&(p=4-r,x[p]||(x[p]=[]),v=H(a.exp)?"fn: "+(a.exp.name||a.exp.toString()):a.exp,v+="; newVal: "+ha(d)+"; oldVal: "+ha(e),x[p].push(v))}catch(G){c(G)}if(!(i= -n.$$childHead||n!==this&&n.$$nextSibling))for(;n!==this&&!(i=n.$$nextSibling);)n=n.$parent}while(n=i);if(z&&!r--)throw h.$$phase=null,Error(b+" $digest() iterations reached. Aborting!\nWatchers fired in the last 5 iterations: "+ha(x));}while(z||u.length);h.$$phase=null},$destroy:function(){if(!(h==this||this.$$destroyed)){var a=this.$parent;this.$broadcast("$destroy");this.$$destroyed=!0;if(a.$$childHead==this)a.$$childHead=this.$$nextSibling;if(a.$$childTail==this)a.$$childTail=this.$$prevSibling; -if(this.$$prevSibling)this.$$prevSibling.$$nextSibling=this.$$nextSibling;if(this.$$nextSibling)this.$$nextSibling.$$prevSibling=this.$$prevSibling;this.$parent=this.$$nextSibling=this.$$prevSibling=this.$$childHead=this.$$childTail=null}},$eval:function(a,b){return d(a)(this,b)},$evalAsync:function(a){this.$$asyncQueue.push(a)},$apply:function(a){try{return g("$apply"),this.$eval(a)}catch(b){c(b)}finally{h.$$phase=null;try{h.$digest()}catch(d){throw c(d),d;}}},$on:function(a,b){var c=this.$$listeners[a]; -c||(this.$$listeners[a]=c=[]);c.push(b);return function(){c[Ga(c,b)]=null}},$emit:function(a,b){var d=[],e,f=this,g=!1,i={name:a,targetScope:f,stopPropagation:function(){g=!0},preventDefault:function(){i.defaultPrevented=!0},defaultPrevented:!1},h=[i].concat(ka.call(arguments,1)),n,x;do{e=f.$$listeners[a]||d;i.currentScope=f;n=0;for(x=e.length;n7),hasEvent:function(a){if(a=="input"&&Z==9)return!1;if(C(c[a])){var b= -e.createElement("div");c[a]="on"+a in b}return c[a]},csp:e.securityPolicy?e.securityPolicy.isActive:!1,vendorPrefix:g,transitions:h,animations:j}}]}function Zc(){this.$get=S(M)}function Wb(b){var a={},c,d,e;if(!b)return a;n(b.split("\n"),function(b){e=b.indexOf(":");c=I(U(b.substr(0,e)));d=U(b.substr(e+1));c&&(a[c]?a[c]+=", "+d:a[c]=d)});return a}function $c(b,a){var c=ad.exec(b);if(c==null)return!0;var d={protocol:c[2],host:c[4],port:N(c[6])||Oa[c[2]]||null,relativeProtocol:c[2]===p||c[2]===""}, -c=jb.exec(a),c={protocol:c[1],host:c[3],port:N(c[5])||Oa[c[1]]||null};return(d.protocol==c.protocol||d.relativeProtocol)&&d.host==c.host&&(d.port==c.port||d.relativeProtocol&&c.port==Oa[c.protocol])}function Xb(b){var a=L(b)?b:p;return function(c){a||(a=Wb(b));return c?a[I(c)]||null:a}}function Yb(b,a,c){if(H(c))return c(b,a);n(c,function(c){b=c(b,a)});return b}function bd(){var b=/^\s*(\[|\{[^\{])/,a=/[\}\]]\s*$/,c=/^\)\]\}',?\n/,d={"Content-Type":"application/json;charset=utf-8"},e=this.defaults= -{transformResponse:[function(d){E(d)&&(d=d.replace(c,""),b.test(d)&&a.test(d)&&(d=ub(d,!0)));return d}],transformRequest:[function(a){return L(a)&&Ea.apply(a)!=="[object File]"?ha(a):a}],headers:{common:{Accept:"application/json, text/plain, */*"},post:d,put:d,patch:d},xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN"},g=this.interceptors=[],i=this.responseInterceptors=[];this.$get=["$httpBackend","$browser","$cacheFactory","$rootScope","$q","$injector",function(a,b,c,d,k,l){function u(a){function c(a){var b= -t({},a,{data:Yb(a.data,a.headers,d.transformResponse)});return 200<=a.status&&a.status<300?b:k.reject(b)}var d={transformRequest:e.transformRequest,transformResponse:e.transformResponse},f={};t(d,a);d.headers=f;d.method=oa(d.method);t(f,e.headers.common,e.headers[I(d.method)],a.headers);(a=$c(d.url,b.url())?b.cookies()[d.xsrfCookieName||e.xsrfCookieName]:p)&&(f[d.xsrfHeaderName||e.xsrfHeaderName]=a);var g=[function(a){var b=Yb(a.data,Xb(f),a.transformRequest);C(a.data)&&delete f["Content-Type"];if(C(a.withCredentials)&& -!C(e.withCredentials))a.withCredentials=e.withCredentials;return o(a,b,f).then(c,c)},p],j=k.when(d);for(n(y,function(a){(a.request||a.requestError)&&g.unshift(a.request,a.requestError);(a.response||a.responseError)&&g.push(a.response,a.responseError)});g.length;)var a=g.shift(),i=g.shift(),j=j.then(a,i);j.success=function(a){j.then(function(b){a(b.data,b.status,b.headers,d)});return j};j.error=function(a){j.then(null,function(b){a(b.data,b.status,b.headers,d)});return j};return j}function o(b,c,g){function j(a, -b,c){n&&(200<=a&&a<300?n.put(s,[a,b,Wb(c)]):n.remove(s));i(b,a,c);d.$$phase||d.$apply()}function i(a,c,d){c=Math.max(c,0);(200<=c&&c<300?l.resolve:l.reject)({data:a,status:c,headers:Xb(d),config:b})}function h(){var a=Ga(u.pendingRequests,b);a!==-1&&u.pendingRequests.splice(a,1)}var l=k.defer(),o=l.promise,n,p,s=z(b.url,b.params);u.pendingRequests.push(b);o.then(h,h);if((b.cache||e.cache)&&b.cache!==!1&&b.method=="GET")n=L(b.cache)?b.cache:L(e.cache)?e.cache:r;if(n)if(p=n.get(s))if(p.then)return p.then(h, -h),p;else F(p)?i(p[1],p[0],V(p[2])):i(p,200,{});else n.put(s,o);p||a(b.method,s,c,j,g,b.timeout,b.withCredentials,b.responseType);return o}function z(a,b){if(!b)return a;var c=[];nc(b,function(a,b){a==null||a==p||(F(a)||(a=[a]),n(a,function(a){L(a)&&(a=ha(a));c.push(wa(b)+"="+wa(a))}))});return a+(a.indexOf("?")==-1?"?":"&")+c.join("&")}var r=c("$http"),y=[];n(g,function(a){y.unshift(E(a)?l.get(a):l.invoke(a))});n(i,function(a,b){var c=E(a)?l.get(a):l.invoke(a);y.splice(b,0,{response:function(a){return c(k.when(a))}, -responseError:function(a){return c(k.reject(a))}})});u.pendingRequests=[];(function(a){n(arguments,function(a){u[a]=function(b,c){return u(t(c||{},{method:a,url:b}))}})})("get","delete","head","jsonp");(function(a){n(arguments,function(a){u[a]=function(b,c,d){return u(t(d||{},{method:a,url:b,data:c}))}})})("post","put");u.defaults=e;return u}]}function cd(){this.$get=["$browser","$window","$document",function(b,a,c){return dd(b,ed,b.defer,a.angular.callbacks,c[0],a.location.protocol.replace(":",""))}]} -function dd(b,a,c,d,e,g){function i(a,b){var c=e.createElement("script"),d=function(){e.body.removeChild(c);b&&b()};c.type="text/javascript";c.src=a;Z?c.onreadystatechange=function(){/loaded|complete/.test(c.readyState)&&d()}:c.onload=c.onerror=d;e.body.appendChild(c);return d}return function(e,h,j,m,k,l,u,o){function z(){p=-1;t&&t();v&&v.abort()}function r(a,d,e,f){var j=(h.match(jb)||["",g])[1];A&&c.cancel(A);t=v=null;d=j=="file"?e?200:404:d;a(d==1223?204:d,e,f);b.$$completeOutstandingRequest(q)} -var p;b.$$incOutstandingRequestCount();h=h||b.url();if(I(e)=="jsonp"){var x="_"+(d.counter++).toString(36);d[x]=function(a){d[x].data=a};var t=i(h.replace("JSON_CALLBACK","angular.callbacks."+x),function(){d[x].data?r(m,200,d[x].data):r(m,p||-2);delete d[x]})}else{var v=new a;v.open(e,h,!0);n(k,function(a,b){a&&v.setRequestHeader(b,a)});v.onreadystatechange=function(){if(v.readyState==4){var a=v.getAllResponseHeaders(),b=["Cache-Control","Content-Language","Content-Type","Expires","Last-Modified", -"Pragma"];a||(a="",n(b,function(b){var c=v.getResponseHeader(b);c&&(a+=b+": "+c+"\n")}));r(m,p||v.status,v.responseType?v.response:v.responseText,a)}};if(u)v.withCredentials=!0;if(o)v.responseType=o;v.send(j||"")}if(l>0)var A=c(z,l);else l&&l.then&&l.then(z)}}function fd(){this.$get=function(){return{id:"en-us",NUMBER_FORMATS:{DECIMAL_SEP:".",GROUP_SEP:",",PATTERNS:[{minInt:1,minFrac:0,maxFrac:3,posPre:"",posSuf:"",negPre:"-",negSuf:"",gSize:3,lgSize:3},{minInt:1,minFrac:2,maxFrac:2,posPre:"\u00a4", -posSuf:"",negPre:"(\u00a4",negSuf:")",gSize:3,lgSize:3}],CURRENCY_SYM:"$"},DATETIME_FORMATS:{MONTH:"January,February,March,April,May,June,July,August,September,October,November,December".split(","),SHORTMONTH:"Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec".split(","),DAY:"Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday".split(","),SHORTDAY:"Sun,Mon,Tue,Wed,Thu,Fri,Sat".split(","),AMPMS:["AM","PM"],medium:"MMM d, y h:mm:ss a","short":"M/d/yy h:mm a",fullDate:"EEEE, MMMM d, y",longDate:"MMMM d, y", -mediumDate:"MMM d, y",shortDate:"M/d/yy",mediumTime:"h:mm:ss a",shortTime:"h:mm a"},pluralCat:function(b){return b===1?"one":"other"}}}}function gd(){this.$get=["$rootScope","$browser","$q","$exceptionHandler",function(b,a,c,d){function e(e,f,h){var j=c.defer(),m=j.promise,k=B(h)&&!h,f=a.defer(function(){try{j.resolve(e())}catch(a){j.reject(a),d(a)}k||b.$apply()},f),h=function(){delete g[m.$$timeoutId]};m.$$timeoutId=f;g[f]=j;m.then(h,h);return m}var g={};e.cancel=function(b){return b&&b.$$timeoutId in -g?(g[b.$$timeoutId].reject("canceled"),a.defer.cancel(b.$$timeoutId)):!1};return e}]}function Zb(b){function a(a,e){return b.factory(a+c,e)}var c="Filter";this.register=a;this.$get=["$injector",function(a){return function(b){return a.get(b+c)}}];a("currency",$b);a("date",ac);a("filter",hd);a("json",id);a("limitTo",jd);a("lowercase",kd);a("number",bc);a("orderBy",cc);a("uppercase",ld)}function hd(){return function(b,a,c){if(!F(b))return b;var d=[];d.check=function(a){for(var b=0;b-1}}var e=function(a,b){if(typeof b=="string"&&b.charAt(0)==="!")return!e(a,b.substr(1));switch(typeof a){case "boolean":case "number":case "string":return c(a,b);case "object":switch(typeof b){case "object":return c(a,b);default:for(var d in a)if(d.charAt(0)!=="$"&&e(a[d],b))return!0}return!1;case "array":for(d= -0;de+1?i="0":(f=i,j=!0)}if(!j){i=(i.split(ec)[1]||"").length;C(e)&&(e=Math.min(Math.max(a.minFrac,i), -a.maxFrac));var i=Math.pow(10,e),b=Math.round(b*i)/i,b=(""+b).split(ec),i=b[0],b=b[1]||"",j=0,m=a.lgSize,k=a.gSize;if(i.length>=m+k)for(var j=i.length-m,l=0;l0||e>-c)e+=c;e===0&&c==-12&&(e=12);return nb(e,a,d)}}function Qa(b,a){return function(c,d){var e=c["get"+b](),g=oa(a?"SHORT"+b:b);return d[g][e]}}function ac(b){function a(a){var b;if(b=a.match(c)){var a=new Date(0),g=0,i=0,f=b[8]?a.setUTCFullYear:a.setFullYear,h=b[8]?a.setUTCHours:a.setHours;b[9]&&(g=N(b[9]+b[10]),i=N(b[9]+b[11]));f.call(a,N(b[1]),N(b[2])-1,N(b[3]));g=N(b[4]||0)-g;i=N(b[5]||0)-i;f= -N(b[6]||0);b=Math.round(parseFloat("0."+(b[7]||0))*1E3);h.call(a,g,i,f,b)}return a}var c=/^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/;return function(c,e){var g="",i=[],f,h,e=e||"mediumDate",e=b.DATETIME_FORMATS[e]||e;E(c)&&(c=md.test(c)?N(c):a(c));Ya(c)&&(c=new Date(c));if(!ra(c))return c;for(;e;)(h=nd.exec(e))?(i=i.concat(ka.call(h,1)),e=i.pop()):(i.push(e),e=null);n(i,function(a){f=od[a];g+=f?f(c,b.DATETIME_FORMATS):a.replace(/(^'|'$)/g, -"").replace(/''/g,"'")});return g}}function id(){return function(b){return ha(b,!0)}}function jd(){return function(b,a){if(!F(b)&&!E(b))return b;a=N(a);if(E(b))return a?a>=0?b.slice(0,a):b.slice(a,b.length):"";var c=[],d,e;a>b.length?a=b.length:a<-b.length&&(a=-b.length);a>0?(d=0,e=a):(d=b.length+a,e=b.length);for(;dl?(d.$setValidity("maxlength",!1),p):(d.$setValidity("maxlength",!0),a)};d.$parsers.push(e);d.$formatters.push(e)}}function ob(b,a){b="ngClass"+b;return aa(function(c,d,e){function g(b){if(a===!0||c.$index%2===a)h&&!ia(b,h)&&i(h),f(b);h=V(b)}function i(a){L(a)&&!F(a)&&(a=Za(a,function(a,b){if(a)return b}));d.removeClass(F(a)?a.join(" "):a)}function f(a){L(a)&&!F(a)&&(a=Za(a, -function(a,b){if(a)return b}));a&&d.addClass(F(a)?a.join(" "):a)}var h=p;c.$watch(e[b],g,!0);e.$observe("class",function(){var a=c.$eval(e[b]);g(a,a)});b!=="ngClass"&&c.$watch("$index",function(d,g){var h=d&1;h!==g&1&&(h===a?f(c.$eval(e[b])):i(c.$eval(e[b])))})})}var I=function(b){return E(b)?b.toLowerCase():b},oa=function(b){return E(b)?b.toUpperCase():b},Z=N((/msie (\d+)/.exec(I(navigator.userAgent))||[])[1]),w,ga,ka=[].slice,Wa=[].push,Ea=Object.prototype.toString,mc=M.angular,Ha=M.angular||(M.angular= -{}),Aa,hb,ba=["0","0","0"];q.$inject=[];qa.$inject=[];hb=Z<9?function(b){b=b.nodeName?b:b[0];return b.scopeName&&b.scopeName!="HTML"?oa(b.scopeName+":"+b.nodeName):b.nodeName}:function(b){return b.nodeName?b.nodeName:b[0].nodeName};var sc=/[A-Z]/g,pd={full:"1.1.5",major:1,minor:1,dot:5,codeName:"triangle-squarification"},Ka=R.cache={},Ja=R.expando="ng-"+(new Date).getTime(),wc=1,gc=M.document.addEventListener?function(b,a,c){b.addEventListener(a,c,!1)}:function(b,a,c){b.attachEvent("on"+a,c)},gb= -M.document.removeEventListener?function(b,a,c){b.removeEventListener(a,c,!1)}:function(b,a,c){b.detachEvent("on"+a,c)},uc=/([\:\-\_]+(.))/g,vc=/^moz([A-Z])/,Ba=R.prototype={ready:function(b){function a(){c||(c=!0,b())}var c=!1;T.readyState==="complete"?setTimeout(a):(this.bind("DOMContentLoaded",a),R(M).bind("load",a))},toString:function(){var b=[];n(this,function(a){b.push(""+a)});return"["+b.join(", ")+"]"},eq:function(b){return b>=0?w(this[b]):w(this[this.length+b])},length:0,push:Wa,sort:[].sort, -splice:[].splice},Na={};n("multiple,selected,checked,disabled,readOnly,required,open".split(","),function(b){Na[I(b)]=b});var Gb={};n("input,select,option,textarea,button,form,details".split(","),function(b){Gb[oa(b)]=!0});n({data:Bb,inheritedData:Ma,scope:function(b){return Ma(b,"$scope")},controller:Eb,injector:function(b){return Ma(b,"$injector")},removeAttr:function(b,a){b.removeAttribute(a)},hasClass:La,css:function(b,a,c){a=Ia(a);if(B(c))b.style[a]=c;else{var d;Z<=8&&(d=b.currentStyle&&b.currentStyle[a], -d===""&&(d="auto"));d=d||b.style[a];Z<=8&&(d=d===""?p:d);return d}},attr:function(b,a,c){var d=I(a);if(Na[d])if(B(c))c?(b[a]=!0,b.setAttribute(a,d)):(b[a]=!1,b.removeAttribute(d));else return b[a]||(b.attributes.getNamedItem(a)||q).specified?d:p;else if(B(c))b.setAttribute(a,c);else if(b.getAttribute)return b=b.getAttribute(a,2),b===null?p:b},prop:function(b,a,c){if(B(c))b[a]=c;else return b[a]},text:t(Z<9?function(b,a){if(b.nodeType==1){if(C(a))return b.innerText;b.innerText=a}else{if(C(a))return b.nodeValue; -b.nodeValue=a}}:function(b,a){if(C(a))return b.textContent;b.textContent=a},{$dv:""}),val:function(b,a){if(C(a))return b.value;b.value=a},html:function(b,a){if(C(a))return b.innerHTML;for(var c=0,d=b.childNodes;c0||parseFloat(h[a+"Duration"])> -0)g="animation",i=a,j=Math.max(parseInt(h[g+"IterationCount"])||0,parseInt(h[i+"IterationCount"])||0,j);f=Math.max(x(h[g+"Delay"]),x(h[i+"Delay"]));g=Math.max(x(h[g+"Duration"]),x(h[i+"Duration"]));d=Math.max(f+j*g,d)}});e.setTimeout(v,d*1E3)}else v()}function v(){if(!v.run)v.run=!0,o(m,r,p),m.removeClass(w),m.removeClass(K),m.removeData(a)}var A=c.$eval(i.ngAnimate),w=A?L(A)?A[j]:A+"-"+j:"",D=d(w),A=D&&D.setup,$=D&&D.start,D=D&&D.cancel;if(w){var K=w+"-active";r||(r=p?p.parent():m.parent());if(!g.transitions&& -!A&&!$||(r.inheritedData(a)||q).running)k(m,r,p),o(m,r,p);else{var O=m.data(a)||{};O.running&&((D||q)(m),O.done());m.data(a,{running:!0,done:v});m.addClass(w);k(m,r,p);if(m.length==0)return v();var P=(A||q)(m);e.setTimeout(t,1)}}else k(m,r,p),o(m,r,p)}}function m(a,c,d){d?d.after(a):c.append(a)}var k={};k.enter=j("enter",m,q);k.leave=j("leave",q,function(a){a.remove()});k.move=j("move",function(a,c,d){m(a,c,d)},q);k.show=j("show",function(a){a.css("display","")},q);k.hide=j("hide",q,function(a){a.css("display", -"none")});k.animate=function(a,c){j(a,q,q)(c)};return k};i.enabled=function(a){if(arguments.length)c.running=!a;return!c.running};return i}]},Kb="Non-assignable model expression: ";Jb.$inject=["$provide"];var Ic=/^(x[\:\-_]|data[\:\-_])/i,jb=/^([^:]+):\/\/(\w+:{0,1}\w*@)?(\{?[\w\.-]*\}?)(:([0-9]+))?(\/[^\?#]*)?(\?([^#]*))?(#(.*))?$/,Pb=/^([^\?#]*)(\?([^#]*))?(#(.*))?$/,Oa={http:80,https:443,ftp:21};Rb.prototype=lb.prototype=Qb.prototype={$$replace:!1,absUrl:Pa("$$absUrl"),url:function(a,c){if(C(a))return this.$$url; -var d=Pb.exec(a);d[1]&&this.path(decodeURIComponent(d[1]));if(d[2]||d[1])this.search(d[3]||"");this.hash(d[5]||"",c);return this},protocol:Pa("$$protocol"),host:Pa("$$host"),port:Pa("$$port"),path:Sb("$$path",function(a){return a.charAt(0)=="/"?a:"/"+a}),search:function(a,c){if(C(a))return this.$$search;B(c)?c===null?delete this.$$search[a]:this.$$search[a]=c:this.$$search=E(a)?vb(a):a;this.$$compose();return this},hash:Sb("$$hash",qa),replace:function(){this.$$replace=!0;return this}};var Da={"null":function(){return null}, -"true":function(){return!0},"false":function(){return!1},undefined:q,"+":function(a,c,d,e){d=d(a,c);e=e(a,c);return B(d)?B(e)?d+e:d:B(e)?e:p},"-":function(a,c,d,e){d=d(a,c);e=e(a,c);return(B(d)?d:0)-(B(e)?e:0)},"*":function(a,c,d,e){return d(a,c)*e(a,c)},"/":function(a,c,d,e){return d(a,c)/e(a,c)},"%":function(a,c,d,e){return d(a,c)%e(a,c)},"^":function(a,c,d,e){return d(a,c)^e(a,c)},"=":q,"===":function(a,c,d,e){return d(a,c)===e(a,c)},"!==":function(a,c,d,e){return d(a,c)!==e(a,c)},"==":function(a, -c,d,e){return d(a,c)==e(a,c)},"!=":function(a,c,d,e){return d(a,c)!=e(a,c)},"<":function(a,c,d,e){return d(a,c)":function(a,c,d,e){return d(a,c)>e(a,c)},"<=":function(a,c,d,e){return d(a,c)<=e(a,c)},">=":function(a,c,d,e){return d(a,c)>=e(a,c)},"&&":function(a,c,d,e){return d(a,c)&&e(a,c)},"||":function(a,c,d,e){return d(a,c)||e(a,c)},"&":function(a,c,d,e){return d(a,c)&e(a,c)},"|":function(a,c,d,e){return e(a,c)(a,c,d(a,c))},"!":function(a,c,d){return!d(a,c)}},Qc={n:"\n",f:"\u000c",r:"\r", -t:"\t",v:"\u000b","'":"'",'"':'"'},mb={},ad=/^(([^:]+):)?\/\/(\w+:{0,1}\w*@)?([\w\.-]*)?(:([0-9]+))?(.*)$/,ed=M.XMLHttpRequest||function(){try{return new ActiveXObject("Msxml2.XMLHTTP.6.0")}catch(a){}try{return new ActiveXObject("Msxml2.XMLHTTP.3.0")}catch(c){}try{return new ActiveXObject("Msxml2.XMLHTTP")}catch(d){}throw Error("This browser does not support XMLHttpRequest.");};Zb.$inject=["$provide"];$b.$inject=["$locale"];bc.$inject=["$locale"];var ec=".",od={yyyy:Q("FullYear",4),yy:Q("FullYear", -2,0,!0),y:Q("FullYear",1),MMMM:Qa("Month"),MMM:Qa("Month",!0),MM:Q("Month",2,1),M:Q("Month",1,1),dd:Q("Date",2),d:Q("Date",1),HH:Q("Hours",2),H:Q("Hours",1),hh:Q("Hours",2,-12),h:Q("Hours",1,-12),mm:Q("Minutes",2),m:Q("Minutes",1),ss:Q("Seconds",2),s:Q("Seconds",1),sss:Q("Milliseconds",3),EEEE:Qa("Day"),EEE:Qa("Day",!0),a:function(a,c){return a.getHours()<12?c.AMPMS[0]:c.AMPMS[1]},Z:function(a){var a=-1*a.getTimezoneOffset(),c=a>=0?"+":"";c+=nb(Math[a>0?"floor":"ceil"](a/60),2)+nb(Math.abs(a%60), -2);return c}},nd=/((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z))(.*)/,md=/^\d+$/;ac.$inject=["$locale"];var kd=S(I),ld=S(oa);cc.$inject=["$parse"];var rd=S({restrict:"E",compile:function(a,c){Z<=8&&(!c.href&&!c.name&&c.$set("href",""),a.append(T.createComment("IE fix")));return function(a,c){c.bind("click",function(a){c.attr("href")||a.preventDefault()})}}}),pb={};n(Na,function(a,c){var d=da("ng-"+c);pb[d]=function(){return{priority:100,compile:function(){return function(a, -g,i){a.$watch(i[d],function(a){i.$set(c,!!a)})}}}}});n(["src","srcset","href"],function(a){var c=da("ng-"+a);pb[c]=function(){return{priority:99,link:function(d,e,g){g.$observe(c,function(c){c&&(g.$set(a,c),Z&&e.prop(a,g[a]))})}}}});var Ta={$addControl:q,$removeControl:q,$setValidity:q,$setDirty:q,$setPristine:q};fc.$inject=["$element","$attrs","$scope"];var Wa=function(a){return["$timeout",function(c){var d={name:"form",restrict:"E",controller:fc,compile:function(){return{pre:function(a,d,i,f){if(!i.action){var h= -function(a){a.preventDefault?a.preventDefault():a.returnValue=!1};gc(d[0],"submit",h);d.bind("$destroy",function(){c(function(){gb(d[0],"submit",h)},0,!1)})}var j=d.parent().controller("form"),m=i.name||i.ngForm;m&&(a[m]=f);j&&d.bind("$destroy",function(){j.$removeControl(f);m&&(a[m]=p);t(f,Ta)})}}}};return a?t(V(d),{restrict:"EAC"}):d}]},sd=Wa(),td=Wa(!0),ud=/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/,vd=/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/, -wd=/^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/,hc={text:Va,number:function(a,c,d,e,g,i){Va(a,c,d,e,g,i);e.$parsers.push(function(a){var c=X(a);return c||wd.test(a)?(e.$setValidity("number",!0),a===""?null:c?a:parseFloat(a)):(e.$setValidity("number",!1),p)});e.$formatters.push(function(a){return X(a)?"":""+a});if(d.min){var f=parseFloat(d.min),a=function(a){return!X(a)&&ah?(e.$setValidity("max",!1),p):(e.$setValidity("max",!0),a)};e.$parsers.push(d);e.$formatters.push(d)}e.$formatters.push(function(a){return X(a)||Ya(a)?(e.$setValidity("number",!0),a):(e.$setValidity("number",!1),p)})},url:function(a,c,d,e,g,i){Va(a,c,d,e,g,i);a=function(a){return X(a)||ud.test(a)?(e.$setValidity("url",!0),a):(e.$setValidity("url",!1),p)};e.$formatters.push(a);e.$parsers.push(a)},email:function(a,c,d,e,g,i){Va(a,c,d,e,g,i);a=function(a){return X(a)||vd.test(a)? -(e.$setValidity("email",!0),a):(e.$setValidity("email",!1),p)};e.$formatters.push(a);e.$parsers.push(a)},radio:function(a,c,d,e){C(d.name)&&c.attr("name",Fa());c.bind("click",function(){c[0].checked&&a.$apply(function(){e.$setViewValue(d.value)})});e.$render=function(){c[0].checked=d.value==e.$viewValue};d.$observe("value",e.$render)},checkbox:function(a,c,d,e){var g=d.ngTrueValue,i=d.ngFalseValue;E(g)||(g=!0);E(i)||(i=!1);c.bind("click",function(){a.$apply(function(){e.$setViewValue(c[0].checked)})}); -e.$render=function(){c[0].checked=e.$viewValue};e.$formatters.push(function(a){return a===g});e.$parsers.push(function(a){return a?g:i})},hidden:q,button:q,submit:q,reset:q},ic=["$browser","$sniffer",function(a,c){return{restrict:"E",require:"?ngModel",link:function(d,e,g,i){i&&(hc[I(g.type)]||hc.text)(d,e,g,i,c,a)}}}],Sa="ng-valid",Ra="ng-invalid",pa="ng-pristine",Ua="ng-dirty",xd=["$scope","$exceptionHandler","$attrs","$element","$parse",function(a,c,d,e,g){function i(a,c){c=c?"-"+bb(c,"-"):""; -e.removeClass((a?Ra:Sa)+c).addClass((a?Sa:Ra)+c)}this.$modelValue=this.$viewValue=Number.NaN;this.$parsers=[];this.$formatters=[];this.$viewChangeListeners=[];this.$pristine=!0;this.$dirty=!1;this.$valid=!0;this.$invalid=!1;this.$name=d.name;var f=g(d.ngModel),h=f.assign;if(!h)throw Error(Kb+d.ngModel+" ("+va(e)+")");this.$render=q;var j=e.inheritedData("$formController")||Ta,m=0,k=this.$error={};e.addClass(pa);i(!0);this.$setValidity=function(a,c){if(k[a]!==!c){if(c){if(k[a]&&m--,!m)i(!0),this.$valid= -!0,this.$invalid=!1}else i(!1),this.$invalid=!0,this.$valid=!1,m++;k[a]=!c;i(c,a);j.$setValidity(a,c,this)}};this.$setPristine=function(){this.$dirty=!1;this.$pristine=!0;e.removeClass(Ua).addClass(pa)};this.$setViewValue=function(d){this.$viewValue=d;if(this.$pristine)this.$dirty=!0,this.$pristine=!1,e.removeClass(pa).addClass(Ua),j.$setDirty();n(this.$parsers,function(a){d=a(d)});if(this.$modelValue!==d)this.$modelValue=d,h(a,d),n(this.$viewChangeListeners,function(a){try{a()}catch(d){c(d)}})}; -var l=this;a.$watch(function(){var c=f(a);if(l.$modelValue!==c){var d=l.$formatters,e=d.length;for(l.$modelValue=c;e--;)c=d[e](c);if(l.$viewValue!==c)l.$viewValue=c,l.$render()}})}],yd=function(){return{require:["ngModel","^?form"],controller:xd,link:function(a,c,d,e){var g=e[0],i=e[1]||Ta;i.$addControl(g);c.bind("$destroy",function(){i.$removeControl(g)})}}},zd=S({require:"ngModel",link:function(a,c,d,e){e.$viewChangeListeners.push(function(){a.$eval(d.ngChange)})}}),jc=function(){return{require:"?ngModel", -link:function(a,c,d,e){if(e){d.required=!0;var g=function(a){if(d.required&&(X(a)||a===!1))e.$setValidity("required",!1);else return e.$setValidity("required",!0),a};e.$formatters.push(g);e.$parsers.unshift(g);d.$observe("required",function(){g(e.$viewValue)})}}}},Ad=function(){return{require:"ngModel",link:function(a,c,d,e){var g=(a=/\/(.*)\//.exec(d.ngList))&&RegExp(a[1])||d.ngList||",";e.$parsers.push(function(a){var c=[];a&&n(a.split(g),function(a){a&&c.push(U(a))});return c});e.$formatters.push(function(a){return F(a)? -a.join(", "):p})}}},Bd=/^(true|false|\d+)$/,Cd=function(){return{priority:100,compile:function(a,c){return Bd.test(c.ngValue)?function(a,c,g){g.$set("value",a.$eval(g.ngValue))}:function(a,c,g){a.$watch(g.ngValue,function(a){g.$set("value",a,!1)})}}}},Dd=aa(function(a,c,d){c.addClass("ng-binding").data("$binding",d.ngBind);a.$watch(d.ngBind,function(a){c.text(a==p?"":a)})}),Ed=["$interpolate",function(a){return function(c,d,e){c=a(d.attr(e.$attr.ngBindTemplate));d.addClass("ng-binding").data("$binding", -c);e.$observe("ngBindTemplate",function(a){d.text(a)})}}],Fd=[function(){return function(a,c,d){c.addClass("ng-binding").data("$binding",d.ngBindHtmlUnsafe);a.$watch(d.ngBindHtmlUnsafe,function(a){c.html(a||"")})}}],Gd=ob("",!0),Hd=ob("Odd",0),Id=ob("Even",1),Jd=aa({compile:function(a,c){c.$set("ngCloak",p);a.removeClass("ng-cloak")}}),Kd=[function(){return{scope:!0,controller:"@"}}],Ld=["$sniffer",function(a){return{priority:1E3,compile:function(){a.csp=!0}}}],kc={};n("click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress".split(" "), -function(a){var c=da("ng-"+a);kc[c]=["$parse",function(d){return function(e,g,i){var f=d(i[c]);g.bind(I(a),function(a){e.$apply(function(){f(e,{$event:a})})})}}]});var Md=aa(function(a,c,d){c.bind("submit",function(){a.$apply(d.ngSubmit)})}),Nd=["$animator",function(a){return{transclude:"element",priority:1E3,terminal:!0,restrict:"A",compile:function(c,d,e){return function(c,d,f){var h=a(c,f),j,m;c.$watch(f.ngIf,function(a){j&&(h.leave(j),j=p);m&&(m.$destroy(),m=p);ua(a)&&(m=c.$new(),e(m,function(a){j= -a;h.enter(a,d.parent(),d)}))})}}}}],Od=["$http","$templateCache","$anchorScroll","$compile","$animator",function(a,c,d,e,g){return{restrict:"ECA",terminal:!0,compile:function(i,f){var h=f.ngInclude||f.src,j=f.onload||"",m=f.autoscroll;return function(f,i,n){var o=g(f,n),p=0,r,t=function(){r&&(r.$destroy(),r=null);o.leave(i.contents(),i)};f.$watch(h,function(g){var h=++p;g?(a.get(g,{cache:c}).success(function(a){h===p&&(r&&r.$destroy(),r=f.$new(),o.leave(i.contents(),i),a=w("
").html(a).contents(), -o.enter(a,i),e(a)(r),B(m)&&(!m||f.$eval(m))&&d(),r.$emit("$includeContentLoaded"),f.$eval(j))}).error(function(){h===p&&t()}),f.$emit("$includeContentRequested")):t()})}}}}],Pd=aa({compile:function(){return{pre:function(a,c,d){a.$eval(d.ngInit)}}}}),Qd=aa({terminal:!0,priority:1E3}),Rd=["$locale","$interpolate",function(a,c){var d=/{}/g;return{restrict:"EA",link:function(e,g,i){var f=i.count,h=g.attr(i.$attr.when),j=i.offset||0,m=e.$eval(h),k={},l=c.startSymbol(),p=c.endSymbol();n(m,function(a,e){k[e]= -c(a.replace(d,l+f+"-"+j+p))});e.$watch(function(){var c=parseFloat(e.$eval(f));return isNaN(c)?"":(c in m||(c=a.pluralCat(c-j)),k[c](e,g,!0))},function(a){g.text(a)})}}}],Sd=["$parse","$animator",function(a,c){return{transclude:"element",priority:1E3,terminal:!0,compile:function(d,e,g){return function(d,e,h){var j=c(d,h),m=h.ngRepeat,k=m.match(/^\s*(.+)\s+in\s+(.*?)\s*(\s+track\s+by\s+(.+)\s*)?$/),l,p,o,z,r,t={$id:la};if(!k)throw Error("Expected ngRepeat in form of '_item_ in _collection_[ track by _id_]' but got '"+ -m+"'.");h=k[1];o=k[2];(k=k[4])?(l=a(k),p=function(a,c,e){r&&(t[r]=a);t[z]=c;t.$index=e;return l(d,t)}):p=function(a,c){return la(c)};k=h.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/);if(!k)throw Error("'item' in 'item in collection' should be identifier or (key, value) but got '"+h+"'.");z=k[3]||k[1];r=k[2];var x={};d.$watchCollection(o,function(a){var c,h,k=e,l,o={},t,q,w,s,B,y,C=[];if(Xa(a))B=a;else{B=[];for(w in a)a.hasOwnProperty(w)&&w.charAt(0)!="$"&&B.push(w);B.sort()}t=B.length;h= -C.length=B.length;for(c=0;c
").html(k).contents();o.enter(k,c);var k=g(k),m=d.current;l=m.scope=a.$new();if(m.controller)f.$scope= -l,f=i(m.controller,f),m.controllerAs&&(l[m.controllerAs]=f),c.children().data("$ngControllerController",f);k(l);l.$emit("$viewContentLoaded");l.$eval(n);e()}else o.leave(c.contents(),c),l&&(l.$destroy(),l=null)}var l,n=m.onload||"",o=f(a,m);a.$on("$routeChangeSuccess",k);k()}}}],ae=["$templateCache",function(a){return{restrict:"E",terminal:!0,compile:function(c,d){d.type=="text/ng-template"&&a.put(d.id,c[0].text)}}}],be=S({terminal:!0}),ce=["$compile","$parse",function(a,c){var d=/^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*?)(?:\s+track\s+by\s+(.*?))?$/, -e={$setViewValue:q};return{restrict:"E",require:["select","?ngModel"],controller:["$element","$scope","$attrs",function(a,c,d){var h=this,j={},m=e,k;h.databound=d.ngModel;h.init=function(a,c,d){m=a;k=d};h.addOption=function(c){j[c]=!0;m.$viewValue==c&&(a.val(c),k.parent()&&k.remove())};h.removeOption=function(a){this.hasOption(a)&&(delete j[a],m.$viewValue==a&&this.renderUnknownOption(a))};h.renderUnknownOption=function(c){c="? "+la(c)+" ?";k.val(c);a.prepend(k);a.val(c);k.prop("selected",!0)};h.hasOption= -function(a){return j.hasOwnProperty(a)};c.$on("$destroy",function(){h.renderUnknownOption=q})}],link:function(e,i,f,h){function j(a,c,d,e){d.$render=function(){var a=d.$viewValue;e.hasOption(a)?(v.parent()&&v.remove(),c.val(a),a===""&&t.prop("selected",!0)):C(a)&&t?c.val(""):e.renderUnknownOption(a)};c.bind("change",function(){a.$apply(function(){v.parent()&&v.remove();d.$setViewValue(c.val())})})}function m(a,c,d){var e;d.$render=function(){var a=new za(d.$viewValue);n(c.find("option"),function(c){c.selected= -B(a.get(c.value))})};a.$watch(function(){ia(e,d.$viewValue)||(e=V(d.$viewValue),d.$render())});c.bind("change",function(){a.$apply(function(){var a=[];n(c.find("option"),function(c){c.selected&&a.push(c.value)});d.$setViewValue(a)})})}function k(e,f,g){function i(){var a={"":[]},c=[""],d,h,q,v,s;q=g.$modelValue;v=u(e)||[];var z=l?qb(v):v,B,y,A;y={};s=!1;var C,D;if(o)if(t&&F(q)){s=new za([]);for(h=0;hA;)v.pop().element.remove()}for(;w.length>y;)w.pop()[0].element.remove()} -var h;if(!(h=q.match(d)))throw Error("Expected ngOptions in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_ (track by _expr_)?' but got '"+q+"'.");var j=c(h[2]||h[1]),k=h[4]||h[6],l=h[5],m=c(h[3]||""),n=c(h[2]?h[1]:k),u=c(h[7]),t=h[8]?c(h[8]):null,w=[[{element:f,label:""}]];r&&(a(r)(e),r.removeClass("ng-scope"),r.remove());f.html("");f.bind("change",function(){e.$apply(function(){var a,c=u(e)||[],d={},h,i,j,m,q,r;if(o){i=[];m=0;for(r=w.length;m@charset "UTF-8";[ng\\:cloak],[ng-cloak],[data-ng-cloak],[x-ng-cloak],.ng-cloak,.x-ng-cloak{display:none;}ng\\:form{display:block;}'); +/* + AngularJS v1.1.5 + (c) 2010-2012 Google, Inc. http://angularjs.org + License: MIT +*/ +(function(M,T,p){'use strict';function lc(){var b=M.angular;M.angular=mc;return b}function Xa(b){return!b||typeof b.length!=="number"?!1:typeof b.hasOwnProperty!="function"&&typeof b.constructor!="function"?!0:b instanceof R||ga&&b instanceof ga||Ea.call(b)!=="[object Object]"||typeof b.callee==="function"}function n(b,a,c){var d;if(b)if(H(b))for(d in b)d!="prototype"&&d!="length"&&d!="name"&&b.hasOwnProperty(d)&&a.call(c,b[d],d);else if(b.forEach&&b.forEach!==n)b.forEach(a,c);else if(Xa(b))for(d= +0;d=0&&b.splice(c,1);return a}function V(b,a){if(sa(b)||b&&b.$evalAsync&&b.$watch)throw Error("Can't copy Window or Scope");if(a){if(b===a)throw Error("Can't copy equivalent objects or arrays");if(F(b))for(var c=a.length=0;c2?ka.call(arguments,2):[];return H(a)&&!(a instanceof RegExp)?c.length?function(){return arguments.length?a.apply(b,c.concat(ka.call(arguments,0))):a.apply(b,c)}:function(){return arguments.length?a.apply(b,arguments):a.call(b)}:a}function qc(b,a){var c=a;/^\$+/.test(b)?c=p:sa(a)?c="$WINDOW":a&&T===a?c="$DOCUMENT":a&&a.$evalAsync&& +a.$watch&&(c="$SCOPE");return c}function ha(b,a){return JSON.stringify(b,qc,a?" ":null)}function ub(b){return E(b)?JSON.parse(b):b}function ua(b){b&&b.length!==0?(b=I(""+b),b=!(b=="f"||b=="0"||b=="false"||b=="no"||b=="n"||b=="[]")):b=!1;return b}function va(b){b=w(b).clone();try{b.html("")}catch(a){}var c=w("
").append(b).html();try{return b[0].nodeType===3?I(c):c.match(/^(<[^>]+>)/)[1].replace(/^<([\w\-]+)/,function(a,b){return"<"+I(b)})}catch(d){return I(c)}}function vb(b){var a={},c,d;n((b|| +"").split("&"),function(b){b&&(c=b.split("="),d=decodeURIComponent(c[0]),a[d]=B(c[1])?decodeURIComponent(c[1]):!0)});return a}function wb(b){var a=[];n(b,function(b,d){a.push(wa(d,!0)+(b===!0?"":"="+wa(b,!0)))});return a.length?a.join("&"):""}function ab(b){return wa(b,!0).replace(/%26/gi,"&").replace(/%3D/gi,"=").replace(/%2B/gi,"+")}function wa(b,a){return encodeURIComponent(b).replace(/%40/gi,"@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,a?"%20":"+")}function rc(b, +a){function c(a){a&&d.push(a)}var d=[b],e,g,i=["ng:app","ng-app","x-ng-app","data-ng-app"],f=/\sng[:\-]app(:\s*([\w\d_]+);?)?\s/;n(i,function(a){i[a]=!0;c(T.getElementById(a));a=a.replace(":","\\:");b.querySelectorAll&&(n(b.querySelectorAll("."+a),c),n(b.querySelectorAll("."+a+"\\:"),c),n(b.querySelectorAll("["+a+"]"),c))});n(d,function(a){if(!e){var b=f.exec(" "+a.className+" ");b?(e=a,g=(b[2]||"").replace(/\s+/g,",")):n(a.attributes,function(b){if(!e&&i[b.name])e=a,g=b.value})}});e&&a(e,g?[g]:[])} +function xb(b,a){var c=function(){b=w(b);a=a||[];a.unshift(["$provide",function(a){a.value("$rootElement",b)}]);a.unshift("ng");var c=yb(a);c.invoke(["$rootScope","$rootElement","$compile","$injector","$animator",function(a,b,c,d,e){a.$apply(function(){b.data("$injector",d);c(b)(a)});e.enabled(!0)}]);return c},d=/^NG_DEFER_BOOTSTRAP!/;if(M&&!d.test(M.name))return c();M.name=M.name.replace(d,"");Ha.resumeBootstrap=function(b){n(b,function(b){a.push(b)});c()}}function bb(b,a){a=a||"_";return b.replace(sc, +function(b,d){return(d?a:"")+b.toLowerCase()})}function cb(b,a,c){if(!b)throw Error("Argument '"+(a||"?")+"' is "+(c||"required"));return b}function xa(b,a,c){c&&F(b)&&(b=b[b.length-1]);cb(H(b),a,"not a function, got "+(b&&typeof b=="object"?b.constructor.name||"Object":typeof b));return b}function tc(b){function a(a,b,e){return a[b]||(a[b]=e())}return a(a(b,"angular",Object),"module",function(){var b={};return function(d,e,g){e&&b.hasOwnProperty(d)&&(b[d]=null);return a(b,d,function(){function a(c, +d,e){return function(){b[e||"push"]([c,d,arguments]);return m}}if(!e)throw Error("No module: "+d);var b=[],c=[],j=a("$injector","invoke"),m={_invokeQueue:b,_runBlocks:c,requires:e,name:d,provider:a("$provide","provider"),factory:a("$provide","factory"),service:a("$provide","service"),value:a("$provide","value"),constant:a("$provide","constant","unshift"),animation:a("$animationProvider","register"),filter:a("$filterProvider","register"),controller:a("$controllerProvider","register"),directive:a("$compileProvider", +"directive"),config:j,run:function(a){c.push(a);return this}};g&&j(g);return m})}})}function Ia(b){return b.replace(uc,function(a,b,d,e){return e?d.toUpperCase():d}).replace(vc,"Moz$1")}function db(b,a){function c(){var e;for(var b=[this],c=a,i,f,h,j,m,k;b.length;){i=b.shift();f=0;for(h=i.length;f 
"+b;a.removeChild(a.firstChild);eb(this,a.childNodes);this.remove()}else eb(this,b)}function fb(b){return b.cloneNode(!0)}function ya(b){zb(b);for(var a=0,b=b.childNodes||[];a-1}function Cb(b,a){a&&n(a.split(" "),function(a){b.className=U((" "+b.className+" ").replace(/[\n\t]/g," ").replace(" "+U(a)+" "," "))})}function Db(b,a){a&&n(a.split(" "),function(a){if(!La(b,a))b.className=U(b.className+" "+U(a))})}function eb(b,a){if(a)for(var a=!a.nodeName&&B(a.length)&&!sa(a)?a:[a],c=0;c 4096 bytes)!")}else{if(h.cookie!==D){D=h.cookie;d=D.split("; ");G={};for(f=0;f0&&(a=unescape(e.substring(0,j)),G[a]===p&&(G[a]=unescape(e.substring(j+1))))}return G}};f.defer=function(a,b){var c;o++;c=k(function(){delete u[c];e(a)},b||0);u[c]=!0;return c};f.defer.cancel=function(a){return u[a]?(delete u[a],l(a),e(q),!0):!1}}function Ec(){this.$get= +["$window","$log","$sniffer","$document",function(b,a,c,d){return new Dc(b,d,a,c)}]}function Fc(){this.$get=function(){function b(b,d){function e(a){if(a!=k){if(l){if(l==a)l=a.n}else l=a;g(a.n,a.p);g(a,k);k=a;k.n=null}}function g(a,b){if(a!=b){if(a)a.p=b;if(b)b.n=a}}if(b in a)throw Error("cacheId "+b+" taken");var i=0,f=t({},d,{id:b}),h={},j=d&&d.capacity||Number.MAX_VALUE,m={},k=null,l=null;return a[b]={put:function(a,b){var c=m[a]||(m[a]={key:a});e(c);if(!C(b))return a in h||i++,h[a]=b,i>j&&this.remove(l.key), +b},get:function(a){var b=m[a];if(b)return e(b),h[a]},remove:function(a){var b=m[a];if(b){if(b==k)k=b.p;if(b==l)l=b.n;g(b.n,b.p);delete m[a];delete h[a];i--}},removeAll:function(){h={};i=0;m={};k=l=null},destroy:function(){m=f=h=null;delete a[b]},info:function(){return t({},f,{size:i})}}}var a={};b.info=function(){var b={};n(a,function(a,e){b[e]=a.info()});return b};b.get=function(b){return a[b]};return b}}function Gc(){this.$get=["$cacheFactory",function(b){return b("templates")}]}function Jb(b){var a= +{},c="Directive",d=/^\s*directive\:\s*([\d\w\-_]+)\s+(.*)$/,e=/(([\d\w\-_]+)(?:\:([^;]+))?;?)/,g="Template must have exactly one root element. was: ",i=/^\s*(https?|ftp|mailto|file):/;this.directive=function h(d,e){E(d)?(cb(e,"directive"),a.hasOwnProperty(d)||(a[d]=[],b.factory(d+c,["$injector","$exceptionHandler",function(b,c){var e=[];n(a[d],function(a){try{var g=b.invoke(a);if(H(g))g={compile:S(g)};else if(!g.compile&&g.link)g.compile=S(g.link);g.priority=g.priority||0;g.name=g.name||d;g.require= +g.require||g.controller&&g.name;g.restrict=g.restrict||"A";e.push(g)}catch(h){c(h)}});return e}])),a[d].push(e)):n(d,rb(h));return this};this.urlSanitizationWhitelist=function(a){return B(a)?(i=a,this):i};this.$get=["$injector","$interpolate","$exceptionHandler","$http","$templateCache","$parse","$controller","$rootScope","$document",function(b,j,m,k,l,u,o,z,r){function y(a,b,c){a instanceof w||(a=w(a));n(a,function(b,c){b.nodeType==3&&b.nodeValue.match(/\S+/)&&(a[c]=w(b).wrap("").parent()[0])}); +var d=W(a,b,a,c);return function(b,c){cb(b,"scope");for(var e=c?Ba.clone.call(a):a,j=0,g=e.length;js.priority)break;if(t=s.scope)O("isolated scope",K,s,J),L(t)&&(x(J,"ng-isolate-scope"),K=s),x(J,"ng-scope"),r=r||s;A=s.name;if(t=s.controller)q=q||{},O("'"+A+"' controller",q[A],s,J),q[A]=s;if(t=s.transclude)O("transclusion",G,s,J),G=s,l=s.priority,t=="element"?(Y=w(b),J=c.$$element=w(T.createComment(" "+A+": "+c[A]+" ")),b=J[0],ja(e,w(Y[0]),b),P=y(Y,d,l)):(Y=w(fb(b)).contents(), +J.html(""),P=y(Y,d));if(s.template)if(O("template",W,s,J),W=s,t=H(s.template)?s.template(J,c):s.template,t=Lb(t),s.replace){Y=w("
"+U(t)+"
").contents();b=Y[0];if(Y.length!=1||b.nodeType!==1)throw Error(g+t);ja(e,J,b);A={$attr:{}};a=a.concat(v(b,a.splice(B+1,a.length-(B+1)),A));D(c,A);C=a.length}else J.html(t);if(s.templateUrl)O("template",W,s,J),W=s,k=$(a.splice(B,a.length-B),k,J,c,e,s.replace,P),C=a.length;else if(s.compile)try{na=s.compile(J,c,P),H(na)?h(null,na):na&&h(na.pre,na.post)}catch(I){m(I, +va(J))}if(s.terminal)k.terminal=!0,l=Math.max(l,s.priority)}k.scope=r&&r.scope;k.transclude=G&&P;return k}function G(d,e,j,g){var l=!1;if(a.hasOwnProperty(e))for(var k,e=b.get(e+c),i=0,o=e.length;ik.priority)&&k.restrict.indexOf(j)!=-1)d.push(k),l=!0}catch(u){m(u)}return l}function D(a,b){var c=b.$attr,d=a.$attr,e=a.$$element;n(a,function(d,e){e.charAt(0)!="$"&&(b[e]&&(d+=(e==="style"?";":" ")+b[e]),a.$set(e,d,!0,c[e]))});n(b,function(b,j){j=="class"?(x(e,b),a["class"]= +(a["class"]?a["class"]+" ":"")+b):j=="style"?e.attr("style",e.attr("style")+";"+b):j.charAt(0)!="$"&&!a.hasOwnProperty(j)&&(a[j]=b,d[j]=c[j])})}function $(a,b,c,d,e,j,h){var i=[],o,m,u=c[0],z=a.shift(),r=t({},z,{controller:null,templateUrl:null,transclude:null,scope:null}),z=H(z.templateUrl)?z.templateUrl(c,d):z.templateUrl;c.html("");k.get(z,{cache:l}).success(function(l){var k,z,l=Lb(l);if(j){z=w("
"+U(l)+"
").contents();k=z[0];if(z.length!=1||k.nodeType!==1)throw Error(g+l);l={$attr:{}}; +ja(e,c,k);v(k,a,l);D(d,l)}else k=u,c.html(l);a.unshift(r);o=A(a,k,d,h);for(m=W(c[0].childNodes,h);i.length;){var ea=i.shift(),l=i.shift();z=i.shift();var x=i.shift(),y=k;l!==u&&(y=fb(k),ja(z,w(l),y));o(function(){b(m,ea,y,e,x)},ea,y,e,x)}i=null}).error(function(a,b,c,d){throw Error("Failed to load template: "+d.url);});return function(a,c,d,e,j){i?(i.push(c),i.push(d),i.push(e),i.push(j)):o(function(){b(m,c,d,e,j)},c,d,e,j)}}function K(a,b){return b.priority-a.priority}function O(a,b,c,d){if(b)throw Error("Multiple directives ["+ +b.name+", "+c.name+"] asking for "+a+" on: "+va(d));}function P(a,b){var c=j(b,!0);c&&a.push({priority:0,compile:S(function(a,b){var d=b.parent(),e=d.data("$binding")||[];e.push(c);x(d.data("$binding",e),"ng-binding");a.$watch(c,function(a){b[0].nodeValue=a})})})}function s(a,b,c,d){var e=j(c,!0);e&&b.push({priority:100,compile:S(function(a,b,c){b=c.$$observers||(c.$$observers={});if(e=j(c[d],!0))c[d]=e(a),(b[d]||(b[d]=[])).$$inter=!0,(c.$$observers&&c.$$observers[d].$$scope||a).$watch(e,function(a){c.$set(d, +a)})})})}function ja(a,b,c){var d=b[0],e=d.parentNode,j,g;if(a){j=0;for(g=a.length;j0){var e=O[0],f=e.text;if(f==a||f==b||f==c||f==d||!a&&!b&&!c&&!d)return e}return!1}function f(b, +c,d,f){return(b=i(b,c,d,f))?(a&&!b.json&&e("is not valid json",b),O.shift(),b):!1}function h(a){f(a)||e("is unexpected, expecting ["+a+"]",i())}function j(a,b){return t(function(c,d){return a(c,d,b)},{constant:b.constant})}function m(a,b,c){return t(function(d,e){return a(d,e)?b(d,e):c(d,e)},{constant:a.constant&&b.constant&&c.constant})}function k(a,b,c){return t(function(d,e){return b(d,e,a,c)},{constant:a.constant&&c.constant})}function l(){for(var a=[];;)if(O.length>0&&!i("}",")",";","]")&&a.push(w()), +!f(";"))return a.length==1?a[0]:function(b,c){for(var d,e=0;e","<=",">="))a=k(a,b.fn,x());return a}function n(){for(var a=v(),b;b=f("*","/","%");)a=k(a,b.fn,v());return a}function v(){var a;return f("+")?A():(a=f("-"))?k($,a.fn,v()):(a=f("!"))?j(a.fn,v()):A()}function A(){var a;if(f("("))a=w(),h(")");else if(f("["))a=G();else if(f("{"))a=D();else{var b=f();(a= +b.fn)||e("not a primary expression",b);if(b.json)a.constant=a.literal=!0}for(var c;b=f("(","[",".");)b.text==="("?(a=s(a,c),c=null):b.text==="["?(c=a,a=ma(a)):b.text==="."?(c=a,a=ja(a)):e("IMPOSSIBLE");return a}function G(){var a=[],b=!0;if(g().text!="]"){do{var c=P();a.push(c);c.constant||(b=!1)}while(f(","))}h("]");return t(function(b,c){for(var d=[],e=0;e1;d++){var e=a.shift(),g=b[e];g||(g={},b[e]=g);b=g}return b[a.shift()]=c}function ib(b,a,c){if(!a)return b;for(var a=a.split("."),d,e=b,g=a.length,i=0;ia)for(b in g++,e)e.hasOwnProperty(b)&&!f.hasOwnProperty(b)&&(x--,delete e[b])}else e!==f&&(e=f,g++);return g}, +function(){b(f,e,c)})},$digest:function(){var a,d,e,i,u=this.$$asyncQueue,o,z,r=b,n,x=[],p,v;g("$digest");do{z=!1;for(n=this;u.length;)try{n.$eval(u.shift())}catch(A){c(A)}do{if(i=n.$$watchers)for(o=i.length;o--;)try{if(a=i[o],(d=a.get(n))!==(e=a.last)&&!(a.eq?ia(d,e):typeof d=="number"&&typeof e=="number"&&isNaN(d)&&isNaN(e)))z=!0,a.last=a.eq?V(d):d,a.fn(d,e===f?d:e,n),r<5&&(p=4-r,x[p]||(x[p]=[]),v=H(a.exp)?"fn: "+(a.exp.name||a.exp.toString()):a.exp,v+="; newVal: "+ha(d)+"; oldVal: "+ha(e),x[p].push(v))}catch(G){c(G)}if(!(i= +n.$$childHead||n!==this&&n.$$nextSibling))for(;n!==this&&!(i=n.$$nextSibling);)n=n.$parent}while(n=i);if(z&&!r--)throw h.$$phase=null,Error(b+" $digest() iterations reached. Aborting!\nWatchers fired in the last 5 iterations: "+ha(x));}while(z||u.length);h.$$phase=null},$destroy:function(){if(!(h==this||this.$$destroyed)){var a=this.$parent;this.$broadcast("$destroy");this.$$destroyed=!0;if(a.$$childHead==this)a.$$childHead=this.$$nextSibling;if(a.$$childTail==this)a.$$childTail=this.$$prevSibling; +if(this.$$prevSibling)this.$$prevSibling.$$nextSibling=this.$$nextSibling;if(this.$$nextSibling)this.$$nextSibling.$$prevSibling=this.$$prevSibling;this.$parent=this.$$nextSibling=this.$$prevSibling=this.$$childHead=this.$$childTail=null}},$eval:function(a,b){return d(a)(this,b)},$evalAsync:function(a){this.$$asyncQueue.push(a)},$apply:function(a){try{return g("$apply"),this.$eval(a)}catch(b){c(b)}finally{h.$$phase=null;try{h.$digest()}catch(d){throw c(d),d;}}},$on:function(a,b){var c=this.$$listeners[a]; +c||(this.$$listeners[a]=c=[]);c.push(b);return function(){c[Ga(c,b)]=null}},$emit:function(a,b){var d=[],e,f=this,g=!1,i={name:a,targetScope:f,stopPropagation:function(){g=!0},preventDefault:function(){i.defaultPrevented=!0},defaultPrevented:!1},h=[i].concat(ka.call(arguments,1)),n,x;do{e=f.$$listeners[a]||d;i.currentScope=f;n=0;for(x=e.length;n7),hasEvent:function(a){if(a=="input"&&Z==9)return!1;if(C(c[a])){var b= +e.createElement("div");c[a]="on"+a in b}return c[a]},csp:e.securityPolicy?e.securityPolicy.isActive:!1,vendorPrefix:g,transitions:h,animations:j}}]}function Zc(){this.$get=S(M)}function Wb(b){var a={},c,d,e;if(!b)return a;n(b.split("\n"),function(b){e=b.indexOf(":");c=I(U(b.substr(0,e)));d=U(b.substr(e+1));c&&(a[c]?a[c]+=", "+d:a[c]=d)});return a}function $c(b,a){var c=ad.exec(b);if(c==null)return!0;var d={protocol:c[2],host:c[4],port:N(c[6])||Oa[c[2]]||null,relativeProtocol:c[2]===p||c[2]===""}, +c=jb.exec(a),c={protocol:c[1],host:c[3],port:N(c[5])||Oa[c[1]]||null};return(d.protocol==c.protocol||d.relativeProtocol)&&d.host==c.host&&(d.port==c.port||d.relativeProtocol&&c.port==Oa[c.protocol])}function Xb(b){var a=L(b)?b:p;return function(c){a||(a=Wb(b));return c?a[I(c)]||null:a}}function Yb(b,a,c){if(H(c))return c(b,a);n(c,function(c){b=c(b,a)});return b}function bd(){var b=/^\s*(\[|\{[^\{])/,a=/[\}\]]\s*$/,c=/^\)\]\}',?\n/,d={"Content-Type":"application/json;charset=utf-8"},e=this.defaults= +{transformResponse:[function(d){E(d)&&(d=d.replace(c,""),b.test(d)&&a.test(d)&&(d=ub(d,!0)));return d}],transformRequest:[function(a){return L(a)&&Ea.apply(a)!=="[object File]"?ha(a):a}],headers:{common:{Accept:"application/json, text/plain, */*"},post:d,put:d,patch:d},xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN"},g=this.interceptors=[],i=this.responseInterceptors=[];this.$get=["$httpBackend","$browser","$cacheFactory","$rootScope","$q","$injector",function(a,b,c,d,k,l){function u(a){function c(a){var b= +t({},a,{data:Yb(a.data,a.headers,d.transformResponse)});return 200<=a.status&&a.status<300?b:k.reject(b)}var d={transformRequest:e.transformRequest,transformResponse:e.transformResponse},f={};t(d,a);d.headers=f;d.method=oa(d.method);t(f,e.headers.common,e.headers[I(d.method)],a.headers);(a=$c(d.url,b.url())?b.cookies()[d.xsrfCookieName||e.xsrfCookieName]:p)&&(f[d.xsrfHeaderName||e.xsrfHeaderName]=a);var g=[function(a){var b=Yb(a.data,Xb(f),a.transformRequest);C(a.data)&&delete f["Content-Type"];if(C(a.withCredentials)&& +!C(e.withCredentials))a.withCredentials=e.withCredentials;return o(a,b,f).then(c,c)},p],j=k.when(d);for(n(y,function(a){(a.request||a.requestError)&&g.unshift(a.request,a.requestError);(a.response||a.responseError)&&g.push(a.response,a.responseError)});g.length;)var a=g.shift(),i=g.shift(),j=j.then(a,i);j.success=function(a){j.then(function(b){a(b.data,b.status,b.headers,d)});return j};j.error=function(a){j.then(null,function(b){a(b.data,b.status,b.headers,d)});return j};return j}function o(b,c,g){function j(a, +b,c){n&&(200<=a&&a<300?n.put(s,[a,b,Wb(c)]):n.remove(s));i(b,a,c);d.$$phase||d.$apply()}function i(a,c,d){c=Math.max(c,0);(200<=c&&c<300?l.resolve:l.reject)({data:a,status:c,headers:Xb(d),config:b})}function h(){var a=Ga(u.pendingRequests,b);a!==-1&&u.pendingRequests.splice(a,1)}var l=k.defer(),o=l.promise,n,p,s=z(b.url,b.params);u.pendingRequests.push(b);o.then(h,h);if((b.cache||e.cache)&&b.cache!==!1&&b.method=="GET")n=L(b.cache)?b.cache:L(e.cache)?e.cache:r;if(n)if(p=n.get(s))if(p.then)return p.then(h, +h),p;else F(p)?i(p[1],p[0],V(p[2])):i(p,200,{});else n.put(s,o);p||a(b.method,s,c,j,g,b.timeout,b.withCredentials,b.responseType);return o}function z(a,b){if(!b)return a;var c=[];nc(b,function(a,b){a==null||a==p||(F(a)||(a=[a]),n(a,function(a){L(a)&&(a=ha(a));c.push(wa(b)+"="+wa(a))}))});return a+(a.indexOf("?")==-1?"?":"&")+c.join("&")}var r=c("$http"),y=[];n(g,function(a){y.unshift(E(a)?l.get(a):l.invoke(a))});n(i,function(a,b){var c=E(a)?l.get(a):l.invoke(a);y.splice(b,0,{response:function(a){return c(k.when(a))}, +responseError:function(a){return c(k.reject(a))}})});u.pendingRequests=[];(function(a){n(arguments,function(a){u[a]=function(b,c){return u(t(c||{},{method:a,url:b}))}})})("get","delete","head","jsonp");(function(a){n(arguments,function(a){u[a]=function(b,c,d){return u(t(d||{},{method:a,url:b,data:c}))}})})("post","put");u.defaults=e;return u}]}function cd(){this.$get=["$browser","$window","$document",function(b,a,c){return dd(b,ed,b.defer,a.angular.callbacks,c[0],a.location.protocol.replace(":",""))}]} +function dd(b,a,c,d,e,g){function i(a,b){var c=e.createElement("script"),d=function(){e.body.removeChild(c);b&&b()};c.type="text/javascript";c.src=a;Z?c.onreadystatechange=function(){/loaded|complete/.test(c.readyState)&&d()}:c.onload=c.onerror=d;e.body.appendChild(c);return d}return function(e,h,j,m,k,l,u,o){function z(){p=-1;t&&t();v&&v.abort()}function r(a,d,e,f){var j=(h.match(jb)||["",g])[1];A&&c.cancel(A);t=v=null;d=j=="file"?e?200:404:d;a(d==1223?204:d,e,f);b.$$completeOutstandingRequest(q)} +var p;b.$$incOutstandingRequestCount();h=h||b.url();if(I(e)=="jsonp"){var x="_"+(d.counter++).toString(36);d[x]=function(a){d[x].data=a};var t=i(h.replace("JSON_CALLBACK","angular.callbacks."+x),function(){d[x].data?r(m,200,d[x].data):r(m,p||-2);delete d[x]})}else{var v=new a;v.open(e,h,!0);n(k,function(a,b){a&&v.setRequestHeader(b,a)});v.onreadystatechange=function(){if(v.readyState==4){var a=v.getAllResponseHeaders(),b=["Cache-Control","Content-Language","Content-Type","Expires","Last-Modified", +"Pragma"];a||(a="",n(b,function(b){var c=v.getResponseHeader(b);c&&(a+=b+": "+c+"\n")}));r(m,p||v.status,v.responseType?v.response:v.responseText,a)}};if(u)v.withCredentials=!0;if(o)v.responseType=o;v.send(j||"")}if(l>0)var A=c(z,l);else l&&l.then&&l.then(z)}}function fd(){this.$get=function(){return{id:"en-us",NUMBER_FORMATS:{DECIMAL_SEP:".",GROUP_SEP:",",PATTERNS:[{minInt:1,minFrac:0,maxFrac:3,posPre:"",posSuf:"",negPre:"-",negSuf:"",gSize:3,lgSize:3},{minInt:1,minFrac:2,maxFrac:2,posPre:"\u00a4", +posSuf:"",negPre:"(\u00a4",negSuf:")",gSize:3,lgSize:3}],CURRENCY_SYM:"$"},DATETIME_FORMATS:{MONTH:"January,February,March,April,May,June,July,August,September,October,November,December".split(","),SHORTMONTH:"Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec".split(","),DAY:"Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday".split(","),SHORTDAY:"Sun,Mon,Tue,Wed,Thu,Fri,Sat".split(","),AMPMS:["AM","PM"],medium:"MMM d, y h:mm:ss a","short":"M/d/yy h:mm a",fullDate:"EEEE, MMMM d, y",longDate:"MMMM d, y", +mediumDate:"MMM d, y",shortDate:"M/d/yy",mediumTime:"h:mm:ss a",shortTime:"h:mm a"},pluralCat:function(b){return b===1?"one":"other"}}}}function gd(){this.$get=["$rootScope","$browser","$q","$exceptionHandler",function(b,a,c,d){function e(e,f,h){var j=c.defer(),m=j.promise,k=B(h)&&!h,f=a.defer(function(){try{j.resolve(e())}catch(a){j.reject(a),d(a)}k||b.$apply()},f),h=function(){delete g[m.$$timeoutId]};m.$$timeoutId=f;g[f]=j;m.then(h,h);return m}var g={};e.cancel=function(b){return b&&b.$$timeoutId in +g?(g[b.$$timeoutId].reject("canceled"),a.defer.cancel(b.$$timeoutId)):!1};return e}]}function Zb(b){function a(a,e){return b.factory(a+c,e)}var c="Filter";this.register=a;this.$get=["$injector",function(a){return function(b){return a.get(b+c)}}];a("currency",$b);a("date",ac);a("filter",hd);a("json",id);a("limitTo",jd);a("lowercase",kd);a("number",bc);a("orderBy",cc);a("uppercase",ld)}function hd(){return function(b,a,c){if(!F(b))return b;var d=[];d.check=function(a){for(var b=0;b-1}}var e=function(a,b){if(typeof b=="string"&&b.charAt(0)==="!")return!e(a,b.substr(1));switch(typeof a){case "boolean":case "number":case "string":return c(a,b);case "object":switch(typeof b){case "object":return c(a,b);default:for(var d in a)if(d.charAt(0)!=="$"&&e(a[d],b))return!0}return!1;case "array":for(d= +0;de+1?i="0":(f=i,j=!0)}if(!j){i=(i.split(ec)[1]||"").length;C(e)&&(e=Math.min(Math.max(a.minFrac,i), +a.maxFrac));var i=Math.pow(10,e),b=Math.round(b*i)/i,b=(""+b).split(ec),i=b[0],b=b[1]||"",j=0,m=a.lgSize,k=a.gSize;if(i.length>=m+k)for(var j=i.length-m,l=0;l0||e>-c)e+=c;e===0&&c==-12&&(e=12);return nb(e,a,d)}}function Qa(b,a){return function(c,d){var e=c["get"+b](),g=oa(a?"SHORT"+b:b);return d[g][e]}}function ac(b){function a(a){var b;if(b=a.match(c)){var a=new Date(0),g=0,i=0,f=b[8]?a.setUTCFullYear:a.setFullYear,h=b[8]?a.setUTCHours:a.setHours;b[9]&&(g=N(b[9]+b[10]),i=N(b[9]+b[11]));f.call(a,N(b[1]),N(b[2])-1,N(b[3]));g=N(b[4]||0)-g;i=N(b[5]||0)-i;f= +N(b[6]||0);b=Math.round(parseFloat("0."+(b[7]||0))*1E3);h.call(a,g,i,f,b)}return a}var c=/^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/;return function(c,e){var g="",i=[],f,h,e=e||"mediumDate",e=b.DATETIME_FORMATS[e]||e;E(c)&&(c=md.test(c)?N(c):a(c));Ya(c)&&(c=new Date(c));if(!ra(c))return c;for(;e;)(h=nd.exec(e))?(i=i.concat(ka.call(h,1)),e=i.pop()):(i.push(e),e=null);n(i,function(a){f=od[a];g+=f?f(c,b.DATETIME_FORMATS):a.replace(/(^'|'$)/g, +"").replace(/''/g,"'")});return g}}function id(){return function(b){return ha(b,!0)}}function jd(){return function(b,a){if(!F(b)&&!E(b))return b;a=N(a);if(E(b))return a?a>=0?b.slice(0,a):b.slice(a,b.length):"";var c=[],d,e;a>b.length?a=b.length:a<-b.length&&(a=-b.length);a>0?(d=0,e=a):(d=b.length+a,e=b.length);for(;dl?(d.$setValidity("maxlength",!1),p):(d.$setValidity("maxlength",!0),a)};d.$parsers.push(e);d.$formatters.push(e)}}function ob(b,a){b="ngClass"+b;return aa(function(c,d,e){function g(b){if(a===!0||c.$index%2===a)h&&!ia(b,h)&&i(h),f(b);h=V(b)}function i(a){L(a)&&!F(a)&&(a=Za(a,function(a,b){if(a)return b}));d.removeClass(F(a)?a.join(" "):a)}function f(a){L(a)&&!F(a)&&(a=Za(a, +function(a,b){if(a)return b}));a&&d.addClass(F(a)?a.join(" "):a)}var h=p;c.$watch(e[b],g,!0);e.$observe("class",function(){var a=c.$eval(e[b]);g(a,a)});b!=="ngClass"&&c.$watch("$index",function(d,g){var h=d&1;h!==g&1&&(h===a?f(c.$eval(e[b])):i(c.$eval(e[b])))})})}var I=function(b){return E(b)?b.toLowerCase():b},oa=function(b){return E(b)?b.toUpperCase():b},Z=N((/msie (\d+)/.exec(I(navigator.userAgent))||[])[1]),w,ga,ka=[].slice,Wa=[].push,Ea=Object.prototype.toString,mc=M.angular,Ha=M.angular||(M.angular= +{}),Aa,hb,ba=["0","0","0"];q.$inject=[];qa.$inject=[];hb=Z<9?function(b){b=b.nodeName?b:b[0];return b.scopeName&&b.scopeName!="HTML"?oa(b.scopeName+":"+b.nodeName):b.nodeName}:function(b){return b.nodeName?b.nodeName:b[0].nodeName};var sc=/[A-Z]/g,pd={full:"1.1.5",major:1,minor:1,dot:5,codeName:"triangle-squarification"},Ka=R.cache={},Ja=R.expando="ng-"+(new Date).getTime(),wc=1,gc=M.document.addEventListener?function(b,a,c){b.addEventListener(a,c,!1)}:function(b,a,c){b.attachEvent("on"+a,c)},gb= +M.document.removeEventListener?function(b,a,c){b.removeEventListener(a,c,!1)}:function(b,a,c){b.detachEvent("on"+a,c)},uc=/([\:\-\_]+(.))/g,vc=/^moz([A-Z])/,Ba=R.prototype={ready:function(b){function a(){c||(c=!0,b())}var c=!1;T.readyState==="complete"?setTimeout(a):(this.bind("DOMContentLoaded",a),R(M).bind("load",a))},toString:function(){var b=[];n(this,function(a){b.push(""+a)});return"["+b.join(", ")+"]"},eq:function(b){return b>=0?w(this[b]):w(this[this.length+b])},length:0,push:Wa,sort:[].sort, +splice:[].splice},Na={};n("multiple,selected,checked,disabled,readOnly,required,open".split(","),function(b){Na[I(b)]=b});var Gb={};n("input,select,option,textarea,button,form,details".split(","),function(b){Gb[oa(b)]=!0});n({data:Bb,inheritedData:Ma,scope:function(b){return Ma(b,"$scope")},controller:Eb,injector:function(b){return Ma(b,"$injector")},removeAttr:function(b,a){b.removeAttribute(a)},hasClass:La,css:function(b,a,c){a=Ia(a);if(B(c))b.style[a]=c;else{var d;Z<=8&&(d=b.currentStyle&&b.currentStyle[a], +d===""&&(d="auto"));d=d||b.style[a];Z<=8&&(d=d===""?p:d);return d}},attr:function(b,a,c){var d=I(a);if(Na[d])if(B(c))c?(b[a]=!0,b.setAttribute(a,d)):(b[a]=!1,b.removeAttribute(d));else return b[a]||(b.attributes.getNamedItem(a)||q).specified?d:p;else if(B(c))b.setAttribute(a,c);else if(b.getAttribute)return b=b.getAttribute(a,2),b===null?p:b},prop:function(b,a,c){if(B(c))b[a]=c;else return b[a]},text:t(Z<9?function(b,a){if(b.nodeType==1){if(C(a))return b.innerText;b.innerText=a}else{if(C(a))return b.nodeValue; +b.nodeValue=a}}:function(b,a){if(C(a))return b.textContent;b.textContent=a},{$dv:""}),val:function(b,a){if(C(a))return b.value;b.value=a},html:function(b,a){if(C(a))return b.innerHTML;for(var c=0,d=b.childNodes;c0||parseFloat(h[a+"Duration"])> +0)g="animation",i=a,j=Math.max(parseInt(h[g+"IterationCount"])||0,parseInt(h[i+"IterationCount"])||0,j);f=Math.max(x(h[g+"Delay"]),x(h[i+"Delay"]));g=Math.max(x(h[g+"Duration"]),x(h[i+"Duration"]));d=Math.max(f+j*g,d)}});e.setTimeout(v,d*1E3)}else v()}function v(){if(!v.run)v.run=!0,o(m,r,p),m.removeClass(w),m.removeClass(K),m.removeData(a)}var A=c.$eval(i.ngAnimate),w=A?L(A)?A[j]:A+"-"+j:"",D=d(w),A=D&&D.setup,$=D&&D.start,D=D&&D.cancel;if(w){var K=w+"-active";r||(r=p?p.parent():m.parent());if(!g.transitions&& +!A&&!$||(r.inheritedData(a)||q).running)k(m,r,p),o(m,r,p);else{var O=m.data(a)||{};O.running&&((D||q)(m),O.done());m.data(a,{running:!0,done:v});m.addClass(w);k(m,r,p);if(m.length==0)return v();var P=(A||q)(m);e.setTimeout(t,1)}}else k(m,r,p),o(m,r,p)}}function m(a,c,d){d?d.after(a):c.append(a)}var k={};k.enter=j("enter",m,q);k.leave=j("leave",q,function(a){a.remove()});k.move=j("move",function(a,c,d){m(a,c,d)},q);k.show=j("show",function(a){a.css("display","")},q);k.hide=j("hide",q,function(a){a.css("display", +"none")});k.animate=function(a,c){j(a,q,q)(c)};return k};i.enabled=function(a){if(arguments.length)c.running=!a;return!c.running};return i}]},Kb="Non-assignable model expression: ";Jb.$inject=["$provide"];var Ic=/^(x[\:\-_]|data[\:\-_])/i,jb=/^([^:]+):\/\/(\w+:{0,1}\w*@)?(\{?[\w\.-]*\}?)(:([0-9]+))?(\/[^\?#]*)?(\?([^#]*))?(#(.*))?$/,Pb=/^([^\?#]*)(\?([^#]*))?(#(.*))?$/,Oa={http:80,https:443,ftp:21};Rb.prototype=lb.prototype=Qb.prototype={$$replace:!1,absUrl:Pa("$$absUrl"),url:function(a,c){if(C(a))return this.$$url; +var d=Pb.exec(a);d[1]&&this.path(decodeURIComponent(d[1]));if(d[2]||d[1])this.search(d[3]||"");this.hash(d[5]||"",c);return this},protocol:Pa("$$protocol"),host:Pa("$$host"),port:Pa("$$port"),path:Sb("$$path",function(a){return a.charAt(0)=="/"?a:"/"+a}),search:function(a,c){if(C(a))return this.$$search;B(c)?c===null?delete this.$$search[a]:this.$$search[a]=c:this.$$search=E(a)?vb(a):a;this.$$compose();return this},hash:Sb("$$hash",qa),replace:function(){this.$$replace=!0;return this}};var Da={"null":function(){return null}, +"true":function(){return!0},"false":function(){return!1},undefined:q,"+":function(a,c,d,e){d=d(a,c);e=e(a,c);return B(d)?B(e)?d+e:d:B(e)?e:p},"-":function(a,c,d,e){d=d(a,c);e=e(a,c);return(B(d)?d:0)-(B(e)?e:0)},"*":function(a,c,d,e){return d(a,c)*e(a,c)},"/":function(a,c,d,e){return d(a,c)/e(a,c)},"%":function(a,c,d,e){return d(a,c)%e(a,c)},"^":function(a,c,d,e){return d(a,c)^e(a,c)},"=":q,"===":function(a,c,d,e){return d(a,c)===e(a,c)},"!==":function(a,c,d,e){return d(a,c)!==e(a,c)},"==":function(a, +c,d,e){return d(a,c)==e(a,c)},"!=":function(a,c,d,e){return d(a,c)!=e(a,c)},"<":function(a,c,d,e){return d(a,c)":function(a,c,d,e){return d(a,c)>e(a,c)},"<=":function(a,c,d,e){return d(a,c)<=e(a,c)},">=":function(a,c,d,e){return d(a,c)>=e(a,c)},"&&":function(a,c,d,e){return d(a,c)&&e(a,c)},"||":function(a,c,d,e){return d(a,c)||e(a,c)},"&":function(a,c,d,e){return d(a,c)&e(a,c)},"|":function(a,c,d,e){return e(a,c)(a,c,d(a,c))},"!":function(a,c,d){return!d(a,c)}},Qc={n:"\n",f:"\u000c",r:"\r", +t:"\t",v:"\u000b","'":"'",'"':'"'},mb={},ad=/^(([^:]+):)?\/\/(\w+:{0,1}\w*@)?([\w\.-]*)?(:([0-9]+))?(.*)$/,ed=M.XMLHttpRequest||function(){try{return new ActiveXObject("Msxml2.XMLHTTP.6.0")}catch(a){}try{return new ActiveXObject("Msxml2.XMLHTTP.3.0")}catch(c){}try{return new ActiveXObject("Msxml2.XMLHTTP")}catch(d){}throw Error("This browser does not support XMLHttpRequest.");};Zb.$inject=["$provide"];$b.$inject=["$locale"];bc.$inject=["$locale"];var ec=".",od={yyyy:Q("FullYear",4),yy:Q("FullYear", +2,0,!0),y:Q("FullYear",1),MMMM:Qa("Month"),MMM:Qa("Month",!0),MM:Q("Month",2,1),M:Q("Month",1,1),dd:Q("Date",2),d:Q("Date",1),HH:Q("Hours",2),H:Q("Hours",1),hh:Q("Hours",2,-12),h:Q("Hours",1,-12),mm:Q("Minutes",2),m:Q("Minutes",1),ss:Q("Seconds",2),s:Q("Seconds",1),sss:Q("Milliseconds",3),EEEE:Qa("Day"),EEE:Qa("Day",!0),a:function(a,c){return a.getHours()<12?c.AMPMS[0]:c.AMPMS[1]},Z:function(a){var a=-1*a.getTimezoneOffset(),c=a>=0?"+":"";c+=nb(Math[a>0?"floor":"ceil"](a/60),2)+nb(Math.abs(a%60), +2);return c}},nd=/((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z))(.*)/,md=/^\d+$/;ac.$inject=["$locale"];var kd=S(I),ld=S(oa);cc.$inject=["$parse"];var rd=S({restrict:"E",compile:function(a,c){Z<=8&&(!c.href&&!c.name&&c.$set("href",""),a.append(T.createComment("IE fix")));return function(a,c){c.bind("click",function(a){c.attr("href")||a.preventDefault()})}}}),pb={};n(Na,function(a,c){var d=da("ng-"+c);pb[d]=function(){return{priority:100,compile:function(){return function(a, +g,i){a.$watch(i[d],function(a){i.$set(c,!!a)})}}}}});n(["src","srcset","href"],function(a){var c=da("ng-"+a);pb[c]=function(){return{priority:99,link:function(d,e,g){g.$observe(c,function(c){c&&(g.$set(a,c),Z&&e.prop(a,g[a]))})}}}});var Ta={$addControl:q,$removeControl:q,$setValidity:q,$setDirty:q,$setPristine:q};fc.$inject=["$element","$attrs","$scope"];var Wa=function(a){return["$timeout",function(c){var d={name:"form",restrict:"E",controller:fc,compile:function(){return{pre:function(a,d,i,f){if(!i.action){var h= +function(a){a.preventDefault?a.preventDefault():a.returnValue=!1};gc(d[0],"submit",h);d.bind("$destroy",function(){c(function(){gb(d[0],"submit",h)},0,!1)})}var j=d.parent().controller("form"),m=i.name||i.ngForm;m&&(a[m]=f);j&&d.bind("$destroy",function(){j.$removeControl(f);m&&(a[m]=p);t(f,Ta)})}}}};return a?t(V(d),{restrict:"EAC"}):d}]},sd=Wa(),td=Wa(!0),ud=/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/,vd=/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/, +wd=/^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/,hc={text:Va,number:function(a,c,d,e,g,i){Va(a,c,d,e,g,i);e.$parsers.push(function(a){var c=X(a);return c||wd.test(a)?(e.$setValidity("number",!0),a===""?null:c?a:parseFloat(a)):(e.$setValidity("number",!1),p)});e.$formatters.push(function(a){return X(a)?"":""+a});if(d.min){var f=parseFloat(d.min),a=function(a){return!X(a)&&ah?(e.$setValidity("max",!1),p):(e.$setValidity("max",!0),a)};e.$parsers.push(d);e.$formatters.push(d)}e.$formatters.push(function(a){return X(a)||Ya(a)?(e.$setValidity("number",!0),a):(e.$setValidity("number",!1),p)})},url:function(a,c,d,e,g,i){Va(a,c,d,e,g,i);a=function(a){return X(a)||ud.test(a)?(e.$setValidity("url",!0),a):(e.$setValidity("url",!1),p)};e.$formatters.push(a);e.$parsers.push(a)},email:function(a,c,d,e,g,i){Va(a,c,d,e,g,i);a=function(a){return X(a)||vd.test(a)? +(e.$setValidity("email",!0),a):(e.$setValidity("email",!1),p)};e.$formatters.push(a);e.$parsers.push(a)},radio:function(a,c,d,e){C(d.name)&&c.attr("name",Fa());c.bind("click",function(){c[0].checked&&a.$apply(function(){e.$setViewValue(d.value)})});e.$render=function(){c[0].checked=d.value==e.$viewValue};d.$observe("value",e.$render)},checkbox:function(a,c,d,e){var g=d.ngTrueValue,i=d.ngFalseValue;E(g)||(g=!0);E(i)||(i=!1);c.bind("click",function(){a.$apply(function(){e.$setViewValue(c[0].checked)})}); +e.$render=function(){c[0].checked=e.$viewValue};e.$formatters.push(function(a){return a===g});e.$parsers.push(function(a){return a?g:i})},hidden:q,button:q,submit:q,reset:q},ic=["$browser","$sniffer",function(a,c){return{restrict:"E",require:"?ngModel",link:function(d,e,g,i){i&&(hc[I(g.type)]||hc.text)(d,e,g,i,c,a)}}}],Sa="ng-valid",Ra="ng-invalid",pa="ng-pristine",Ua="ng-dirty",xd=["$scope","$exceptionHandler","$attrs","$element","$parse",function(a,c,d,e,g){function i(a,c){c=c?"-"+bb(c,"-"):""; +e.removeClass((a?Ra:Sa)+c).addClass((a?Sa:Ra)+c)}this.$modelValue=this.$viewValue=Number.NaN;this.$parsers=[];this.$formatters=[];this.$viewChangeListeners=[];this.$pristine=!0;this.$dirty=!1;this.$valid=!0;this.$invalid=!1;this.$name=d.name;var f=g(d.ngModel),h=f.assign;if(!h)throw Error(Kb+d.ngModel+" ("+va(e)+")");this.$render=q;var j=e.inheritedData("$formController")||Ta,m=0,k=this.$error={};e.addClass(pa);i(!0);this.$setValidity=function(a,c){if(k[a]!==!c){if(c){if(k[a]&&m--,!m)i(!0),this.$valid= +!0,this.$invalid=!1}else i(!1),this.$invalid=!0,this.$valid=!1,m++;k[a]=!c;i(c,a);j.$setValidity(a,c,this)}};this.$setPristine=function(){this.$dirty=!1;this.$pristine=!0;e.removeClass(Ua).addClass(pa)};this.$setViewValue=function(d){this.$viewValue=d;if(this.$pristine)this.$dirty=!0,this.$pristine=!1,e.removeClass(pa).addClass(Ua),j.$setDirty();n(this.$parsers,function(a){d=a(d)});if(this.$modelValue!==d)this.$modelValue=d,h(a,d),n(this.$viewChangeListeners,function(a){try{a()}catch(d){c(d)}})}; +var l=this;a.$watch(function(){var c=f(a);if(l.$modelValue!==c){var d=l.$formatters,e=d.length;for(l.$modelValue=c;e--;)c=d[e](c);if(l.$viewValue!==c)l.$viewValue=c,l.$render()}})}],yd=function(){return{require:["ngModel","^?form"],controller:xd,link:function(a,c,d,e){var g=e[0],i=e[1]||Ta;i.$addControl(g);c.bind("$destroy",function(){i.$removeControl(g)})}}},zd=S({require:"ngModel",link:function(a,c,d,e){e.$viewChangeListeners.push(function(){a.$eval(d.ngChange)})}}),jc=function(){return{require:"?ngModel", +link:function(a,c,d,e){if(e){d.required=!0;var g=function(a){if(d.required&&(X(a)||a===!1))e.$setValidity("required",!1);else return e.$setValidity("required",!0),a};e.$formatters.push(g);e.$parsers.unshift(g);d.$observe("required",function(){g(e.$viewValue)})}}}},Ad=function(){return{require:"ngModel",link:function(a,c,d,e){var g=(a=/\/(.*)\//.exec(d.ngList))&&RegExp(a[1])||d.ngList||",";e.$parsers.push(function(a){var c=[];a&&n(a.split(g),function(a){a&&c.push(U(a))});return c});e.$formatters.push(function(a){return F(a)? +a.join(", "):p})}}},Bd=/^(true|false|\d+)$/,Cd=function(){return{priority:100,compile:function(a,c){return Bd.test(c.ngValue)?function(a,c,g){g.$set("value",a.$eval(g.ngValue))}:function(a,c,g){a.$watch(g.ngValue,function(a){g.$set("value",a,!1)})}}}},Dd=aa(function(a,c,d){c.addClass("ng-binding").data("$binding",d.ngBind);a.$watch(d.ngBind,function(a){c.text(a==p?"":a)})}),Ed=["$interpolate",function(a){return function(c,d,e){c=a(d.attr(e.$attr.ngBindTemplate));d.addClass("ng-binding").data("$binding", +c);e.$observe("ngBindTemplate",function(a){d.text(a)})}}],Fd=[function(){return function(a,c,d){c.addClass("ng-binding").data("$binding",d.ngBindHtmlUnsafe);a.$watch(d.ngBindHtmlUnsafe,function(a){c.html(a||"")})}}],Gd=ob("",!0),Hd=ob("Odd",0),Id=ob("Even",1),Jd=aa({compile:function(a,c){c.$set("ngCloak",p);a.removeClass("ng-cloak")}}),Kd=[function(){return{scope:!0,controller:"@"}}],Ld=["$sniffer",function(a){return{priority:1E3,compile:function(){a.csp=!0}}}],kc={};n("click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress".split(" "), +function(a){var c=da("ng-"+a);kc[c]=["$parse",function(d){return function(e,g,i){var f=d(i[c]);g.bind(I(a),function(a){e.$apply(function(){f(e,{$event:a})})})}}]});var Md=aa(function(a,c,d){c.bind("submit",function(){a.$apply(d.ngSubmit)})}),Nd=["$animator",function(a){return{transclude:"element",priority:1E3,terminal:!0,restrict:"A",compile:function(c,d,e){return function(c,d,f){var h=a(c,f),j,m;c.$watch(f.ngIf,function(a){j&&(h.leave(j),j=p);m&&(m.$destroy(),m=p);ua(a)&&(m=c.$new(),e(m,function(a){j= +a;h.enter(a,d.parent(),d)}))})}}}}],Od=["$http","$templateCache","$anchorScroll","$compile","$animator",function(a,c,d,e,g){return{restrict:"ECA",terminal:!0,compile:function(i,f){var h=f.ngInclude||f.src,j=f.onload||"",m=f.autoscroll;return function(f,i,n){var o=g(f,n),p=0,r,t=function(){r&&(r.$destroy(),r=null);o.leave(i.contents(),i)};f.$watch(h,function(g){var h=++p;g?(a.get(g,{cache:c}).success(function(a){h===p&&(r&&r.$destroy(),r=f.$new(),o.leave(i.contents(),i),a=w("
").html(a).contents(), +o.enter(a,i),e(a)(r),B(m)&&(!m||f.$eval(m))&&d(),r.$emit("$includeContentLoaded"),f.$eval(j))}).error(function(){h===p&&t()}),f.$emit("$includeContentRequested")):t()})}}}}],Pd=aa({compile:function(){return{pre:function(a,c,d){a.$eval(d.ngInit)}}}}),Qd=aa({terminal:!0,priority:1E3}),Rd=["$locale","$interpolate",function(a,c){var d=/{}/g;return{restrict:"EA",link:function(e,g,i){var f=i.count,h=g.attr(i.$attr.when),j=i.offset||0,m=e.$eval(h),k={},l=c.startSymbol(),p=c.endSymbol();n(m,function(a,e){k[e]= +c(a.replace(d,l+f+"-"+j+p))});e.$watch(function(){var c=parseFloat(e.$eval(f));return isNaN(c)?"":(c in m||(c=a.pluralCat(c-j)),k[c](e,g,!0))},function(a){g.text(a)})}}}],Sd=["$parse","$animator",function(a,c){return{transclude:"element",priority:1E3,terminal:!0,compile:function(d,e,g){return function(d,e,h){var j=c(d,h),m=h.ngRepeat,k=m.match(/^\s*(.+)\s+in\s+(.*?)\s*(\s+track\s+by\s+(.+)\s*)?$/),l,p,o,z,r,t={$id:la};if(!k)throw Error("Expected ngRepeat in form of '_item_ in _collection_[ track by _id_]' but got '"+ +m+"'.");h=k[1];o=k[2];(k=k[4])?(l=a(k),p=function(a,c,e){r&&(t[r]=a);t[z]=c;t.$index=e;return l(d,t)}):p=function(a,c){return la(c)};k=h.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/);if(!k)throw Error("'item' in 'item in collection' should be identifier or (key, value) but got '"+h+"'.");z=k[3]||k[1];r=k[2];var x={};d.$watchCollection(o,function(a){var c,h,k=e,l,o={},t,q,w,s,B,y,C=[];if(Xa(a))B=a;else{B=[];for(w in a)a.hasOwnProperty(w)&&w.charAt(0)!="$"&&B.push(w);B.sort()}t=B.length;h= +C.length=B.length;for(c=0;c
").html(k).contents();o.enter(k,c);var k=g(k),m=d.current;l=m.scope=a.$new();if(m.controller)f.$scope= +l,f=i(m.controller,f),m.controllerAs&&(l[m.controllerAs]=f),c.children().data("$ngControllerController",f);k(l);l.$emit("$viewContentLoaded");l.$eval(n);e()}else o.leave(c.contents(),c),l&&(l.$destroy(),l=null)}var l,n=m.onload||"",o=f(a,m);a.$on("$routeChangeSuccess",k);k()}}}],ae=["$templateCache",function(a){return{restrict:"E",terminal:!0,compile:function(c,d){d.type=="text/ng-template"&&a.put(d.id,c[0].text)}}}],be=S({terminal:!0}),ce=["$compile","$parse",function(a,c){var d=/^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*?)(?:\s+track\s+by\s+(.*?))?$/, +e={$setViewValue:q};return{restrict:"E",require:["select","?ngModel"],controller:["$element","$scope","$attrs",function(a,c,d){var h=this,j={},m=e,k;h.databound=d.ngModel;h.init=function(a,c,d){m=a;k=d};h.addOption=function(c){j[c]=!0;m.$viewValue==c&&(a.val(c),k.parent()&&k.remove())};h.removeOption=function(a){this.hasOption(a)&&(delete j[a],m.$viewValue==a&&this.renderUnknownOption(a))};h.renderUnknownOption=function(c){c="? "+la(c)+" ?";k.val(c);a.prepend(k);a.val(c);k.prop("selected",!0)};h.hasOption= +function(a){return j.hasOwnProperty(a)};c.$on("$destroy",function(){h.renderUnknownOption=q})}],link:function(e,i,f,h){function j(a,c,d,e){d.$render=function(){var a=d.$viewValue;e.hasOption(a)?(v.parent()&&v.remove(),c.val(a),a===""&&t.prop("selected",!0)):C(a)&&t?c.val(""):e.renderUnknownOption(a)};c.bind("change",function(){a.$apply(function(){v.parent()&&v.remove();d.$setViewValue(c.val())})})}function m(a,c,d){var e;d.$render=function(){var a=new za(d.$viewValue);n(c.find("option"),function(c){c.selected= +B(a.get(c.value))})};a.$watch(function(){ia(e,d.$viewValue)||(e=V(d.$viewValue),d.$render())});c.bind("change",function(){a.$apply(function(){var a=[];n(c.find("option"),function(c){c.selected&&a.push(c.value)});d.$setViewValue(a)})})}function k(e,f,g){function i(){var a={"":[]},c=[""],d,h,q,v,s;q=g.$modelValue;v=u(e)||[];var z=l?qb(v):v,B,y,A;y={};s=!1;var C,D;if(o)if(t&&F(q)){s=new za([]);for(h=0;hA;)v.pop().element.remove()}for(;w.length>y;)w.pop()[0].element.remove()} +var h;if(!(h=q.match(d)))throw Error("Expected ngOptions in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_ (track by _expr_)?' but got '"+q+"'.");var j=c(h[2]||h[1]),k=h[4]||h[6],l=h[5],m=c(h[3]||""),n=c(h[2]?h[1]:k),u=c(h[7]),t=h[8]?c(h[8]):null,w=[[{element:f,label:""}]];r&&(a(r)(e),r.removeClass("ng-scope"),r.remove());f.html("");f.bind("change",function(){e.$apply(function(){var a,c=u(e)||[],d={},h,i,j,m,q,r;if(o){i=[];m=0;for(r=w.length;m@charset "UTF-8";[ng\\:cloak],[ng-cloak],[data-ng-cloak],[x-ng-cloak],.ng-cloak,.x-ng-cloak{display:none;}ng\\:form{display:block;}'); diff --git a/src/main/webapp/resources/bootstrap/bootstrap.min.css b/src/main/resources/static/bootstrap/bootstrap.min.css similarity index 100% rename from src/main/webapp/resources/bootstrap/bootstrap.min.css rename to src/main/resources/static/bootstrap/bootstrap.min.css diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..26d798de88313b1e60f65a967249b36022078d07 Binary files /dev/null and b/src/main/resources/static/favicon.ico differ diff --git a/src/main/webapp/resources/fonts/glyphicons-halflings-regular.eot b/src/main/resources/static/fonts/glyphicons-halflings-regular.eot similarity index 100% rename from src/main/webapp/resources/fonts/glyphicons-halflings-regular.eot rename to src/main/resources/static/fonts/glyphicons-halflings-regular.eot diff --git a/src/main/webapp/resources/fonts/glyphicons-halflings-regular.svg b/src/main/resources/static/fonts/glyphicons-halflings-regular.svg similarity index 100% rename from src/main/webapp/resources/fonts/glyphicons-halflings-regular.svg rename to src/main/resources/static/fonts/glyphicons-halflings-regular.svg diff --git a/src/main/webapp/resources/fonts/glyphicons-halflings-regular.ttf b/src/main/resources/static/fonts/glyphicons-halflings-regular.ttf similarity index 100% rename from src/main/webapp/resources/fonts/glyphicons-halflings-regular.ttf rename to src/main/resources/static/fonts/glyphicons-halflings-regular.ttf diff --git a/src/main/webapp/resources/fonts/glyphicons-halflings-regular.woff b/src/main/resources/static/fonts/glyphicons-halflings-regular.woff similarity index 100% rename from src/main/webapp/resources/fonts/glyphicons-halflings-regular.woff rename to src/main/resources/static/fonts/glyphicons-halflings-regular.woff diff --git a/src/main/webapp/resources/fonts/glyphicons-halflings-regular.woff2 b/src/main/resources/static/fonts/glyphicons-halflings-regular.woff2 similarity index 100% rename from src/main/webapp/resources/fonts/glyphicons-halflings-regular.woff2 rename to src/main/resources/static/fonts/glyphicons-halflings-regular.woff2 diff --git a/src/main/resources/static/oauth2_device_code.png b/src/main/resources/static/oauth2_device_code.png new file mode 100644 index 0000000000000000000000000000000000000000..ad24e6de6b92d22fa0c82a1abf8735cf134fba1b Binary files /dev/null and b/src/main/resources/static/oauth2_device_code.png differ diff --git a/src/main/resources/templates/access_token_result.html b/src/main/resources/templates/access_token_result.html new file mode 100644 index 0000000000000000000000000000000000000000..6ec5ab8934e533783195dad9c0a08f82b32dd153 --- /dev/null +++ b/src/main/resources/templates/access_token_result.html @@ -0,0 +1,75 @@ + + + + + + + + + authorization_code . spring-oauth-client + + + + +
+ Home + +

authorization_code + 用 'access_token' 去访问 spring-oauth-server 的API +

+
+ + +
+
步骤3: 用 'access_token' 去访问 spring-oauth-server 的API
+
+
+
access_token
+
+
id_token
+
+
token_type
+
[[${accessTokenDto.tokenType}]]
+
refresh_token
+
+
scope
+
[[${accessTokenDto.scope}]]
+
expires_in
+
[[${accessTokenDto.expiresIn}]]
+
+
+

+ 获取access_token成功, 现在可以访问spring-oauth-server资源了, 以下提供两种方式去访问spring-oauth-server资源(或API). +

+
    +
  • + 方式1 调用本地的接口,由后台去向服务器获取资源并进行处理(如将JSON数据转化成对象), 通过页面展示信息 +
    +
    + + +
    +
  • +
  • +

    方式2 直接通过access_token去访问服务器的资源(该方式将直接获取JSON数据)

    +

    + spring-oauth-server中提供了 /userinfo API, 完整URL: +

    + 可在代码中使用HttpClient去调用此API获取JSON数据, 以下是cURL的示例: +
    curl --location '[[${userinfoUrl}]]' \
    +  --header 'Content-Type: application/json' \
    +  --header 'Authorization: Bearer [[${accessTokenDto.accessToken}]]' \
    +
    +
  • +
  • ...
  • +
+

+ 至于使用哪一种方式, 在实际中请根据具体的需求或服务器资源提供的访问方式去选择 +

+
+
+ +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/authorization_code.html b/src/main/resources/templates/authorization_code.html new file mode 100644 index 0000000000000000000000000000000000000000..9324360c53270689a2f3b34f88a608e90f726df6 --- /dev/null +++ b/src/main/resources/templates/authorization_code.html @@ -0,0 +1,218 @@ + + + + + + + + + authorization_code . spring-oauth-client + + + + +
+ Home + +

authorization_code + 从 spring-oauth-server获取 'code' +

+
PKCE
+
+ +
+

+ grant_type = 'authorization_code' 模式是OAuth中最常用的, 一般是通过浏览器来完成. + 整个流程分3步完成,依次为: +

+
    +
  1. +

    + 从 spring-oauth-server获取 'code' +
    + -- 该步骤将根据从 spring-oauth-server 中获取的client信息(如client_id,client_secret)将用户引导到server的登录页面. +
    + + 在实际应用中, 表现为在登录页面中展示的 '通过第三方登录' 或 '通过其他账号登录' + +

    +
  2. +
  3. +

    + 用 'code' 换取 'access_token' +
    + -- 在server端当用户登录成功并授权后将返回到spring-oauth-client的回调地址(即redirect_uri), +
    + 当检查通过后(指检查返回是否有code与state值), 将根据server端获取access_token的URL去换取access_token值. +
    + + 在实际应用中, 该步骤一般由client后端代码完成,前端不需要表现. + +

    +
  4. +
  5. +

    + 用 'access_token' 去访问 spring-oauth-server 的API +
    + -- 在成功获取access_token后,将根据 spring-oauth-server 中提供的API去获取 资源服务器 中的数据. +
    + 在 spring-oauth-server 中当前只有一个API可供调用(即/userinfo), 用于获取当前登录用户的信息. +
    + + 在实际应用中, 资源服务器都会提供许多API供调用 + +

    +
  6. +
+
+
+ +
+
步骤1: 从 spring-oauth-server获取 'code'
+
+
+ +
+ + + +
+ + +
+

[[${userAuthorizationUri}]] +  测试连接 +

+ +
+
+ + 显示请求参数 + +
+
+ + +
+ + +

固定值 'code'

+
+
+ +
+ + +
+ +

OIDC标准中定义的scope有: openid, profile, email, address, phone; + 具体支持哪些由注册的client决定

+
+ +
+ +
+ + +
+ +
+
+ +
+ + +
+ + +

+ redirect_uri 是在 'AuthorizationCodeController.java' 中定义的一个 URI, 用于检查server端返回的 + 'code'与'state',并发起对 access_token 的调用

+
+
+ +
+ + +
+ + +

一个随机值, spring-oauth-server 将原样返回,用于检测是否为跨站请求(CSRF)等

+
+
+ +
+ + +
+ + +

固定值 'S256'

+
+
+
+ + +
+ + +

(后台代码生成,不可修改)

+
+
+ + +
+ 最终发给 spring-oauth-server的 URL: +
+ +
+ {{userAuthorizationUri}}?response_type={{responseType}}&scope={{scope}}&client_id={{clientId}}&redirect_uri={{redirectUri}}&state={{state}}&code_challenge_method={{codeChallengeMethod}}&code_challenge={{codeChallenge}} +
+
+
+
+
+ + 将重定向到 'spring-oauth-server' 的登录页面 GET +
+ +
+
+
+ + + + +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/client_credentials.html b/src/main/resources/templates/client_credentials.html new file mode 100644 index 0000000000000000000000000000000000000000..41d150124c5d1e08c4a3c0408306f2e79295c339 --- /dev/null +++ b/src/main/resources/templates/client_credentials.html @@ -0,0 +1,189 @@ + + + + + + + + + client_credentials . spring-oauth-client + + + + +
+ Home + +

client_credentials

+ +

+ grant_type = 'client_credentials' 模式不需要用户去资源服务器登录并授权, 因为客户端(client)已经有了访问资源服务器的凭证(credentials). +
+ 所以当用户访问时,由client直接向资源服务器获取access_token并访问资源即可. +
+

+ +

+ 若客户端需要登录(或注册), 则用户仅需在客户端登录(或注册)即可,与资源服务器没有关系 +

+ +

+ + 在实际应用中, client_credentials一般都是由后台来完成的,前台没有任何表现, + 常用于子应用中去访问主应用的资源或API(实现服务与服务之间互信). + +

+ +
+

在本操作中, 需要client支持client_credentials的grant_type(若不支持请求会失败).

+
+ +
+ +
+
+
第一步 获取access_token
+
+
+

+ 点击 '获取access_token' 按钮, 将向spring-auth-server请求获取access_token. +
+ 若是开发者关心请求的参数,可点击'显示请求参数' 展示请求的参数细节. +

+ +
+
+ + +
+

[[${accessTokenUri}]] +  测试连接

+
+
+ 显示请求参数 + +
+
+ + +
+ + +

客户端从 spring-oauth-server 申请的client_id, 有的OAuth服务器中又叫 + AppKey

+
+
+
+ + +
+ + +

客户端从 spring-oauth-server 申请的client_secret, 有的Oauth服务器中又叫 + AppSecret

+
+
+
+ + +
+ + +

固定值 'client_credentials'

+
+
+
+ + +
+ +

OIDC标准中定义的scope有: openid, profile, email, address, phone; + 具体支持哪些由注册的client决定

+
+
+ +
+
+
+ + POST +
+
+
+
+ +
+
第二步 访问资源服务器的API
+
+
请先获取access_token
+
+
+
+
access_token
+
+
token_type
+
{{tokenType}}
+
scope
+
{{tokenScope}}
+
expires_in
+
{{expiresIn}}
+
+

{{tokenError}}

+

提示: client_credentials 响应中无 id_token与 refresh_token, 若需要, 请使用authorization_code

+
+ +

+ 获取access_token成功, 可访问资源服务器开放的API +

+ +

spring-oauth-server 中暂无相应的API

+
+
+
+ +
+ 返回 +
+
+ + + +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/code_access_token.html b/src/main/resources/templates/code_access_token.html new file mode 100644 index 0000000000000000000000000000000000000000..067c4bb55876ae012b9aae2174aaacd01af62206 --- /dev/null +++ b/src/main/resources/templates/code_access_token.html @@ -0,0 +1,156 @@ + + + + + + + + + authorization_code . spring-oauth-client + + + + +
+ Home + +

authorization_code + 用 'code' 换取 'access_token' +

+
PKCE
+
+ + +
+
步骤2: 用 'code' 换取 'access_token'
+
+
+ +
+ + +
+ + +
+

[[${accessTokenDto.accessTokenUri}]] +  测试连接 +

+ +
+
+ + 显示请求参数 + +
+
+ + +
+ + +

固定值 '[[${accessTokenDto.grantType}]]'

+
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ + +

值是从 'spring-oauth-server' 返回的

+
+
+ +
+ + +
+ + +

这一步的 'redirect_uri' 必须与上一步的 'redirect_uri' 一样

+
+
+
+ + +
+ + +

(后台代码生成,不可修改)

+
+
+ + +
+ 最终获取 'access_token'的 URL: +
+ +
+ {{accessTokenUri}}?client_id={{clientId}}&client_secret={{clientSecret}}&grant_type={{grantType}}&redirect_uri={{redirectUri}}&code={{code}}&code_verifier={{codeVerifier}} +
+
+
+
+
+ + 后台将通过 HttpClient 去获取 access_token POST +
+ + 在实际应用中, 该步骤一般由后台代码完成,前端不需要表现. + +
+ +
+
+
+ + + + +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/device_code.html b/src/main/resources/templates/device_code.html new file mode 100644 index 0000000000000000000000000000000000000000..5e0477aa2251c3e62604f453b8a0f4d4ebac10d6 --- /dev/null +++ b/src/main/resources/templates/device_code.html @@ -0,0 +1,339 @@ + + + + + + + + + device_code . spring-oauth-client + + + + +
+ Home + +

device_code + urn:ietf:params:oauth:grant-type:device_code +

+
+ +
+

+ device_code(全称 urn:ietf:params:oauth:grant-type:device_code)适用于各类无输入键盘的物联网智能设备进行认证授权, + 通过类似’扫码登录’形式完成整个授权流程【OAuth2.1新增】. +
+ 其流程图如下: +

+ device_code_flow +
    +
  • + Authorization Server 授权服务端, 此处指 spring-oauth-server 并支持device_code授权. +
  • +
  • + Client Device 客户端设备, 一般是各类无输入键盘的物联网智能设备(如智能手表, 有屏幕能显示但无键盘输入). +
  • +
  • + Secondary Device 同一账户的另一个已授权的终端或设备, 一般是PC端浏览器或手机端. +
  • +
+

+ 整个流程分3步完成,依次为: +

+
    +
  1. +
    + 从 spring-oauth-server获取 'user_code' 'device_code' +
    + -- 该步骤将根据从 spring-oauth-server 中获取的client信息(如client_id,client_secret)去请求获取 'user_code' + 'device_code' 'verification_uri_complete'等信息. +
    +

    + 在实际应用中, 由Client Device后端完成, + 并展示 'user_code' 等待用户在另一设备上授权(通过二维码展示在另一设备上扫一扫也是不错的方式). +

    +
    +
  2. +
  3. +
    + 用户在另一设备上完成授权 +
    + -- 用户在另一设备(如PC端浏览器或手机端)上输入 'user_code' 或扫一扫打开'verification_uri_complete' 后完成授权. +
    +

    + 在此步骤进行的同时, + Client Device上后台将定时(如每隔5秒)向 spring-oauth-server 发起获取token的请求/oauth2/token (需要使用第1步中获取到 + device_code 的值). +

    +
    +
  4. +
  5. +
    + Client Device获取 'access_token' +
    + -- 在第2步进行同时, Client Device将定时(如每隔5秒)向 spring-oauth-server 发起获取token的请求/oauth2/token (需要使用第1步中获取到 + device_code 的值), + 直到获取成功(即第2步操作完成授权设备登录)或超时(即设备轮询请求等待的时长超出第1步返回的时间expires_in). +
    +

    + 注意: 完成授权设备登录后, + Client Device将会获取到 'access_token' 并保存到本地, 后续的请求都将使用该 'access_token' + 调用资源服务器的API(或spring-oauth-server的API). +

    +
    +
  6. +
+
+
+ +
+
步骤1: 从 spring-oauth-server获取 'user_code' 'device_code'
+
+
+ +
+ +
+ + +
+

[[${deviceAuthorizeUrl}]] +  测试连接 +

+ +
+
+ + 显示请求参数 + +
+
+ + +
+ +
+
+
+ + +
+ +
+
+ +
+ + +
+ +

OIDC标准中定义的scope有: openid, profile, email, address, phone; + 具体支持哪些由注册的client决定

+
+
+ +
+ 最终发给 spring-oauth-server的 URL: +
+ +
+ {{deviceAuthorizeUrl}}?client_id={{clientId}}&client_secret={{clientSecret}}&scope={{scope}} +
+
+
+
+
+ + POST +
+ +
+
+
+ +
+
步骤2: 用户在另一设备上完成授权
+
+
+
+
+
+
user_code
+
{{userCode}}
+
device_code
+
{{deviceCode}}
+
verification_uri
+
{{verificationUri}}
+
verification_uri_complete
+
{{verificationUriComplete}}
+
expires_in
+
{{expiresIn}}
+
+

{{authError}}

+

提示: 用户授权必须在指定的时间({{expiresIn}}秒内)完成

+
+
+

+ 在设备上展示user_code或显示一个二维码(内容为第一步响应的 verification_uri_complete URL) +
+ 用已经登录成功的浏览器(或另一个已经认证的设备)访问verification_uri_complete URL(可通过扫码等方式获取内容) +

+

+ 此处方便演示, 请点击spring-oauth-server的 /oauth2/device_verification + 并输入上一步获取到的user_code + (若未认证将跳转到登录) +

+ +
+
+
+ +
+
步骤3: Client Device获取 'access_token'
+
+
+ +

+ 在第2步进行的同时, 设备上后台将定时(如每隔5秒)向spring-oauth-server发起获取token的请求/oauth2/token (需要使用第1步中获取到 device_code + 的值), + 直到获取成功(即第2步操作完成授权设备登录)或超时(即设备轮询请求等待的时长超出第1步返回的时间expires_in) +

+ +
+
+ +
+ + +
+

[[${tokenUrl}]] +  测试连接 +

+ +
+
+ + 显示请求参数 + +
+
+ + +
+ +
+
+
+ + +
+ +
+
+ +
+ + +
+ +

固定值: urn:ietf:params:oauth:grant-type:device_code

+
+
+
+ + +
+ +

spring-oauth-server 响应的 device_code值; 若为空请先操作第1步

+
+
+ +
+ 最终发给 spring-oauth-server的 URL: +
+ +
+ {{tokenUrl}}?client_id={{clientId}}&client_secret={{clientSecret}}&device_code={{deviceCode}}&grant_type={{grantType}} +
+
+
+
+
+ + POST +
提示:在第2步进行过程中调用第3步获取token API时会响应等待授权的结果(Http状态码 400, + error='authorization_pending') +
+
+ +
+ +
+
+
+ +
+ 返回 +
+ + + + +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/main.html b/src/main/resources/templates/fragments/main.html new file mode 100644 index 0000000000000000000000000000000000000000..1864e7745fd15780ad127fe512d842613cb6df96 --- /dev/null +++ b/src/main/resources/templates/fragments/main.html @@ -0,0 +1,28 @@ + + + + + + Fragments + +
+ + +
+ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..58e3462492992873b92017d5e3335fc3650af681 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,200 @@ + + + + + + + + + Home . spring-oauth-client + + + + +
+

Spring OAuth Client is work! v2.0.0

+ + +
+ 操作说明 +
    +
  1. +

    + spring-oauth-client 的实现没有使用开源项目 spring-security-oauth2 中提供的代码与配置, 如:<oauth:client + id="oauth2ClientFilter" /> +

    +
  2. +
  3. + 按照OAuth2.1支持的grant_type依次去实现. +
    +
      +
    • authorization_code
    • +
    • authorization_code + PKCE
    • +
    • client_credentials
    • +
    • device_code
    • +
    • jwt-bearer
    • +
    • refresh_token
    • +
    +

    相比OAuth2.0, 不再支持 + password + 与 + implicit +

    +
  4. +
  5. +

    + + 在开始使用之前, 请确保 spring-oauth-server + (版本v3.0.0以上) + 项目已正确运行, 且 application.properties (位于项目的 src/main/resources 目录) 配置正确 + +

    +
  6. +
  7. +

    + 可先访问 spring-oauth-server 提供的OIDC .well-known/openid-configuration 获取 + authorization_endpoint, + token_endpoint, + userinfo_endpoint, + end_session_endpoint, + jwks_uri, + issuer, + revocation_endpoint, + introspection_endpoint 等信息 +

    +

    + spring-oauth-client后端会根据配置文件 application.properties 中配置的参数 + oauth2.server.host值去获取相应信息(详见 OAuth2Holder.java) +

    +
  8. +
  9. + 在对各菜单进行操作之前,请先填写从 spring-oauth-server 中获取或创建的客户端(client_details)信息并保存. +
    +
    提示 创建时的redirect_uris必须填写 +
    + jwk_set_url必须填写 +
    +
    +
    + +
    + +

    填写 spring-oauth-server 中创建的客户端的 client_id

    +
    +
    +
    + +
    + +

    填写 spring-oauth-server 中创建的客户端的 client_secret

    +
    +
    +
    + +
    + +

    redirect_uri 必须填写由 spring-oauth-client 提供的, 否则流程将无法通畅

    +
    +
    +
    + +
    + +

    填写 spring-oauth-server 中创建的客户端的 scopes, 多个值之间用空格分隔, 如: openid profile + email

    +
    +
    +
    + +
    + +

    填写 spring-oauth-server 中创建的客户端的 grant_type(s), 多个值之间用空格分隔, 如: + authorization_code refresh_token

    +
    +
    +
    + +
    + + +

    选择 spring-oauth-server 中创建的客户端的 equire_proof_key(PKCE) 相同的选项

    +
    +
    + +
    + +
    + +
    +
    +
    +
  10. +
+
+
+ +

+ Δ 注意: 项目前端使用了 Angular-JS 来处理动态数据展现. +

+
+ +
+ 菜单 +
    +
  • +

    authorization_code
    授权码模式(即先登录获取code,再获取token) [最常用]

    +
  • +
  • +

    authorization_code + PKCE
    + 授权码模式+PKCE (即先登录获取code, 请求时增加参数code_challenge与code_challenge_method; 再获取token,增加参数code_verifier)

    +
  • +
  • +

    client_credentials
    客户端模式(无用户,用户向客户端注册,然后客户端以自己的名义向'服务端'获取资源) +

    +
  • +
  • +

    device_code
    + (全称 urn:ietf:params:oauth:grant-type:device_code)适用于各类无输入键盘的物联网智能设备进行认证授权, + 通过类似’扫码登录’形式完成整个授权流程 OAuth2.1新增

    +
  • +
  • +

    jwt-bearer
    + (全称 urn:ietf:params:oauth:grant-type:jwt-bearer)是一类增强client端请求安全性的断言(assertion)实现; + 通过类似’双向SSL’的机制来让server端验证client端的签名实现强安全性 OAuth2.1新增

    +
  • +
  • +

    refresh_token
    刷新access_token

    +
  • +
+
+ +

+ 注意: 在测试时默认填写的数据有可能不正确, 建议先在 spring-oauth-server + 添加 client_details 后, 使用其client_id, client_secret来进行测试. +

+
+ +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/jwt_bearer.html b/src/main/resources/templates/jwt_bearer.html new file mode 100644 index 0000000000000000000000000000000000000000..3b4ecc983b76c6dd91311d925e7ae55eaaf2a011 --- /dev/null +++ b/src/main/resources/templates/jwt_bearer.html @@ -0,0 +1,161 @@ + + + + + + + + + jwt-bearer . spring-oauth-client + + + + +
+ Home + +

jwt-bearer + urn:ietf:params:oauth:client-assertion-type:jwt-bearer +

+
+ +
+
    +
  • +

    jwt-bearer是一类增强client端请求安全性的断言(assertion)实现; + 通过类似'双向SSL'的机制来让server端验证client端的签名实现强安全性.

    +
  • +
  • +

    当注册或添加client端时需要填写一个jwk URL地址(用来获取验签的公钥), 指定认证jwt签名算法(如RS256), + 设置methods为client_secret_jwt(对称算法, + 使用client_secret为MacKey)或private_key_jwt(非对称算法)

    +

    注意: grant_type不能只有jwt-bearer, 无实用意义

    +
  • +
  • + 重要 spring-oauth-client中的jwk URL地址为: [[${jwkUrl}]] + ; + 在测试前需要将其正确配置到 spring-oauth-server 中 +
    +

    注意: spring-oauth-client中的 jwk 只用于测试, 生产环境或正式使用时请另行生成

    +
  • +
+ +
+ 一个 jwt-bearer 的 cURL请求示例: +
curl --location 'http://localhost:8080/oauth2/token' \
+  --header 'Content-Type: application/json' \
+  --form 'client_id="dofOx6hjxlWw9qe2bnFvqbiPhuWwGWdn"' \
+  --form 'client_assertion_type="urn:ietf:params:oauth:client-assertion-type:jwt-bearer"' \
+  --form 'scope="openid"' \
+  --form 'grant_type="client_credentials"' \
+  --form 'client_assertion="eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJkb2ZPeDZoanhsV3c5..."' \
+  --form 'client_secret="dofOx6hjxlWw9qe2bnFvqbiPhuWwGWdn"'
+ 增加两个请求参数: + + + + + + + + + +
client_assertion_type固定值: urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion + 使用spring-oauth-client提供的 jwk URL中的 private_key进行签名生成的 JWT(如何生成详见: + JwtBearerFlowTest.java) +
+

+ 下面以 grant_type=client_credentials 中使用 jwt-bearer 来说明. +

+
+
+ +
+
在 grant_type=client_credentials 中使用 jwt-bearer
+
+
+ +

输入client_assertion值, 点击按钮地址即可测试

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
client_id + +
client_secret + +
scope + +
grant_type + +

grant_type根据需要值可以是authorization_code client_credentials + refresh_token等 +

+
client_assertion_type + +

固定值

+
client_assertion + +

如何生成client_assertion, 详见示例类: JwtBearerFlowTest.java +

+
+ +
+ tokenUrl: [[${tokenUrl}]] +
+ + + POST +
+ +
+
+
+ +
+ 返回 +
+ + + + +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/oauth_error.html b/src/main/resources/templates/oauth_error.html new file mode 100644 index 0000000000000000000000000000000000000000..9fba67de9e7ad6c2dce1069b79379bf63af12139 --- /dev/null +++ b/src/main/resources/templates/oauth_error.html @@ -0,0 +1,27 @@ + + + + + + + + + OAuth Error . spring-oauth-client + + + + +
+ Home + +

OAuth Error

+ +
+

+
+
+ +
+
+ + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/refresh_token.jsp b/src/main/resources/templates/refresh_token.html similarity index 31% rename from src/main/webapp/WEB-INF/jsp/refresh_token.jsp rename to src/main/resources/templates/refresh_token.html index f71d3e9a69efe71fbea55f36581cb3bb62b43a10..e6fcc60d40b6902b3ff0f4ccd77ca223b2c21801 100644 --- a/src/main/webapp/WEB-INF/jsp/refresh_token.jsp +++ b/src/main/resources/templates/refresh_token.html @@ -1,320 +1,185 @@ -<%-- - * - * @author Shengzhao Li ---%> - -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - - - refresh_token - - -Home - -

refresh_token

- -
- grant_type = 'refresh_token' 模式用在当获取的access_token未过期之前向服务端换取新的access_token. -
- 在获取access_token时返回的数据如下 -
-{"access_token":"3420d0e0-ed77-45e1-8370-2b55af0a62e8","token_type":"bearer","refresh_token":"b36f4978-a172-4aa8-af89-60f58abe3ba1","expires_in":43199,"scope":"read write"}
-    
-

- 数据中的expires_in即access_token的有效时间(单位:秒), 默认的有效时间为43199(约12小时), 在服务端可配置默认的有效时间. -

- -

- 若在一个时间段内(即在43199秒之内)多次去获取access_token, 将返回相同的access_token值, 但expires_in的值将会减少. -
- (比如: 相隔10秒去获取access_token, 第一次返回的expires_in=43199,第二次返回的expires_in=43189) -

- -

- 数据中refresh_token的值将用于换取新的access_token. -
- 在此将首先通过 grant_type='password' 去获取access_token, 然后调用 grant_type='refresh_token' 去换取新的access_token. -
- 换取成功后将得到新的access_token,并会看到expires_in值又还原成43199. -
- 注意 refresh_token 成功后旧的access_token将不能再使用. -

- - 在实际应用中, refresh_token一般都是由后台来完成的,前台没有任何表现. - -
- -
-
-
-
1. 通过 grant_type='password' 去获取access_token
-
-
-

- 点击 '获取access_token' 按钮, 从spring-oauth-server取到access_token数据. -
- 若是开发者关心请求的参数,可点击'显示请求参数' 展示请求的参数细节. -

- -
-
- - -
-

${accessTokenUri} -  测试连接

-
-
- 显示请求参数 - -
-
- - -
- - -

客户端从 Oauth Server 申请的client_id, 有的Oauth服务器中又叫 appKey

-
-
-
- - -
- - -

客户端从 Oauth Server 申请的client_secret, 有的Oauth服务器中又叫 appSecret

-
-
-
- - -
- - -

固定值 'password'

-
-
- -
- - -
- -
-
- -
- - -
- - -

用户在 Oauth Server 中的账号名称

-
-
-
- - -
- - -

用户在 Oauth Server 中的账号密码

-
-
- -
-
-
- -
-
-
-
- -
-
2. 调用 grant_type='refresh_token' 去换取新的access_token
-
-
请先获取access_token
-
-
-
-
access_token
-
{{accessToken}}
-
token_type
-
{{tokenType}}
-
refresh_token
-
{{refreshToken}}
-
scope
-
{{tokenScope}}
-
expires_in
-
{{expiresIn}}
-
-

{{tokenError}}

- -

多次点击 '获取access_token' 将会看到expires_in的变化

-
- -
-
- - -
-

${accessTokenUri} -  测试连接

-
-
- 显示请求参数 - -
- -
- - -
- - -

客户端从 Oauth Server 申请的client_id, 有的Oauth服务器中又叫 appKey

-
-
-
- - -
- - -

客户端从 Oauth Server 申请的client_secret, 有的Oauth服务器中又叫 appSecret

-
-
-
- - -
- - -

固定值 'refresh_token'

-
-
-
- - -
- - -

从 Oauth Server 返回的 'refresh_token'

-
-
- -
-
-
- - POST -
- -
-
- 刷新后的access_token信息 -
-
-
access_token
-
{{newAccessToken}}
-
token_type
-
{{newTokenType}}
-
refresh_token
-
{{newRefreshToken}}
-
scope
-
{{newTokenScope}}
-
expires_in
-
{{newExpiresIn}}
-
-

{{newTokenError}}

-
-
-
-
-
-
- - - + + + + + + + + + refresh_token . spring-oauth-client + + + + +
+ Home + +

refresh_token

+ +
+ grant_type = 'refresh_token' 模式用在当获取的access_token未过期之前向服务端换取新的access_token. +
+ 在获取access_token时返回的数据如下 +
{
+  "access_token": "7154afT_cxvLDq1naSg6Aq9ueSFSW8xRr5txryW5MlddRe7nV0RogTYwPsJc_rrRqwaIvLleerLhkjtIN2E2U-...",
+  "refresh_token": "TZ9tzVwE_VLoJxALUSw4A4A0Nj7SLSWXCc69U9rvNmSnqR8Hbz-...",
+  "scope": "openid profile",
+  "id_token": "eyJraWQiOiJzb3MtZWNjLWtpZDEiLCJhbGciOiJFUzI1NiJ9..3w-7EY9SwKA-...",
+  "token_type": "Bearer",
+  "expires_in": 3599
+}
+

+ 数据中的expires_in即access_token的有效时间(单位:秒), 在服务端可配置client的access_token有效时间. +

+ +

+ 数据中refresh_token的值将用于换取新的access_token. +
+ 注意 refresh_token 成功后旧的access_token将不能再使用. +

+ + 在实际应用中, refresh_token一般都是由后台来完成的,前台没有任何表现. + +
+ +
+
+
+
调用 grant_type='refresh_token' 去换取新的access_token
+
+
+
+ + +
+

[[${accessTokenUri}]] +  测试连接

+
+
+ 显示请求参数 + +
+ +
+ + +
+ + +

客户端从 spring-oauth-server 申请的client_id, 有的Oauth服务器中又叫 AppKey

+
+
+
+ + +
+ + +

客户端从 spring-oauth-server 申请的client_secret, 有的Oauth服务器中又叫 + AppSecret

+
+
+
+ + +
+ + +

固定值 'refresh_token'

+
+
+
+ + +
+ + +

从 spring-oauth-server 获取的 'refresh_token'

+
+
+ +
+
提示: 若client关闭了复用refresh_token功能(默认开启), + 则每次请求后会响应一个新的refresh_token值(即一个refresh_token只能使用一次, 安全性更高). +
+
+ + POST +
+ +
+
+ 刷新后的access_token信息 +
+
+
access_token
+
+
token_type
+
{{newTokenType}}
+
refresh_token
+
+
scope
+
{{newTokenScope}}
+
expires_in
+
{{newExpiresIn}}
+
+

{{newTokenError}}

+
+
+
+
+ +
+ 返回 +
+
+
+ + + +
+
+ \ No newline at end of file diff --git a/src/main/resources/templates/resources/unity_user_info.html b/src/main/resources/templates/resources/unity_user_info.html new file mode 100644 index 0000000000000000000000000000000000000000..eeea3d45c65ccbe4f9dcb7263c7322d796a424a3 --- /dev/null +++ b/src/main/resources/templates/resources/unity_user_info.html @@ -0,0 +1,41 @@ + + + + + + + + + User Info [Unity]<. spring-oauth-client + + + + +
+ Home + +

User Info [Unity] + 数据来源于 'spring-oauth-server' 中提供的API接口 +

+ +
+
username
+
${userDto.username}
+
uuid
+
${userDto.uuid}
+
phone
+
${userDto.phone}
+
email
+
${userDto.email}
+
privileges
+
${userDto.privileges}
+ +
+ + Back + + +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/resources/userinfo.html b/src/main/resources/templates/resources/userinfo.html new file mode 100644 index 0000000000000000000000000000000000000000..87415b133e663d57b1a6f7c43e0036748319ac3b --- /dev/null +++ b/src/main/resources/templates/resources/userinfo.html @@ -0,0 +1,42 @@ + + + + + + + + + User Info . spring-oauth-client + + + + +
+ Home +

User Info + 数据来源于 'spring-oauth-server' 中提供的API接口 +

+ +
+
sub
+
[[${userDto.sub}]]
+
nickname
+
[[${userDto.nickname}]]
+
phone
+
[[${userDto.phone}]]
+
email
+
[[${userDto.email}]]
+
address
+
[[${userDto.address}]]
+
updated_at
+
[[${userDto.updated_at}]]
+ +
+ + Back + + +
+
+ + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/decorators.xml b/src/main/webapp/WEB-INF/decorators.xml deleted file mode 100644 index 77a65648784f0635f1d4bd22bec861bd5b6d6412..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/decorators.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - /* - - - - - /resources/* - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/access_token_result.jsp b/src/main/webapp/WEB-INF/jsp/access_token_result.jsp deleted file mode 100644 index 2633adfc327480082d0e9976aa84c9bae3e9bebe..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/jsp/access_token_result.jsp +++ /dev/null @@ -1,61 +0,0 @@ -<%-- - * - * @author Shengzhao Li ---%> -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - - - authorization_code - - -Home - -

authorization_code - 用 'access_token' 去访问 spring-oauth-server 的API -

-
- - -
-
步骤3: 用 'access_token' 去访问 spring-oauth-server 的API
-
-
-
access_token
-
${accessTokenDto.accessToken}
-
token_type
-
${accessTokenDto.tokenType}
-
refresh_token
-
${accessTokenDto.refreshToken}
-
scope
-
${accessTokenDto.scope}
-
expires_in
-
${accessTokenDto.expiresIn}
-
-
-

- 获取access_token成功, 现在可以访问spring-oauth-server资源了, 以下提供两种方式去访问spring-oauth-server资源(或API). -

-
    -
  • - 方式1: 调用本地的接口,由后台去向服务器获取资源并进行处理(如将JSON数据转化成对象), 通过页面展示信息 -
    - Oauth Server - 用户信息 -
  • -
  • - 方式2: 直接通过access_token去访问服务器的资源(该方式将直接获取JSON数据) -
    - Oauth Server - 用户信息 - [JSON] -
  • -
  • ...
  • -
-

- 至于使用哪一种方式, 在实际中请根据具体的需求或服务器资源提供的访问方式去选择 -

-
-
- - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/authorization_code.jsp b/src/main/webapp/WEB-INF/jsp/authorization_code.jsp deleted file mode 100644 index ef11a582315b14c77625ad4760337ad0e4431f3a..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/jsp/authorization_code.jsp +++ /dev/null @@ -1,187 +0,0 @@ -<%-- - * - * @author Shengzhao Li ---%> - -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - - - authorization_code - - -Home - -

authorization_code - 从 spring-oauth-server获取 'code' -

-
- -
-

- grant_type = 'authorization_code' 模式是Oauth中最常用的, 一般是通过浏览器来完成. - 整个流程分3步完成,依次为: -

-
    -
  1. -

    - 从 spring-oauth-server获取 'code' -
    - -- 该步骤将根据从 spring-oauth-server 中获取的client信息(如client_id,client_secret)将用户引导到server的登录页面. -
    - - 在实际应用中, 表现为在登录页面中展示的 '通过第三方登录' 或 '通过其他账号登录' - -

    -
  2. -
  3. -

    - 用 'code' 换取 'access_token' -
    - -- 在server端当用户登录成功并授权后将返回到spring-oauth-client的回调地址(即redirect_uri), -
    - 当检查通过后(指检查返回是否有code与state值), 将根据server端获取access_token的URL去换取access_token值. -
    - - 在实际应用中, 该步骤一般由client后端代码完成,前端不需要表现. - -

    -
  4. -
  5. -

    - 用 'access_token' 去访问 spring-oauth-server 的API -
    - -- 在成功获取access_token后,将根据 spring-oauth-server 中提供的API去获取 资源服务器 中的数据. -
    - 在 spring-oauth-server 中当前只有一个API可供调用(即${unityUserInfoUri}), 用于获取当前登录用户的信息. -
    - - 在实际应用中, 资源服务器都会提供许多API供调用 - -

    -
  6. -
-
-
- -
-
步骤1: 从 spring-oauth-server获取 'code'
-
-
- -
- - -
- - -
-

${userAuthorizationUri} -  测试连接 -

- -

- authorizationUri value from 'spring-oauth-client.properties' -

-
-
- - 显示请求参数 - -
-
- - -
- - -

固定值 'code'

-
-
- -
- - -
- -
-
- -
- - -
- -
-
- -
- - -
- - -

- redirect_uri 是在 'AuthorizationCodeController.java' 中定义的一个 URI, 用于检查server端返回的 - 'code'与'state',并发起对 access_token 的调用

-
-
- -
- - -
- - -

一个随机值, spring-oauth-server 将原样返回,用于检测是否为跨站请求(CSRF)等

-
-
- -
- 最终发给 spring-oauth-server的 URL: -
- -
- {{userAuthorizationUri}}?response_type={{responseType}}&scope={{scope}}&client_id={{clientId}}&redirect_uri={{redirectUri}}&state={{state}} -
-
-
-
-
- - 将重定向到 'spring-oauth-server' 的登录页面 GET -
- -
-
-
- - - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/client_credentials.jsp b/src/main/webapp/WEB-INF/jsp/client_credentials.jsp deleted file mode 100644 index df3c0b2a019d4ee25e2c596a465b54bb336bb47c..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/jsp/client_credentials.jsp +++ /dev/null @@ -1,190 +0,0 @@ -<%-- - * - * @author Shengzhao Li ---%> - -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - - - client_credentials - - -Home - -

client_credentials

- -

- grant_type = 'client_credentials' 模式不需要用户去资源服务器登录并授权, 因为客户端(client)已经有了访问资源服务器的凭证(credentials). -
- 所以当用户访问时,由client直接向资源服务器获取access_token并访问资源即可. -
-

- -

- 若客户端需要登录(或注册), 则用户仅需在客户端登录(或注册)即可,与资源服务器没有关系 -

- -

- - 在实际应用中, client_credentials一般都是由后台来完成的,前台没有任何表现, - 常用于子应用中去访问主应用的资源或API. - -

- -
-

在本操作中, 首先需要向 spring-oauth-server 数据库中添加client_credentials的client信息(oauth_client_details表),SQL如下:

-
-insert into oauth_client_details
-(client_id, resource_ids, client_secret, scope, authorized_grant_types,
-web_server_redirect_uri,authorities, access_token_validity,
-refresh_token_validity, additional_information, create_time, archived, trusted)
-values
-('credentials-client','sos-resource', '$2a$10$Dl2VwWVv/3h5KzK02gysheH7sy28weESL84DiO/CvUiGKcoXGTVlO', 'read,write','client_credentials',
-null,'ROLE_UNITY,ROLE_USER',null,
-null,null, now(), 0, 0);
-    
- (若已添加则忽略; 在实际应用中, 添加的数据是需要向服务端申请注册的) -
- -
- -
-
-
第一步 获取access_token
-
-
-

- 点击 '获取access_token' 按钮, 将向spring-auth-server请求获取access_token. -
- 若是开发者关心请求的参数,可点击'显示请求参数' 展示请求的参数细节. -

- -
-
- - -
-

${accessTokenUri} -  测试连接

-
-
- 显示请求参数 - -
-
- - -
- - -

客户端从 Oauth Server 申请的client_id, 有的Oauth服务器中又叫 appKey

-
-
-
- - -
- - -

客户端从 Oauth Server 申请的client_secret, 有的Oauth服务器中又叫 appSecret

-
-
-
- - -
- - -

固定值 'client_credentials'

-
-
-
- - -
- -
-
- -
-
-
- - POST -
-
-
-
- -
-
第二步 访问资源服务器的API
-
-
请先获取access_token
-
-
-
-
access_token
-
{{accessToken}}
-
token_type
-
{{tokenType}}
-
scope
-
{{tokenScope}}
-
expires_in
-
{{expiresIn}}
-
-

{{tokenError}}

-
- -

- 获取access_token成功, 访问资源服务器API -

- ${unityUserInfoUri}?access_token={{accessToken}} - -

JSON格式的资源服务器数据

-
-
-
-
- - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/code_access_token.jsp b/src/main/webapp/WEB-INF/jsp/code_access_token.jsp deleted file mode 100644 index 2e350179936d6d8fae86a3e9e0b780aa143faab6..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/jsp/code_access_token.jsp +++ /dev/null @@ -1,140 +0,0 @@ -<%-- - * - * @author Shengzhao Li ---%> -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - - - authorization_code - - -Home - -

authorization_code - 用 'code' 换取 'access_token' -

-
- - -
-
步骤2: 用 'code' 换取 'access_token'
-
-
- -
- - -
- - -
-

${accessTokenDto.accessTokenUri} -  测试连接 -

- -

- accessTokenUri value from 'spring-oauth-client.properties' -

-
-
- - 显示请求参数 - -
-
- - -
- - -

固定值 '${accessTokenDto.grantType}'

-
-
- -
- - -
- -
-
- -
- - -
- -
-
- -
- - -
- - -

值是从 'spring-oauth-server' 返回的

-
-
- -
- - -
- - -

这一步的 'redirect_uri' 必须与上一步的 'redirect_uri' 一样

-
-
- - -
- 最终获取 'access_token'的 URL: -
- -
- {{accessTokenUri}}?client_id={{clientId}}&client_secret={{clientSecret}}&grant_type={{grantType}}&redirect_uri={{redirectUri}}&code={{code}} -
-
-
-
-
- - 后台将通过 HttpClient 去获取 access_token POST -
- - 在实际应用中, 该步骤一般由后台代码完成,前端不需要表现. - -
- -
-
-
- - - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/decorators/main.jsp b/src/main/webapp/WEB-INF/jsp/decorators/main.jsp deleted file mode 100644 index 86f512881694299814dd4cc7f4170046a7dc81da..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/jsp/decorators/main.jsp +++ /dev/null @@ -1,45 +0,0 @@ -<%-- - * - * @author Shengzhao Li ---%> - -<%@ page contentType="text/html;charset=UTF-8" language="java" trimDirectiveWhitespaces="true" %> -<%@ taglib uri="http://www.opensymphony.com/sitemesh/decorator" prefix="decorator" %> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - - - - - - - - - - <decorator:title default=""/> - Spring Security&OAuth2 Client - - - - - - - - -
-
- -
- - <%--footer--%> -
-
-
-

- © 2013 - 2018 - sz@monkeyk.com from spring-oauth-server -

-
-
-
- - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/implicit.jsp b/src/main/webapp/WEB-INF/jsp/implicit.jsp deleted file mode 100644 index ab77aeaaa191c9d5b64e0a2fb4735cf433bacf17..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/jsp/implicit.jsp +++ /dev/null @@ -1,203 +0,0 @@ -<%-- - * - * @author Shengzhao Li ---%> - -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - - - implicit - - -Home - -

implicit

- -

- grant_type = 'implicit' 模式通过在redirect_uri 的Hash传递token; Auth客户端运行在浏览器中,如JS,Flash(不通过client服务器). -
- 使用该模式时要求浏览器绝对可信,不然可能会将访问信息(client_id,client_secret等)泄露给恶意用户或应用程序. 一般使用在临时访问的场景. -
-

- -
-

在本操作中, 首先需要向 spring-oauth-server 数据库中添加implicit的client信息(oauth_client_details表),SQL如下:

-
-insert into oauth_client_details
-(client_id, resource_ids, client_secret, scope, authorized_grant_types,
-web_server_redirect_uri,authorities, access_token_validity,
-refresh_token_validity, additional_information, create_time, archived, trusted)
-values
-('implicit-client','sos-resource', 'implicit-secret', 'read','implicit',
-'http://localhost:7777/spring-oauth-client/implicit','ROLE_UNITY,ROLE_USER',null,
-null,null, now(), 0, 0);
-    
-

- 注意检查SQL中的web_server_redirect_uri字段的值,必须是正确能访问的. -

- (若已添加则忽略; 在实际应用中, 添加的数据是需要向服务端申请注册的) -
- -
- -
-
-
第一步 去spring-oauth-server登录并授权
-
-
-

- 点击 '登录并授权' 按钮, 将跳转到spring-auth-server的登录页面. -
- 若是开发者关心请求的参数,可点击'显示请求参数' 展示请求的参数细节. -

- -
-
- - -
-

${userAuthorizationUri} -  测试连接

-
-
- 显示请求参数 - -
-
- - -
- - -

客户端从 Oauth Server 申请的client_id, 有的Oauth服务器中又叫 appKey

-
-
-
- - -
- - -

客户端从 Oauth Server 申请的client_secret, 有的Oauth服务器中又叫 appSecret

-
-
-
- - -
- - -

固定值 'token'

-
-
-
- - -
- - -

必须是client_details中支持的scope; 当前的只支持read

-
-
- -
- - -
- - -

必须与client_details中的web_server_redirect_uri一致

-
-
- -
-
-
- GET -
-
-
-
- -
-
第二步 访问资源服务器的API
-
-
请先 登录并授权
-
-

注意查看地址栏中URL的hash部分(#号后面)已经包含了access_token信息,通过JS解析即可获取

- -
-
-
access_token
-
{{accessToken}}
-
token_type
-
{{tokenType}}
-
expires_in
-
{{expiresIn}}
-
-

{{tokenError}}

-
- -

- 获取access_token成功, 访问资源服务器API -

- ${unityUserInfoUri}?access_token={{accessToken}} - -

JSON格式的资源服务器数据

-
-
-
-
- - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/oauth_error.jsp b/src/main/webapp/WEB-INF/jsp/oauth_error.jsp deleted file mode 100644 index b2fc18799916b8344f3be5da4e064fd085ae146b..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/jsp/oauth_error.jsp +++ /dev/null @@ -1,22 +0,0 @@ -<%-- - * - * @author Shengzhao Li ---%> - -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - - - Oauth Error - - -Home - -

Oauth Error

- -
-

${error}

- ${message} -
- - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/password.jsp b/src/main/webapp/WEB-INF/jsp/password.jsp deleted file mode 100644 index d89a37d752c56baf0caa4eae2f0e566a1446569c..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/jsp/password.jsp +++ /dev/null @@ -1,149 +0,0 @@ -<%-- - * - * @author Shengzhao Li ---%> - -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - - - password - - -Home - -

password

- -

- grant_type = 'password' 模式一般用在移动设备上(如IOS,Android), 适用于非浏览器的环境. -
- 在此提供两种方式来展示该模式. -

- -
-
方式1
-
-

- Java代码, 详见项目中的PasswordOauthHandler.java文件. -
- 使用HttpClient来向服务器发送请求,处理数据, - 获取access_token并调用资源API [推荐] -

-
-
- -
-
方式2
-
-
-

- 在页面上点击链接 'Password grant_type' 按钮, 将打开新窗口,展示服务器端响应的JSON数据. -
- 若是开发者关心请求的参数,可点击'显示请求参数' 展示请求的参数细节. -

- -
-
- - -
-

${accessTokenUri} -  测试连接

-
-
- 显示请求参数 - -
-
- - -
- - -

客户端从 Oauth Server 申请的client_id, 有的Oauth服务器中又叫 appKey

-
-
-
- - -
- - -

客户端从 Oauth Server 申请的client_secret, 有的Oauth服务器中又叫 appSecret

-
-
-
- - -
- - -

固定值 'password'

-
-
- -
- - -
- -
-
- -
- - -
- - -

用户在 Oauth Server 中的账号名称

-
-
-
- - -
- - -

用户在 Oauth Server 中的账号密码

-
-
- -
-
-
- - POST -
-
-
-
- - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/resources/unity_user_info.jsp b/src/main/webapp/WEB-INF/jsp/resources/unity_user_info.jsp deleted file mode 100644 index 2dc2837fe65a75ebc35c20a230bd1cfb483ecf16..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/jsp/resources/unity_user_info.jsp +++ /dev/null @@ -1,34 +0,0 @@ -<%-- - * - * @author Shengzhao Li ---%> -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - - - User Info [Unity] - - -Home - -

User Info [Unity] - 数据来源于 'spring-oauth-server' 中提供的API接口 -

- -
-
username
-
${userDto.username}
-
uuid
-
${userDto.uuid}
-
phone
-
${userDto.phone}
-
email
-
${userDto.email}
-
privileges
-
${userDto.privileges}
- -
- -Back - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/log4j.xml b/src/main/webapp/WEB-INF/log4j.xml deleted file mode 100644 index 89e223ae92b1df0eb1dcde4a8d21abdb0d4da7e6..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/log4j.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/soc-servlet.xml b/src/main/webapp/WEB-INF/soc-servlet.xml deleted file mode 100644 index 565d1303291e49cc7e09e7fc1a47bd752bb8db12..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/soc-servlet.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml deleted file mode 100644 index 9e92daa2689688ed25975b99d569a03d641bfbc3..0000000000000000000000000000000000000000 --- a/src/main/webapp/WEB-INF/web.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - spring-oauth-client - - - - webAppRootKey - spring-oauth-client - - - - - encodingFilter - org.springframework.web.filter.CharacterEncodingFilter - - encoding - UTF-8 - - - forceEncoding - true - - - - encodingFilter - /* - - - - - - sitemesh - com.opensymphony.sitemesh.webapp.SiteMeshFilter - - - sitemesh - /* - - - - ico - image/vnd.microsoft.icon - - - - - contextConfigLocation - classpath:spring/*.xml - - - log4jConfigLocation - /WEB-INF/log4j.xml - - - org.springframework.web.util.Log4jConfigListener - - - - - org.springframework.web.context.ContextLoaderListener - - - - - soc - org.springframework.web.servlet.DispatcherServlet - 2 - - - soc - / - - - - - - - - - 30 - - - - - index.jsp - - - - \ No newline at end of file diff --git a/src/main/webapp/index.jsp b/src/main/webapp/index.jsp deleted file mode 100644 index 88246838d4c1d453a3d3817dffabc3e58119f9ae..0000000000000000000000000000000000000000 --- a/src/main/webapp/index.jsp +++ /dev/null @@ -1,83 +0,0 @@ -<%-- - * - * @author Shengzhao Li ---%> - -<%@ page contentType="text/html;charset=UTF-8" language="java" %> - - - - Home - - -

Spring Security&Oauth2 Client is work!

- - -
- 操作说明: -
    -
  1. -

    - spring-oauth-client 的实现没有使用开源项目 spring-security-oauth2 中提供的代码与配置, 如:<oauth:client - id="oauth2ClientFilter" /> -

    -
  2. -
  3. -

    - 按照Oauth2支持的grant_type依次去实现. 共5类. -
    -

      -
    • authorization_code
    • -
    • password
    • -
    • client_credentials
    • -
    • implicit
    • -
    • refresh_token
    • -
    -
  4. -
  5. -

    - - 在开始使用之前, 请确保 spring-oauth-server - 项目已正确运行, 且 spring-oauth-client.properties (位于项目的 src/main/resources 目录) 配置正确 - -

    -
  6. -
-
-
- -

- Δ 注意: 项目前端使用了 Angular-JS 来处理动态数据展现. -

-
- -
- 菜单 -
    -
  • -

    authorization_code
    授权码模式(即先登录获取code,再获取token) [最常用]

    -
  • -
  • -

    password
    密码模式(将用户名,密码传过去,直接获取token) [适用于移动设备]

    -
  • -
  • -

    client_credentials
    客户端模式(无用户,用户向客户端注册,然后客户端以自己的名义向'服务端'获取资源)

    -
  • -
  • -

    implicit
    简化模式(在redirect_uri 的Hash传递token; Auth客户端运行在浏览器中,如JS,Flash)

    -
  • -
  • -

    refresh_token
    刷新access_token

    -
  • -
-
- -

- 注意: 在测试时默认填写的数据有可能不正确, 建议先在 spring-oauth-server - 添加 client_details 后, 使用其client_id, client_secret来进行测试. -

-
- - \ No newline at end of file diff --git a/src/test/java/com/andaily/springoauth/ContextTest.java b/src/test/java/com/andaily/springoauth/ContextTest.java new file mode 100644 index 0000000000000000000000000000000000000000..406fa0237d4484efe7cebf20cb42f51dbe974ec4 --- /dev/null +++ b/src/test/java/com/andaily/springoauth/ContextTest.java @@ -0,0 +1,23 @@ +package com.andaily.springoauth; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.transaction.BeforeTransaction; + +/** + * @author Shengzhao Li + * @since 2.0.0 + */ + +@SpringBootTest +@TestPropertySource(locations = "classpath:application-test.properties") +public abstract class ContextTest { + + + @BeforeTransaction + public void before() throws Exception { + + } + + +} \ No newline at end of file diff --git a/src/test/java/com/andaily/springoauth/SpringOAuthClientApplicationTest.java b/src/test/java/com/andaily/springoauth/SpringOAuthClientApplicationTest.java new file mode 100644 index 0000000000000000000000000000000000000000..e76af6d51bbd784a00a8d6e1061aad8a04a6cfff --- /dev/null +++ b/src/test/java/com/andaily/springoauth/SpringOAuthClientApplicationTest.java @@ -0,0 +1,25 @@ +package com.andaily.springoauth; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 2023/11/6 23:05 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +@SpringBootTest +@TestPropertySource(locations = "classpath:application-test.properties") +class SpringOAuthClientApplicationTest { + + + @Test + public void contextLoads() { + } + + +} \ No newline at end of file diff --git a/src/test/java/com/andaily/springoauth/client/AndroidClientTest.java b/src/test/java/com/andaily/springoauth/client/AndroidClientTest.java index 9e9d4986d6899d0ab07c4c4ed8817364b68651a7..99da0809c6ec0acb108aa058d60d375965819883 100644 --- a/src/test/java/com/andaily/springoauth/client/AndroidClientTest.java +++ b/src/test/java/com/andaily/springoauth/client/AndroidClientTest.java @@ -16,9 +16,11 @@ import com.andaily.springoauth.service.dto.AccessTokenDto; import com.andaily.springoauth.service.dto.UserDto; import com.andaily.springoauth.service.impl.AccessTokenResponseHandler; import com.andaily.springoauth.service.impl.UserDtoResponseHandler; -import org.testng.annotations.Test; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.testng.Assert.assertNotNull; /** * 2015/11/6 @@ -31,25 +33,28 @@ public class AndroidClientTest { /** - * http://localhost:8080/som/oauth/token?client_id=mobile-client&client_secret=mobile&grant_type=password&scope=read,write&username=mobile&password=mobile + * http://localhost:8080/oauth2/token?client_id=mobile-client&client_secret=mobile&grant_type=password&scope=read,write&username=mobile&password=mobile + *

+ * TODO: 注意: OAuth2.1中不再支持 password 模式, 请使用 authorization_code 模式 或 device_code 模式 替换 * - * @throws Exception + * @throws Exception e */ - @Test(enabled = false) + @Test + @Disabled public void getAccessToken() throws Exception { /* - * 对于每一类设备(client), clientId, clientSecret 是固定的 - * */ + * 对于每一类设备(client), clientId, clientSecret 是固定的 + * */ String clientId = "passw"; String clientSecret = "passwpassw"; String authUrl = "http://localhost:8080/som/oauth/token"; /* - * 用户在 UI界面上输入 username, password - * */ + * 用户在 UI界面上输入 username, password + * */ String username = "mobile"; String password = "mobile"; @@ -63,8 +68,8 @@ public class AndroidClientTest { final AccessTokenDto accessTokenDto = tokenResponseHandler.getAccessTokenDto(); assertNotNull(accessTokenDto); - System.out.println("access_token = " + accessTokenDto.getAccessToken()); - System.out.println(accessTokenDto.getOriginalText()); +// System.out.println("access_token = " + accessTokenDto.getAccessToken()); +// System.out.println(accessTokenDto.getOriginalText()); } @@ -74,7 +79,8 @@ public class AndroidClientTest { * * @throws Exception */ - @Test(enabled = false) + @Test + @Disabled public void getResource() throws Exception { String accessToken = "e07b43a3-1b33-4b59-b8e0-2c0445f52b3f"; @@ -92,8 +98,8 @@ public class AndroidClientTest { final UserDto userDto = responseHandler.getUserDto(); assertNotNull(userDto); - System.out.println(userDto.getOriginalText()); - System.out.println("username = " + userDto.getUsername()); +// System.out.println(userDto.getOriginalText()); +// System.out.println("username = " + userDto.getUsername()); } diff --git a/src/test/java/com/andaily/springoauth/infrastructure/JwtBearerUtilsTest.java b/src/test/java/com/andaily/springoauth/infrastructure/JwtBearerUtilsTest.java new file mode 100644 index 0000000000000000000000000000000000000000..262dd223a4d7f1caf43277fab137c9acd7ae274b --- /dev/null +++ b/src/test/java/com/andaily/springoauth/infrastructure/JwtBearerUtilsTest.java @@ -0,0 +1,57 @@ +package com.andaily.springoauth.infrastructure; + +import com.nimbusds.jose.jwk.JWK; +import org.junit.jupiter.api.Test; + +import static com.andaily.springoauth.web.controller.JwtBearerJwksController.ES256_KEY; +import static com.andaily.springoauth.web.controller.JwtBearerJwksController.RS256_KEY; +import static org.junit.jupiter.api.Assertions.*; + +/** + * 2023/11/9 15:54 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +class JwtBearerUtilsTest { + + /** + * client id + */ + private final String clientId = "vLIXDF9GXg6Psfh1uzwVFUj0fucX2Zn9"; + + /** + * auth-server 地址 + */ + private final String audience = "http://127.0.0.1:8080"; + + + @Test + void generateRsAssertion() throws Exception { + + JWK rsJwk = JWK.parse(RS256_KEY); + + String assertion = JwtBearerUtils.generateRsAssertion(clientId, audience, rsJwk); + assertNotNull(assertion); + } + + @Test + void generateEsAssertion() throws Exception { + + JWK esJwk = JWK.parse(ES256_KEY); + + String assertion = JwtBearerUtils.generateEsAssertion(clientId, audience, esJwk); + assertNotNull(assertion); + } + + @Test + void generateMacAssertion() throws Exception { + + + // client_secret 加密后的值, 从 spring-oauth-server 数据库中查询获取 + String macSecret = "$2a$10$kjjdfA8SIuhlVx0q4B1GYeU..9TNU9.Aj6Vdc2v/iQTJhhmT/0xCi"; + + String assertion = JwtBearerUtils.generateMacAssertion(clientId, audience, macSecret); + assertNotNull(assertion); + } +} \ No newline at end of file diff --git a/src/test/java/com/andaily/springoauth/infrastructure/OAuth2HolderTest.java b/src/test/java/com/andaily/springoauth/infrastructure/OAuth2HolderTest.java new file mode 100644 index 0000000000000000000000000000000000000000..cbd3effc60c094814a5728ca8515cfed15ab69e6 --- /dev/null +++ b/src/test/java/com/andaily/springoauth/infrastructure/OAuth2HolderTest.java @@ -0,0 +1,27 @@ +package com.andaily.springoauth.infrastructure; + +import com.andaily.springoauth.ContextTest; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 2023/11/7 10:40 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +class OAuth2HolderTest extends ContextTest { + + + @Test + @Disabled + void properties() { + + assertEquals("http://localhost:8080/oauth2/token", OAuth2Holder.tokenUrl()); + + + } + +} \ No newline at end of file diff --git a/src/test/java/com/andaily/springoauth/infrastructure/PKCEUtilsTest.java b/src/test/java/com/andaily/springoauth/infrastructure/PKCEUtilsTest.java new file mode 100644 index 0000000000000000000000000000000000000000..013e6336803b4b63f0f6f607b8582869c3c395d4 --- /dev/null +++ b/src/test/java/com/andaily/springoauth/infrastructure/PKCEUtilsTest.java @@ -0,0 +1,57 @@ +package com.andaily.springoauth.infrastructure; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 2023/11/8 19:57 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +class PKCEUtilsTest { + + @Test + void generateCodeVerifier() { + + String verifier = PKCEUtils.generateCodeVerifier(); + assertNotNull(verifier); + assertTrue(verifier.length() >= 32); + } + + + @Test + void generateCodeChallenge() { + + String verifier = PKCEUtils.generateCodeVerifier(); + assertNotNull(verifier); + + String challenge = PKCEUtils.generateCodeChallenge(verifier); + assertNotNull(challenge); + + } + + + /** + * PKCE 需要的参数生成测试 + * code_challenge_method : S256 (alg: SHA-256) 固定值 + * code_verifier : 随机生成且base64 encode的值 (推荐随机值至少32位) + * code_challenge : 对 code_verifier 使用指定算法进行计算(digest)并base encode的值 + * + */ + @Test + void pkceFlow() { + + // 1. 随机生成code_verifier + String codeVerifier = PKCEUtils.generateCodeVerifier(); +// System.out.println("code_verifier -> " + codeVerifier); + + //2. 按指定算法计算 挑战码 code_challenge + String codeChallenge = PKCEUtils.generateCodeChallenge(codeVerifier); + + assertNotNull(codeChallenge); +// System.out.println("code_challenge -> " + codeChallenge); + } + +} \ No newline at end of file diff --git a/src/test/java/com/andaily/springoauth/infrastructure/repository/ClientDetailsRepositoryFileTest.java b/src/test/java/com/andaily/springoauth/infrastructure/repository/ClientDetailsRepositoryFileTest.java new file mode 100644 index 0000000000000000000000000000000000000000..859c5299ed174e9759d7b086c683a0ccadf49247 --- /dev/null +++ b/src/test/java/com/andaily/springoauth/infrastructure/repository/ClientDetailsRepositoryFileTest.java @@ -0,0 +1,41 @@ +package com.andaily.springoauth.infrastructure.repository; + +import com.andaily.springoauth.ContextTest; +import com.andaily.springoauth.service.dto.ClientDetailsDto; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 2023/11/7 15:39 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +class ClientDetailsRepositoryFileTest extends ContextTest { + + + @Autowired + private ClientDetailsRepositoryFile repositoryFile; + + + @Test + void saveClientDetails() { + + ClientDetailsDto dto = new ClientDetailsDto(); + dto.setClientId("client_id"); + dto.setClientSecret("client_secret"); + dto.setSupportPkce(true); + dto.setRedirectUris("https://..."); + dto.setAuthorizationGrantTypes("authorization_code"); + + repositoryFile.saveClientDetails(dto); + + ClientDetailsDto clientDetails = repositoryFile.findDefaultClientDetails(); + assertNotNull(clientDetails); + assertNotNull(clientDetails.getClientId()); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/andaily/springoauth/service/JwksTest.java b/src/test/java/com/andaily/springoauth/service/JwksTest.java new file mode 100644 index 0000000000000000000000000000000000000000..e76c8b34a65cfd336e496f2a138c264492ed02c0 --- /dev/null +++ b/src/test/java/com/andaily/springoauth/service/JwksTest.java @@ -0,0 +1,109 @@ +package com.andaily.springoauth.service; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.RSAKey; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Set; + +import static com.nimbusds.jose.jwk.KeyOperation.*; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * 2023/11/8 15:12 + *

+ * JWK + * generate + * + * @author Shengzhao Li + * @since 2.0.0 + */ +public class JwksTest { + + + /** + * ES256 jwk generate + * + * @throws Exception e + */ + @Test + void jwkEC() throws Exception { + + Curve point = Curve.P_256; +// Curve point = Curve.P_384; +// Curve point = Curve.P_521; + + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + keyPairGenerator.initialize(point.toECParameterSpec()); + + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + PublicKey aPublic = keyPair.getPublic(); + PrivateKey aPrivate = keyPair.getPrivate(); + + + ECKey key = new ECKey.Builder(point, (ECPublicKey) aPublic) + .privateKey(aPrivate) + .keyOperations(Set.of( + SIGN, + VERIFY, + ENCRYPT, + DECRYPT, + DERIVE_KEY)) + // keyId 必须唯一 + .keyID("sos-ecc-kidxx") + .algorithm(JWSAlgorithm.ES256) + .build(); + assertNotNull(key); + + String json = key.toJSONString(); + assertNotNull(json); +// System.out.println(json); + + + } + + /** + * RS256 jwk generate + * + * @throws Exception e + */ + @Test + void jwkRS() throws Exception { + + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + PrivateKey aPrivate = keyPair.getPrivate(); + PublicKey aPublic = keyPair.getPublic(); + + + RSAKey key = new RSAKey.Builder((RSAPublicKey) aPublic) + .privateKey(aPrivate) +// .keyUse(KeyUse.SIGNATURE) + .keyOperations(Set.of( + SIGN, + VERIFY, + ENCRYPT, + DECRYPT, + DERIVE_KEY)) + .algorithm(JWSAlgorithm.RS256) + .keyID("sos-rsa-kidx") + .build(); + + assertNotNull(key); + String json = key.toJSONString(); + assertNotNull(json); +// System.out.println(json); + } + +} diff --git a/src/test/java/com/andaily/springoauth/service/JwtBearerFlowTest.java b/src/test/java/com/andaily/springoauth/service/JwtBearerFlowTest.java new file mode 100644 index 0000000000000000000000000000000000000000..f519ab3adb9cde58fc0b71f2c4929475f0e1b53a --- /dev/null +++ b/src/test/java/com/andaily/springoauth/service/JwtBearerFlowTest.java @@ -0,0 +1,92 @@ +package com.andaily.springoauth.service; + +import com.andaily.springoauth.infrastructure.JwtBearerUtils; +import com.nimbusds.jose.jwk.JWK; +import org.junit.jupiter.api.Test; + +import static com.andaily.springoauth.web.controller.JwtBearerJwksController.ES256_KEY; +import static com.andaily.springoauth.web.controller.JwtBearerJwksController.RS256_KEY; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * 2023/11/08 10:25 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +public class JwtBearerFlowTest { + + + /** + * RSA 生成 assertion + * SignatureAlgorithm: RS256 + * method: PRIVATE_KEY_JWT + * + * @throws Exception e + */ + @Test + void rs256Assertion() throws Exception { + + JWK rsJwk = JWK.parse(RS256_KEY); + + String clientId = "dofOx6hjxlWw9qe2bnFvqbiPhuWwGWdn"; + String aud = "http://127.0.0.1:8080"; + + + // 将 assertion 复制放到请求参数 client_assertion 的值 + String assertion = JwtBearerUtils.generateRsAssertion(clientId, aud, rsJwk); + assertNotNull(assertion); +// System.out.println(assertion); + + } + + /** + * ES 生成 assertion + * SignatureAlgorithm: ES256 + * method: PRIVATE_KEY_JWT + * + * @throws Exception e + */ + @Test + void es256Assertion() throws Exception { + + JWK rsJwk = JWK.parse(ES256_KEY); + + String clientId = "pRC9j1mwGNMuchoI8nwJ6blr1lmPBLha"; + String aud = "http://127.0.0.1:8080"; + + // 将 assertion 复制放到请求参数 client_assertion 的值 + String assertion = JwtBearerUtils.generateEsAssertion(clientId, aud, rsJwk); + assertNotNull(assertion); +// System.out.println(assertion); + + } + + + /** + * MAC 生成 assertion + * HS256 + * method: CLIENT_SECRET_JWT + *

+ * TODO: 不推荐使用 + * + * @throws Exception e + */ + @Test + void macAssertion() throws Exception { + + String clientId = "vLIXDF9GXg6Psfh1uzwVFUj0fucX2Zn9"; + String aud = "http://127.0.0.1:8080"; + + // client_secret 加密后的值, 从 spring-oauth-server 数据库中查询获取 + String macSecret = "$2a$10$kjjdfA8SIuhlVx0q4B1GYeU..9TNU9.Aj6Vdc2v/iQTJhhmT/0xCi"; + + + // 将 assertion 复制放到请求参数 client_assertion 的值 + String assertion = JwtBearerUtils.generateMacAssertion(clientId, aud, macSecret); + assertNotNull(assertion); +// System.out.println(assertion); + + } + +} diff --git a/src/test/java/com/andaily/springoauth/service/impl/OauthServiceImplTest.java b/src/test/java/com/andaily/springoauth/service/impl/OauthServiceImplTest.java index ebf64b00f7547458b7c7dd7aeaf3ffb700b349a7..d44a4cea4936900d3bfb00a7867a03ce2496200b 100644 --- a/src/test/java/com/andaily/springoauth/service/impl/OauthServiceImplTest.java +++ b/src/test/java/com/andaily/springoauth/service/impl/OauthServiceImplTest.java @@ -2,9 +2,10 @@ package com.andaily.springoauth.service.impl; import com.andaily.springoauth.infrastructure.json.JsonUtils; import com.andaily.springoauth.service.dto.AccessTokenDto; -import org.testng.annotations.Test; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.testng.Assert.assertNotNull; /** * @author Shengzhao Li @@ -20,7 +21,7 @@ public class OauthServiceImplTest { final AccessTokenDto accessTokenDto = JsonUtils.textToBean(new AccessTokenDto(), text); assertNotNull(accessTokenDto); - System.out.println(accessTokenDto); +// System.out.println(accessTokenDto); } diff --git a/src/test/java/com/andaily/springoauth/service/impl/UserDtoResponseHandlerTest.java b/src/test/java/com/andaily/springoauth/service/impl/UserDtoResponseHandlerTest.java index 057d361385677ec098531478be56744cf40460cb..0888ed31b53bef76aef9426f23c72eb8ac340cde 100644 --- a/src/test/java/com/andaily/springoauth/service/impl/UserDtoResponseHandlerTest.java +++ b/src/test/java/com/andaily/springoauth/service/impl/UserDtoResponseHandlerTest.java @@ -1,7 +1,7 @@ package com.andaily.springoauth.service.impl; import com.andaily.springoauth.service.dto.UserDto; -import org.testng.annotations.Test; +import org.junit.jupiter.api.Test; import org.xml.sax.Attributes; import org.xml.sax.SAXException; import org.xml.sax.helpers.DefaultHandler; @@ -10,7 +10,8 @@ import javax.xml.parsers.SAXParser; import javax.xml.parsers.SAXParserFactory; import java.io.ByteArrayInputStream; -import static org.testng.Assert.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; + /** * @author Shengzhao Li @@ -54,7 +55,7 @@ public class UserDtoResponseHandlerTest { }); - System.out.println(userDto); +// System.out.println(userDto); } } \ No newline at end of file diff --git a/src/test/java/com/andaily/springoauth/service/password/PasswordOauthHandlerTest.java b/src/test/java/com/andaily/springoauth/service/password/PasswordOauthHandlerTest.java index e23b041ca6e9d1aaa65b8b49f72adf2e7aaccb02..ba1777204e9408eca00789388fa3e87254783be2 100644 --- a/src/test/java/com/andaily/springoauth/service/password/PasswordOauthHandlerTest.java +++ b/src/test/java/com/andaily/springoauth/service/password/PasswordOauthHandlerTest.java @@ -2,14 +2,14 @@ package com.andaily.springoauth.service.password; import com.andaily.springoauth.service.dto.AccessTokenDto; import com.andaily.springoauth.service.dto.UserDto; -import org.testng.annotations.BeforeTest; -import org.testng.annotations.Test; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + import java.util.UUID; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.*; + /** * @author Shengzhao Li @@ -20,7 +20,7 @@ public class PasswordOauthHandlerTest { private PasswordOauthHandler passwordOauthHandler; - @BeforeTest +// @BeforeTest public void before() { this.passwordOauthHandler = new PasswordOauthHandler(); } @@ -31,8 +31,10 @@ public class PasswordOauthHandlerTest { * * @throws Exception */ - @Test(enabled = false) + @Test + @Disabled public void getAccessToken() throws Exception { + before(); final String accessTokenUri = "http://localhost:8080/spring-oauth-server/oauth/token"; /* @@ -116,7 +118,8 @@ public class PasswordOauthHandlerTest { } - @Test(enabled = false) + @Test + @Disabled public void getMobileUserDto() throws Exception { final String accessTokenUri = "http://localhost:8080/spring-oauth-server/oauth/token"; diff --git a/src/test/java/com/andaily/springoauth/web/UnityControllerTest.java b/src/test/java/com/andaily/springoauth/web/UnityControllerTest.java index 70d99235efb0b6fcba44c35f4581ad2fefaaf227..1c63d76d2ba11c45ded8f0be60f1dd397e18a7ff 100644 --- a/src/test/java/com/andaily/springoauth/web/UnityControllerTest.java +++ b/src/test/java/com/andaily/springoauth/web/UnityControllerTest.java @@ -1,6 +1,8 @@ package com.andaily.springoauth.web; -import org.testng.annotations.Test; + + +import org.junit.jupiter.api.Test; import java.net.URLEncoder; diff --git a/src/test/java/com/andaily/springoauth/web/controller/JwtBearerJwksControllerTest.java b/src/test/java/com/andaily/springoauth/web/controller/JwtBearerJwksControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..58999cd951f2bb5ae78757cad2d4ac3c07c57d58 --- /dev/null +++ b/src/test/java/com/andaily/springoauth/web/controller/JwtBearerJwksControllerTest.java @@ -0,0 +1,22 @@ +package com.andaily.springoauth.web.controller; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 2023/11/8 19:48 + * + * @author Shengzhao Li + * @since 2.0.0 + */ +class JwtBearerJwksControllerTest { + + + @Test + void jwks() { + + + } + +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000000000000000000000000000000000000..c5f5e90a5f466b7290272839d17548bc2727c59a --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,16 @@ +# +spring.application.name=spring-oauth-client +# +server.port=8082 +# +# MVC +spring.thymeleaf.encoding=UTF-8 +spring.thymeleaf.cache=false +# +# spring-oauth-client application host +#Must be end with '/' +application-host=http://localhost:${server.port}}/ +# +# spring-oauth-server or myoidc-server host; since 2.0.0 +oauth2.server.host=http://localhost:8080 + diff --git a/src/test/resources/log4j.properties b/src/test/resources/log4j.properties deleted file mode 100644 index 3d0d5fdebcc174f2907f0bbb65874cfd7bd41c8c..0000000000000000000000000000000000000000 --- a/src/test/resources/log4j.properties +++ /dev/null @@ -1,8 +0,0 @@ -log4j.rootLogger=INFO, stdout - -log4j.appender.stdout=org.apache.log4j.ConsoleAppender -#log4j.appender.stdout.Threshold=INFO -#log4j.appender.stdout.ImmediateFlush=true -log4j.appender.stdout.Target=System.out -log4j.appender.stdout.layout=org.apache.log4j.PatternLayout -log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] - %m%n diff --git a/src/test/resources/test.properties b/src/test/resources/test.properties deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000