Browse Source

Merge remote-tracking branch 'origin/master' into dev

xingyu 2 years ago
parent
commit
0143749988
52 changed files with 2521 additions and 53 deletions
  1. 3 1
      pom.xml
  2. 22 0
      yudao-example/pom.xml
  3. 65 0
      yudao-example/yudao-sso-demo-by-code/pom.xml
  4. 13 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/SSODemoApplication.java
  5. 157 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/OAuth2Client.java
  6. 73 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/UserClient.java
  7. 28 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/CommonResult.java
  8. 45 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/oauth2/OAuth2AccessTokenRespDTO.java
  9. 59 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/oauth2/OAuth2CheckTokenRespDTO.java
  10. 97 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/user/UserInfoRespDTO.java
  11. 35 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/user/UserUpdateReqDTO.java
  12. 63 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/controller/AuthController.java
  13. 40 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/controller/UserController.java
  14. 48 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/config/SecurityConfiguration.java
  15. 37 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/LoginUser.java
  16. 66 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/filter/TokenAuthenticationFilter.java
  17. 44 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/handler/AccessDeniedHandlerImpl.java
  18. 36 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/handler/AuthenticationEntryPointImpl.java
  19. 103 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/util/SecurityUtils.java
  20. 28 0
      yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/util/ServletUtils.java
  21. 2 0
      yudao-example/yudao-sso-demo-by-code/src/main/resources/application.yaml
  22. 61 0
      yudao-example/yudao-sso-demo-by-code/src/main/resources/static/callback.html
  23. 159 0
      yudao-example/yudao-sso-demo-by-code/src/main/resources/static/index.html
  24. 65 0
      yudao-example/yudao-sso-demo-by-password/pom.xml
  25. 13 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/SSODemoApplication.java
  26. 127 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/OAuth2Client.java
  27. 73 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/UserClient.java
  28. 28 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/CommonResult.java
  29. 45 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/oauth2/OAuth2AccessTokenRespDTO.java
  30. 59 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/oauth2/OAuth2CheckTokenRespDTO.java
  31. 97 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/user/UserInfoRespDTO.java
  32. 35 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/user/UserUpdateReqDTO.java
  33. 50 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/controller/AuthController.java
  34. 40 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/controller/UserController.java
  35. 48 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/config/SecurityConfiguration.java
  36. 37 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/LoginUser.java
  37. 66 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/filter/TokenAuthenticationFilter.java
  38. 44 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/handler/AccessDeniedHandlerImpl.java
  39. 36 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/handler/AuthenticationEntryPointImpl.java
  40. 103 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/util/SecurityUtils.java
  41. 28 0
      yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/util/ServletUtils.java
  42. 2 0
      yudao-example/yudao-sso-demo-by-password/src/main/resources/application.yaml
  43. 154 0
      yudao-example/yudao-sso-demo-by-password/src/main/resources/static/index.html
  44. 74 0
      yudao-example/yudao-sso-demo-by-password/src/main/resources/static/login.html
  45. 1 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/OAuth2OpenCheckTokenRespVO.java
  46. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/user/OAuth2UserInfoRespVO.java
  47. 1 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/tenant/TenantPackageDO.java
  48. 5 5
      yudao-server/src/main/resources/application-local.yaml
  49. 2 1
      yudao-ui-admin/src/permission.js
  50. 1 1
      yudao-ui-admin/src/views/login.vue
  51. 1 1
      yudao-ui-admin/src/views/socialLogin.vue
  52. 1 42
      yudao-ui-admin/src/views/sso.vue

+ 3 - 1
pom.xml

@@ -12,7 +12,7 @@
         <module>yudao-framework</module>
         <!-- Server 主项目 -->
         <module>yudao-server</module>
-        <!--  各种 module 拓展 -->
+        <!-- 各种 module 拓展 -->
         <module>yudao-module-member</module>
         <module>yudao-module-bpm</module>
         <module>yudao-module-system</module>
@@ -20,6 +20,8 @@
         <module>yudao-module-pay</module>
         <module>yudao-module-mall</module>
         <module>yudao-module-visualization</module>
+        <!-- 示例项目 -->
+        <module>yudao-example</module>
     </modules>
 
     <name>${project.artifactId}</name>

+ 22 - 0
yudao-example/pom.xml

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <!-- 由于方便大家拷贝,使用不使用 yudao 作为 Maven parent -->
+
+    <groupId>cn.iocoder.boot</groupId>
+    <artifactId>yudao-example</artifactId>
+    <version>1.0.0-snapshot</version>
+    <packaging>pom</packaging>
+    <modules>
+        <module>yudao-sso-demo-by-code</module>
+        <module>yudao-sso-demo-by-password</module>
+    </modules>
+
+    <name>${project.artifactId}</name>
+    <description>提供各种示例,例如说:SSO 单点登录</description>
+    <url>https://github.com/YunaiV/ruoyi-vue-pro</url>
+
+</project>

+ 65 - 0
yudao-example/yudao-sso-demo-by-code/pom.xml

@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <!-- 由于方便大家拷贝,使用不使用 yudao 作为 Maven parent -->
+
+    <groupId>cn.iocoder.boot</groupId>
+    <artifactId>yudao-sso-demo-by-code</artifactId>
+    <version>1.0.0-snapshot</version>
+    <packaging>jar</packaging>
+
+    <name>${project.artifactId}</name>
+    <description>基于授权码模式,如何实现 SSO 单点登录?</description>
+    <url>https://github.com/YunaiV/ruoyi-vue-pro</url>
+
+    <properties>
+        <!-- Maven 相关 -->
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <!-- 统一依赖管理 -->
+        <spring.boot.version>2.6.10</spring.boot.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <!-- 统一依赖管理 -->
+            <dependency>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-dependencies</artifactId>
+                <version>${spring.boot.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <!-- Web 相关 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>cn.hutool</groupId>
+            <artifactId>hutool-all</artifactId>
+            <version>5.8.5</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+    </dependencies>
+
+</project>

+ 13 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/SSODemoApplication.java

@@ -0,0 +1,13 @@
+package cn.iocoder.yudao.ssodemo;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SSODemoApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(SSODemoApplication.class, args);
+    }
+
+}

+ 157 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/OAuth2Client.java

