一、概要
实战文章,主要分为两部分。 一是搭建SpringCloud微服务与集群;二是基于OAuth2.1(Spring Authorization Server)搭建前后分离安全框架。
后端主要采用的技术栈:SpringBoot3.0,Security6.0,OAuth2.1。
前端主要采用的技术栈:Vue3.0+Vite,ElementPlus。
二、环境
系统:MAC Apple M2 13.3
开发工具:IntelliJ IDEA 2022.3.3 (Ultimate Edition)
JDK版本:OpenJDK 17.0.8
三、文章内容
本章内容,编写通用服,并实现统一响应类。 基于OAuth2.1,实现安全框架(重点)。
10、编写通用服
前面已经创建了一个通用服cloud-common,主要用于存放一些共用东西,如统一响应类,枚举类或工具类等等。
(1)添加依赖
这里需要添加一个SpringWeb依赖,主要需要用到一些相关的注解。
<?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>
<parent>
<groupId>com.example</groupId>
<artifactId>cloud</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>cloud-common</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- SpringWeb依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>
(2)编写统一响应HttpCode
在src.main.java.com.example.enums包中,创建一个枚举类HttpCode,并创建一个构造方法。
package com.example.enums;
import lombok.Getter;
@Getter
public enum HttpCode {
// 成功相关
SUCCESS(1000, "操作成功"),
S_CODE_SEND(1010, "验证码已发送"),
// 失败相关
FAILURE(2000, "操作失败"),
F_USERNAME_EMPTY(2101, "用户名不能为空"),
F_USER_INCORRECT(2102, "用户名或密码错误"),
F_MAIL_SEND(2201, "邮件发送失败"),
// 数据错误
DB_ERROR(3000, "数据错误"),
// 系统错误
SYS_ERROR(5000, "系统错误"),
// 非法操作
USER_ERROR(6000, "非法操作");
final private Integer code;
final private String msg;
HttpCode(Integer code, String msg){
this.code = code;
this.msg = msg;
}
}
(3)编写统一响应类
在src.main.java.com.example.domain包中,创建一个抽象类ResponseBase。
- 需要实现Set和Get方法
- 需要实现序列化Serializable接口
package com.example.domain;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 响应属性模版
*/
@Getter
@Setter
public abstract class ResponseBase<T> implements Serializable {
/**
* 请求响应是否成功
* @mock true
* @since v1.0
*/
private Boolean success;
/**
* 请求返回code码
* @mock 200
* @since v1.0
*/
private Integer code;
/**
* 请求返回一句话信息,用于toast
* @mock 登录成功
* @since v1.0
*/
private String msg;
/**
* 请求返回携带的数据,通常是json数据
* @mock {"token" : "JWTToken"}
* @since v1.0
*/
private T data;
/**
* 响应时间戳(目前没有用)
* @mock 1685507117
* @since v1.0
*/
private String timestamp;
}
在src.main.java.com.example.domain包中,创建一个类ResponseResult,继承ResponseBase,后续直接调用ResponseResult的静态方法。
package com.example.domain;
import com.example.enums.HttpCode;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Getter;
import lombok.Setter;
/**
* 统一响应类
*/
@Getter
@Setter
@JsonInclude(JsonInclude.Include.NON_NULL)
@SuppressWarnings("unused")
public class ResponseResult<T> extends ResponseBase<T>{
public ResponseResult(Boolean success, Integer code, String msg, T data) {
this.setSuccess(success);
this.setCode(code);
this.setMsg(msg);
this.setData(data);
}
/** 请求成功(默认返回success=true, code=200;自定义msg) */
public static <T> ResponseResult<T> success(String msg){
return new ResponseResult<>(true, 200, msg, null);
}
/** 请求成功(默认返回success=true, code=200;自定义msg、data) */
public static <T> ResponseResult<T> success(String msg, T data){
return new ResponseResult<>(true, 200, msg, data);
}
/** 请求失败(默认返回success=false, code=400;自定义msg) */
public static <T> ResponseResult<T> failure(String msg){
return new ResponseResult<>(false, 400, msg, null);
}
/** 请求失败(默认返回success=false;自定义code、msg) */
public static <T> ResponseResult<T> failure(Integer code, String msg){
return new ResponseResult<>(false, code, msg, null);
}
/* ---- 直接使用自定义HttpCode返回 ---- */
/** 请求成功(默认返回success=true, 模板code与msg) */
public static <T> ResponseResult<T> success(HttpCode httpCode){
return new ResponseResult<>(true, httpCode.getCode(), httpCode.getMsg(), null);
}
/** 请求成功(默认返回success=true, 模板code与msg;自定义data) */
public static <T> ResponseResult<T> success(HttpCode httpCode, T data){
return new ResponseResult<>(true, httpCode.getCode(), httpCode.getMsg(), data);
}
/** 请求失败(默认返回success=false;模板code与msg) */
public static <T> ResponseResult<T> failure(HttpCode httpCode){
return new ResponseResult<>(false, httpCode.getCode(), httpCode.getMsg(), null);
}
}
(4)启动类
在src.main.java.com.example包中,将启动类Main.class删除,通用服主要用于提供公共模块引用,不需要启动服务,因此可以删除掉启动类。
这时,打包会出现提示没有启动类的问题,可以在依赖文件中,增加配置忽略掉。
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 解决main不存在的问题 -->
<mainClass>none</mainClass>
</configuration>
</plugin>
</plugins>
</build>
(5)测试
这时候,通用服基本已经完成。使用测试服引入响应模块试试效果。
打开测试服(cloud-test)的依赖,增加以下配置引用cloud-common。
<dependencies>
<!-- 导入公共模块 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>cloud-common</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
完整依赖如下:
<?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>
<parent>
<groupId>com.example</groupId>
<artifactId>cloud</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>cloud-test</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- 导入公共模块 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>cloud-user</artifactId>
<version>${project.version}</version>
</dependency>
<!-- SpringWeb依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Eureka客户端依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- openfeign依赖(内部服调用) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- JDBC驱动 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mysql驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- 导入公共模块 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>cloud-common</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
修改src.main.java.com.example.controller包中的TestController,使用ResponseResult来
响应:
package com.example.controller;
import com.example.domain.ResponseResult;
import com.example.domain.entity.User;
import com.example.enums.HttpCode;
import com.example.service.client.UserClient;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/test")
public class TestController {
@Resource
UserClient userClient;
@GetMapping("/info/{username}")
public ResponseResult<?> testOne(@PathVariable String username) {
User user = userClient.getUser(username);
// 使用统一响应类 响应访问,将user放入data里面
return ResponseResult.success(HttpCode.SUCCESS, user);
}
@GetMapping("/my")
public String testOne() {
return "访问成功";
}
}
重启服务,打开浏览器访问http://127.0.0.1:8101/test/info/user后,可以成功拿到JSON格式的数据,如下:
11、OAuth2.1 授权
(1)主要技术栈:
使用框架:Spring Security6.0,Spring Authorization Server1.0(OAuth2.1)
官方地址:Spring Authorization Server
(2)简述:
当用户需要访问集群的服务时,我们不可能像独立服一样,让用户在每个服务上都进行鉴权,Oauth2就提供了一套解决方案。如下图(官方图例):
上图中,大致流程解释:
- 当用户通过API接口(或前端页面)请求集群的业务服时(图中Client),业务服会判断用户是否已经鉴权,若没有则会重定向至授权服(图中AuthorizationServer)。
- 授权服,判断用户没有授权,则会给这次请求返回一个登录页(授权服自己的页面)。
- 用户进行登录操作,确认身份后,还会确认授予的权限(若有)。
- 鉴权完成后,授权服会给业务服发送一个Code,业务服拿到Code后,会向授权服请求获取一个Access Token(以及Refresh Token)
- 业务服会向用户最初访问的接口重新发起一次请求。这时候有两种情况:
- 若访问的接口在业务服中,那么请求通过,并获取接口的数据;
- 若访问的接口在资源服(Resouce Server),则会携带Access Token去请求,资源服会审核Access Token是否合法,如果通过则返回数据。
整个大概流程是这样,中间还有很多细节,甚至有很多作者也还在探索当中。有条件,可以翻翻官方说明。
在这个框架当中,会有三个角色(参考oauth2.1规范文档 Roles):
- Authorization server(认证服务器):即用来认证与颁发令牌(如token)的服务。
- Client(客户端):即访问的客户端,如集群内的业务服、网关服,或第三方网站。
- Resource server(资源服务器):托管受保护资源的服务器,能够使用访问令牌接受和响应受保护的资源请求。
- Resource Owner(资源拥有者):能够授予对受保护资源的访问权限的实体,通常指的是终端用户。
授权方式,会有下面几种:
- 授权码模式(authorization_code):最正规的模式,上述举例说明的就是授权码模式。比如微信、Gitee、Github都是采用此方式。
- 刷新模式(refresh_token):用Refresh Token获取Access Token。
- 客户端模式(client_credentials):第三方应用使用自己的客户端凭证(client_id 和 client_secret)向授权服务器获取令牌。
- 密码模式(password):该方式已被OAuth2.1弃用,主要是因为在第三方中直接输入用户的账号密码是不安全的。
(3)搭建说明:
这里,先简单概括需要分配的服务:
服务 | 名称 | 地址 | 作用 |
授权服 | cloud-auth | 127.0.0.1:9000 | 身份认证及授权、颁发令牌 |
业务服 | cloud-test | 127.0.0.1:8101 127.0.0.1:8102 | 提供业务数据接口 |
资源服 | cloud-user | 127.0.0.1:8001 127.0.0.1:8002 | 提供内部资源接口 |
授权服前端 | master | 127.0.0.1:5666 | 提供授权服自定义页面 如登录页面、权限页面 |
Gitee | 第三方联合登录 |
12、父工程依赖
本章中,父工程相对于上一章,需要添加的依赖如下:
- spring-security-oauth2-authorization-server 1.0.3 授权服依赖
13、搭建OAuth2授权服(cloud-auth)
开始搭建一个授权服
(1)创建授权服
老规矩,创建一个子项目cloud-auth作为授权服。
(2)添加依赖
主要涉及依赖说明:
- cloud-common,公共模块
- spring-boot-starter-web,SpringWeb。
- spring-security-oauth2-authorization-server,授权服依赖
- spring-cloud-starter-netflix-eureka-client,Eureka客户端。
- spring-boot-starter-jdbc,JDBC驱动。
- mysql-connector-j,Mysql驱动。
<?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>
<parent>
<groupId>com.example</groupId>
<artifactId>cloud</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>cloud-auth</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- 导入公共模块 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>cloud-common</artifactId>
<version>${project.version}</version>
</dependency>
<!-- SpringWeb依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- SpringSecurity依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2依赖(授权服) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
</dependency>
<!-- Eureka客户端依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- JDBC驱动 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mysql驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
</dependencies>
</project>
(3)配置参数
在src.main.resources包中,创建application.yml配置文件,主要配置说明:
- server.port,分配端口9000。
- server.servlet.session.cookie.name,管理session名称,避免前端浏览器多个session导致相互覆盖。
- spring.application.name,修改服务名称,后续方便在注册中心查看。
- spring.application.datasource,mysql参数配置,本服务使用的数据库。
- eureka.client.service-url.defaultZone,填写两个Eureka服务地址。
server:
port: 9000
servlet:
session:
cookie:
name: "JSESSIONID-${spring.application.name}-${server.port}"
spring:
application:
name: AUTH-SERVICE
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/cloud?useUnicode=true&characterEncoding=utf-8
username: cloud
password: c123456
eureka:
client:
service-url:
defaultZone: http://eureka01:7101/eureka, http://eureka02:7102/eureka
(4)修改启动类
在src.main.java.com.example包中,将Main启动类修改为:
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AuthApplication {
public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
}
}
(5)配置类编写
这里主要编写两个配置类(其实是一个拆成两个),按照官方文档说明,可以归纳为以下配置:
- SpringAuthorizationServer的配置,实现OAuth2授权服功能。
- SpringSecurity的配置,实现基础登录功能(与单服类似)
- UserDetailsService的配置,实现内存缓存一个初始用户,后期可改为Mysql获取。
- RegisteredClientRepository的配置,实现内存缓存一个OAuth2客户端及其配置参数,后期可改为Mysql获取。
- JWK、JWT的相关配置,用于生成密钥、处理token。
- AuthorizationServerSettings的配置,AuthorizationServer基础配置,暂时可以不管。
- 官方例子地址:Spring Authorization Server
在src.main.java.com.example.config包中,编写第一个配置类SecurityOAuth2ServerConfig。
package com.example.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
@Configuration
public class SecurityOAuth2ServerConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
/* 使用OAuth2.1默认配置,默认关闭端点csrf校验 */
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http
/* Enable OpenID Connect 1.0 */
.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults());
http
// Redirect to the login page when not authenticated from the
// authorization endpoint
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
// 设置未登录重定向页面,重写LoginUrlAuthenticationEntryPoint
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// Accept access tokens for User Info and/or Client Registration
.oauth2ResourceServer(OAuth2ResourceServerConfigurer -> OAuth2ResourceServerConfigurer
.jwt(Customizer.withDefaults())
);
http
// 禁用 csrf
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
/**
* 配置客户端Repository
*
* @return 基于数据库的repository
*/
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString())
// 客户端id
.clientId("oidc-client")
// 客户端秘钥,使用密码解析器加密
.clientSecret("{noop}secret")
.clientName("MyAuth")
// 客户端认证方式,基于请求头的认证
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
// 配置资源服务器使用该客户端获取授权时支持的方式
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
// 授权码模式回调地址,oauth2.1已改为精准匹配,不能只设置域名,并且屏蔽了localhost
.redirectUri("http://127.0.0.1:8101/login/oauth2/code/myOauth2")
.redirectUri("https://www.baidu.com")
// 该客户端的授权范围,OPENID与PROFILE是IdToken的scope,获取授权时请求OPENID的scope时认证服务会返回IdToken
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
// 自定scope
.scope("message.read")
.scope("message.write")
// 客户端设置,设置用户需要确认授权
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
// 基于内存实现客户端信息存储
return new InMemoryRegisteredClientRepository(oidcClient);
}
/**
* 配置jwk源,使用非对称加密,公开用于检索匹配指定选择器的JWK的方法
* @return JWKSource
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
// 公钥
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
// 私钥
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
// 生成jwk令牌
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
/**
* 生成rsa密钥对,提供给jwk
*
* @return 密钥对
*/
private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
/**
* 配置jwt解析器
*
* @param jwkSource jwk源
* @return JwtDecoder
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
/**
* 添加认证服务器配置,设置jwt签发者、默认端点请求地址等
*
* @return AuthorizationServerSettings
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
}
再编写第二个配置类SecurityDefaultConfig。
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityDefaultConfig {
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
http
// 禁用 csrf
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
@Bean
@SuppressWarnings("deprecation")
public UserDetailsService userDetailsService() {
UserDetails userDetails = User.withDefaultPasswordEncoder()
.username("user")
.password("123456")
.roles("USER")
.authorities("app", "web")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
}
14、搭建OAuth2客户端
将测试服(cloud-test)作为OAuth2客户端
(1)添加配置
在原来的基础上,增加配置说明:
- spring-boot-starter-security,security安全框。
- spring-boot-starter-oauth2-client,OAuth2客户端依赖。
<?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>
<parent>
<groupId>com.example</groupId>
<artifactId>cloud</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>cloud-test</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- 导入公共模块 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>cloud-user</artifactId>
<version>${project.version}</version>
</dependency>
<!-- SpringWeb依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Eureka客户端依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- openfeign依赖(内部服调用) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- JDBC驱动 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mysql驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- 导入公共模块 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>cloud-common</artifactId>
<version>${project.version}</version>
</dependency>
<!-- SpringSecurity依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
</dependencies>
</project>
(2)配置参数
在原来的基础上,增加的参数说明:
- spring.security.oauth2.client,OAuth2客户端配置(基本就是填写上述授权服生成的客户端参数)。
- 其中registration.myOauth2与registration.myOauth2.redirect-uri最末尾的myOauth2,需要保持一致,并且这个地址需要与授权服填写的回调地址要保持一致。其中地址/login/oauth2/code是固定格式,其他可以自定义。
- registration.myOauth2.provider与provider.myOauth2的myOauth2需要保持一致,可以自定义。
- scope,权限范围,需要授权服提供的范围之内。该参数会向用户展示需要获取的权限范围。
- provider.myOauth2.issuer-uri,这里需要填写授权服地址,会自动提供所有获取内容的地址(后面会展示)。
server:
port: 8101
spring:
application:
name: CLOUD-TEST
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/cloud?useUnicode=true&characterEncoding=utf-8
username: cloud
password: c123456
cloud:
openfeign:
client:
config:
default:
# 连接超时时间
connectTimeout: 5000
# 读取超时时间
readTimeout: 5000
security:
oauth2:
client:
registration:
myOauth2:
provider: myOauth2
client-id: oidc-client
client-secret: secret
client-name: MyAuth
authorization-grant-type: authorization_code
redirect-uri: http://127.0.0.1:8101/login/oauth2/code/myOauth2
#redirect-uri: https://www.baidu.com
scope:
- openid
- profile
- message.read
- message.write
provider:
myOauth2:
issuer-uri: http://127.0.0.1:9000
eureka:
client:
service-url:
defaultZone: http://eureka01:7101/eureka, http://eureka02:7102/eureka
(3)编写配置类
在src.main.java.com.example.config包中,编写一个SpringSecurity的配置类SecurityConfigurationa。
主要是配置oauth2Login登录方式以及配置为oauth2Client客户端。
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 所有请求都需经过授权认证
.authorizeHttpRequests(authorize -> authorize
// 放行登录页面及授权确认页面
.requestMatchers( "/login").permitAll()
.requestMatchers("/oauth2/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults())
.oauth2Client(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
}
到这,OAuth2的授权服和客户端,最基础的配置已经编写好,我们可以将两个服务启动起来。(这里优先启动授权服,再启动客户端,主要是与issuer-uri接口需要提供地址有关系,后续再填坑)
15、授权过程
(1)访问->授权服登录
打开浏览器访问http://127.0.0.1:8101/test/my (由于用户信息服未接入OAuth2,这里我们只访问测试服自己的资源)。确认后,发现重定向至授权服的登录页(这里相信学过单服安全框架的小伙伴应该很熟悉,就是Security自带的登录页)。
这里,我们可以打开开发者模式,查看Network请求资源的情况,发现总共发起了四次请求(含重定向)。
查看第一次请求my(http://127.0.0.1:8101/test/my)时,出现302重定向,再查看重定向地址为http://127.0.0.1:8101/oauth2/authorization/myOauth2的,这个地址是我们配置的OAuth2客户端,发现用户没有鉴权后,重定向至这个接口,它会帮我们组装携带指定的参数(如client_id、redirect_uri等)去访问授权服,这里需要注意,这个过程仍然发生在测试服中。
我们继续查看第二次请求myOauth2(http://127.0.0.1:8101/oauth2/authorization/myOauth2)时,仍然出现302重定向。查看重定向地址为http://127.0.0.1:9000/oauth2/authorize?response_type=code&client_id=oidc-client&scope=openid%20profile%20message.read%20message.write&state=71kUPmwZMzQEazxGi39vcIdHgiEVG9hjVMcB8HZ7Nxo%3D&redirect_uri=http://127.0.0.1:8101/login/oauth2/code/myOauth2&nonce=qIKLinWDzSMdz-gP6H8xTiMlXtd7aqNbW0QBNNGy6KI,访问的地址是授权服的地址http://127.0.0.1:9000/oauth2/authorize,并且携带了这些参数:
response_type=code 这个是请求模式,本次请求是为了获取code码
client_id=oidc-client 这里是我们授权服和测试服填写的client_id
scope=openid%20profile%20message.read%20message.write 这里是我们测试服填写的权限范围
state=71kUPmwZMzQEazxGi39vcIdHgiEVG9hjVMcB8HZ7Nxo%3D 这是state随机码,主要用于防止 CSRF 攻击
redirect_uri=http://127.0.0.1:8101/login/oauth2/code/myOauth2 这里是我们测试服的回调地址
nonce=qIKLinWDzSMdz-gP6H8xTiMlXtd7aqNbW0QBNNGy6KI 这里nonce随机码,用于确保请求的唯一性。
最后第三次请求authorize?response_ty……(http://127.0.0.1:9000/oauth2/authorize?……)时,再次出现302重定向。查看重定向地址为http://127.0.0.1:9000/login,这正是我们当前页面的地址。
输入帐号密码(user与123456) 提交后,再次出现302重定向,重定向地址与第二次请求相同(最后附带了continue,证明是再次访问)。再次请求http://127.0.0.1:9000/oauth2/authorize?……地址时,授权服已经确认我们身份,回调给我们一个确认权限的页面。
(2)确认授权范围->访问成功
选择权限确认后,浏览器会向http://127.0.0.1:9000/oauth2/authorize发起post请求,并携带client_id、state以及权限信息scope表单。授权服收到post请求,会先确认用户身份(通过session),接受并确认参数后,又再次(第三次)重定向至http://127.0.0.1:8101/login/oauth2/code/myOauth2地址,并携带关键信息code(以及state),这个code正是测试服用于获取Access Token。
最后因为是访问测试服本身的资源,所以直接重定向回最初的http://127.0.0.1:8101/test/my?continue地址,并获得访问接口的数据,到这里,所有的流程已经走完。
(3)Access Token
最后,我们尝试下用API工具,通过code获取Access Token。
还记得我们在授权服SecurityOAuth2ServerConfig配置中,填写了一个https://www.baidu.com回调地址吗?现在我们用参数自己拼接一个,让code码返回到https://www.baidu.com回调地址中,拼接地址如下:
在浏览器中访问该地址并授权后,可以在Network中,找到一个请求是返回code码(或者直接看地址栏),这个code码,因为我们要求回调到百度上面,所以是还没有被使用的。
我们打开API工具,填写一下参数:
- 在Authorization中,选择Basic Auth,填写client_id和client_secret。
- 在Params中,填写grant_type、code(上面获取的code)和redirect_uri。
(注意,redirect_uri需要与授权服填写的地址一致,别在地址后面多加一些其他符号,比如/ )
填写好参数以后,选择post提交请求,发现拿到Access Token。
如果授权范围有填写openid,会默认获取,并且在获取Access Token的同时,还会有一个Id Token。这个是用于储存用户的相关信息,我们可以利用工具解析Id Token看看里面存放了什么信息。
这里,后期我们自定义时,将一些需要用到的用户信息通过这个特性,来进行信息交换。这个后面再说。
以上,就是一个基础版本的Spring Authorization Server1.0(OAuth2.1)的基本内容。
四、后续
下一章:实现授权服的前后分离(自定义登录页与授权页)。