OAuth2 实战
上期详细介绍了OAuth2的相关知识点,重点在OAuth2的授权模式(其中最重要的是授权码模式),这期我们就通过Demo的方式来实践下OAuth2认证协议。
SQL脚本 && Postman测试脚本:github.com/dingxinlian…
作者环境:
- MacOS
- Open JDK 11
- Nacos 2.2.2
- Maven 3.6.1
- MySQL 8.0.31
- Spring Boot 2.7.0
- Spring Cloud 2021.0.1
- Spring Cloud Alibaba 2021.0.1.0
- IntelliJ IDEA Community Edition 2023.1.3
一、初始化项目
1.1 创建Maven项目
pom.xml核心内容:进行版本控制和工具类引入
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.boot.version>2.7.0</spring.boot.version>
<spring.cloud.version>2021.0.1</spring.cloud.version>
<spring.cloud.alibaba.version>2021.0.1.0</spring.cloud.alibaba.version>
<mysql.version>8.0.31</mysql.version>
</properties>
<!--spring boot parent-->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/>
</parent>
<dependencyManagement>
<dependencies>
<!--spring cloud -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring.cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring.cloud.alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!--mysql-->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!--util-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>
1.2 数据库环境搭建
OAuth2环境需要的数据库脚本可以从官方仓库中找到:github.com/spring-atti…
此处作者稍稍做了优化,添加了模拟数据,下面是数据库(oauth)脚本文件内容schema.sql
/*
Navicat Premium Data Transfer
Source Server : localhost
Source Server Type : MySQL
Source Server Version : 80031
Source Host : localhost:3306
Source Schema : oauth
Target Server Type : MySQL
Target Server Version : 80031
File Encoding : 65001
Date: 08/08/2023 10:25:03
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for clientdetails
-- ----------------------------
DROP TABLE IF EXISTS `clientdetails`;
CREATE TABLE `clientdetails` (
`appId` varchar(200) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`resourceIds` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`appSecret` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`scope` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`grantTypes` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`redirectUrl` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`authorities` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`access_token_validity` int DEFAULT NULL,
`refresh_token_validity` int DEFAULT NULL,
`additionalInformation` varchar(4096) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`autoApproveScopes` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
PRIMARY KEY (`appId`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT;
-- ----------------------------
-- Records of clientdetails
-- ----------------------------
BEGIN;
COMMIT;
-- ----------------------------
-- Table structure for oauth_access_token
-- ----------------------------
DROP TABLE IF EXISTS `oauth_access_token`;
CREATE TABLE `oauth_access_token` (
`token_id` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`token` blob,
`authentication_id` varchar(200) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`user_name` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`client_id` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`authentication` blob,
`refresh_token` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
PRIMARY KEY (`authentication_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT;
-- ----------------------------
-- Records of oauth_access_token
-- ----------------------------
BEGIN;
COMMIT;
-- ----------------------------
-- Table structure for oauth_approvals
-- ----------------------------
DROP TABLE IF EXISTS `oauth_approvals`;
CREATE TABLE `oauth_approvals` (
`userId` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`clientId` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`scope` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`status` varchar(10) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`expiresAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`lastModifiedAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT;
-- ----------------------------
-- Records of oauth_approvals
-- ----------------------------
BEGIN;
COMMIT;
-- ----------------------------
-- Table structure for oauth_client_details
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
`client_id` varchar(200) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`resource_ids` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`client_secret` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`scope` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`authorized_grant_types` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`web_server_redirect_uri` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`authorities` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`access_token_validity` int DEFAULT NULL,
`refresh_token_validity` int DEFAULT NULL,
`additional_information` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`autoapprove` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
PRIMARY KEY (`client_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT;
-- ----------------------------
-- Records of oauth_client_details
-- ----------------------------
BEGIN;
INSERT INTO `oauth_client_details` (`client_id`, `resource_ids`, `client_secret`, `scope`, `authorized_grant_types`, `web_server_redirect_uri`, `authorities`, `access_token_validity`, `refresh_token_validity`, `additional_information`, `autoapprove`) VALUES ('18266668888', 'PHONE_NUMBER', '$2a$10$mDVS2QYmhzZa/YoGcput4.xhESQCleIoRk1eSySFiwsDUV4Z.MrOu', 'web', 'refresh_token,client_credentials', NULL, 'PHONE_NUMBER', NULL, NULL, NULL, NULL);
INSERT INTO `oauth_client_details` (`client_id`, `resource_ids`, `client_secret`, `scope`, `authorized_grant_types`, `web_server_redirect_uri`, `authorities`, `access_token_validity`, `refresh_token_validity`, `additional_information`, `autoapprove`) VALUES ('dingxinliang01', 'USER_PASSWORD', '$2a$10$94rQmlAeO5Ur4qAKAzmew.SYtbYhPap5i6P3shTzpD61TZEAuJHTq', 'web', 'refresh_token,password', NULL, 'USER_PASSWORD', NULL, NULL, NULL, NULL);
INSERT INTO `oauth_client_details` (`client_id`, `resource_ids`, `client_secret`, `scope`, `authorized_grant_types`, `web_server_redirect_uri`, `authorities`, `access_token_validity`, `refresh_token_validity`, `additional_information`, `autoapprove`) VALUES ('GITEE-codejuzi', 'THIRD_PARTY', '$2a$10$ukYD1AorKH6ZBQm5J49XsOw7AdHpOGnBQFr6ao6Nt.VF7bTiJSq5O', 'web', 'refresh_token,client_credentials', NULL, 'THIRD_PARTY', NULL, NULL, NULL, NULL);
COMMIT;
-- ----------------------------
-- Table structure for oauth_client_token
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_token`;
CREATE TABLE `oauth_client_token` (
`token_id` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`token` blob,
`authentication_id` varchar(200) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`user_name` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`client_id` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
PRIMARY KEY (`authentication_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT;
-- ----------------------------
-- Records of oauth_client_token
-- ----------------------------
BEGIN;
COMMIT;
-- ----------------------------
-- Table structure for oauth_code
-- ----------------------------
DROP TABLE IF EXISTS `oauth_code`;
CREATE TABLE `oauth_code` (
`code` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT;
-- ----------------------------
-- Records of oauth_code
-- ----------------------------
BEGIN;
COMMIT;
-- ----------------------------
-- Table structure for oauth_refresh_token
-- ----------------------------
DROP TABLE IF EXISTS `oauth_refresh_token`;
CREATE TABLE `oauth_refresh_token` (
`token_id` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`token` blob,
`authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT;
-- ----------------------------
-- Records of oauth_refresh_token
-- ----------------------------
BEGIN;
COMMIT;
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`passwd` varchar(265) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`user_name` varchar(256) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`user_role` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`user_email` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`user_idcard` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`user_phone` varchar(15) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`user_province` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`vip_epoch` int NOT NULL,
`vip_buy_date` datetime DEFAULT NULL,
`vip_end_date` datetime DEFAULT NULL,
`vip_status` int NOT NULL,
`user_real_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT;
-- ----------------------------
-- Records of user
-- ----------------------------
BEGIN;
INSERT INTO `user` (`id`, `passwd`, `user_name`, `user_role`, `user_email`, `user_idcard`, `user_phone`, `user_province`, `vip_epoch`, `vip_buy_date`, `vip_end_date`, `vip_status`, `user_real_name`) VALUES (19, '$2a$10$94rQmlAeO5Ur4qAKAzmew.SYtbYhPap5i6P3shTzpD61TZEAuJHTq', 'dingxinliang01', 'USER_ROLE', NULL, NULL, '18266668888', NULL, 0, NULL, NULL, 0, NULL);
INSERT INTO `user` (`id`, `passwd`, `user_name`, `user_role`, `user_email`, `user_idcard`, `user_phone`, `user_province`, `vip_epoch`, `vip_buy_date`, `vip_end_date`, `vip_status`, `user_real_name`) VALUES (22, '', 'MALL_16913784274218888', 'PHONE_NUMBER', NULL, NULL, '18266668888', NULL, 0, NULL, NULL, 0, NULL);
INSERT INTO `user` (`id`, `passwd`, `user_name`, `user_role`, `user_email`, `user_idcard`, `user_phone`, `user_province`, `vip_epoch`, `vip_buy_date`, `vip_end_date`, `vip_status`, `user_real_name`) VALUES (23, '', 'GITEE-codejuzi', 'THIRD_PARTY', NULL, NULL, '18266668888', NULL, 0, NULL, NULL, 0, NULL);
COMMIT;
SET FOREIGN_KEY_CHECKS = 1;
上述数据库中,除user表之外,其余的表都是OAuth2的必备表,user表是可以根据实际业务去自定义的
1.3 Nacos环境准备
此处我们选择Nacos集群环境,具体的Nacos集群搭建过程可以查看官网教程(最权威、最新的),如果你是MacOS(M系列芯片)用户,集群搭建有些许不同,具体的可以查看我的博客文章:www.codejuzi.icu/#/docs/envc…
二、OAuth2模块
2.1 搭建环境
创建OAuth2模块,pom.xml核心内容:
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.cloud.oauth2.version>2.2.5.RELEASE</spring.cloud.oauth2.version>
</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>
<!-- spring security + oauth2 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>${spring.cloud.oauth2.version}</version>
</dependency>
<!-- jpa + mysql -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
</dependencies>
PS:不一定非要使用JPA,只是OAuth2模块的SQL查询使用JPA会比较方便
配置文件application.yml内容
server:
port: 8500
spring:
application:
name: oauth2-service
cloud:
nacos:
discovery:
server-addr: 192.168.0.100:8837,192.168.0.100:8848,192.168.0.100:8859
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/oauth?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
username: root
password: 12345678
PS:具体的数据库配置信息和Nacos地址信息以实际环境为准
2.2 创建实体类
前面说过,user表是我们自定义的,OAuth2并不知道有这张表的存在,因此我们需要去创建对应的实体类去操作这张表,其余的表格都是有OAuth2内部操作,不需要我们操心。
User.java
package com.juzi.oauth2.pojo;
import lombok.Data;
import javax.persistence.*;
@Data
@Entity
@Table(name = "user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "user_name")
private String userName;
@Column(name = "passwd")
private String passwd;
@Column(name = "user_role")
private String userRole;
}
PS:此处为了操作方便,就只操作上述的几个字段,其余字段可以自行扩展哦~
2.3 OAuth2 + SpringSecurity 配置
SpringSecurity认证流程链:
因为我们的用户数据存储在数据库中,需要让SpringSecurity知道去数据库中查询用户信息,然后才能认证授权。因此我们需要去实现接口org.springframework.security.core.userdetails.UserDetailsService,实现其中的loadUserByUsername()方法,从方法名也可以看出,是根据用户名去查询用户。
那么此时我们还需要知道用什么去查询,JDBC?MyBatis?我们刚刚引入的JPA这个时候就派上用场了,我们只需要定义一个接口,继承org.springframework.data.jpa.repository.JpaRepository接口,指定实体类和主键类型,定义查询方法就可以了。
UserRepository.java
package com.juzi.oauth2.repo;
import com.juzi.oauth2.pojo.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Integer> {
/**
* 根据用户名查询用户
*
* @param userName 用户名
* @return 用户信息
*/
User queryByUserName(String userName);
}
至此,我们定义了从数据库查询用户信息的入口,接下来就应该实现UserDetailService接口,让SpringSecurity从数据库查询用户信息,进行认证授权了。
UserDetailServiceImpl.java
package com.juzi.oauth2.service;
import com.juzi.oauth2.pojo.User;
import com.juzi.oauth2.repo.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
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;
@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.queryByUserName(username);
if (user == null) {
throw new UsernameNotFoundException("User Not Found");
}
return new org.springframework.security.core.userdetails.User(
user.getUserName(),
user.getPasswd(),
AuthorityUtils.createAuthorityList(user.getPasswd()));
}
}
但是实现了接口就能够让SpringSecurity从数据库查询用户了吗?还不行,我们还需要将我们实现的类注册到SpringSecurity中,也就是配置。此处呢,我们需要编写配置类,继承org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter配置类,重写其中的配置方法。
PS:尽管WebSecurityConfigurerAdapter在Spring Security 5.7.1及更新版本或者Spring Boot 2.7.0及更新版本已经被弃用了,但是依然有很多项目使用它。当然,你也可以使用官方推荐的基于组件的(component-based)的安全配置去实现配置类,难度也不是很大,就是换了中写法而已啦。
WebSecurityConfig.java
package com.juzi.oauth2.config;
import com.juzi.oauth2.service.UserDetailServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
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;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailServiceImpl userDetailService;
/**
* 加密存储方式
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService)
.passwordEncoder(passwordEncoder());
}
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers("/swagger-*");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests().anyRequest().authenticated()
.and().httpBasic()
.and().cors()
.and().csrf().disable();
}
}
首先需要明确一点,用户密码是绝对不可以以明文的形式存储数据库的,此处我们采用BCryptPasswordEncoder的形式去加密存储,只需要注册一个PasswordEncoder Bean即可。
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService)
.passwordEncoder(passwordEncoder());
}
上述代码就是在将我们实现的从数据库查询用户信息的类注册到SpringSecurity中,使得SpringSecurity调用我们实现的loadUserByUserName方法查询,同时还指定了密码的加密方式。
public void configure(WebSecurity web) {
web.ignoring().antMatchers("/swagger-*");
}
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests().anyRequest().authenticated()
.and().httpBasic()
.and().cors()
.and().csrf().disable();
}
上述代码实在配置认证授权需要忽略的路径,以及配置http的简要内容,比如禁用csrf、开启跨域等等。
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
上述代码是在注册AuthenticationManager类,至于作用嘛,我们先按下不表,等到进行OAuth2配置时自然就知道了。
至此为止,我们已经配置完了SpringSecurity,接下来就要配置OAuth2了。准备好了吗?发车了!
配置OAuth2需要我们去继承org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter类,复写其中的configure方法。
Oauth2Config.java
package com.juzi.oauth2.config;
import com.juzi.oauth2.service.UserDetailServiceImpl;
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.security.authentication.AuthenticationManager;
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.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;
import javax.sql.DataSource;
@Configuration
@EnableAuthorizationServer
public class Oauth2Config extends AuthorizationServerConfigurerAdapter {
@Autowired
public DataSource dataSource;
@Autowired
private UserDetailServiceImpl userDetailService;
@Autowired
private AuthenticationManager authenticationManager;
/**
* token配置
*/
@Bean
@Primary
public DefaultTokenServices defaultTokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
// token 过期时间为 30day
defaultTokenServices.setAccessTokenValiditySeconds(30 * 24 * 60 * 60);
defaultTokenServices.setTokenStore(tokenStore());
return defaultTokenServices;
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(clientDetails());
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
// 自定义安全配置:放行token检测的接口,
security
// 允许表单认证
.allowFormAuthenticationForClients()
// 允许客户端认证
.checkTokenAccess("permitAll()");
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.userDetailsService(userDetailService)
.tokenServices(defaultTokenServices())
.authenticationManager(authenticationManager);
}
/**
* 存储token位置为mysql
*/
@Bean
public TokenStore tokenStore() {
return new JdbcTokenStore(dataSource);
}
/**
* 存储client_details, client_secret的位置
*/
@Bean
public ClientDetailsService clientDetails() {
return new JdbcClientDetailsService(dataSource);
}
}
PS:我们这里采取默认的数据源,当然可以自定义数据源,比如Druid数据源。
/**
* 存储token位置为mysql
*/
@Bean
public TokenStore tokenStore() {
return new JdbcTokenStore(dataSource);
}
/**
* 存储client_details, client_secret的位置
*/
@Bean
public ClientDetailsService clientDetails() {
return new JdbcClientDetailsService(dataSource);
}
因为我们的数据都存放在数据库中,因此需要指定token和client相关信息的存放位置为Jdbc,在本项目中也就是MySQL。
@Bean
@Primary
public DefaultTokenServices defaultTokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
// token 过期时间为 30day
defaultTokenServices.setAccessTokenValiditySeconds(30 * 24 * 60 * 60);
defaultTokenServices.setTokenStore(tokenStore());
return defaultTokenServices;
}
上述代码是在配置默认的token管理器,注册了我们在上面提到的token存储位置的bean,设置了token过期时间等。
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(clientDetails());
}
上述代码是在配置OAuth2中的Client Details的存储位置,这次设置为MySQL。
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
// 自定义安全配置:放行token检测的接口,
security
// 允许表单认证
.allowFormAuthenticationForClients()
// 允许客户端认证
.checkTokenAccess("permitAll()");
}
上述代码是在进行安全配置,比如允许表单认证、允许客户端认证等。
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.userDetailsService(userDetailService)
.tokenServices(defaultTokenServices())
.authenticationManager(authenticationManager);
}
因为OAuth2模块也是要从数据库读取用户信息和存取token的,因此需要在上述的终端配置endpoints中注册相关的bean,除此之外,还需要注册我们刚刚声明的认证管理器类AuthenticationManager,用于OAuth2认证。
至此OAuth2的配置也就结束了,接下来就是编写启动类,测试启动了(仅仅启动,并不做任何的请求,因为还有模块没写呢~)。
Oauth2Application.java
package com.juzi.oauth2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
@EnableDiscoveryClient
@SpringBootApplication
@EnableResourceServer
public class Oauth2Application {
public static void main(String[] args) {
SpringApplication.run(Oauth2Application.class, args);
}
}
需要标注服务发现、资源服务器等注解。
三、通用模块
这一模块主要为了网关模块和用户模块做准备,定义通用返回类、返回工具类等公共类;这一模块不需要引入任何依赖,因此只需要默认的pom.xml文件即可。
通用返回类CommonResponse.java
package com.juzi.common.resp;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class CommonResponse {
private Integer code;
private String msg;
private Object content;
}
返回值枚举类RespCodeEnum.java
package com.juzi.common.resp;
public enum RespCodeEnum {
SUCCESS(200, "success"),
BAD_REQUEST(400, "bad request"),
UNAUTHORIZED(401, "unauthorized"),
FORBIDDEN(403, "forbidden"),
;
private final Integer code;
private final String msg;
RespCodeEnum(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public Integer getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
返回工具类RespUtils.java
package com.juzi.common.resp;
public class RespUtils {
public static CommonResponse success(Object content) {
return CommonResponse.builder()
.code(RespCodeEnum.SUCCESS.getCode())
.msg(RespCodeEnum.SUCCESS.getMsg())
.content(content)
.build();
}
public static CommonResponse fail(Integer code, Object content, String msg) {
return CommonResponse.builder()
.code(code)
.msg(msg)
.content(content)
.build();
}
}
四、用户模块
4.1 搭建环境
pom.xml核心内容:
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</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>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!--nacos-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--http client-->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</dependency>
<!--mysql + redis-->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--util-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.21</version>
</dependency>
<!--common-->
<dependency>
<groupId>com.juzi</groupId>
<artifactId>common</artifactId>
<version>0.0.1</version>
</dependency>
</dependencies>
application.yml
server:
port: 9001
spring:
application:
name: user-service
cloud:
nacos:
discovery:
server-addr: 192.168.0.100:8837,192.168.0.100:8848,192.168.0.100:8859
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/oauth?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
username: root
password: 12345678
redis:
host: localhost
port: 6379
timeout: 10s
lettuce:
pool:
max-active: 50
max-idle: 50
max-wait: -1ms
min-idle: 0
PS:Mysql、Nacos、Redis的具体配置还需要根据实际情形来调整哦~
4.2 业务需求
1)注册部分
- 账号密码注册
- 电话验证码注册
- 第三方平台注册
2)登录部分
- 账号密码登录
3)用户信息部分
- 绑定电话
4.3 创建实体类和对应操作数据库Repo
用户模块我们需要操作两张表,分别是user和oauth_client_details,因此我们需要创建两个实体类。
User.java
package com.juzi.user.pojo.po;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.util.Date;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "passwd")
private String passwd;
@Column(name = "user_name")
private String userName;
@Column(name = "user_email")
private String userEmail;
@Column(name = "user_idcard")
private String userIdCard;
@Column(name = "user_role")
private String userRole;
@Column(name = "user_phone")
private String userPhone;
@Column(name = "user_province")
private String userProvince;
@Column(name = "vip_status")
private int vipStatus;
@Column(name = "vip_epoch")
private int vipEpoch;
@Column(name = "vip_buy_date")
private Date vipBuyDate;
@Column(name = "vip_end_date")
private Date vipEndDate;
}
OAuth2Client.java
package com.juzi.user.pojo.po;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "oauth_client_details")
public class OAuth2Client {
@Id
@Column(name = "client_id")
private String clientId;
@Column(name = "resource_ids")
private String resourceIds;
@Column(name = "client_secret")
private String clientSecret;
@Column(name = "scope")
private String scope;
@Column(name = "authorized_grant_types")
private String authorizedGrantTypes;
@Column(name = "web_server_redirect_uri")
private String redirectUrl;
@Column(name = "authorities")
private String authorities;
@Column(name = "access_token_validity")
private Integer accessTokenValidity;
@Column(name = "refresh_token_validity")
private Integer refreshTokenValidity;
@Column(name = "additional_information")
private String additionalInfo;
@Column(name = "autoapprove")
private String autoApprove;
}
创建完实体类,就应该创建对应的JPA Repo类了
UserRepository.java
package com.juzi.user.repo;
import com.juzi.user.pojo.po.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Integer> {
}
OAuth2ClientRepository.java
package com.juzi.user.repo;
import com.juzi.user.pojo.po.OAuth2Client;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface OAuth2ClientRepository extends JpaRepository<OAuth2Client, Integer> {
}
PS:不要忘记标注注解@Repository
除此之外,我们还需要创建两个枚举类,分别是注册类别(RegisterType)、权限授予类别(AuthGrantType)
RegisterType.java
package com.juzi.user.pojo.enums;
public enum RegisterType {
USER_PASSWORD,
PHONE_NUMBER,
THIRD_PARTY
}
按照我们本项目的处理,注册类别分别是账号密码、手机验证码、第三方平台
AuthGrantType.java
package com.juzi.user.pojo.enums;
public enum AuthGrantType {
implicit,
client_credentials,
authorization_code,
refresh_token,
password
}
4.4 创建配置类和工具类
首先就是Redis的配置类RedisConfig.java,主要是配置Redis中的key和value的序列化方式
package com.juzi.user.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean(name = "redisTemplate")
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 设置key的序列化方式
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
// 设置value的序列化方式
Jackson2JsonRedisSerializer<?> redisValueSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
redisValueSerializer.setObjectMapper(objectMapper);
redisTemplate.setValueSerializer(redisValueSerializer);
redisTemplate.setHashValueSerializer(redisValueSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
因为我们要使用RestTemplate来发送HTTP请求,因此还需要配置RestTemplate,在此之前,我们先配置HttpClient,目的就是替换掉RedisTemplate底层使用的URL Connection(JDK),适用于复杂场景。
HttpClientConfig.java
package com.juzi.user.config;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HeaderElement;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.ConnectionKeepAliveStrategy;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.TrustSelfSignedStrategy;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.message.BasicHeaderElementIterator;
import org.apache.http.protocol.HTTP;
import org.apache.http.ssl.SSLContextBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Scheduled;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Slf4j
@Configuration
public class HttpClientConfig {
/**
* 连接超时时间(ms)
*/
private static final int CONNECTION_TIMEOUT = 30000;
/**
* 请求超时时间(ms)
*/
private static final int REQUEST_TIMEOUT = 30000;
/**
* socket 超时时间(ms)
*/
private static final int SOCKET_TIMEOUT = 60000;
/**
* 最大连接数
*/
private static final int MAX_CONNECTIONS = 50;
/**
* 默认 keep-alive 时间(ms)
*/
private static final int DEFAULT_KEEP_ALIVE_TIME = 20000;
/**
* 清理空闲线程的任务定时时间(ms)
*/
private static final int CLOSE_IDLE_CONN_TIME = 30000;
@Bean
public CloseableHttpClient httpClient() {
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(CONNECTION_TIMEOUT)
.setConnectionRequestTimeout(REQUEST_TIMEOUT)
.setSocketTimeout(SOCKET_TIMEOUT)
.build();
return HttpClients.custom()
.setDefaultRequestConfig(config)
// pooling 线程池设置
.setConnectionManager(poolingHttpClientConnectionManager())
// keep-alive 设置
.setKeepAliveStrategy(connectionKeepAliveStrategy())
.build();
}
/**
* 进行 http、 https 的注册, 设置最大连接数
*/
@Bean
public PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
SSLContextBuilder sslContextBuilder = new SSLContextBuilder();
try {
// 内部通信不需要 key-store(trust-store)
sslContextBuilder.loadTrustMaterial(null, new TrustSelfSignedStrategy());
} catch (NoSuchAlgorithmException | KeyStoreException e) {
log.error("loadTrustMaterial error: ", e);
}
SSLConnectionSocketFactory sslConnectionSocketFactory = null;
try {
sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContextBuilder.build());
} catch (NoSuchAlgorithmException | KeyManagementException e) {
log.error("SSLConnectionSocketFactory create error: ", e);
}
@SuppressWarnings("DataFlowIssue") Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder
.<ConnectionSocketFactory>create()
.register("http", new PlainConnectionSocketFactory())
.register("https", sslConnectionSocketFactory)
.build();
PoolingHttpClientConnectionManager poolingHttpClientConnectionManager
= new PoolingHttpClientConnectionManager(socketFactoryRegistry);
poolingHttpClientConnectionManager.setMaxTotal(MAX_CONNECTIONS);
return poolingHttpClientConnectionManager;
}
@Bean
public ConnectionKeepAliveStrategy connectionKeepAliveStrategy() {
return (response, context) -> {
BasicHeaderElementIterator iterator = new BasicHeaderElementIterator(response.headerIterator(HTTP.CONN_KEEP_ALIVE));
while (iterator.hasNext()) {
HeaderElement headerElement = iterator.nextElement();
String name = headerElement.getName();
String value = headerElement.getValue();
if (StringUtils.isNotEmpty(value) && "timeout".equalsIgnoreCase(name)) {
return Long.parseLong(value) * 1000;
}
}
return DEFAULT_KEEP_ALIVE_TIME;
};
}
/**
* 定时清理空闲线程(10s)
*/
@Bean
public Runnable idleConnCleaner(final PoolingHttpClientConnectionManager connectionManager) {
return new Runnable() {
@Override
@Scheduled(fixedDelay = 10000L)
public void run() {
try {
if (!Objects.isNull(connectionManager)) {
connectionManager.closeExpiredConnections();
connectionManager.closeIdleConnections(CLOSE_IDLE_CONN_TIME, TimeUnit.MILLISECONDS);
} else {
log.warn("PoolingHttpClientConnectionManager is not initialized");
}
} catch (Exception e) {
log.error("idleConnCleaner error: ", e);
}
}
};
}
}
至此,我们可以开始进行RestTemplate的配置了,RestTemplateConfig.java
package com.juzi.user.config;
import org.apache.http.impl.client.CloseableHttpClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
@Configuration
public class RestTemplateConfig {
/**
* 替换RestTemplate底层使用的URL Connection(JDK),适用复杂场景
*/
@Autowired
private CloseableHttpClient httpClient;
@Bean
public HttpComponentsClientHttpRequestFactory clientHttpRequestFactory() {
HttpComponentsClientHttpRequestFactory httpRequestFactory = new HttpComponentsClientHttpRequestFactory();
httpRequestFactory.setHttpClient(httpClient);
return httpRequestFactory;
}
@Bean(name = "innerRestTemplate")
@LoadBalanced
public RestTemplate innerRestTemplate() {
RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory());
for (HttpMessageConverter<?> messageConverter : restTemplate.getMessageConverters()) {
if (messageConverter instanceof StringHttpMessageConverter) {
// 一般接收 UTF-8 的消息
((StringHttpMessageConverter) messageConverter).setDefaultCharset(StandardCharsets.UTF_8);
}
}
return restTemplate;
}
@Bean(name = "outerRestTemplate")
public RestTemplate outerRestTemplate() {
RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory());
for (HttpMessageConverter<?> messageConverter : restTemplate.getMessageConverters()) {
if (messageConverter instanceof StringHttpMessageConverter) {
// 一般接收 UTF-8 的消息
((StringHttpMessageConverter) messageConverter).setDefaultCharset(StandardCharsets.UTF_8);
}
}
return restTemplate;
}
}
声明两个RestTemplate的原因是因为我们需要有两部分请求,一部分请求第三方平台的接口,这是外部接口,是不需要进行负载均衡的;而还有一部分请求是请求内部OAuth2接口,这是内部接口,是需要进行负载均衡的(@LoadBalanced)
PS:负载均衡的配置会在网关模块中进行
本项目中,我们还需要进行第三方平台(此处选择的是GITEE)的注册登录,因为还需要创建对应的配置类,并且以配置的形式注入。
package com.juzi.user.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "third-party.gitee")
public class GiteeConfig {
private String clientId;
private String clientSecret;
private String callBack;
private String tokenUrl;
private String userUrl;
private String state;
}
具体的在application.yml中的配置,等到测试的时候再去申请对应的值即可。
至此,配置类完结,接下来就是操作Redis的工具类的编写,仅仅为了操作Redis的方便,RedisCommonProcessor.java
package com.juzi.user.utils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class RedisCommonProcessor {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public Object get(String key) {
validateKey(key);
return redisTemplate.opsForValue().get(key);
}
public void set(String key, Object value) {
validateKey(key);
redisTemplate.opsForValue().set(key, value);
}
public void set(String key, Object value, long time, TimeUnit timeUnit) {
validateKey(key);
if (time > 0) {
redisTemplate.opsForValue().set(key, value, time, timeUnit);
} else {
set(key, value);
}
}
public void setExpiredSeconds(String key, Object value, long seconds) {
set(key, value, seconds, TimeUnit.SECONDS);
}
public void setExpiredMinutes(String key, Object value, long minutes) {
set(key, value, minutes, TimeUnit.MINUTES);
}
public void setExpiredHours(String key, Object value, long hours) {
set(key, value, hours, TimeUnit.HOURS);
}
public void setExpiredDays(String key, Object value, long days) {
set(key, value, days, TimeUnit.DAYS);
}
public void remove(String key) {
validateKey(key);
redisTemplate.delete(key);
}
private void validateKey(String key) {
if (StringUtils.isEmpty(key)) {
throw new IllegalArgumentException("Key should not be null or empty.");
}
}
}
4.5 用户服务类,实现具体的业务逻辑
4.5.1 登录注册部分
首先创建接口(UserRegisterLoginService)及其实现类(UserRegisterLoginServiceImpl)
package com.juzi.user.service;
public interface UserRegisterLoginService {
}
// =========================
package com.juzi.user.service.impl;
import com.juzi.user.service.UserRegisterLoginService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class UserRegisterLoginServiceImpl implements UserRegisterLoginService {
}
1)账号密码注册
实现账号密码注册业务流程:
-
判断用户是否存在(依据userName或者clientId是否存在)
-
如果存在,返回错误信息(用户已经存在)
-
如果不存在,就是新用户注册
- 先进行密码的加密(BCryptPasswordEncoder)
- 构建OAuth2Client信息
- 保存 user, oauth2 信息到数据库中,需要内部开启事务,因为需要保证最后返回数据时能够查询到最新的消息,因此事务只能在保存到数据库这个方法中开启和关闭
- 在Redis中保存用户信息
- 返回 user、token信息
首先我们需要在Repo类中添加查询方法,根据userName和clientId来查询用户信息
在UserRepository中加入
User findByUserName(String username);
在OAuth2ClientRepository中加入
OAuth2Client findByClientId(String clientId);
然后思考,UserRegisterLoginServiceImpl类中需要操作Repo和Redis,同时还需要向OAuth2服务申请token,因此需要注入这些类
@Autowired
private UserRepository userRepository;
@Autowired
private OAuth2ClientRepository oAuth2ClientRepository;
@Autowired
private RedisCommonProcessor redisCommonProcessor;
@Autowired
private RestTemplate innerRestTemplate;
在UserRegisterLoginService中定义接口:
CommonResponse namePasswordRegister(User user);
在UserRegisterLoginServiceImpl中进行实现:
@Resource(name = "transactionManager")
private JpaTransactionManager transactionManager;
@Override
public CommonResponse namePasswordRegister(User user) {
if (!(userRepository.findByUserName(user.getUserName()) == null
&& oAuth2ClientRepository.findByClientId(user.getUserName()) == null)) {
return RespUtils.fail(RespCodeEnum.BAD_REQUEST.getCode(), null, "User Already Exists! Please Login!");
}
// 新用户注册
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String originPwd = user.getPasswd();
user.setPasswd(bCryptPasswordEncoder.encode(originPwd));
OAuth2Client oAuth2Client = OAuth2Client.builder()
.clientId(user.getUserName())
.clientSecret(user.getPasswd())
.resourceIds(RegisterType.USER_PASSWORD.name())
.authorizedGrantTypes(AuthGrantType.refresh_token.name().concat(",")
.concat(AuthGrantType.password.name()))
.scope("web")
.authorities(RegisterType.USER_PASSWORD.name())
.build();
// 保存 user, oauth2 信息,内部开启事务
Integer uid = this.saveUserAndOAuth2Client(user, oAuth2Client);
String personId = String.valueOf(uid + 10000000);
redisCommonProcessor.setExpiredDays(personId, user, 30);
// 返回 user、token信息
return RespUtils.success(
formatUserAndToken(user,
generateOAuthToken(AuthGrantType.password, user.getUserName(), originPwd, user.getUserName(), originPwd))
);
}
@SuppressWarnings("rawtypes")
private Map<String, Object> formatUserAndToken(User user, Map map) {
return new HashMap<>() {{
put("user", user);
put("oauth", map);
}};
}
@SuppressWarnings("rawtypes")
private Map generateOAuthToken(AuthGrantType authGrantType, String username,
String password, String clientId, String clientSecret) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", authGrantType.name());
params.add("client_id", clientId);
params.add("client_secret", clientSecret);
if (authGrantType.equals(AuthGrantType.password)) {
params.add("username", username);
params.add("password", password);
}
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);
return innerRestTemplate.postForObject("http://oauth2-service/oauth/token", requestEntity, Map.class);
}
private Integer saveUserAndOAuth2Client(User user, OAuth2Client oAuth2Client) {
DefaultTransactionDefinition dtx = new DefaultTransactionDefinition();
dtx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
dtx.setTimeout(30);
// 开启事务
TransactionStatus status = transactionManager.getTransaction(dtx);
try {
user = userRepository.save(user);
oAuth2ClientRepository.save(oAuth2Client);
transactionManager.commit(status);
} catch (Exception e) {
if (!status.isCompleted()) {
// 回滚事务
transactionManager.rollback(status);
}
log.error("DB Save Error", e);
}
return user.getId();
// 结束事务
}
📢:注意需要在保存数据库信息的方法内部开启事务,才能够保证在返回信息是查询到最新的信息。
PS:此处使用了Map进行了简单结果处理,也可以定义VO类进行结果的封装;除此之外,具体的OAuth2Client信息的封装(尤其是ClientId、ClientSecret)、uid的生成可以自定义,只要统一一套规范即可。
2)手机验证码注册
PS:这部分需要短信模块先进行验证码的生成、保存、发送,具体的sms-service模块可以看:github.com/dingxinlian…,因为这部分需要买云厂商的服务,我们就mock数据了,直接往Redis里存入一对键值对(手机号-验证码)
实现手机验证码注册的业务逻辑:
-
发送验证码,存储验证码(这步此处略过)
-
从Redis中查询验证码,与用户传入的验证码比对
-
若不一致,返回错误信息
-
若一致:
-
以验证码为用户的密码进行加密,主要是作为ClientSecret存储
-
根据手机号查找用户,若用户存在则更新ClientSecret即可
-
若不存在:
- 封装User和OAuth2Client信息
- 保存 user, oauth2 信息到数据库中
- 保存用户信息到Redis中
-
返回user、token信息
-
首先添加Repo方法:根据手机号查询用户、根据ClientId更新用户ClientSecret信息
在UserRepository中添加
User findByUserPhone(String phoneNumber);
在OAuth2ClientRepository中添加
/**
* 根据clientId修改client secret
*
* @param secret new client secret
* @param clientId client id
*/
@Modifying
@Transactional
@Query(
value = "update oauth_client_details set client_secret = ?1 where client_id = ?2",
nativeQuery = true)
void updateSecretByClientId(String secret, String clientId);
然后在UserRegisterLoginService类中定义接口:
CommonResponse phoneCodeRegister(String phoneNumber, String code);
在UserRegisterLoginServiceImpl类中实现:
@Override
public CommonResponse phoneCodeRegister(String phoneNumber, String code) {
String cacheCode = String.valueOf(redisCommonProcessor.get(phoneNumber));
if (StringUtils.isEmpty(cacheCode)) {
return RespUtils.fail(RespCodeEnum.BAD_REQUEST.getCode(), null, "Phone Code is Expired!");
}
if (!cacheCode.equals(code)) {
return RespUtils.fail(RespCodeEnum.BAD_REQUEST.getCode(), null, "Phone Code is Wrong!");
}
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
// 验证码加密作为 client_secret,符合 oauth2
String encodePwd = bCryptPasswordEncoder.encode(code);
User user = userRepository.findByUserPhone(phoneNumber);
if (Objects.isNull(user)) {
// 新用户注册
String username = getSystemDefineUserName(phoneNumber);
user = User.builder()
.userName(username)
// 可不存
.passwd("")
.userPhone(phoneNumber)
.userRole(RegisterType.PHONE_NUMBER.name())
.build();
OAuth2Client oAuth2Client = OAuth2Client.builder()
.clientId(phoneNumber)
.clientSecret(encodePwd)
.resourceIds(RegisterType.PHONE_NUMBER.name())
.authorizedGrantTypes(AuthGrantType.refresh_token.name().concat(",")
.concat(AuthGrantType.client_credentials.name()))
.scope("web")
.authorities(RegisterType.PHONE_NUMBER.name())
.build();
Integer uid = saveUserAndOAuth2Client(user, oAuth2Client);
String personId = String.valueOf(uid + 10000000);
redisCommonProcessor.setExpiredDays(personId, user, 30);
} else {
// secret 过期,更新
oAuth2ClientRepository.updateSecretByClientId(encodePwd, phoneNumber);
}
return RespUtils.success(
formatUserAndToken(user,
generateOAuthToken(AuthGrantType.client_credentials, null, null, phoneNumber, code))
);
}
private String getSystemDefineUserName(String phoneNumber) {
//前缀 MALL_ + 当前时间戳 + 手机号后4位
return "MALL_" + System.currentTimeMillis() + phoneNumber.substring(phoneNumber.length() - 4);
}
3)第三方平台注册
第三方平台(Gitee)注册业务逻辑:
- 组装tokenUrl,向第三方平台请求token
- 拿着token向第三方平台请求用户信息
- 封装User和OAuth2Client信息
- 保存 user, oauth2 信息到数据库中
- 保存用户信息到Redis中
- 返回user、token信息
在UserRegisterLoginService类中添加接口:
CommonResponse thirdPlatformGiteeRegister(HttpServletRequest request);
在ServiceImpl中实现接口:
@Autowired
private GiteeConfig giteeConfig;
@Autowired
private RestTemplate outerRestTemplate;
@Override
public CommonResponse thirdPlatformGiteeRegister(HttpServletRequest request) {
String code = request.getParameter("code");
String state = request.getParameter("state");
if (!giteeConfig.getState().equals(state)) {
return RespUtils.fail(RespCodeEnum.BAD_REQUEST.getCode(), null, "Invalid State!");
}
// https://gitee.com/oauth/token?grant_type=authorization_code&client_id=%s&client_secret=%s&redirect_uri=%s&code=%s
String tokenUrl = String.format(giteeConfig.getTokenUrl(),
giteeConfig.getClientId(), giteeConfig.getClientSecret(),
giteeConfig.getCallBack(), code);
JSONObject tokenRes = outerRestTemplate.postForObject(tokenUrl, null, JSONObject.class);
assert tokenRes != null;
String token = String.valueOf(tokenRes.get("access_token"));
// https://gitee.com/api/v5/user?access_token=%s
String userUrl = String.format(giteeConfig.getUserUrl(), token);
JSONObject giteeUserInfo = outerRestTemplate.getForObject(userUrl, JSONObject.class);
assert giteeUserInfo != null;
String username = giteeConfig.getState().concat("-").concat(String.valueOf(giteeUserInfo.get("login")));
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String encodePwd = bCryptPasswordEncoder.encode(username);
User user = userRepository.findByUserName(username);
if (Objects.isNull(user)) {
user = User.builder()
.userName(username)
.passwd("")
.userRole(RegisterType.THIRD_PARTY.name())
.build();
OAuth2Client oAuth2Client = OAuth2Client.builder()
.clientId(username)
.clientSecret(encodePwd)
.resourceIds(RegisterType.THIRD_PARTY.name())
.authorizedGrantTypes(AuthGrantType.refresh_token.name().concat(",")
.concat(AuthGrantType.client_credentials.name()))
.scope("web")
.authorities(RegisterType.THIRD_PARTY.name())
.build();
Integer uid = saveUserAndOAuth2Client(user, oAuth2Client);
String personId = String.valueOf(uid + 10000000);
redisCommonProcessor.setExpiredDays(personId, user, 30);
}
return RespUtils.success(
formatUserAndToken(user,
generateOAuthToken(AuthGrantType.client_credentials, null, null, username, username))
);
}
第三方平台(Gitee)clientId, clientSecret等参数的申请和配置
1)前往Gitee个人主页设置页面(gitee.com/profile/acc…)
2)点击侧边栏的「数据管理 => 第三方应用」选项
3)点击右上角创建应用
4)点击创建应用后会得到一组clientId和clientSecret
5)将得到的clientId和clientSecret复制下来,修改用户模块下的application.yml文件,添加如下内容
third-party: gitee: clientId: please replace it with your own clientSecret: please replace it with your own call-back: http://localhost:9001/user/register/gitee token-url: https://gitee.com/oauth/token?grant_type=authorization_code&client_id=%s&client_secret=%s&redirect_uri=%s&code=%s user-url: https://gitee.com/api/v5/user?access_token=%s state: GITEE上述内容中clientId、clientSecret、call-back请换成实际场景下的真实值。
至此申请完毕!
4)账号密码登录
账号密码登录业务逻辑:
- 根据userName查询用户
- 若不存在,返回错误信息
- 若存在,刷新缓存用户的过期时间
- 返回user、token信息
在UserRegisterLoginService类中定义接口:
CommonResponse login(String username, String password);
在UserRegisterLoginServiceImpl类中实现接口:
@Override
public CommonResponse login(String username, String password) {
User user = userRepository.findByUserName(username);
if (Objects.isNull(user)) {
return RespUtils.fail(RespCodeEnum.BAD_REQUEST.getCode(), null, "User Not Exists!");
}
Map<String, Object> content = formatUserAndToken(user, generateOAuthToken(AuthGrantType.password, username, password, username, password));
// 刷新缓存用户信息过期时间
String personId = String.valueOf(user.getId() + 10000000);
redisCommonProcessor.setExpiredDays(personId, user, 30);
return RespUtils.success(content);
}
4.5.2 用户信息部分
首先创建接口(UserInfoService)及其实现类(UserInfoServiceImpl)
package com.juzi.user.service;
public interface UserInfoService {
}
// =========================
package com.juzi.user.service.impl;
import com.juzi.user.service.UserInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class UserInfoServiceImpl implements UserInfoService {
}
1)查看手机绑定状态
查看手机绑定状态业务逻辑:
- 先从缓存查询用户信息,若能查找到,判断用户是否绑定手机
- 否则从数据库查询用户信息,判断用户是否绑定手机
在UserInfoService类中添加接口:
CommonResponse checkPhoneBindStatus(String personId);
在UserInfoServiceImpl中实现接口:
@Autowired
private UserRepository userRepository;
@Autowired
private RedisCommonProcessor redisCommonProcessor;
@Override
public CommonResponse checkPhoneBindStatus(String personId) {
User user = (User) redisCommonProcessor.get(personId);
boolean isBind;
if (user != null) {
isBind = user.getUserPhone() != null;
return RespUtils.success(isBind);
}
// 查询db
Integer userId = Integer.parseInt(personId) - 10000000;
Optional<User> userOptional = userRepository.findById(userId);
if (userOptional.isPresent()) {
isBind = userOptional.get().getUserPhone() != null;
redisCommonProcessor.setExpiredDays(personId, userOptional.get(), 30);
return RespUtils.success(isBind);
}
return RespUtils.fail(RespCodeEnum.BAD_REQUEST.getCode(), null, "Invalid User");
}
2)用户绑定手机
用户绑定手机业务逻辑:
-
发送验证码,存储验证码(这步此处略过)
-
从Redis中查询验证码,与用户传入的验证码比对
-
若不一致,返回错误信息
-
若一致:
-
根据用户id查询用户信息
-
判断用户是否存在
-
若不存在:返回错误信息
-
若存在:
- 根据id修改用户手机号
- 返回成功信息
-
在User Repo类中添加修改手机号的方法:
@Modifying
@Transactional
@Query(
value = "update user set user_phone = ?1 where id = ?2",
nativeQuery = true)
void updateUserPhoneById(String phoneNumber, Integer id);
在UserInfoService中定义接口:
CommonResponse bindPhone(String personId, String phoneNumber, String code);
在UserInfoServiceImpl中实现接口:
@Override
public CommonResponse bindPhone(String personId, String phoneNumber, String code) {
String cacheCode = String.valueOf(redisCommonProcessor.get(phoneNumber));
if (StringUtils.isEmpty(cacheCode)) {
return RespUtils.fail(RespCodeEnum.BAD_REQUEST.getCode(), null, "Phone Code is Expired!");
}
if (!cacheCode.equals(code)) {
return RespUtils.fail(RespCodeEnum.BAD_REQUEST.getCode(), null, "Phone Code is Wrong!");
}
Integer userId = Integer.parseInt(personId) - 10000000;
Optional<User> userOptional = userRepository.findById(userId);
if (userOptional.isPresent()) {
userRepository.updateUserPhoneById(phoneNumber, userId);
// 删除缓存
redisCommonProcessor.remove(personId);
return RespUtils.success(Boolean.TRUE);
}
return RespUtils.fail(RespCodeEnum.BAD_REQUEST.getCode(), null, "Invalid User");
}
4.6 用户HTTP接口层
UserRegisterLoginController.java
package com.juzi.user.controller;
import com.juzi.common.resp.CommonResponse;
import com.juzi.user.pojo.po.User;
import com.juzi.user.service.UserRegisterLoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/user")
public class UserRegisterLoginController {
@Autowired
private UserRegisterLoginService userRegisterLoginService;
// region register
@PostMapping("/register/name-password")
public CommonResponse namePasswordRegister(@RequestBody User user) {
return userRegisterLoginService.namePasswordRegister(user);
}
@PostMapping("/register/phone-code")
public CommonResponse phoneCodeRegister(@RequestParam String phoneNumber, @RequestParam String code) {
return userRegisterLoginService.phoneCodeRegister(phoneNumber, code);
}
/**
* 此接口提供给 gitee 平台使用,用于第三方登录
*/
@RequestMapping("/register/gitee")
public CommonResponse thirdPlatformGiteeRegister(HttpServletRequest request) {
return userRegisterLoginService.thirdPlatformGiteeRegister(request);
}
// endregion
// region login
@PostMapping("/login")
public CommonResponse login(@RequestParam String username,
@RequestParam String password) {
return userRegisterLoginService.login(username, password);
}
// endregion
}
UserInfoController.java
package com.juzi.user.controller;
import com.juzi.common.resp.CommonResponse;
import com.juzi.user.service.UserInfoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/user/info")
public class UserInfoController {
@Autowired
private UserInfoService userInfoService;
@PostMapping("check-phone-bind-status")
public CommonResponse checkPhoneBindStatus(@RequestHeader(value = "personId") String personId) {
return userInfoService.checkPhoneBindStatus(personId);
}
@PostMapping("bind-phone")
public CommonResponse bindPhone(@RequestHeader(value = "personId") String personId,
@RequestParam(value = "phoneNumber") String phoneNumber,
@RequestParam(value = "code") String code) {
return userInfoService.bindPhone(personId, phoneNumber, code);
}
}
DemoController.java,该接口用于测试gateway网关的拦截
package com.juzi.user.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DemoController {
@GetMapping
public String test() {
return "Hello!";
}
}
4.7 用户模块启动类
package com.juzi.user;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class UserApplication {
public static void main(String[] args) {
SpringApplication.run(UserApplication.class, args);
}
}
五、网关模块
5.1 搭建环境
创建Gateway模块,pom.xml核心内容:
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<netty.resolver.classifier.version>4.1.80.Final</netty.resolver.classifier.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</dependency>
<!--nacos 注册中心-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--open feign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-resolver-dns-native-macos</artifactId>
<version>${netty.resolver.classifier.version}</version>
<classifier>osx-aarch_64</classifier>
<scope>runtime</scope>
</dependency>
</dependencies>
PS:上述依赖中io.netty.netty-resolver-dns-native-macos依赖需要根据实际环境添加
除了使用open-feign外,还可以使用Dubbo等RPC框架
application.yml
server:
port: 9090
spring:
application:
name: gateway-service
main:
allow-bean-definition-overriding: true
cloud:
nacos:
discovery:
server-addr: 192.168.0.100:8837,192.168.0.100:8848,192.168.0.100:8859
gateway:
discovery:
locator:
enabled: true
routes:
- id: user_service_route
uri: lb://user-service
predicates:
- Path=/**
filters:
- StripPrefix=1
- id: oauth2_service_route
uri: lb://oauth2-service
predicates:
- Path=/**
filters:
- StripPrefix=1
5.2 环境配置
网关层是需要调用远程接口,此处我们使用OpenFeign,接下来需要配置Feign,主要是完成对消息的解析
FeignConfig.java
package com.juzi.gateway.feign.config;
import feign.codec.Decoder;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.cloud.openfeign.support.ResponseEntityDecoder;
import org.springframework.cloud.openfeign.support.SpringDecoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import java.util.ArrayList;
import java.util.List;
@Configuration
public class FeignConfig {
@Bean
public Decoder feignDecoder() {
// 完成msg的解析,converter
ObjectFactory<HttpMessageConverters> objectFactory = () -> {
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
List<MediaType> mediaTypes = new ArrayList<>();
mediaTypes.add(MediaType.valueOf(MediaType.TEXT_HTML_VALUE + ";charset=utf-8"));
converter.setSupportedMediaTypes(mediaTypes);
return new HttpMessageConverters(converter);
};
return new ResponseEntityDecoder(new SpringDecoder(objectFactory));
}
}
5.3 远程接口定义
网关层面需要调用OAuth2的校验token接口:
OAuth2ServerClient
package com.juzi.gateway.feign.api;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.Map;
@FeignClient(name = "oauth2-service")
public interface OAuth2ServerClient {
/**
* oauth2 校验token 接口
*
* @param token token
* @return resp
*/
@GetMapping("/oauth/check_token")
Map<String, Object> checkToken(@RequestParam(value = "token") String token);
}
注意FeignClient注解中定义的name属性要和OAuth2模块的名称一致,最终是依靠注册中心Nacos来实现RPC的。
5.4 网关层过滤器
AuthFilter.java
package com.juzi.gateway.feign.filter;
import com.juzi.gateway.feign.api.OAuth2ServerClient;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.Ordered;
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.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentSkipListSet;
@Component
public class AuthFilter implements GlobalFilter, Ordered {
private static final AntPathMatcher ANT_PATH_MATCHER = new AntPathMatcher();
private static final Set<String> WHITE_ACCESS_URLS = new ConcurrentSkipListSet<>() {{
add("/**/oauth/**");
add("/**/user/register/**");
add("/**/user/login/**");
}};
/**
* 懒加载,否则和Gateway启动过程会产生死锁
*/
@Lazy
@Autowired
private OAuth2ServerClient oAuth2ServerClient;
@SneakyThrows
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String reqUrl = request.getURI().getPath();
if (checkAccessUrl(reqUrl)) {
return chain.filter(exchange);
}
// 获取token
String token = request.getHeaders().getFirst("Authorization");
CompletableFuture<Map<String, Object>> authCheckFuture
= CompletableFuture.supplyAsync(() -> oAuth2ServerClient.checkToken(token));
Map<String, Object> authCheckResult = authCheckFuture.get();
boolean active = (boolean) authCheckResult.get("active");
if (!active) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 转发到微服务,并携带一些header,比如链路id
ServerHttpRequest httpRequest = request.mutate().headers(httpHeaders -> {
httpHeaders.set("personId", request.getHeaders().getFirst("personId"));
// httpHeaders.set("tracingId", request.getHeaders().getFirst("tracingId"));
}).build();
exchange.mutate().request(httpRequest);
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
private boolean checkAccessUrl(String reqUrl) {
return WHITE_ACCESS_URLS.stream().anyMatch(url -> ANT_PATH_MATCHER.match(url, reqUrl));
}
}
定义路径白名单,直接放行的路径;同时需要对需要校验的路径进行OAuth2的token检验。
📢:Feign接口是需要进行懒加载,否则和Gateway启动过程会产生死锁。
5.5 启动类
GatewayApplication.java
package com.juzi.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
注意:需要标注@EnableFeignClients注解,开启Feign的客户端调用。
六、Postman测试
6.1 预先启动环境
需要先启动Nacos集群(至少一个节点存活)、Redis、MySQL
6.2 启动项目
- Gateway
- OAuth2
- User
6.3 Postman接口测试
接口一:用户账号密码注册
注册接口:
校验token接口,拿着上一步返回的access_token:
数据库信息:
接口二:手机号验证码注册
首先需要在Redis中存入事先的手机号和验证码,此处我存入的手机号是18266668888,验证码是666666
注册接口:
校验token:……
数据库信息:……
接口三:第三方平台注册
首先我们需要在浏览器地址栏输入以下网址:
https://gitee.com/oauth/authorize/?client_id=%s&redirect_uri=http://localhost:9001/user/register/gitee&response_type=code&state=GITEE
其中的client_id为之前申请的client_id,替换即可,redirect_uri需要根据实际的情形调整
请求之后会跳转到以下页面:
在点击同意授权之后,就会请求redirect_uri,并返回如下信息:
token校验:……
数据库数据:
接口四:用户账号密码登录
登录接口:
token校验:……
接口五:查看手机绑定状态
接口:
注意📢:上述的personId在本次代码中是以用户在数据库中的id + 10000000组成的,具体的需要根据代码逻辑调整
接口六:用户绑定手机
接口:
数据库信息:
接口七:测试用户服务有无token的情况
1)不携带token的情况
2)携带token
七、写在最后
除上述模块之外,还可以加入canel,实现延迟双删,实现数据双写一致性。具体的可以查看github.com/dingxinlian…。
上述只是OAuth2实践的一种形式,还有很多其他的形式,多阅读、多实践!