@@ -0,0 +1,157 @@
+package cn.iocoder.yudao.ssodemo.client;
+
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.client.dto.oauth2.OAuth2AccessTokenRespDTO;
+import cn.iocoder.yudao.ssodemo.client.dto.oauth2.OAuth2CheckTokenRespDTO;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.*;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+import org.springframework.util.Base64Utils;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * OAuth 2.0 客户端
+ *
+ * 对应调用 OAuth2OpenController 接口
+ */
+@Component
+public class OAuth2Client {
+
+    private static final String BASE_URL = "http://127.0.0.1:48080/admin-api/system/oauth2";
+
+    /**
+     * 租户编号
+     *
+     * 默认使用 1;如果使用别的租户,可以调整
+     */
+    public static final Long TENANT_ID = 1L;
+
+    private static final String CLIENT_ID = "yudao-sso-demo-by-code";
+    private static final String CLIENT_SECRET = "test";
+
+
+//    @Resource // 可优化,注册一个 RestTemplate Bean,然后注入
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    /**
+     * 使用 code 授权码,获得访问令牌
+     *
+     * @param code        授权码
+     * @param redirectUri 重定向 URI
+     * @return 访问令牌
+     */
+    public CommonResult<OAuth2AccessTokenRespDTO> postAccessToken(String code, String redirectUri) {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.set("tenant-id", TENANT_ID.toString());
+        addClientHeader(headers);
+        // 1.2 构建请求参数
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+        body.add("grant_type", "authorization_code");
+        body.add("code", code);
+        body.add("redirect_uri", redirectUri);
+//        body.add("state", ""); // 选填;填了会校验
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<OAuth2AccessTokenRespDTO>> exchange = restTemplate.exchange(
+                BASE_URL + "/token",
+                HttpMethod.POST,
+                new HttpEntity<>(body, headers),
+                new ParameterizedTypeReference<CommonResult<OAuth2AccessTokenRespDTO>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+    /**
+     * 校验访问令牌,并返回它的基本信息
+     *
+     * @param token 访问令牌
+     * @return 访问令牌的基本信息
+     */
+    public CommonResult<OAuth2CheckTokenRespDTO> checkToken(String token) {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.set("tenant-id", TENANT_ID.toString());
+        addClientHeader(headers);
+        // 1.2 构建请求参数
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+        body.add("token", token);
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<OAuth2CheckTokenRespDTO>> exchange = restTemplate.exchange(
+                BASE_URL + "/check-token",
+                HttpMethod.POST,
+                new HttpEntity<>(body, headers),
+                new ParameterizedTypeReference<CommonResult<OAuth2CheckTokenRespDTO>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+    /**
+     * 使用刷新令牌,获得(刷新)访问令牌
+     *
+     * @param refreshToken 刷新令牌
+     * @return 访问令牌
+     */
+    public CommonResult<OAuth2AccessTokenRespDTO> refreshToken(String refreshToken) {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.set("tenant-id", TENANT_ID.toString());
+        addClientHeader(headers);
+        // 1.2 构建请求参数
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+        body.add("grant_type", "refresh_token");
+        body.add("refresh_token", refreshToken);
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<OAuth2AccessTokenRespDTO>> exchange = restTemplate.exchange(
+                BASE_URL + "/token",
+                HttpMethod.POST,
+                new HttpEntity<>(body, headers),
+                new ParameterizedTypeReference<CommonResult<OAuth2AccessTokenRespDTO>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+    /**
+     * 删除访问令牌
+     *
+     * @param token 访问令牌
+     * @return 成功
+     */
+    public CommonResult<Boolean> revokeToken(String token) {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.set("tenant-id", TENANT_ID.toString());
+        addClientHeader(headers);
+        // 1.2 构建请求参数
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+        body.add("token", token);
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<Boolean>> exchange = restTemplate.exchange(
+                BASE_URL + "/token",
+                HttpMethod.DELETE,
+                new HttpEntity<>(body, headers),
+                new ParameterizedTypeReference<CommonResult<Boolean>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+    private static void addClientHeader(HttpHeaders headers) {
+        // client 拼接,需要 BASE64 编码
+        String client = CLIENT_ID + ":" + CLIENT_SECRET;
+        client = Base64Utils.encodeToString(client.getBytes(StandardCharsets.UTF_8));
+        headers.add("Authorization", "Basic " + client);
+    }
+
+}

+ 73 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/UserClient.java

@@ -0,0 +1,73 @@
+package cn.iocoder.yudao.ssodemo.client;
+
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.client.dto.user.UserInfoRespDTO;
+import cn.iocoder.yudao.ssodemo.client.dto.user.UserUpdateReqDTO;
+import cn.iocoder.yudao.ssodemo.framework.core.LoginUser;
+import cn.iocoder.yudao.ssodemo.framework.core.util.SecurityUtils;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.*;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * 用户 User 信息的客户端
+ *
+ * 对应调用 OAuth2UserController 接口
+ */
+@Component
+public class UserClient {
+
+    private static final String BASE_URL = "http://127.0.0.1:48080/admin-api//system/oauth2/user";
+
+    //    @Resource // 可优化,注册一个 RestTemplate Bean,然后注入
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    public CommonResult<UserInfoRespDTO> getUser() {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.set("tenant-id", OAuth2Client.TENANT_ID.toString());
+        addTokenHeader(headers);
+        // 1.2 构建请求参数
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<UserInfoRespDTO>> exchange = restTemplate.exchange(
+                BASE_URL + "/get",
+                HttpMethod.GET,
+                new HttpEntity<>(body, headers),
+                new ParameterizedTypeReference<CommonResult<UserInfoRespDTO>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+    public CommonResult<Boolean> updateUser(UserUpdateReqDTO updateReqDTO) {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.set("tenant-id", OAuth2Client.TENANT_ID.toString());
+        addTokenHeader(headers);
+        // 1.2 构建请求参数
+        // 使用 updateReqDTO 即可
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<Boolean>> exchange = restTemplate.exchange(
+                BASE_URL + "/update",
+                HttpMethod.PUT,
+                new HttpEntity<>(updateReqDTO, headers),
+                new ParameterizedTypeReference<CommonResult<Boolean>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+
+    private static void addTokenHeader(HttpHeaders headers) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        Assert.notNull(loginUser, "登录用户不能为空");
+        headers.add("Authorization", "Bearer " + loginUser.getAccessToken());
+    }
+}

+ 28 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/CommonResult.java

@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.ssodemo.client.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 通用返回
+ *
+ * @param <T> 数据泛型
+ */
+@Data
+public class CommonResult<T> implements Serializable {
+
+    /**
+     * 错误码
+     */
+    private Integer code;
+    /**
+     * 返回数据
+     */
+    private T data;
+    /**
+     * 错误提示,用户可阅读
+     */
+    private String msg;
+
+}

+ 45 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/oauth2/OAuth2AccessTokenRespDTO.java

@@ -0,0 +1,45 @@
+package cn.iocoder.yudao.ssodemo.client.dto.oauth2;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 访问令牌 Response DTO
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OAuth2AccessTokenRespDTO {
+
+    /**
+     * 访问令牌
+     */
+    @JsonProperty("access_token")
+    private String accessToken;
+
+    /**
+     * 刷新令牌
+     */
+    @JsonProperty("refresh_token")
+    private String refreshToken;
+
+    /**
+     * 令牌类型
+     */
+    @JsonProperty("token_type")
+    private String tokenType;
+
+    /**
+     * 过期时间;单位:秒
+     */
+    @JsonProperty("expires_in")
+    private Long expiresIn;
+
+    /**
+     * 授权范围;如果多个授权范围,使用空格分隔
+     */
+    private String scope;
+
+}

+ 59 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/oauth2/OAuth2CheckTokenRespDTO.java

@@ -0,0 +1,59 @@
+package cn.iocoder.yudao.ssodemo.client.dto.oauth2;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 校验令牌 Response DTO
+ *
+ * @author 芋道源码
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OAuth2CheckTokenRespDTO {
+
+    /**
+     * 用户编号
+     */
+    @JsonProperty("user_id")
+    private Long userId;
+    /**
+     * 用户类型
+     */
+    @JsonProperty("user_type")
+    private Integer userType;
+    /**
+     * 租户编号
+     */
+    @JsonProperty("tenant_id")
+    private Long tenantId;
+
+    /**
+     * 客户端编号
+     */
+    @JsonProperty("client_id")
+    private String clientId;
+    /**
+     * 授权范围
+     */
+    private List<String> scopes;
+
+    /**
+     * 访问令牌
+     */
+    @JsonProperty("access_token")
+    private String accessToken;
+
+    /**
+     * 过期时间
+     *
+     * 时间戳 / 1000,即单位:秒
+     */
+    private Long exp;
+
+}

+ 97 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/user/UserInfoRespDTO.java

@@ -0,0 +1,97 @@
+package cn.iocoder.yudao.ssodemo.client.dto.user;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 获得用户基本信息 Response dto
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserInfoRespDTO {
+
+    /**
+     * 用户编号
+     */
+    private Long id;
+
+    /**
+     * 用户账号
+     */
+    private String username;
+
+    /**
+     * 用户昵称
+     */
+    private String nickname;
+
+    /**
+     * 用户邮箱
+     */
+    private String email;
+    /**
+     * 手机号码
+     */
+    private String mobile;
+
+    /**
+     * 用户性别
+     */
+    private Integer sex;
+
+    /**
+     * 用户头像
+     */
+    private String avatar;
+
+    /**
+     * 所在部门
+     */
+    private Dept dept;
+
+    /**
+     * 所属岗位数组
+     */
+    private List<Post> posts;
+
+    /**
+     * 部门
+     */
+    @Data
+    public static class Dept {
+
+        /**
+         * 部门编号
+         */
+        private Long id;
+
+        /**
+         * 部门名称
+         */
+        private String name;
+
+    }
+
+    /**
+     * 岗位
+     */
+    @Data
+    public static class Post {
+
+        /**
+         * 岗位编号
+         */
+        private Long id;
+
+        /**
+         * 岗位名称
+         */
+        private String name;
+
+    }
+
+}

+ 35 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/user/UserUpdateReqDTO.java

@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.ssodemo.client.dto.user;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 更新用户基本信息 Request DTO
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserUpdateReqDTO {
+
+    /**
+     * 用户昵称
+     */
+    private String nickname;
+
+    /**
+     * 用户邮箱
+     */
+    private String email;
+
+    /**
+     * 手机号码
+     */
+    private String mobile;
+
+    /**
+     * 用户性别
+     */
+    private Integer sex;
+
+}

+ 63 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/controller/AuthController.java

@@ -0,0 +1,63 @@
+package cn.iocoder.yudao.ssodemo.controller;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.ssodemo.client.OAuth2Client;
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.client.dto.oauth2.OAuth2AccessTokenRespDTO;
+import cn.iocoder.yudao.ssodemo.framework.core.util.SecurityUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+@RequestMapping("/auth")
+public class AuthController {
+
+    @Resource
+    private OAuth2Client oauth2Client;
+
+    /**
+     * 使用 code 访问令牌,获得访问令牌
+     *
+     * @param code 授权码
+     * @param redirectUri 重定向 URI
+     * @return 访问令牌;注意,实际项目中,最好创建对应的 ResponseVO 类,只返回必要的字段
+     */
+    @PostMapping("/login-by-code")
+    public CommonResult<OAuth2AccessTokenRespDTO> loginByCode(@RequestParam("code") String code,
+                                                              @RequestParam("redirectUri") String redirectUri) {
+        return oauth2Client.postAccessToken(code, redirectUri);
+    }
+
+    /**
+     * 使用刷新令牌,获得(刷新)访问令牌
+     *
+     * @param refreshToken 刷新令牌
+     * @return 访问令牌;注意,实际项目中,最好创建对应的 ResponseVO 类,只返回必要的字段
+     */
+    @PostMapping("/refresh-token")
+    public CommonResult<OAuth2AccessTokenRespDTO> refreshToken(@RequestParam("refreshToken") String refreshToken) {
+        return oauth2Client.refreshToken(refreshToken);
+    }
+
+    /**
+     * 退出登录
+     *
+     * @param request 请求
+     * @return 成功
+     */
+    @PostMapping("/logout")
+    public CommonResult<Boolean> logout(HttpServletRequest request) {
+        String token = SecurityUtils.obtainAuthorization(request, "Authorization");
+        if (StrUtil.isNotBlank(token)) {
+            return oauth2Client.revokeToken(token);
+        }
+        // 返回成功
+        return new CommonResult<>();
+    }
+
+}

+ 40 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/controller/UserController.java

@@ -0,0 +1,40 @@
+package cn.iocoder.yudao.ssodemo.controller;
+
+import cn.iocoder.yudao.ssodemo.client.UserClient;
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.client.dto.user.UserInfoRespDTO;
+import cn.iocoder.yudao.ssodemo.client.dto.user.UserUpdateReqDTO;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+
+@RestController
+@RequestMapping("/user")
+public class UserController {
+
+    @Resource
+    private UserClient userClient;
+
+    /**
+     * 获得当前登录用户的基本信息
+     *
+     * @return 用户信息;注意,实际项目中,最好创建对应的 ResponseVO 类,只返回必要的字段
+     */
+    @GetMapping("/get")
+    public CommonResult<UserInfoRespDTO> getUser() {
+        return userClient.getUser();
+    }
+
+    /**
+     * 更新当前登录用户的昵称
+     *
+     * @param nickname 昵称
+     * @return 成功
+     */
+    @PutMapping("/update")
+    public CommonResult<Boolean> updateUser(@RequestParam("nickname") String nickname) {
+        UserUpdateReqDTO updateReqDTO = new UserUpdateReqDTO(nickname, null, null, null);
+        return userClient.updateUser(updateReqDTO);
+    }
+
+}

+ 48 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/config/SecurityConfiguration.java

@@ -0,0 +1,48 @@
+package cn.iocoder.yudao.ssodemo.framework.config;
+
+import cn.iocoder.yudao.ssodemo.framework.core.filter.TokenAuthenticationFilter;
+import cn.iocoder.yudao.ssodemo.framework.core.handler.AccessDeniedHandlerImpl;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+import javax.annotation.Resource;
+
+@Configuration
+public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
+
+    @Resource
+    private TokenAuthenticationFilter tokenAuthenticationFilter;
+
+    @Resource
+    private AccessDeniedHandlerImpl accessDeniedHandler;
+    @Resource
+    private AuthenticationEntryPoint authenticationEntryPoint;
+
+    @Override
+    protected void configure(HttpSecurity httpSecurity) throws Exception {
+        // 设置 URL 安全权限
+        httpSecurity.csrf().disable() // 禁用 CSRF 保护
+                .authorizeRequests()
+                // 1. 静态资源,可匿名访问
+                .antMatchers(HttpMethod.GET, "/*.html", "/**/*.html", "/**/*.css", "/**/*.js").permitAll()
+                // 2. 登录相关的接口,可匿名访问
+                .antMatchers("/auth/login-by-code").permitAll()
+                .antMatchers("/auth/refresh-token").permitAll()
+                .antMatchers("/auth/logout").permitAll()
+                // last. 兜底规则,必须认证
+                .and().authorizeRequests()
+                .anyRequest().authenticated();
+
+        // 设置处理器
+        httpSecurity.exceptionHandling().accessDeniedHandler(accessDeniedHandler)
+                .authenticationEntryPoint(authenticationEntryPoint);
+
+        // 添加 Token Filter
+        httpSecurity.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+    }
+
+}

+ 37 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/LoginUser.java

@@ -0,0 +1,37 @@
+package cn.iocoder.yudao.ssodemo.framework.core;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 登录用户信息
+ *
+ * @author 芋道源码
+ */
+@Data
+public class LoginUser {
+
+    /**
+     * 用户编号
+     */
+    private Long id;
+    /**
+     * 用户类型
+     */
+    private Integer userType;
+    /**
+     * 租户编号
+     */
+    private Long tenantId;
+    /**
+     * 授权范围
+     */
+    private List<String> scopes;
+
+    /**
+     * 访问令牌
+     */
+    private String accessToken;
+
+}

+ 66 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/filter/TokenAuthenticationFilter.java

@@ -0,0 +1,66 @@
+package cn.iocoder.yudao.ssodemo.framework.core.filter;
+
+import cn.iocoder.yudao.ssodemo.client.OAuth2Client;
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.client.dto.oauth2.OAuth2CheckTokenRespDTO;
+import cn.iocoder.yudao.ssodemo.framework.core.LoginUser;
+import cn.iocoder.yudao.ssodemo.framework.core.util.SecurityUtils;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.annotation.Resource;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * Token 过滤器,验证 token 的有效性
+ * 验证通过后,获得 {@link LoginUser} 信息,并加入到 Spring Security 上下文
+ *
+ * @author 芋道源码
+ */
+@Component
+public class TokenAuthenticationFilter extends OncePerRequestFilter {
+
+    @Resource
+    private OAuth2Client oauth2Client;
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+        // 1. 获得访问令牌
+        String token = SecurityUtils.obtainAuthorization(request, "Authorization");
+        if (StringUtils.hasText(token)) {
+            // 2. 基于 token 构建登录用户
+            LoginUser loginUser = buildLoginUserByToken(token);
+            // 3. 设置当前用户
+            if (loginUser != null) {
+                SecurityUtils.setLoginUser(loginUser, request);
+            }
+        }
+
+        // 继续过滤链
+        filterChain.doFilter(request, response);
+    }
+
+    private LoginUser buildLoginUserByToken(String token) {
+        try {
+            CommonResult<OAuth2CheckTokenRespDTO> accessTokenResult = oauth2Client.checkToken(token);
+            OAuth2CheckTokenRespDTO accessToken = accessTokenResult.getData();
+            if (accessToken == null) {
+                return null;
+            }
+            // 构建登录用户
+            return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType())
+                    .setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes())
+                    .setAccessToken(accessToken.getAccessToken());
+        } catch (Exception exception) {
+            // 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可
+            return null;
+        }
+    }
+
+}

+ 44 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/handler/AccessDeniedHandlerImpl.java

@@ -0,0 +1,44 @@
+package cn.iocoder.yudao.ssodemo.framework.core.handler;
+
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.framework.core.util.SecurityUtils;
+import cn.iocoder.yudao.ssodemo.framework.core.util.ServletUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.security.web.access.ExceptionTranslationFilter;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 访问一个需要认证的 URL 资源,已经认证(登录)但是没有权限的情况下,返回 {@link GlobalErrorCodeConstants#FORBIDDEN} 错误码。
+ *
+ * 补充:Spring Security 通过 {@link ExceptionTranslationFilter#handleAccessDeniedException(HttpServletRequest, HttpServletResponse, FilterChain, AccessDeniedException)} 方法,调用当前类
+ *
+ * @author 芋道源码
+ */
+@Component
+@SuppressWarnings("JavadocReference")
+@Slf4j
+public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
+
+    @Override
+    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e)
+            throws IOException, ServletException {
+        // 打印 warn 的原因是,不定期合并 warn,看看有没恶意破坏
+        log.warn("[commence][访问 URL({}) 时,用户({}) 权限不够]", request.getRequestURI(),
+                SecurityUtils.getLoginUserId(), e);
+        // 返回 403
+        CommonResult<Object> result = new CommonResult<>();
+        result.setCode(HttpStatus.FORBIDDEN.value());
+        result.setMsg("没有该操作权限");
+        ServletUtils.writeJSON(response, result);
+    }
+
+}

+ 36 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/handler/AuthenticationEntryPointImpl.java

@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.ssodemo.framework.core.handler;
+
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.framework.core.util.ServletUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.access.ExceptionTranslationFilter;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 访问一个需要认证的 URL 资源,但是此时自己尚未认证(登录)的情况下,返回 {@link GlobalErrorCodeConstants#UNAUTHORIZED} 错误码,从而使前端重定向到登录页
+ *
+ * 补充:Spring Security 通过 {@link ExceptionTranslationFilter#sendStartAuthentication(HttpServletRequest, HttpServletResponse, FilterChain, AuthenticationException)} 方法,调用当前类
+ */
+@Component
+@Slf4j
+@SuppressWarnings("JavadocReference") // 忽略文档引用报错
+public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
+
+    @Override
+    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
+        log.debug("[commence][访问 URL({}) 时,没有登录]", request.getRequestURI(), e);
+        // 返回 401
+        CommonResult<Object> result = new CommonResult<>();
+        result.setCode(HttpStatus.UNAUTHORIZED.value());
+        result.setMsg("账号未登录");
+        ServletUtils.writeJSON(response, result);
+    }
+
+}

+ 103 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/util/SecurityUtils.java

@@ -0,0 +1,103 @@
+package cn.iocoder.yudao.ssodemo.framework.core.util;
+
+import cn.iocoder.yudao.ssodemo.framework.core.LoginUser;
+import org.springframework.lang.Nullable;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.util.StringUtils;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Collections;
+
+/**
+ * 安全服务工具类
+ *
+ * @author 芋道源码
+ */
+public class SecurityUtils {
+
+    public static final String AUTHORIZATION_BEARER = "Bearer";
+
+    private SecurityUtils() {}
+
+    /**
+     * 从请求中,获得认证 Token
+     *
+     * @param request 请求
+     * @param header 认证 Token 对应的 Header 名字
+     * @return 认证 Token
+     */
+    public static String obtainAuthorization(HttpServletRequest request, String header) {
+        String authorization = request.getHeader(header);
+        if (!StringUtils.hasText(authorization)) {
+            return null;
+        }
+        int index = authorization.indexOf(AUTHORIZATION_BEARER + " ");
+        if (index == -1) { // 未找到
+            return null;
+        }
+        return authorization.substring(index + 7).trim();
+    }
+
+    /**
+     * 获得当前认证信息
+     *
+     * @return 认证信息
+     */
+    public static Authentication getAuthentication() {
+        SecurityContext context = SecurityContextHolder.getContext();
+        if (context == null) {
+            return null;
+        }
+        return context.getAuthentication();
+    }
+
+    /**
+     * 获取当前用户
+     *
+     * @return 当前用户
+     */
+    @Nullable
+    public static LoginUser getLoginUser() {
+        Authentication authentication = getAuthentication();
+        if (authentication == null) {
+            return null;
+        }
+        return authentication.getPrincipal() instanceof LoginUser ? (LoginUser) authentication.getPrincipal() : null;
+    }
+
+    /**
+     * 获得当前用户的编号,从上下文中
+     *
+     * @return 用户编号
+     */
+    @Nullable
+    public static Long getLoginUserId() {
+        LoginUser loginUser = getLoginUser();
+        return loginUser != null ? loginUser.getId() : null;
+    }
+
+    /**
+     * 设置当前用户
+     *
+     * @param loginUser 登录用户
+     * @param request 请求
+     */
+    public static void setLoginUser(LoginUser loginUser, HttpServletRequest request) {
+        // 创建 Authentication,并设置到上下文
+        Authentication authentication = buildAuthentication(loginUser, request);
+        SecurityContextHolder.getContext().setAuthentication(authentication);
+    }
+
+    private static Authentication buildAuthentication(LoginUser loginUser, HttpServletRequest request) {
+        // 创建 UsernamePasswordAuthenticationToken 对象
+        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
+                loginUser, null, Collections.emptyList());
+        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+        return authenticationToken;
+    }
+
+}

+ 28 - 0
yudao-example/yudao-sso-demo-by-code/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/util/ServletUtils.java

@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.ssodemo.framework.core.util;
+
+import cn.hutool.extra.servlet.ServletUtil;
+import cn.hutool.json.JSONUtil;
+import org.springframework.http.MediaType;
+
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 客户端工具类
+ *
+ * @author 芋道源码
+ */
+public class ServletUtils {
+
+    /**
+     * 返回 JSON 字符串
+     *
+     * @param response 响应
+     * @param object 对象,会序列化成 JSON 字符串
+     */
+    @SuppressWarnings("deprecation") // 必须使用 APPLICATION_JSON_UTF8_VALUE,否则会乱码
+    public static void writeJSON(HttpServletResponse response, Object object) {
+        String content = JSONUtil.toJsonStr(object);
+        ServletUtil.write(response, content, MediaType.APPLICATION_JSON_UTF8_VALUE);
+    }
+
+}

+ 2 - 0
yudao-example/yudao-sso-demo-by-code/src/main/resources/application.yaml

@@ -0,0 +1,2 @@
+server:
+  port: 18080

+ 61 - 0
yudao-example/yudao-sso-demo-by-code/src/main/resources/static/callback.html

@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<title>SSO 授权后的回调页</title>
+	<!-- jQuery:操作 dom、发起请求等 -->
+	<script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/jquery/2.1.2/jquery.min.js" type="application/javascript"></script>
+	<!-- 工具类 -->
+	<script type="application/javascript">
+    (function ($) {
+      /**
+       * 获得 URL 的指定参数的值
+       *
+       * @param name 参数名
+       * @returns 参数值
+       */
+      $.getUrlParam = function (name) {
+        const reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
+        const r = window.location.search.substr(1).match(reg);
+        if (r != null) return unescape(r[2]); return null;
+      }
+    })(jQuery);
+	</script>
+
+	<script type="application/javascript">
+    $(function () {
+      // 获得 code 授权码
+      const code = $.getUrlParam('code');
+      if (!code) {
+        alert('获取不到 code 参数,请排查!')
+        return;
+      }
+
+      // 提交
+      const redirectUri = 'http://127.0.0.1:18080/callback.html'; // 需要修改成,你回调的地址,就是在 index.html 拼接的 redirectUri
+      $.ajax({
+        url:  "http://127.0.0.1:18080/auth/login-by-code?code=" + code
+          + '&redirectUri=' + redirectUri,
+        method: 'POST',
+        success: function( result ) {
+          if (result.code !== 0) {
+            alert('获得访问令牌失败,原因:' + result.msg)
+            return;
+          }
+          alert('获得访问令牌成功!点击确认,跳转回首页')
+
+          // 设置到 localStorage 中
+          localStorage.setItem('ACCESS-TOKEN', result.data.access_token);
+          localStorage.setItem('REFRESH-TOKEN', result.data.refresh_token);
+
+          // 跳转回首页
+          window.location.href = '/index.html';
+        }
+      })
+    })
+	</script>
+</head>
+<body>
+正在使用 code 授权码,进行 accessToken 访问令牌的获取
+</body>
+</html>

+ 159 - 0
yudao-example/yudao-sso-demo-by-code/src/main/resources/static/index.html

@@ -0,0 +1,159 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<title>首页</title>
+	<!-- jQuery:操作 dom、发起请求等 -->
+	<script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/jquery/2.1.2/jquery.min.js" type="application/javascript"></script>
+
+	<script type="application/javascript">
+
+    /**
+     * 跳转单点登录
+     */
+    function ssoLogin() {
+      const clientId = 'yudao-sso-demo-by-code'; // 可以改写成,你的 clientId
+      const redirectUri = encodeURIComponent('http://127.0.0.1:18080/callback.html'); // 注意,需要使用 encodeURIComponent 编码地址
+      const responseType = 'code'; // 1)授权码模式,对应 code;2)简化模式,对应 token
+      window.location.href = 'http://127.0.0.1:1024/sso?client_id=' + clientId
+        + '&redirect_uri=' + redirectUri
+        + '&response_type=' + responseType;
+    }
+
+    /**
+     * 修改昵称
+     */
+    function updateNickname() {
+      const nickname = prompt("请输入新的昵称", "");
+      if (!nickname) {
+        return;
+      }
+      // 更新用户的昵称
+      const accessToken = localStorage.getItem('ACCESS-TOKEN');
+      $.ajax({
+        url: "http://127.0.0.1:18080/user/update?nickname=" + nickname,
+        method: 'PUT',
+        headers: {
+          'Authorization': 'Bearer ' + accessToken
+        },
+        success: function (result) {
+          if (result.code !== 0) {
+            alert('更新昵称失败,原因:' + result.msg)
+            return;
+          }
+          alert('更新昵称成功!');
+          $('#nicknameSpan').html(nickname);
+        }
+      });
+    }
+
+    /**
+     * 刷新令牌
+     */
+    function refreshToken() {
+      const refreshToken = localStorage.getItem('REFRESH-TOKEN');
+      if (!refreshToken) {
+        alert("获取不到刷新令牌");
+        return;
+      }
+      $.ajax({
+        url: "http://127.0.0.1:18080/auth/refresh-token?refreshToken=" + refreshToken,
+        method: 'POST',
+        success: function (result) {
+          if (result.code !== 0) {
+            alert('刷新访问令牌失败,原因:' + result.msg)
+            return;
+          }
+          alert('更新访问令牌成功!');
+          $('#accessTokenSpan').html(result.data.access_token);
+
+          // 设置到 localStorage 中
+          localStorage.setItem('ACCESS-TOKEN', result.data.access_token);
+          localStorage.setItem('REFRESH-TOKEN', result.data.refresh_token);
+        }
+      });
+    }
+
+    /**
+     * 登出,删除访问令牌
+     */
+    function logout() {
+      const accessToken = localStorage.getItem('ACCESS-TOKEN');
+      if (!accessToken) {
+        location.reload();
+        return;
+      }
+      $.ajax({
+        url: "http://127.0.0.1:18080/auth/logout",
+        method: 'POST',
+        headers: {
+          'Authorization': 'Bearer ' + accessToken
+        },
+        success: function (result) {
+          if (result.code !== 0) {
+            alert('退出登录失败,原因:' + result.msg)
+            return;
+          }
+          alert('退出登录成功!');
+          // 删除 localStorage 中
+          localStorage.removeItem('ACCESS-TOKEN');
+          localStorage.removeItem('REFRESH-TOKEN');
+
+          location.reload();
+        }
+      });
+    }
+
+    $(function () {
+      const accessToken = localStorage.getItem('ACCESS-TOKEN');
+      // 情况一:未登录
+      if (!accessToken) {
+        $('#noLoginDiv').css("display", "block");
+        return;
+      }
+
+      // 情况二:已登录
+      $('#yesLoginDiv').css("display", "block");
+      $('#accessTokenSpan').html(accessToken);
+      // 获得登录用户的信息
+      $.ajax({
+        url: "http://127.0.0.1:18080/user/get",
+        method: 'GET',
+        headers: {
+          'Authorization': 'Bearer ' + accessToken
+        },
+        success: function (result) {
+          if (result.code !== 0) {
+            alert('获得个人信息失败,原因:' + result.msg)
+            return;
+          }
+          $('#nicknameSpan').html(result.data.nickname);
+        }
+      });
+    })
+	</script>
+</head>
+<body>
+<!-- 情况一:未登录:1)跳转 ruoyi-vue-pro 的 SSO 登录页 -->
+<div id="noLoginDiv" style="display: none">
+	您未登录,点击 <a href="#" onclick="ssoLogin()">跳转 </a> SSO 单点登录
+</div>
+
+<!-- 情况二:已登录:1)展示用户信息;2)刷新访问令牌;3)退出登录 -->
+<div id="yesLoginDiv" style="display: none">
+	您已登录!<button onclick="logout()">退出登录</button> <br />
+	昵称:<span id="nicknameSpan"> 加载中... </span> <button onclick="updateNickname()">修改昵称</button> <br />
+	访问令牌:<span id="accessTokenSpan"> 加载中... </span> <button onclick="refreshToken()">刷新令牌</button> <br />
+</div>
+</body>
+<style>
+    body { /** 页面居中 */
+        border-radius: 20px;
+        height: 350px;
+        position: absolute;
+        left: 50%;
+        top: 50%;
+        transform: translate(-50%,-50%);
+    }
+</style>
+</html>

+ 65 - 0
yudao-example/yudao-sso-demo-by-password/pom.xml

@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <!-- 由于方便大家拷贝,使用不使用 yudao 作为 Maven parent -->
+
+    <groupId>cn.iocoder.boot</groupId>
+    <artifactId>yudao-sso-demo-by-password</artifactId>
+    <version>1.0.0-snapshot</version>
+    <packaging>jar</packaging>
+
+    <name>${project.artifactId}</name>
+    <description>基于密码模式,如何实现 SSO 单点登录?</description>
+    <url>https://github.com/YunaiV/ruoyi-vue-pro</url>
+
+    <properties>
+        <!-- Maven 相关 -->
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <!-- 统一依赖管理 -->
+        <spring.boot.version>2.6.10</spring.boot.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <!-- 统一依赖管理 -->
+            <dependency>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-dependencies</artifactId>
+                <version>${spring.boot.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <!-- Web 相关 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>cn.hutool</groupId>
+            <artifactId>hutool-all</artifactId>
+            <version>5.8.5</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+    </dependencies>
+
+</project>

+ 13 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/SSODemoApplication.java

@@ -0,0 +1,13 @@
+package cn.iocoder.yudao.ssodemo;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SSODemoApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(SSODemoApplication.class, args);
+    }
+
+}

+ 127 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/OAuth2Client.java

@@ -0,0 +1,127 @@
+package cn.iocoder.yudao.ssodemo.client;
+
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.client.dto.oauth2.OAuth2AccessTokenRespDTO;
+import cn.iocoder.yudao.ssodemo.client.dto.oauth2.OAuth2CheckTokenRespDTO;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.*;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+import org.springframework.util.Base64Utils;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * OAuth 2.0 客户端
+ *
+ * 对应调用 OAuth2OpenController 接口
+ */
+@Component
+public class OAuth2Client {
+
+    private static final String BASE_URL = "http://127.0.0.1:48080/admin-api/system/oauth2";
+
+    /**
+     * 租户编号
+     *
+     * 默认使用 1;如果使用别的租户,可以调整
+     */
+    public static final Long TENANT_ID = 1L;
+
+    private static final String CLIENT_ID = "yudao-sso-demo-by-password";
+    private static final String CLIENT_SECRET = "test";
+
+
+//    @Resource // 可优化,注册一个 RestTemplate Bean,然后注入
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    /**
+     * 校验访问令牌,并返回它的基本信息
+     *
+     * @param token 访问令牌
+     * @return 访问令牌的基本信息
+     */
+    public CommonResult<OAuth2CheckTokenRespDTO> checkToken(String token) {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.set("tenant-id", TENANT_ID.toString());
+        addClientHeader(headers);
+        // 1.2 构建请求参数
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+        body.add("token", token);
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<OAuth2CheckTokenRespDTO>> exchange = restTemplate.exchange(
+                BASE_URL + "/check-token",
+                HttpMethod.POST,
+                new HttpEntity<>(body, headers),
+                new ParameterizedTypeReference<CommonResult<OAuth2CheckTokenRespDTO>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+    /**
+     * 使用刷新令牌,获得(刷新)访问令牌
+     *
+     * @param refreshToken 刷新令牌
+     * @return 访问令牌
+     */
+    public CommonResult<OAuth2AccessTokenRespDTO> refreshToken(String refreshToken) {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.set("tenant-id", TENANT_ID.toString());
+        addClientHeader(headers);
+        // 1.2 构建请求参数
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+        body.add("grant_type", "refresh_token");
+        body.add("refresh_token", refreshToken);
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<OAuth2AccessTokenRespDTO>> exchange = restTemplate.exchange(
+                BASE_URL + "/token",
+                HttpMethod.POST,
+                new HttpEntity<>(body, headers),
+                new ParameterizedTypeReference<CommonResult<OAuth2AccessTokenRespDTO>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+    /**
+     * 删除访问令牌
+     *
+     * @param token 访问令牌
+     * @return 成功
+     */
+    public CommonResult<Boolean> revokeToken(String token) {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.set("tenant-id", TENANT_ID.toString());
+        addClientHeader(headers);
+        // 1.2 构建请求参数
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+        body.add("token", token);
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<Boolean>> exchange = restTemplate.exchange(
+                BASE_URL + "/token",
+                HttpMethod.DELETE,
+                new HttpEntity<>(body, headers),
+                new ParameterizedTypeReference<CommonResult<Boolean>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+    private static void addClientHeader(HttpHeaders headers) {
+        // client 拼接,需要 BASE64 编码
+        String client = CLIENT_ID + ":" + CLIENT_SECRET;
+        client = Base64Utils.encodeToString(client.getBytes(StandardCharsets.UTF_8));
+        headers.add("Authorization", "Basic " + client);
+    }
+
+}

+ 73 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/UserClient.java

@@ -0,0 +1,73 @@
+package cn.iocoder.yudao.ssodemo.client;
+
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.client.dto.user.UserInfoRespDTO;
+import cn.iocoder.yudao.ssodemo.client.dto.user.UserUpdateReqDTO;
+import cn.iocoder.yudao.ssodemo.framework.core.LoginUser;
+import cn.iocoder.yudao.ssodemo.framework.core.util.SecurityUtils;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.*;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * 用户 User 信息的客户端
+ *
+ * 对应调用 OAuth2UserController 接口
+ */
+@Component
+public class UserClient {
+
+    private static final String BASE_URL = "http://127.0.0.1:48080/admin-api//system/oauth2/user";
+
+    //    @Resource // 可优化,注册一个 RestTemplate Bean,然后注入
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    public CommonResult<UserInfoRespDTO> getUser() {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        headers.set("tenant-id", OAuth2Client.TENANT_ID.toString());
+        addTokenHeader(headers);
+        // 1.2 构建请求参数
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<UserInfoRespDTO>> exchange = restTemplate.exchange(
+                BASE_URL + "/get",
+                HttpMethod.GET,
+                new HttpEntity<>(body, headers),
+                new ParameterizedTypeReference<CommonResult<UserInfoRespDTO>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+    public CommonResult<Boolean> updateUser(UserUpdateReqDTO updateReqDTO) {
+        // 1.1 构建请求头
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.set("tenant-id", OAuth2Client.TENANT_ID.toString());
+        addTokenHeader(headers);
+        // 1.2 构建请求参数
+        // 使用 updateReqDTO 即可
+
+        // 2. 执行请求
+        ResponseEntity<CommonResult<Boolean>> exchange = restTemplate.exchange(
+                BASE_URL + "/update",
+                HttpMethod.PUT,
+                new HttpEntity<>(updateReqDTO, headers),
+                new ParameterizedTypeReference<CommonResult<Boolean>>() {}); // 解决 CommonResult 的泛型丢失
+        Assert.isTrue(exchange.getStatusCode().is2xxSuccessful(), "响应必须是 200 成功");
+        return exchange.getBody();
+    }
+
+
+    private static void addTokenHeader(HttpHeaders headers) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        Assert.notNull(loginUser, "登录用户不能为空");
+        headers.add("Authorization", "Bearer " + loginUser.getAccessToken());
+    }
+}

+ 28 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/CommonResult.java

@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.ssodemo.client.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 通用返回
+ *
+ * @param <T> 数据泛型
+ */
+@Data
+public class CommonResult<T> implements Serializable {
+
+    /**
+     * 错误码
+     */
+    private Integer code;
+    /**
+     * 返回数据
+     */
+    private T data;
+    /**
+     * 错误提示,用户可阅读
+     */
+    private String msg;
+
+}

+ 45 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/oauth2/OAuth2AccessTokenRespDTO.java

@@ -0,0 +1,45 @@
+package cn.iocoder.yudao.ssodemo.client.dto.oauth2;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 访问令牌 Response DTO
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OAuth2AccessTokenRespDTO {
+
+    /**
+     * 访问令牌
+     */
+    @JsonProperty("access_token")
+    private String accessToken;
+
+    /**
+     * 刷新令牌
+     */
+    @JsonProperty("refresh_token")
+    private String refreshToken;
+
+    /**
+     * 令牌类型
+     */
+    @JsonProperty("token_type")
+    private String tokenType;
+
+    /**
+     * 过期时间;单位:秒
+     */
+    @JsonProperty("expires_in")
+    private Long expiresIn;
+
+    /**
+     * 授权范围;如果多个授权范围,使用空格分隔
+     */
+    private String scope;
+
+}

+ 59 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/oauth2/OAuth2CheckTokenRespDTO.java

@@ -0,0 +1,59 @@
+package cn.iocoder.yudao.ssodemo.client.dto.oauth2;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 校验令牌 Response DTO
+ *
+ * @author 芋道源码
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OAuth2CheckTokenRespDTO {
+
+    /**
+     * 用户编号
+     */
+    @JsonProperty("user_id")
+    private Long userId;
+    /**
+     * 用户类型
+     */
+    @JsonProperty("user_type")
+    private Integer userType;
+    /**
+     * 租户编号
+     */
+    @JsonProperty("tenant_id")
+    private Long tenantId;
+
+    /**
+     * 客户端编号
+     */
+    @JsonProperty("client_id")
+    private String clientId;
+    /**
+     * 授权范围
+     */
+    private List<String> scopes;
+
+    /**
+     * 访问令牌
+     */
+    @JsonProperty("access_token")
+    private String accessToken;
+
+    /**
+     * 过期时间
+     *
+     * 时间戳 / 1000,即单位:秒
+     */
+    private Long exp;
+
+}

+ 97 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/user/UserInfoRespDTO.java

@@ -0,0 +1,97 @@
+package cn.iocoder.yudao.ssodemo.client.dto.user;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 获得用户基本信息 Response dto
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserInfoRespDTO {
+
+    /**
+     * 用户编号
+     */
+    private Long id;
+
+    /**
+     * 用户账号
+     */
+    private String username;
+
+    /**
+     * 用户昵称
+     */
+    private String nickname;
+
+    /**
+     * 用户邮箱
+     */
+    private String email;
+    /**
+     * 手机号码
+     */
+    private String mobile;
+
+    /**
+     * 用户性别
+     */
+    private Integer sex;
+
+    /**
+     * 用户头像
+     */
+    private String avatar;
+
+    /**
+     * 所在部门
+     */
+    private Dept dept;
+
+    /**
+     * 所属岗位数组
+     */
+    private List<Post> posts;
+
+    /**
+     * 部门
+     */
+    @Data
+    public static class Dept {
+
+        /**
+         * 部门编号
+         */
+        private Long id;
+
+        /**
+         * 部门名称
+         */
+        private String name;
+
+    }
+
+    /**
+     * 岗位
+     */
+    @Data
+    public static class Post {
+
+        /**
+         * 岗位编号
+         */
+        private Long id;
+
+        /**
+         * 岗位名称
+         */
+        private String name;
+
+    }
+
+}

+ 35 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/client/dto/user/UserUpdateReqDTO.java

@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.ssodemo.client.dto.user;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 更新用户基本信息 Request DTO
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserUpdateReqDTO {
+
+    /**
+     * 用户昵称
+     */
+    private String nickname;
+
+    /**
+     * 用户邮箱
+     */
+    private String email;
+
+    /**
+     * 手机号码
+     */
+    private String mobile;
+
+    /**
+     * 用户性别
+     */
+    private Integer sex;
+
+}

+ 50 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/controller/AuthController.java

@@ -0,0 +1,50 @@
+package cn.iocoder.yudao.ssodemo.controller;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.ssodemo.client.OAuth2Client;
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.client.dto.oauth2.OAuth2AccessTokenRespDTO;
+import cn.iocoder.yudao.ssodemo.framework.core.util.SecurityUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+@RequestMapping("/auth")
+public class AuthController {
+
+    @Resource
+    private OAuth2Client oauth2Client;
+
+    /**
+     * 使用刷新令牌,获得(刷新)访问令牌
+     *
+     * @param refreshToken 刷新令牌
+     * @return 访问令牌;注意,实际项目中,最好创建对应的 ResponseVO 类,只返回必要的字段
+     */
+    @PostMapping("/refresh-token")
+    public CommonResult<OAuth2AccessTokenRespDTO> refreshToken(@RequestParam("refreshToken") String refreshToken) {
+        return oauth2Client.refreshToken(refreshToken);
+    }
+
+    /**
+     * 退出登录
+     *
+     * @param request 请求
+     * @return 成功
+     */
+    @PostMapping("/logout")
+    public CommonResult<Boolean> logout(HttpServletRequest request) {
+        String token = SecurityUtils.obtainAuthorization(request, "Authorization");
+        if (StrUtil.isNotBlank(token)) {
+            return oauth2Client.revokeToken(token);
+        }
+        // 返回成功
+        return new CommonResult<>();
+    }
+
+}

+ 40 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/controller/UserController.java

@@ -0,0 +1,40 @@
+package cn.iocoder.yudao.ssodemo.controller;
+
+import cn.iocoder.yudao.ssodemo.client.UserClient;
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.client.dto.user.UserInfoRespDTO;
+import cn.iocoder.yudao.ssodemo.client.dto.user.UserUpdateReqDTO;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+
+@RestController
+@RequestMapping("/user")
+public class UserController {
+
+    @Resource
+    private UserClient userClient;
+
+    /**
+     * 获得当前登录用户的基本信息
+     *
+     * @return 用户信息;注意,实际项目中,最好创建对应的 ResponseVO 类,只返回必要的字段
+     */
+    @GetMapping("/get")
+    public CommonResult<UserInfoRespDTO> getUser() {
+        return userClient.getUser();
+    }
+
+    /**
+     * 更新当前登录用户的昵称
+     *
+     * @param nickname 昵称
+     * @return 成功
+     */
+    @PutMapping("/update")
+    public CommonResult<Boolean> updateUser(@RequestParam("nickname") String nickname) {
+        UserUpdateReqDTO updateReqDTO = new UserUpdateReqDTO(nickname, null, null, null);
+        return userClient.updateUser(updateReqDTO);
+    }
+
+}

+ 48 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/config/SecurityConfiguration.java

@@ -0,0 +1,48 @@
+package cn.iocoder.yudao.ssodemo.framework.config;
+
+import cn.iocoder.yudao.ssodemo.framework.core.filter.TokenAuthenticationFilter;
+import cn.iocoder.yudao.ssodemo.framework.core.handler.AccessDeniedHandlerImpl;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+import javax.annotation.Resource;
+
+@Configuration
+public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
+
+    @Resource
+    private TokenAuthenticationFilter tokenAuthenticationFilter;
+
+    @Resource
+    private AccessDeniedHandlerImpl accessDeniedHandler;
+    @Resource
+    private AuthenticationEntryPoint authenticationEntryPoint;
+
+    @Override
+    protected void configure(HttpSecurity httpSecurity) throws Exception {
+        // 设置 URL 安全权限
+        httpSecurity.csrf().disable() // 禁用 CSRF 保护
+                .authorizeRequests()
+                // 1. 静态资源,可匿名访问
+                .antMatchers(HttpMethod.GET, "/*.html", "/**/*.html", "/**/*.css", "/**/*.js").permitAll()
+                // 2. 登录相关的接口,可匿名访问
+                .antMatchers("/auth/login-by-code").permitAll()
+                .antMatchers("/auth/refresh-token").permitAll()
+                .antMatchers("/auth/logout").permitAll()
+                // last. 兜底规则,必须认证
+                .and().authorizeRequests()
+                .anyRequest().authenticated();
+
+        // 设置处理器
+        httpSecurity.exceptionHandling().accessDeniedHandler(accessDeniedHandler)
+                .authenticationEntryPoint(authenticationEntryPoint);
+
+        // 添加 Token Filter
+        httpSecurity.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+    }
+
+}

+ 37 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/LoginUser.java

@@ -0,0 +1,37 @@
+package cn.iocoder.yudao.ssodemo.framework.core;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 登录用户信息
+ *
+ * @author 芋道源码
+ */
+@Data
+public class LoginUser {
+
+    /**
+     * 用户编号
+     */
+    private Long id;
+    /**
+     * 用户类型
+     */
+    private Integer userType;
+    /**
+     * 租户编号
+     */
+    private Long tenantId;
+    /**
+     * 授权范围
+     */
+    private List<String> scopes;
+
+    /**
+     * 访问令牌
+     */
+    private String accessToken;
+
+}

+ 66 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/filter/TokenAuthenticationFilter.java

@@ -0,0 +1,66 @@
+package cn.iocoder.yudao.ssodemo.framework.core.filter;
+
+import cn.iocoder.yudao.ssodemo.client.OAuth2Client;
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.client.dto.oauth2.OAuth2CheckTokenRespDTO;
+import cn.iocoder.yudao.ssodemo.framework.core.LoginUser;
+import cn.iocoder.yudao.ssodemo.framework.core.util.SecurityUtils;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.annotation.Resource;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * Token 过滤器,验证 token 的有效性
+ * 验证通过后,获得 {@link LoginUser} 信息,并加入到 Spring Security 上下文
+ *
+ * @author 芋道源码
+ */
+@Component
+public class TokenAuthenticationFilter extends OncePerRequestFilter {
+
+    @Resource
+    private OAuth2Client oauth2Client;
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+        // 1. 获得访问令牌
+        String token = SecurityUtils.obtainAuthorization(request, "Authorization");
+        if (StringUtils.hasText(token)) {
+            // 2. 基于 token 构建登录用户
+            LoginUser loginUser = buildLoginUserByToken(token);
+            // 3. 设置当前用户
+            if (loginUser != null) {
+                SecurityUtils.setLoginUser(loginUser, request);
+            }
+        }
+
+        // 继续过滤链
+        filterChain.doFilter(request, response);
+    }
+
+    private LoginUser buildLoginUserByToken(String token) {
+        try {
+            CommonResult<OAuth2CheckTokenRespDTO> accessTokenResult = oauth2Client.checkToken(token);
+            OAuth2CheckTokenRespDTO accessToken = accessTokenResult.getData();
+            if (accessToken == null) {
+                return null;
+            }
+            // 构建登录用户
+            return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType())
+                    .setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes())
+                    .setAccessToken(accessToken.getAccessToken());
+        } catch (Exception exception) {
+            // 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可
+            return null;
+        }
+    }
+
+}

+ 44 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/handler/AccessDeniedHandlerImpl.java

@@ -0,0 +1,44 @@
+package cn.iocoder.yudao.ssodemo.framework.core.handler;
+
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.framework.core.util.SecurityUtils;
+import cn.iocoder.yudao.ssodemo.framework.core.util.ServletUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.security.web.access.ExceptionTranslationFilter;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 访问一个需要认证的 URL 资源,已经认证(登录)但是没有权限的情况下,返回 {@link GlobalErrorCodeConstants#FORBIDDEN} 错误码。
+ *
+ * 补充:Spring Security 通过 {@link ExceptionTranslationFilter#handleAccessDeniedException(HttpServletRequest, HttpServletResponse, FilterChain, AccessDeniedException)} 方法,调用当前类
+ *
+ * @author 芋道源码
+ */
+@Component
+@SuppressWarnings("JavadocReference")
+@Slf4j
+public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
+
+    @Override
+    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e)
+            throws IOException, ServletException {
+        // 打印 warn 的原因是,不定期合并 warn,看看有没恶意破坏
+        log.warn("[commence][访问 URL({}) 时,用户({}) 权限不够]", request.getRequestURI(),
+                SecurityUtils.getLoginUserId(), e);
+        // 返回 403
+        CommonResult<Object> result = new CommonResult<>();
+        result.setCode(HttpStatus.FORBIDDEN.value());
+        result.setMsg("没有该操作权限");
+        ServletUtils.writeJSON(response, result);
+    }
+
+}

+ 36 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/handler/AuthenticationEntryPointImpl.java

@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.ssodemo.framework.core.handler;
+
+import cn.iocoder.yudao.ssodemo.client.dto.CommonResult;
+import cn.iocoder.yudao.ssodemo.framework.core.util.ServletUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.access.ExceptionTranslationFilter;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 访问一个需要认证的 URL 资源,但是此时自己尚未认证(登录)的情况下,返回 {@link GlobalErrorCodeConstants#UNAUTHORIZED} 错误码,从而使前端重定向到登录页
+ *
+ * 补充:Spring Security 通过 {@link ExceptionTranslationFilter#sendStartAuthentication(HttpServletRequest, HttpServletResponse, FilterChain, AuthenticationException)} 方法,调用当前类
+ */
+@Component
+@Slf4j
+@SuppressWarnings("JavadocReference") // 忽略文档引用报错
+public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
+
+    @Override
+    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
+        log.debug("[commence][访问 URL({}) 时,没有登录]", request.getRequestURI(), e);
+        // 返回 401
+        CommonResult<Object> result = new CommonResult<>();
+        result.setCode(HttpStatus.UNAUTHORIZED.value());
+        result.setMsg("账号未登录");
+        ServletUtils.writeJSON(response, result);
+    }
+
+}

+ 103 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/util/SecurityUtils.java

@@ -0,0 +1,103 @@
+package cn.iocoder.yudao.ssodemo.framework.core.util;
+
+import cn.iocoder.yudao.ssodemo.framework.core.LoginUser;
+import org.springframework.lang.Nullable;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.util.StringUtils;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Collections;
+
+/**
+ * 安全服务工具类
+ *
+ * @author 芋道源码
+ */
+public class SecurityUtils {
+
+    public static final String AUTHORIZATION_BEARER = "Bearer";
+
+    private SecurityUtils() {}
+
+    /**
+     * 从请求中,获得认证 Token
+     *
+     * @param request 请求
+     * @param header 认证 Token 对应的 Header 名字
+     * @return 认证 Token
+     */
+    public static String obtainAuthorization(HttpServletRequest request, String header) {
+        String authorization = request.getHeader(header);
+        if (!StringUtils.hasText(authorization)) {
+            return null;
+        }
+        int index = authorization.indexOf(AUTHORIZATION_BEARER + " ");
+        if (index == -1) { // 未找到
+            return null;
+        }
+        return authorization.substring(index + 7).trim();
+    }
+
+    /**
+     * 获得当前认证信息
+     *
+     * @return 认证信息
+     */
+    public static Authentication getAuthentication() {
+        SecurityContext context = SecurityContextHolder.getContext();
+        if (context == null) {
+            return null;
+        }
+        return context.getAuthentication();
+    }
+
+    /**
+     * 获取当前用户
+     *
+     * @return 当前用户
+     */
+    @Nullable
+    public static LoginUser getLoginUser() {
+        Authentication authentication = getAuthentication();
+        if (authentication == null) {
+            return null;
+        }
+        return authentication.getPrincipal() instanceof LoginUser ? (LoginUser) authentication.getPrincipal() : null;
+    }
+
+    /**
+     * 获得当前用户的编号,从上下文中
+     *
+     * @return 用户编号
+     */
+    @Nullable
+    public static Long getLoginUserId() {
+        LoginUser loginUser = getLoginUser();
+        return loginUser != null ? loginUser.getId() : null;
+    }
+
+    /**
+     * 设置当前用户
+     *
+     * @param loginUser 登录用户
+     * @param request 请求
+     */
+    public static void setLoginUser(LoginUser loginUser, HttpServletRequest request) {
+        // 创建 Authentication,并设置到上下文
+        Authentication authentication = buildAuthentication(loginUser, request);
+        SecurityContextHolder.getContext().setAuthentication(authentication);
+    }
+
+    private static Authentication buildAuthentication(LoginUser loginUser, HttpServletRequest request) {
+        // 创建 UsernamePasswordAuthenticationToken 对象
+        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
+                loginUser, null, Collections.emptyList());
+        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+        return authenticationToken;
+    }
+
+}

+ 28 - 0
yudao-example/yudao-sso-demo-by-password/src/main/java/cn/iocoder/yudao/ssodemo/framework/core/util/ServletUtils.java

@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.ssodemo.framework.core.util;
+
+import cn.hutool.extra.servlet.ServletUtil;
+import cn.hutool.json.JSONUtil;
+import org.springframework.http.MediaType;
+
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 客户端工具类
+ *
+ * @author 芋道源码
+ */
+public class ServletUtils {
+
+    /**
+     * 返回 JSON 字符串
+     *
+     * @param response 响应
+     * @param object 对象,会序列化成 JSON 字符串
+     */
+    @SuppressWarnings("deprecation") // 必须使用 APPLICATION_JSON_UTF8_VALUE,否则会乱码
+    public static void writeJSON(HttpServletResponse response, Object object) {
+        String content = JSONUtil.toJsonStr(object);
+        ServletUtil.write(response, content, MediaType.APPLICATION_JSON_UTF8_VALUE);
+    }
+
+}

+ 2 - 0
yudao-example/yudao-sso-demo-by-password/src/main/resources/application.yaml

@@ -0,0 +1,2 @@
+server:
+  port: 18080

+ 154 - 0
yudao-example/yudao-sso-demo-by-password/src/main/resources/static/index.html

@@ -0,0 +1,154 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<title>首页</title>
+	<!-- jQuery:操作 dom、发起请求等 -->
+	<script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/jquery/2.1.2/jquery.min.js" type="application/javascript"></script>
+
+	<script type="application/javascript">
+
+    /**
+     * 跳转单点登录
+     */
+    function passwordLogin() {
+      window.location.href = '/login.html'
+    }
+
+    /**
+     * 修改昵称
+     */
+    function updateNickname() {
+      const nickname = prompt("请输入新的昵称", "");
+      if (!nickname) {
+        return;
+      }
+      // 更新用户的昵称
+      const accessToken = localStorage.getItem('ACCESS-TOKEN');
+      $.ajax({
+        url: "http://127.0.0.1:18080/user/update?nickname=" + nickname,
+        method: 'PUT',
+        headers: {
+          'Authorization': 'Bearer ' + accessToken
+        },
+        success: function (result) {
+          if (result.code !== 0) {
+            alert('更新昵称失败,原因:' + result.msg)
+            return;
+          }
+          alert('更新昵称成功!');
+          $('#nicknameSpan').html(nickname);
+        }
+      });
+    }
+
+    /**
+     * 刷新令牌
+     */
+    function refreshToken() {
+      const refreshToken = localStorage.getItem('REFRESH-TOKEN');
+      if (!refreshToken) {
+        alert("获取不到刷新令牌");
+        return;
+      }
+      $.ajax({
+        url: "http://127.0.0.1:18080/auth/refresh-token?refreshToken=" + refreshToken,
+        method: 'POST',
+        success: function (result) {
+          if (result.code !== 0) {
+            alert('刷新访问令牌失败,原因:' + result.msg)
+            return;
+          }
+          alert('更新访问令牌成功!');
+          $('#accessTokenSpan').html(result.data.access_token);
+
+          // 设置到 localStorage 中
+          localStorage.setItem('ACCESS-TOKEN', result.data.access_token);
+          localStorage.setItem('REFRESH-TOKEN', result.data.refresh_token);
+        }
+      });
+    }
+
+    /**
+     * 登出,删除访问令牌
+     */
+    function logout() {
+      const accessToken = localStorage.getItem('ACCESS-TOKEN');
+      if (!accessToken) {
+        location.reload();
+        return;
+      }
+      $.ajax({
+        url: "http://127.0.0.1:18080/auth/logout",
+        method: 'POST',
+        headers: {
+          'Authorization': 'Bearer ' + accessToken
+        },
+        success: function (result) {
+          if (result.code !== 0) {
+            alert('退出登录失败,原因:' + result.msg)
+            return;
+          }
+          alert('退出登录成功!');
+          // 删除 localStorage 中
+          localStorage.removeItem('ACCESS-TOKEN');
+          localStorage.removeItem('REFRESH-TOKEN');
+
+          location.reload();
+        }
+      });
+    }
+
+    $(function () {
+      const accessToken = localStorage.getItem('ACCESS-TOKEN');
+      // 情况一:未登录
+      if (!accessToken) {
+        $('#noLoginDiv').css("display", "block");
+        return;
+      }
+
+      // 情况二:已登录
+      $('#yesLoginDiv').css("display", "block");
+      $('#accessTokenSpan').html(accessToken);
+      // 获得登录用户的信息
+      $.ajax({
+        url: "http://127.0.0.1:18080/user/get",
+        method: 'GET',
+        headers: {
+          'Authorization': 'Bearer ' + accessToken
+        },
+        success: function (result) {
+          if (result.code !== 0) {
+            alert('获得个人信息失败,原因:' + result.msg)
+            return;
+          }
+          $('#nicknameSpan').html(result.data.nickname);
+        }
+      });
+    })
+	</script>
+</head>
+<body>
+<!-- 情况一:未登录:1)跳转 ruoyi-vue-pro 的 SSO 登录页 -->
+<div id="noLoginDiv" style="display: none">
+	您未登录,点击 <a href="#" onclick="passwordLogin()">跳转 </a> 账号密码登录
+</div>
+
+<!-- 情况二:已登录:1)展示用户信息;2)刷新访问令牌;3)退出登录 -->
+<div id="yesLoginDiv" style="display: none">
+	您已登录!<button onclick="logout()">退出登录</button> <br />
+	昵称:<span id="nicknameSpan"> 加载中... </span> <button onclick="updateNickname()">修改昵称</button> <br />
+	访问令牌:<span id="accessTokenSpan"> 加载中... </span> <button onclick="refreshToken()">刷新令牌</button> <br />
+</div>
+</body>
+<style>
+    body { /** 页面居中 */
+        border-radius: 20px;
+        height: 350px;
+        position: absolute;
+        left: 50%;
+        top: 50%;
+        transform: translate(-50%,-50%);
+    }
+</style>
+</html>

