Oauth2概述
OAuth2是一个开放的授权协议,它允许用户授权第三方应用访问他们在某个服务上的资源,而无需暴露自己的账号和密码。OAuth2的主要优点是简化了客户端开发,提供了多种适用于不同场景的授权流程,增强了安全性和灵活性。
OAuth2的核心概念包括以下几个:
- 资源所有者(Resource Owner):拥有资源的用户。
- 资源服务器(Resource Server):存储用户资源的服务。
- 客户端(Client):想要访问用户资源的第三方应用,例如一个网站或一个手机应用。
- 授权服务器(Authorization Server):负责验证用户身份和授权客户端访问资源的服务,例如Google的账号服务。
OAuth2定义了四种授权流程(Grant Type),分别适用于不同类型的客户端:
- 授权码模式(Authorization Code):适用于有后端服务器的客户端,例如网站应用。客户端先通过用户的浏览器向授权服务器请求一个授权码(Authorization Code),然后再通过后端服务器向授权服务器交换一个访问令牌(Access Token),最后使用访问令牌向资源服务器请求资源。
- 简化模式(Implicit):适用于没有后端服务器的客户端,例如单页面应用(SPA)。客户端直接通过用户的浏览器向授权服务器请求一个访问令牌,然后使用访问令牌向资源服务器请求资源。这种模式比较简单,但也比较不安全,因为访问令牌可能会被截获或泄露。
- 客户端凭证模式(Client Credentials):适用于只需要访问自己拥有的资源的客户端,例如后台服务。客户端直接向授权服务器提供自己的凭证(Client ID和Client Secret),然后获取一个访问令牌,最后使用访问令牌向资源服务器请求资源。
- 密码模式(Password):适用于用户对客户端高度信任的情况,例如自己开发的应用。客户端直接向授权服务器提供用户的账号和密码,然后获取一个访问令牌,最后使用访问令牌向资源服务器请求资源。这种模式比较方便,但也比较不安全,因为用户的账号和密码可能会被泄露或滥用。
项目环境
项目中使用到的一些技术栈:
技术 | 版本号 |
---|---|
Spring Cloud | 2021.0.1 |
Spring Cloud Alibaba | 2021.0.1.0 |
Spring Boot | 2.7.0 |
Nacos | 2.0.4 |
MyBatis-Plus | 3.5.3.1 |
Lombok | 1.18.24 |
JDK | 8 |
MySQL | 8.0 |
Redis | - |
项目结构
- common - 通用组件服务,包含一些共享的工具类、通用模型等。
- gateway - 网关服务,负责请求路由、过滤、鉴权功能。
- oauth2-service - 提供Oauth2认证服务的服务、用户注册、登录。
- user-service - 提供用户管理功能的服务,如查询用户信息等接口。
授权码模式执行流程
- 客户端需要登录跳转到认证中心并且在url携带client_id和redirect_url
- 登录完成后,重定向到 redirect_url并且携带code
- 使用 code 到授权服务器换取 access_token 和 refresh_token(这一步是在客户端服务器完成,因为需要使用code,client_id和client_secret去交换token,而client_secret是不允许公开的)
- 客户端使用 access_token 访问获取用户信息
- 过期后使用 refresh_token 刷新 token 再次使用
项目搭建
父pom文件
<?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>
<groupId>cc.oolo</groupId>
<artifactId>myCode</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<parent>
<artifactId>spring-boot-starter-parent</artifactId>
<groupId>org.springframework.boot</groupId>
<version>2.7.0</version>
<relativePath />
</parent>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
common模块
创建通用模块common-core:主要定义了一个通用的返回实体类ResultBody
oauth2-service模块
创建oauth2-service模块:
oauth2-service pom文件
<?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">
<parent>
<artifactId>myCode</artifactId>
<groupId>cc.oolo</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>oauth2</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
<dependency>
<groupId>cc.oolo</groupId>
<artifactId>common-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- oauth2 需要依赖持久层。username+password。clientid + clientsecret token 暂时存储在数据库-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>
</project>
yml配置文件
server:
port: 8500
# 服务将在 8500 端口上运行
spring:
application:
name: oauth2-service
# 应用的名称是 gateway-service
redis:
port: 6379
password: 123456
host: localhost
main:
allow-bean-definition-overriding: true
# 如果一个已经存在的bean定义(有相同的名字)被再次定义,那么允许覆盖这个bean定义
cloud:
nacos:
discovery:
server-addr: localhost:8848
# 服务发现的 Nacos 服务器地址
username: nacos
# Nacos 的用户名
password: nacos
# Nacos 的密码
config:
server-addr: localhost:8848
# 配置的 Nacos 服务器地址
file-extension: yaml
# 配置文件的格式是 yaml
username: nacos
# Nacos 的用户名
password: nacos
# Nacos 的密码
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/oauth?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
- 准备数据表
https://cdn.oolo.cc//source/tables.sql
- 准备用户认证服务
这个类的作用是实现用户身份认证。在用户登录时,该类会查询数据库中对应的用户信息,并将其转换为Spring Security框架中的UserDetails对象。然后,框架会使用该对象进行用户身份认证,判断用户输入的用户名和密码是否匹配。
package cc.oolo.oauth.service;
import cc.oolo.oauth.mapper.UserMapper;
import cc.oolo.oauth.pojo.User;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.Objects;
@Service
public class UserService implements UserDetailsService {
@Resource
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username",username);
User user = userMapper.selectOne(queryWrapper);
if (Objects.isNull(user)){
throw new UsernameNotFoundException("用户名不存在");
}
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
Collections.emptyList());
}
}
- 定义授权服务器
在搭建授权服务器时,我们需要配置各个组件来处理认证、授权、令牌管理等功能。
在config包下创建 Oauth2Config 类,首先,我们需要配置Token的存储方式,Spring Security提供了基于数据库JdbcTokenStore和RedisRedisTokenStore两种实现,选择其中一种:
// 配置JdbcTokenStore
@Bean
public TokenStore tokenStore() {
return new JdbcTokenStore(dataSource);
}
// 配置RedisTokenStore
@Bean
public TokenStore tokenStore(){
return new RedisTokenStore(redisConnectionFactory);
}
然后,我们需要配置授权码服务AuthorizationCodeServices,授权码Spring Security只提供了 JdbcAuthorizationCodeServices ,也就是基于数据库存储,如果要使用 Redis 存储,需要我们手动重写方法
使用数据库存储授权码:
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
return new JdbcAuthorizationCodeServices(dataSource);
}
使用Redis存储授权码:
因为Spring Security 并没有提供将授权码存储在 Redis 的类,所以需要我们自定义一个类,并且实现 AuthorizationCodeServices 接口
package cc.oolo.oauth.service;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import javax.annotation.Resource;
import java.util.UUID;
public class RedisAuthorizationCodeServices implements AuthorizationCodeServices {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override
public String createAuthorizationCode(OAuth2Authentication authentication) {
String code = generateAuthorizationCode(); // 生成授权码
String key = "oauth2:authorization_code:" + code;
redisTemplate.opsForValue().set(key, authentication); // 将授权码存储在 Redis 中
return code;
}
@Override
public OAuth2Authentication consumeAuthorizationCode(String code) {
String key = "oauth2:authorization_code:" + code;
OAuth2Authentication authentication = (OAuth2Authentication) redisTemplate.opsForValue().get(key); // 从 Redis 中检索授权码
redisTemplate.delete(key); // 从 Redis 中删除已使用的授权码
return authentication;
}
private String generateAuthorizationCode() {
// 生成授权码的逻辑,可以使用 UUID 等方式生成唯一的授权码
String code = UUID.randomUUID().toString();
// 返回生成的授权码
return code;
}
}
然后 创建Bean
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
return new RedisAuthorizationCodeServices();
}
配置DefaultTokenServices
DefaultTokenServices用于设置token的有效期、是否支持刷新等参数:
@Primary
@Bean
public DefaultTokenServices defaultTokenServices() {
DefaultTokenServices services = new DefaultTokenServices();
services.setTokenStore(tokenStore());
services.setAccessTokenValiditySeconds(expire);
services.setSupportRefreshToken(true);
services.setReuseRefreshToken(false);
return services;
}
配置ClientDetailsService
ClientDetailsService用于从数据库加载客户端信息,secret_id使用Bcrypt加密
@Bean
public ClientDetailsService clientDetailsService(DataSource dataSource) {
JdbcClientDetailsService service = new JdbcClientDetailsService(dataSource);
service.setPasswordEncoder(new BCryptPasswordEncoder());
return service;
}
配置AuthorizationServerEndpointsConfigurer
注入endpoint所需的组件服务:
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.userDetailsService(userService);
endpoints.tokenServices(defaultTokenServices());
endpoints.authenticationManager(authenticationManager);
endpoints.authorizationCodeServices(authorizationCodeServices());
}
配置AuthorizationServerSecurityConfigurer
设置token端点的访问控制:
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients()
.checkTokenAccess("permitAll()")
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();
}
配置ClientDetailsServiceConfigurer
设置客户端信息来源为数据库:
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource).passwordEncoder(new BCryptPasswordEncoder());
}
完整代码
package cc.oolo.oauth.config;
import cc.oolo.oauth.service.RedisAuthorizationCodeServices;
import cc.oolo.oauth.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.code.JdbcAuthorizationCodeServices;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
import javax.annotation.Resource;
import javax.sql.DataSource;
@Configuration
@EnableAuthorizationServer
public class Oauth2Config extends AuthorizationServerConfigurerAdapter {
@Resource
private DataSource dataSource;
@Resource
private UserService userService;
@Resource
private AuthenticationManager authenticationManager;
@Resource
private RedisConnectionFactory redisConnectionFactory;
public static final int expire = 30 * 24 * 3600;
// @Bean
// public TokenStore tokenStore() {
// return new JdbcTokenStore(dataSource);
// }
@Bean
public TokenStore tokenStore(){
return new RedisTokenStore(redisConnectionFactory);
}
// @Bean
// public AuthorizationCodeServices authorizationCodeServices() {
// return new JdbcAuthorizationCodeServices(dataSource);
// }
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
return new RedisAuthorizationCodeServices();
}
@Primary
@Bean
public DefaultTokenServices defaultTokenServices() {
DefaultTokenServices services = new DefaultTokenServices();
services.setTokenStore(tokenStore());
services.setAccessTokenValiditySeconds(expire);
services.setSupportRefreshToken(true);
services.setReuseRefreshToken(false);
return services;
}
@Bean
public ClientDetailsService clientDetailsService(DataSource dataSource) {
JdbcClientDetailsService service = new JdbcClientDetailsService(dataSource);
service.setPasswordEncoder(new BCryptPasswordEncoder());
return service;
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.userDetailsService(userService);
endpoints.tokenServices(defaultTokenServices());
endpoints.authenticationManager(authenticationManager);
endpoints.authorizationCodeServices(authorizationCodeServices());
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients()
.checkTokenAccess("permitAll()")
.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource).passwordEncoder(new BCryptPasswordEncoder());
}
}
- Spring Security 配置类
创建一个配置类,用来定义我们的安全策略。这个配置类需要使用@Configuration和@EnableWebSecurity两个注解来标记,表示这是一个Spring的配置类,并且启用了Spring Security的功能。然后,我们需要让这个配置类继承WebSecurityConfigurerAdapter这个抽象类,这个类提供了一些默认的配置方法,我们可以根据需要进行重写。
配置AuthenticationManager:
AuthenticationManager是一个接口,负责处理用户的认证请求。我们可以通过重写authenticationManager()方法来创建一个AuthenticationManager的Bean,供其他地方使用。这个方法调用了父类的authenticationManager()方法,返回一个默认的AuthenticationManager实例。
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
配置PasswordEncoder:
使用BCrypt进行加密
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
重写configure(AuthenticationManagerBuilder auth)方法,配置用户认证的管理器,使用BCryptPasswordEncoder对密码进行加密和匹配
@Resource
private UserService userService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService)
.passwordEncoder(new PasswordEncoder() {
@Override
public String encode(CharSequence rawPassword) {
return passwordEncoder().encode(rawPassword);
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return passwordEncoder().matches(rawPassword,encodedPassword);
}
});
}
配置 WebSecurity
重写configure(WebSecurity webSecurity)方法来配置WebSecurity。使用ignoring(),指定了哪些请求不需要经过Spring Security的过滤器链
@Override
public void configure(WebSecurity webSecurity) {
// /login和register不需要认证
//webSecurity.ignoring()
//.antMatchers("/login")
//.antMatchers("/register");
}
配置HttpSecurity
重写configure(HttpSecurity httpSecurity)方法来配置HttpSecurity,authorizeHttpRequests()方法,指定了每个请求需要满足什么条件才能访问,anyRequest().authenticated()方法,表示所有请求都需要认证才能访问。httpBasic()方法,指定了使用HTTP Basic认证方式。最后使用了cors()和csrf().disable()两个方法,分别启用了跨域资源共享和禁用了跨站请求伪造防护。
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeHttpRequests().anyRequest().authenticated().and()
.httpBasic()
.and().cors()
.and().csrf().disable();
}
完整配置:
package cc.oolo.oauth.config;
import cc.oolo.oauth.service.UserService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import javax.annotation.Resource;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserService userService;
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService)
.passwordEncoder(new PasswordEncoder() {
@Override
public String encode(CharSequence rawPassword) {
return passwordEncoder().encode(rawPassword);
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return passwordEncoder().matches(rawPassword,encodedPassword);
}
});
}
@Override
public void configure(WebSecurity webSecurity) {
// /login和register不需要认证
//webSecurity.ignoring()
//.antMatchers("/login")
//.antMatchers("/register");
}
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeHttpRequests().anyRequest().authenticated().and()
.httpBasic()
.and().cors()
.and().csrf().disable();
}
}
测试
数据库中oauth_client_details对应数据
启动oauth2-service服务
在浏览器访问:
localhost:8500/oauth/authorize?client_id=client1&response_type=code
浏览器会直接提示登录
登录完成后,就会跳转到授权页面
点击Authorize授权过后,就会跳转到数据库中指定的回调地址,并且携带code
拿到code过后,再由客户端后端使用client_id和client_secret去换取token
这里使用postman演示:
拿到token后交给客户端保存,如果access_token过期,可以使用refresh_token进行续期,例如:
自定义认证授权页面
引入 thymeleaf 依赖
<!-- thymeleaf 模板引擎-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
默认提供的登录和授权页面过于丑陋,我们可以自定义登录和授权页面
首先在resource下创建static目录,然后创建login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>请登录</title>
<link href="https://cdn.oolo.cc//source/cdn.jsdelivr.net_npm_tailwindcss@2.2.19_dist_tailwind.min.css" rel="stylesheet">
<style>
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #edf2f7;
}
</style>
</head>
<body>
<div class="w-full max-w-xs">
<form class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" method="post" action="/login">
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="username">
用户名
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" type="text" placeholder="Username" name="username" required autofocus>
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="password">
密码
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" id="password" type="password" placeholder="******************" name="password" required>
</div>
<div class="flex items-center justify-between">
<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
登录
</button>
</div>
<div id="error-message" style="display: none;">
<span class="text-red-500 text-xs italic">用户名或密码错误</span>
</div>
</form>
</div>
</body>
<script>
window.onload = function() {
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('error')) {
document.getElementById('error-message').style.display = 'block';
}
};
</script>
</html>
修改 WebSecurityConfig 中 HttpSecurity 规则,其中loginProcessingUrl 登录页面中表单提交的接口,也是Spring Security提供的接口,一开始我以为这个/login是需要自己定义接口,结果是由Spring Security提供的,根本不会走自己的登录接口逻辑。
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.formLogin() //启用 form login 方式.
.loginPage("/login.html") //自定义登录页面的 URL.
.loginProcessingUrl("/login") //登录处理接口.
.permitAll() //允许所有用户访问登录页面和处理接口.
.and()
.authorizeHttpRequests() //开始配置权限.
.antMatchers("/login.html").permitAll() //匹配 "/login.html" 路径,用户可以无条件访问.
.anyRequest().authenticated() //所有其他请求都需要经过认证.
.and().httpBasic() //启用 http 基本验证.
.and().cors() //启用 CORS (跨源资源共享), 默认配置即可.
.and().csrf().disable(); //禁用 CSRF (跨站请求伪造) 支持.
}
在Spring Security OAuth2 中 /oauth/confirm_access 端点就是用于显示授权页面和处理用户的授权决策的。当用户达到这个端点时,他们将看到一个页面,询问他们是否愿意授权应用访问他们的数据,所以我们可以写一个接口用于跳转到自己的授权页面,并且在配置中将 /oauth/confirm_access 修改为自己的接口
在 resource/templates 下创建授权页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>授权</title>
<link href="https://cdn.oolo.cc//source/stackpath.bootstrapcdn.com_bootstrap_5.0.0-alpha1_css_bootstrap.min.css" rel="stylesheet">
<style>
body {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
.card {
width: 90%;
max-width: 500px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1); /* Add shadow to the card */
border-radius: 15px; /* Round the corners of the card */
}
.card-header {
font-size: 1.5rem;
background: #007BFF; /* Change the header color to match the button color */
color: white; /* Make the text in the header white */
border-radius: 15px 15px 0 0; /* Round the corners of the header */
}
.card-body {
padding: 2rem; /* Add more padding to the body */
}
.btn-primary {
border-radius: 20px; /* Round the corners of the button */
padding: .75rem 1.5rem; /* Add more padding to the button */
}
</style>
</head>
<body>
<div class="card">
<div class="card-header text-center">
<i class="fas fa-unlock-alt"></i> OAUTH 授权
</div>
<div class="card-body">
<h5 class="card-title" th:text="${clientId}+' 请求授权,该应用将获取你的以下信息'"></h5>
<th:block th:each="item:${scopes}">
<p class="card-text" th:text="${item}"></p>
</th:block>
<p class="card-text">授权后表明你已同意 <a href="#boot" style="color: #007BFF">OAUTH 服务协议</a></p>
<form method="post" action="/oauth/authorize">
<input type="hidden" name="user_oauth_approval" value="true">
<div th:each="item:${scopes}">
<input type="radio" th:name="'scope.'+${item}" value="true" hidden="hidden" checked="checked"/>
</div>
<button class="btn btn-primary btn-block mt-4" type="submit"> 同意/授权</button>
</form>
</div>
</div>
</body>
</html>
创建 GrantController
package cc.oolo.oauth.controller;
import org.springframework.security.oauth2.provider.AuthorizationRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.servlet.ModelAndView;
import java.util.Map;
@Controller
@SessionAttributes("authorizationRequest")
public class GrantController {
@RequestMapping("/custom/confirm_access")
public ModelAndView getAccessConfirmation(Map<String, Object> model) {
AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
ModelAndView view = new ModelAndView();
view.setViewName("grant");
view.addObject("clientId", authorizationRequest.getClientId());
view.addObject("scopes",authorizationRequest.getScope());
return view;
}
}
修改 Oauth2Config 中,AuthorizationServerEndpointsConfigurer 的规则
添加:endpoints.pathMapping("/oauth/confirm_access","/custom/confirm_access");
将从 /oauth/confirm_access 端点认证,修改为从 /custom/confirm_access 认证
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.userDetailsService(userService);
endpoints.tokenServices(defaultTokenServices());
endpoints.authenticationManager(authenticationManager);
endpoints.authorizationCodeServices(authorizationCodeServices());
endpoints.pathMapping("/oauth/confirm_access","/custom/confirm_access");
}
演示:
首先访问以下链接,将跳转到登录页面
http://localhost:8500/oauth/authorize?client_id=client2&response_type=code&scope=web&redirect_uri=http://redirect2.uri
登录后跳转到授权页面
点击授权后,重定向到传入的redirect_uri,并且携带code
gif 演示:
使用分布式Session
在OAuth2中,认证中心(Authorization Server)负责处理用户的身份验证和授权应用程序访问用户数据的请求。在用户第一次登录时,认证中心会在服务器端创建一个Session,并将对应的Session ID存储在用户的浏览器Cookie中。
在单体项目中,使用这种方式没有什么问题,但是在分布式项目中,用户首先登录,登录成功后将Session ID保存在Cookie,后端创建一个Session。如果用户再一次访问,可能请求会被负载均衡到另外一台服务器上,那一台服务器并没有创建Session,所以就需要重新登录。
为了解决这个问题,我们可以使用基于redis的分布式Session,
方法很简单,只需要引入 spring-session-data-redis 依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
然后 在启动类上添加 @EnableRedisHttpSession
再次访问,session已经存储在了redis中
自定义 CustomUserDetails
我们之前在重写loadUserByUsername的时候,返回的类型为 userdetails 包下的User,这个类是由Spring Security 定义的
返回 User 对象可能并不包含我们所需要的一些字段,比如用户id,而我们往往都是使用用户id来查询用户的信息的。
我们查看 UserDetails,发现是一个接口,并且 org.springframework.security.core.userdetails.User 也实现了这个接口所以,我们可以自定义一个实体类,实现这个接口,我们也可以添加一些其他属性
package cc.oolo.oauth.pojo;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.Set;
@Data
public class CustomUserDetails implements UserDetails {
private String userId;
private String username;
private String password;
private int age;
private String address;
private String email;
private String phone;
private String birthday;
private String createAt;
private String updateAt;
private String lastLogin;
private int isEnabled;
private Set<GrantedAuthority> authorities;
public CustomUserDetails() {
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return isEnabled==1;
}
}
其中 isAccountNonExpired、isAccountNonlocked、isCredentialsNonExpired 方法,如果没有其需求,一定要设置为true,否则所有用户登录就会失败。
然后根据用户名 查询用户的信息和用户的角色
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cc.oolo.oauth.mapper.UserMapper">
<select id="getUserByUsername" resultMap="userResultMap" parameterType="String">
SELECT u.*, r.name AS role_name FROM user u
LEFT JOIN user_role ur ON u.id = ur.user_id
LEFT JOIN role r ON r.id = ur.role_id WHERE u.username = #{username}
</select>
<resultMap id="userResultMap" type="cc.oolo.oauth.pojo.User" autoMapping="true">
<id property="userId" column="id"></id>
<collection property="roles" javaType="java.util.ArrayList" ofType="java.lang.String" >
<result column="role_name" />
</collection>
</resultMap>
</mapper>
修改 loadUserByUsername 方法
package cc.oolo.oauth.service;
import cc.oolo.oauth.mapper.UserMapper;
import cc.oolo.oauth.pojo.CustomUserDetails;
import cc.oolo.oauth.pojo.User;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.*;
@Service
public class UserService implements UserDetailsService {
@Resource
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//查询用户
User user = userMapper.getUserByUsername(username);
if (Objects.isNull(user)){
throw new UsernameNotFoundException("用户名不存在");
}
//查询用户权限
Set<GrantedAuthority> authorityList = new HashSet<>();
for (String role : user.getRoles()) {
authorityList.add(new SimpleGrantedAuthority(role));
}
CustomUserDetails customUserDetails = new CustomUserDetails();
customUserDetails.setAuthorities(authorityList);
BeanUtils.copyProperties(user,customUserDetails);
// return new org.springframework.security.core.userdetails.User(user.getUsername(),user.getPassword(), Collections.emptyList());
return customUserDetails;
}
}
创建 CustomTokenEnhancer,并继承 TokenEnhancer
将 userId 添加到redis中 access_token 对应的 value 中,后续可以通过access_token 获取到用户的数据
package cc.oolo.oauth.enhancer;
import cc.oolo.oauth.pojo.CustomUserDetails;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class CustomTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
Map<String, Object> additionalInfo = new HashMap<>();
Map<String, Object> userInfo = new HashMap<>();
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
//添加用户信息
additionalInfo.put("userId", userDetails.getUserId());
additionalInfo.put("age",userDetails.getAge());
additionalInfo.put("address",userDetails.getAddress());
additionalInfo.put("email",userDetails.getEmail());
additionalInfo.put("phone",userDetails.getPhone());
additionalInfo.put("birthday",userDetails.getBirthday());
additionalInfo.put("createAt",userDetails.getCreateAt());
additionalInfo.put("updateAt",userDetails.getUpdateAt());
additionalInfo.put("lastLogin",userDetails.getLastLogin());
additionalInfo.put("isEnabled",userDetails.isEnabled());
additionalInfo.put("authorities",userDetails.getAuthorities());
userInfo.put("user_info",userDetails);
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(userInfo);
return accessToken;
}
}
然后将 customTokenEnhancer 注册到 DefaultTokenServices 中
自定义 /oauth/token 和 /oauth/check_token 返回格式
在调用/oauth/token 或 /oauth/check_token接口的时候,除了会返回 token 的信息外,还会将 additionalInfo 的数据一起返回,如图所示
所以我们需要自定义返回格式以及返回的内容,避免敏感数据泄漏
创建 AuthController,通过 TokenEndpoint 和 CheckTokenEndpoint 拿到 /oauth/token 和 /oauth/check_token 的返回结果,然后处理敏感数据后,再进行返回,
package cc.oolo.oauth.controller;
import cc.oolo.common.domain.ResultBody;
import cc.oolo.oauth.pojo.Oauth2TokenDto;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.endpoint.CheckTokenEndpoint;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.security.Principal;
import java.util.Map;
@RestController
@RequestMapping("/oauth")
public class AuthController {
@Resource
private TokenEndpoint tokenEndpoint;
@Resource
private CheckTokenEndpoint checkTokenEndpoint;
/**
* Oauth2 登录认证
*/
@PostMapping("/token")
public ResultBody postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(principal, parameters).getBody();
Oauth2TokenDto result = Oauth2TokenDto.builder()
.access_token(oAuth2AccessToken.getValue())
.refresh_token(oAuth2AccessToken.getRefreshToken().getValue())
.expiresIn(oAuth2AccessToken.getExpiresIn())
.build();
return ResultBody.success(result);
}
@GetMapping("/check_token")
public ResultBody postCheckToken(@RequestParam("access_token") String accessToken) throws HttpRequestMethodNotSupportedException {
Map<String, ?> stringMap = checkTokenEndpoint.checkToken(accessToken);
stringMap.remove("user_info");
stringMap.remove("authorities");
stringMap.remove("user_name");
return ResultBody.success(stringMap);
}
}
测试:
访问 /oauth/token 接口使用 code 换取 token:
访问 /oauth/check_token 接口 检查token是否有效
创建 user-service 服务
创建 user-service 服务,提供获取用户信息的接口
pom.xml 配置文件
<?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">
<parent>
<artifactId>myCode</artifactId>
<groupId>cc.oolo</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>user-service</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>cc.oolo</groupId>
<artifactId>oauth2</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
<dependency>
<groupId>cc.oolo</groupId>
<artifactId>common-utils</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!-- oauth2 需要依赖持久层。username+password。clientid + clientsecret token存起来
先用mysql做持久层,后续升级成redis-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>
</project>
application.yml文件
server:
port: 9001
# 服务将在 9001 端口上运行
spring:
application:
name: user-service
# 应用的名称是 user-service
redis:
port: 6379
password: 123456
host: localhost
main:
allow-bean-definition-overriding: true
# 如果一个已经存在的bean定义(有相同的名字)被再次定义,那么允许覆盖这个bean定义
cloud:
nacos:
discovery:
server-addr: localhost:8848
# 服务发现的 Nacos 服务器地址
username: nacos
# Nacos 的用户名
password: nacos
# Nacos 的密码
config:
server-addr: localhost:8848
# 配置的 Nacos 服务器地址
file-extension: yaml
# 配置文件的格式是 yaml
username: nacos
# Nacos 的用户名
password: nacos
# Nacos 的密码
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/oauth?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
在 user-service中,获取用户信息,只需要通过 access_token 从redis中获取用户信息即可,所以我们只需要使用 TokenStore 的 readAuthentication 方法,就可以拿到 OAuth2Authentication,然后调用 oAuth2AccessToken.getUserAuthentication().getPrincipal() 方法,就可以拿到用户的数据了。
创建 TokenStoreConfig
package cc.oolo.user.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
import javax.annotation.Resource;
@Configuration
public class TokenStoreConfig {
@Resource
private RedisConnectionFactory redisConnectionFactory;
@Bean
public TokenStore tokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}
}
创建 Oauth2Config
package cc.oolo.user.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
import javax.annotation.Resource;
@Configuration
@EnableAuthorizationServer
public class Oauth2Config extends AuthorizationServerConfigurerAdapter {
@Resource
private RedisTokenStore tokenStore;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.tokenStore(tokenStore);
}
}
创建 UserController
我们定义了两个接口,一个是前端可以访问的接口,将返回用户的一些非敏感数据
另外一个是服务内获取用户信息使用的接口,将返回用户的所有数据
其中接口url的前缀:
pb
(public):所有请求均可访问,不需要进行任何认证或授权。pt
(protected):需要进行 Token 认证后才能访问的资源。请求必须携带有效的 Token,并且 Token 必须通过认证才能访问受保护的资源。pv
(private):无法通过网关直接访问的资源,只能在微服务内部调用。这些资源对外部客户端不可见,只能由其他微服务内部进行调用。df
(default):网关对请求进行 Token 认证,并且对请求参数和返回结果进行加解密处理。这种模式通常用于保护敏感数据的传输,确保数据在传输过程中的安全性。
这样定义的好处是,我们可以在网关直接根据url判断目标接口是否可以被前端直接调用
package cc.oolo.user.controller;
import cc.oolo.common.domain.ResultBody;
import cc.oolo.common.domain.User;
import cc.oolo.oauth.pojo.PublicUserInfo;
import org.springframework.beans.BeanUtils;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@RestController
public class UserController {
@Resource
private RedisTokenStore tokenStore;
/**
* 常规信息,不包含敏感信息,可由前端调用
* @param accessToken
* @return
*/
@GetMapping("/pb/getUserInfo")
public ResultBody getUserInfo(@RequestParam("access_token") String accessToken){
User user = getUserByToken(accessToken);
PublicUserInfo publicUserInfo = new PublicUserInfo();
BeanUtils.copyProperties(user,publicUserInfo);
return ResultBody.success(publicUserInfo);
}
/**
* 包含用户所有信息
* @param accessToken
* @return
*/
@GetMapping("/pv/getUserAll")
public User getUser(@RequestParam("access_token") String accessToken){
User user = getUserByToken(accessToken);
return user;
}
public User getUserByToken(String accessToken){
OAuth2Authentication oAuth2AccessToken = tokenStore.readAuthentication(accessToken);
User user = new User();
CustomUserDetails principal = (CustomUserDetails) oAuth2AccessToken.getUserAuthentication().getPrincipal();
List<String> roles = new ArrayList<>();
for (GrantedAuthority authority : principal.getAuthorities()) {
roles.add(authority.getAuthority());
}
user.setRoles(roles);
BeanUtils.copyProperties(principal,user);
return user;
}
}
测试:
搭建 gateway 网关
配置pom.xml文件
<?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">
<parent>
<artifactId>myCode</artifactId>
<groupId>cc.oolo</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>gateway</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- spring cloud gateway,如果想要进行请求的转发,在我们当前使用的2021.0.1.0版本,必须引入loadbalance,否则报错503-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>cc.oolo</groupId>
<artifactId>common-utils</artifactId>
<version>1.0-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
</project>
配置 application.yml
server:
port: 9090
# 服务将在 9090 端口上运行
spring:
application:
name: gateway-service
# 应用的名称是 gateway-service
redis:
port: 6379
password: s5YHyEsFSAWrZkzCRfbK
host: 101.5.10.189
main:
allow-bean-definition-overriding: true
# 如果一个已经存在的bean定义(有相同的名字)被再次定义,那么允许覆盖这个bean定义
cloud:
nacos:
discovery:
server-addr: localhost:8848
# 服务发现的 Nacos 服务器地址
username: nacos
# Nacos 的用户名
password: nacos
# Nacos 的密码
config:
server-addr: localhost:8848
# 配置的 Nacos 服务器地址
file-extension: yaml
# 配置文件的格式是 yaml
username: nacos
# Nacos 的用户名
password: nacos
# Nacos 的密码
gateway:
discovery:
locator:
enabled: true
# 启用通过服务发现自动创建路由的功能
routes:
- id: user-service-routes
uri: lb://user-service
predicates:
- Path=/**
filters:
- StripPrefix=1
配置 Openfeign 和 拦截器
我们需要在网关首先需要进行 url 放行(过滤),如果没有被放行的 url ,首先会进行 token 空判断,如果为空则直接返回错误,然后再通过 openfeign,访问 Oauth2-service 服务的 /oauth/check_token 接口检查 token是否有效,如何有效则根据token获取用户的信息,将用户的信息放入请求体,传递给下游服务,下图为调用 /oauth/check_token 接口的返回结果
其中 active 代表token是否有效
创建 Oauth2FeignClient,用于请求 Oauth2-service 服务的 /oauth/check_token 和 /getUserAll 接口
package oolo.cc.gateway.feign;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.Map;
@FeignClient("oauth2-service")
public interface Oauth2FeignClient {
@RequestMapping("/oauth/check_token")
Map<String,Object> checkToken(@RequestParam("access_token") String accessToken);
@GetMapping("/pv/getUserAll")
ResultBody getUserAll(@RequestParam("access_token") String accessToken);
}
创建 Oauth2Filter
package oolo.cc.gateway.filter;
import cc.oolo.common.domain.ResultBody;
import cc.oolo.common.domain.User;
import cc.oolo.common.utils.ServletUtils;
import com.alibaba.fastjson.JSONObject;
import com.google.common.collect.Maps;
import io.netty.util.internal.StringUtil;
import lombok.SneakyThrows;
import oolo.cc.gateway.feign.Oauth2FeignClient;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.*;
@Order(0)
@Component
public class Oauth2Filter implements GlobalFilter {
@Resource
@Lazy
private Oauth2FeignClient oauth2FeignClient;
@SneakyThrows
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String path = request.getURI().getPath();
// 放行指定url
if (path.matches("^/oauth.*") || path.equals("/getUserInfo")) {
return chain.filter(exchange);
}
// 判断 接口 是否可以被前端访问
if (path.contains("/pv/")){
return unauthorizedResponse(exchange,"该接口只允许系统内部访问");
}
// token 校验
String token = request.getHeaders().getFirst("Authorization");
if (StringUtil.isNullOrEmpty(token)){
return unauthorizedResponse(exchange, "令牌不能为空");
}
// 检查 access_token 是否有效,必须使用异步执行,否则会报错
CompletableFuture<ResultBody> checkFuture = CompletableFuture.supplyAsync(() -> {
return oauth2FeignClient.checkToken(token);
});
ResultBody resultBody = checkFuture.get();
Map<String,Object> map = (Map<String, Object>) resultBody.getData();
boolean active = (boolean)map.get("active");
if (!active){
return unauthorizedResponse(exchange, "令牌已失效,请重新登录");
}
// 根据 access_token 获取用户信息,将用户信息写入请求头转发给下游服务
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> {
return oauth2FeignClient.getUserAll(token);
});
User user = userFuture.get();
String traceId = UUID.randomUUID().toString();
ServerHttpRequest serverHttpRequest = request.mutate().headers(httpHeaders -> {
httpHeaders.set("userInfo", JSONObject.toJSONString(user));
httpHeaders.set("traceId", traceId);
}).build();
exchange.mutate().request(serverHttpRequest);
return chain.filter(exchange);
}
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String msg) {
return ServletUtils.webFluxResponseWriter(exchange.getResponse(), msg, HttpStatus.UNAUTHORIZED.value());
}
}
当调用方法的时候报错
feign.codec.EncodeException: No qualifying bean of type 'org.springframework.boot.autoconfigure.http.HttpMessageConverters'
available: expected at least 1 bean which qualifies as autowire candidate.
Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
因为Spring Cloud Gateway是基于WebFlux的,是ReactiveWeb,所以HttpMessageConverters不会自动注入。在gateway服务中配置以下Bean,即可解决。
package oolo.cc.gateway.config;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import java.util.stream.Collectors;
@Configuration
public class ConvertersConfig {
@Bean
@ConditionalOnMissingBean
public HttpMessageConverters messageConverters(ObjectProvider<HttpMessageConverter<?>> converters) {
return new HttpMessageConverters(converters.orderedStream().collect(Collectors.toList()));
}
}
测试:
授权
常用的授权方式有两种,第一种是在各自的服务进行授权,第二种是在网关进行统一授权
一、用户和接口权限授权
用户和接口权限授权是指根据用户和接口的权限信息进行授权判断。每个服务都有自己的授权逻辑,根据请求中的用户身份和接口权限,决定是否允许请求通过。
二、网关统一授权
网关统一授权是指将授权逻辑集中在系统的网关层进行处理。当用户发起请求时,所有的授权判断都在网关上完成,网关根据用户的身份和权限信息,决定是否允许请求通过并将请求转发给相应的服务,前提条件是,需要提前将接口和权限的映射关系保存在网关。
这里采用网关统一授权的方式:
首先需要存储接口 url 对应的权限映射关系
private static final Map<String, String> AUTH_MAP = Maps.newConcurrentMap();
// 此处映射关系可以从数据库中获取
@PostConstruct
public void initAuthMap() {
AUTH_MAP.put("/getUserList", "admin");
}
在根据 access_token 获取到用户信息过后,根据获取到的用户信息和当前访问url对应的权限对比:
List<String> roles = user.getRoles();
if (authorities != null && !roles.contains(authorities)) {
return unauthorizedResponse(exchange, "没有权限访问");
}
如果没有权限拒绝访问即可
1 条评论
大佬,有完整的项目代码么,我发现粘贴的代码有一些缺少了,进行不下去了,拜谢~