+ 74 - 0
yudao-example/yudao-sso-demo-by-password/src/main/resources/static/login.html

@@ -0,0 +1,74 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<title>登录</title>
+	<!-- jQuery:操作 dom、发起请求等 -->
+	<script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/jquery/2.1.2/jquery.min.js" type="application/javascript"></script>
+
+	<script type="application/javascript">
+
+    /**
+     * 账号密码登录
+     */
+    function login() {
+      const clientId = 'yudao-sso-demo-by-password'; // 可以改写成,你的 clientId
+      const clientSecret = 'test'; // 可以改写成,你的 clientSecret
+      const grantType = 'password'; // 密码模式
+
+      // 账号 + 密码
+      const username = $('#username').val();
+      const password = $('#password').val();
+      if (username.length === 0 || password.length === 0) {
+        alert('账号或密码未输入');
+        return;
+      }
+
+      // 发起请求
+      $.ajax({
+        url: "http://127.0.0.1:48080/admin-api/system/oauth2/token?"
+          // 客户端
+          + "client_id=" + clientId
+          + "&client_secret=" + clientSecret
+          // 密码模式的参数
+          + "&grant_type=" + grantType
+          + "&username=" + username
+          + "&password=" + password
+          + '&scope=user.read user.write',
+        method: 'POST',
+        headers: {
+          'tenant-id': '1', // 多租户编号,写死
+        },
+        success: function (result) {
+          if (result.code !== 0) {
+            alert('登录失败,原因:' + result.msg)
+            return;
+          }
+          // 设置到 localStorage 中
+          localStorage.setItem('ACCESS-TOKEN', result.data.access_token);
+          localStorage.setItem('REFRESH-TOKEN', result.data.refresh_token);
+
+          // 提示登录成功
+          alert('登录成功!点击确认,跳转回首页');
+          window.location.href = '/index.html';
+        }
+      });
+    }
+	</script>
+</head>
+<body>
+账号:<input id="username" value="admin" /> <br />
+密码:<input id="password" value="admin123" > <br />
+<button style="float: right; margin-top: 5px;" onclick="login()">登录</button>
+</body>
+<style>
+    body { /** 页面居中 */
+        border-radius: 20px;
+        height: 350px;
+        position: absolute;
+        left: 50%;
+        top: 50%;
+        transform: translate(-50%,-50%);
+    }
+</style>
+</html>

+ 1 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/open/OAuth2OpenCheckTokenRespVO.java

@@ -26,6 +26,7 @@ public class OAuth2OpenCheckTokenRespVO {
     private Long tenantId;
 
     @ApiModelProperty(value = "客户端编号", required = true, example = "car")
+    @JsonProperty("client_id")
     private String clientId;
     @ApiModelProperty(value = "授权范围", required = true, example = "user_info")
     private List<String> scopes;

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/vo/user/OAuth2UserInfoRespVO.java

@@ -17,7 +17,7 @@ public class OAuth2UserInfoRespVO {
     @ApiModelProperty(value = "用户编号", required = true, example = "1")
     private Long id;
 
-    @ApiModelProperty(value = "用户昵称", required = true, example = "芋艿")
+    @ApiModelProperty(value = "用户账号", required = true, example = "芋艿")
     private String username;
 
     @ApiModelProperty(value = "用户昵称", required = true, example = "芋道")

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/tenant/TenantPackageDO.java

@@ -34,7 +34,7 @@ public class TenantPackageDO extends BaseDO {
      */
     private String name;
     /**
-     * 租户状态
+     * 租户套餐状态
      *
      * 枚举 {@link CommonStatusEnum}
      */

+ 5 - 5
yudao-server/src/main/resources/application-local.yaml

@@ -45,24 +45,24 @@ spring:
       datasource:
         master:
           name: ruoyi-vue-pro
-          url: jdbc:mysql://139.9.196.247:3306/${spring.datasource.dynamic.datasource.master.name}?allowMultiQueries=true&useUnicode=true&useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例
+          url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.master.name}?allowMultiQueries=true&useUnicode=true&useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例
           #          url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.master.name}?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=CTT # MySQL Connector/J 5.X 连接的示例
           #          url: jdbc:postgresql://127.0.0.1:5432/${spring.datasource.dynamic.datasource.slave.name} # PostgreSQL 连接的示例
           #          url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle 连接的示例
           #          url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=${spring.datasource.dynamic.datasource.master.name} # SQLServer 连接的示例
           username: root
-          password: ${RUOYI_VUE_PRO}
+          password: 123456
         #          username: sa
         #          password: JSm:g(*%lU4ZAkz06cd52KqT3)i1?H7W
         slave: # 模拟从库,可根据自己需要修改
           name: ruoyi-vue-pro
-          url: jdbc:mysql://139.9.196.247:3306/${spring.datasource.dynamic.datasource.slave.name}?allowMultiQueries=true&useUnicode=true&useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例
+          url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.slave.name}?allowMultiQueries=true&useUnicode=true&useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例
           #          url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.slave.name}?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=CTT # MySQL Connector/J 5.X 连接的示例
           #          url: jdbc:postgresql://127.0.0.1:5432/${spring.datasource.dynamic.datasource.slave.name} # PostgreSQL 连接的示例
           #          url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle 连接的示例
           #          url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=${spring.datasource.dynamic.datasource.slave.name} # SQLServer 连接的示例
           username: root
-          password: ${RUOYI_VUE_PRO}
+          password: 123456
   #          username: sa
   #          password: JSm:g(*%lU4ZAkz06cd52KqT3)i1?H7W
 
@@ -197,7 +197,7 @@ wx:
 # 芋道配置项,设置当前项目所有自定义的配置
 yudao:
   captcha:
-    enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试
+    enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试
   security:
     mock-enable: true
   xss:

+ 2 - 1
yudao-ui-admin/src/permission.js

@@ -48,7 +48,8 @@ router.beforeEach((to, from, next) => {
       // 在免登录白名单,直接进入
       next()
     } else {
-      next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
+      const redirect = encodeURIComponent(to.fullPath) // 编码 URI,保证参数跳转回去后,可以继续带上
+      next(`/login?redirect=${redirect}`) // 否则全部重定向到登录页
       NProgress.done()
     }
   }

+ 1 - 1
yudao-ui-admin/src/views/login.vue

@@ -190,7 +190,7 @@ export default {
     // 验证码开关
     this.captchaEnable = getCaptchaEnable();
     // 重定向地址
-    this.redirect = this.$route.query.redirect;
+    this.redirect = this.$route.query.redirect ? decodeURIComponent(this.$route.query.redirect) : undefined;
     this.getCookie();
   },
   methods: {

+ 1 - 1
yudao-ui-admin/src/views/socialLogin.vue

@@ -117,7 +117,7 @@ export default {
     // 验证码开关
     this.captchaEnable = getCaptchaEnable();
     // 重定向地址
-    this.redirect = this.$route.query.redirect;
+    this.redirect = this.$route.query.redirect ? decodeURIComponent(this.$route.query.redirect) : undefined;
     // 社交登录相关
     this.type = this.$route.query.type;
     this.code = this.$route.query.code;

+ 1 - 42
yudao-ui-admin/src/views/sso.vue

@@ -19,12 +19,7 @@
             </el-tab-pane>
           </el-tabs>
           <div>
-            <el-form ref="loginForm" :model="loginForm" :rules="LoginRules" class="login-form">
-              <el-form-item prop="tenantName" v-if="tenantEnable">
-                <el-input v-model="loginForm.tenantName" type="text" auto-complete="off" placeholder='租户'>
-                  <svg-icon slot="prefix" icon-class="tree" class="el-input__icon input-icon"/>
-                </el-input>
-              </el-form-item>
+            <el-form ref="loginForm" :model="loginForm" class="login-form">
               <!-- 授权范围的选择 -->
               此第三方应用请求获得以下权限:
               <el-form-item prop="scopes">
@@ -56,10 +51,7 @@
 </template>
 
 <script>
-import {getTenantIdByName} from "@/api/system/tenant";
-import {getTenantEnable} from "@/utils/ruoyi";
 import {authorize, getAuthorize} from "@/api/login";
-import {getTenantName, setTenantId} from "@/utils/auth";
 
 export default {
   name: "Login",
@@ -67,7 +59,6 @@ export default {
     return {
       tenantEnable: true,
       loginForm: {
-        tenantName: "芋道源码",
         scopes: [], // 已选中的 scope 数组
       },
       params: { // URL 上的 client_id、scope 等参数
@@ -81,35 +72,10 @@ export default {
         name: '',
         logo: '',
       },
-      LoginRules: {
-        tenantName: [
-          {required: true, trigger: "blur", message: "租户不能为空"},
-          {
-            validator: (rule, value, callback) => {
-              // debugger
-              getTenantIdByName(value).then(res => {
-                const tenantId = res.data;
-                if (tenantId && tenantId >= 0) {
-                  // 设置租户
-                  setTenantId(tenantId)
-                  callback();
-                } else {
-                  callback('租户不存在');
-                }
-              });
-            },
-            trigger: 'blur'
-          }
-        ]
-      },
       loading: false
     };
   },
   created() {
-    // 租户开关
-    this.tenantEnable = getTenantEnable();
-    this.getCookie();
-
     // 解析参数
     // 例如说【自动授权不通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write
     // 例如说【自动授权通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read
@@ -162,13 +128,6 @@ export default {
     })
   },
   methods: {
-    getCookie() {
-      const tenantName = getTenantName();
-      this.loginForm = {
-        ...this.loginForm,
-        tenantName: tenantName ? tenantName : this.loginForm.tenantName,
-      };
-    },
     handleAuthorize(approved) {
       this.$refs.loginForm.validate(valid => {
         if (!valid) {