首先咱们先来弄清楚这里的先后端分离指的是什么?咱们上篇文章已经指出oauth2有四种角色分别是(客户端、受权服务端和资源服务端和资源全部者),资源服务端和资源全部者是指用户数据和用户本身,因此这里的先后端要么是客户端应用要么是受权服务端那么究竟是哪一个呢?由于受权服务端已经实现了登陆和受权相关页面所以咱们只须要改造一下便可,因此这里的先后端指的就是客户端应用了。javascript
说到客户端应用通常有两种实现方式,一种是传统的先后台一体的单体架构项目如:jsp、asp.net等等,另外一种是使用分布式架构的先后端分离技术,如React、Vue这样的前端框架作到先后台相互隔离。css
还有一点咱们这里实现的单点登陆是使用受权码(authorization-code)模式不要搞晕最后所有实现了都不知道使用的是OAuth2的哪一种模式就有点搞笑了。html
不论是单体架构仍是分布式架构,受权服务器都是同样的,因此咱们先来构建受权服务器及相关代码实现来开始本章。前端
单体架构上面已经提到了就是先后台一体的应用,后面会介绍分布式架构先后端分离应用实现单点登陆,咱们先从单体架构来实现单点登陆来比较他们二者的区别,也请你们自行思考二者的优缺点并在实际项目中作出选择。java
这个单体架构的单点登陆系统包括下面几个模块:mysql
awbeci-sso: 父模块
awbeci-sso-server: 认证和受权服务端(端口:8900)
awbeci-sso-client1: 单点登陆客户端示例(端口:8901)
awbeci-sso-client2: 单点登陆客户端示例(端口:8902) jquery
首先,我想让初学者了解受权服务端的做用以及相关概念,受权服务端主要作这样几件事:
1. 受权服务端接受客户端的访问(废话)
2. 客户端向受权服务端发起请求令牌后,受权服务端首先会验证是哪一个应用(client_id和client_secret),接着会验证是哪一个用户(username和password)并要求用户受权。注意:这些参数和凭据都是客户端和用户给的
3. 受权服务端验证都经过了,就会根据客户端传的redicrect_uri并带着code跳转到该连接返回给客户端
4. 客户端带着原先传递的参数加上受权服务端给的code再次请求受权服务端,受权服务端接收并再次验证是哪一个应用,哪一个用户,哪一个code经过一系列的验证经过以后就正式返回token给客户端。 git
如今知道了受权服务端到底作了哪些事,这样接下来咱们要作的就是去实现这样一整套流程,若是后面有什么仍是不清楚能够回头再来看看。web
首先咱们先来构建一个maven项目,名称为awbeci-sso-server并在pom.xml中添加相关依赖包,以下所示:spring
<modelVersion>4.0.0</modelVersion> <packaging>jar</packaging> <artifactId>awbeci-sso-server</artifactId> <groupId>org.awbeci</groupId> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.2.5.RELEASE</version> </parent> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Hoxton.SR3</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency><!--热部署依赖--> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
接着新建相关Package并在下面添加SsoServerApplication类,以下所示:
@SpringBootApplication public class SsoServerApplication { @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } public static void main(String[] args) { SpringApplication.run(SsoServerApplication.class, args); } }
首先添加添加SsoAuthorizationServerConfig类它是继承自AuthorizationServerConfigurerAdapter类,以下所示:
@Configuration @EnableAuthorizationServer public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private BCryptPasswordEncoder passwordEncoder; @Override public void configure(final AuthorizationServerSecurityConfigurer oauthServer) throws Exception { oauthServer.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { // 配置客户端的client_id和client_secret并保存到内存中 clients.inMemory() .withClient("client1") .secret(passwordEncoder.encode("123456")) .redirectUris("http://127.0.0.1:8901/client1/login") .authorizedGrantTypes("authorization_code") .scopes("all") .and() .withClient("client2") .secret(passwordEncoder.encode("123456")) .redirectUris("http://127.0.0.1:8902/client2/login") .authorizedGrantTypes("authorization_code") .scopes("all"); } }
注意:必须设置回调地址redirectUris
,而且格式是http://客户端IP:端口/login
的格式,不然会报OAuth Error error=”invalid_request”, error_description=”At least one redirect_uri must be registered with the client.”
Spring Security 安全配置。在安全配置类里咱们配置了:
@Configuration @EnableWebSecurity public class SsoWebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService userDetailsService; // 用来处理用户验证 // 被注入OAuth2Config类中的 endpoints方法中 @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Autowired BCryptPasswordEncoder bCryptPasswordEncoder; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) .passwordEncoder(bCryptPasswordEncoder); } @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login") .and() .authorizeRequests() .antMatchers("/login").permitAll() .anyRequest() .authenticated() .and().csrf().disable().cors(); } }
接下来配置UserDetailsService,代码以下所示:
public class SsoUserDetailsService implements UserDetailsService { @Autowired private BCryptPasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 模拟从数据库获取用户信息,实际项目中要从数据库中获取用户信息 return User.withUsername(username) .password(passwordEncoder.encode("123456")) .authorities("ROLE_ADMIN") .build(); } }
这样就所有配置好了认证和受权的服务端了,下面咱们就来配置客户端。
客户端咱们来新建两个maven项目,固然你也能够新建2个以上的,下面是新建的client1和client2的pom.xml依赖包,以下:
<modelVersion>4.0.0</modelVersion> <packaging>jar</packaging> <artifactId>awbeci-sso-client2</artifactId> <groupId>org.awbeci</groupId> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.2.5.RELEASE</version> </parent> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Hoxton.SR3</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency><!--热部署依赖--> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
接着新建相关Package并在下面添加SsoServerApplication类,以下所示:
@SpringBootApplication // 这里开启了单点登陆访问 @EnableOAuth2Sso public class SsoClient1Application { public static void main(String[] args) { SpringApplication.run(SsoClient1Application.class, args); } }
如今咱们来配置application.yml并设置好客户端应用以及受权地址和获取token的地址
server: port: 8901 servlet: context-path: /client1 spring: application: name: client1service thymeleaf: cache: false mode: HTML5 encoding: UTF-8 servlet: content-type: text/html prefix: classpath:/templates/ suffix: .html resources: chain: strategy: content: enabled: true paths: /** security: oauth2: client: client-id: client1 client-secret: 123456 user-authorization-uri: http://127.0.0.1:8900/sso/oauth/authorize access-token-uri: http://127.0.0.1:8900/sso/oauth/token resource: token-info-uri: http://127.0.0.1:8900/sso/oauth/check_token
注意:上面配置中security做用就是当客户端发现没有身份认证的时候会自动跳转到http://127.0.0.1:8900/sso/oauth/authorize去认证用户,认证成功以后会返回到客户端,客户端就能够经过http://127.0.0.1:8900/sso/oauth/token去自动获取token,就不须要手动去跳转和获取了,这两个自动操做的过程当中已经作了code的获取和返回,可是你是感受不到的,这也是单体架构的优点了,而咱们后面作的先后台分离项目就要手动去操做了。
咱们会在client1和client2项目上新建首页页面,这个首页页面就是一个简单的html页面咱们使用的是thymeleaf来制做这样的页面,咱们会在客户端1(client1)和客户端2(client2)上面制做相同的这样的页面,目的:就是当无论访问哪一个客户端页面当你已经认证了用户那么你相互跳转并访问就不须要再跑去验证用户了,可是若是你没经过验证那么无论你访问哪一个页面就要去验证用户。就是模拟一个多应用下的验证和受权操做,若是大家实在想像不出来,能够想一想淘宝和天猫登陆的操做,咱们就是模拟这样的一个操做过程。下面是客户端1和客户端2的首页代码:
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <title>client1</title> <meta charset="utf-8"/> <meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width"/> <link type="text/css" rel="stylesheet" href="https://cdn.bootcss.com/twitter-bootstrap/4.4.1/css/bootstrap.min.css"> <body> <div> 访问<a href="http://127.0.0.1:8902/client2">客户端2</a> </div> <script type="text/javascript" src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script> <script type="text/javascript" src="https://cdn.bootcss.com/twitter-bootstrap/4.4.1/js/bootstrap.min.js"></script> </body> </html>
记得加上Controller
@RestController public class TestController { @GetMapping("/") public ModelAndView index(){ ModelAndView mv = new ModelAndView(); mv.setViewName("index"); return mv; } }
客户端Client2的设置同上,这里就不详细讲了,你们能够看个人源码,下面咱们把三个项目运行起来试试。
当我访问客户端1client1的时候会直接跳转到localhost:8900/sso/login,缘由上面已经说过了,下面咱们输入用户名和密码点击登陆试试。
上面提示你要不要给应用client1受权访问,选择Approve(容许)再点击Authoriza(受权)便可,这样就会跳转到你返回过来的redirect_uri连接了。
如今就能够访问客户端1的页面了,中间已经作了code和token的验证了(上面已经说过了),用户是感觉不到的。如今你点击客户端2,它会跳转到客户端2,由于咱们已经验证了用户,因此跳转到客户端2是不用再验证用户了,咱们点击试试。
虽然不用再验证用户了,可是这里仍是要你受权,这里像客户端同样选择便可。
这样客户端2也能访问了,这样就完成了单体架构的单点登陆了。不过上面的过程有几个问题须要咱们去解决,我列举了一下:
一、自动受权
二、使用数据库保存客户端和token信息
三、使用jwt生成token
下面咱们就来一个个改造,首先咱们来改造下自动受权,不用每次去点击了。
改造也很简单,咱们改下SsoAuthorizationServerConfig的configure(ClientDetailsServiceConfigurer clients)方法,以下所示:
clients.inMemory() .withClient("client1") .secret(passwordEncoder.encode("123456")) .redirectUris("http://127.0.0.1:8901/client1/login") .authorizedGrantTypes("authorization_code") + .autoApprove(true) .scopes("all") .and() .withClient("client2") .secret(passwordEncoder.encode("123456")) .redirectUris("http://127.0.0.1:8902/client2/login") .authorizedGrantTypes("authorization_code") + .autoApprove(true) .scopes("all");
如今你再输入用户名和密码以后就不用点击受权了,就直接跳转到客户端了,你们能够试试。
下面咱们来改造下代码使用数据库保存客户端信息。
首先咱们要建几个跟OAuth2相关的表来保存client和token相关信息,以下所示:
CREATE TABLE `oauth_access_token` ( `token_id` varchar(255) DEFAULT NULL, `token` longblob, `authentication_id` varchar(255) DEFAULT NULL, `user_name` varchar(255) DEFAULT NULL, `client_id` varchar(255) DEFAULT NULL, `authentication` longblob, `refresh_token` varchar(255) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `oauth_approvals` ( `userId` varchar(255) DEFAULT NULL, `clientId` varchar(255) DEFAULT NULL, `scope` varchar(255) DEFAULT NULL, `status` varchar(10) DEFAULT NULL, `expiresAt` datetime DEFAULT NULL, `lastModifiedAt` datetime DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `oauth_client_details` ( `client_id` varchar(255) NOT NULL, `resource_ids` varchar(255) DEFAULT NULL, `client_secret` varchar(255) DEFAULT NULL, `scope` varchar(255) DEFAULT NULL, `authorized_grant_types` varchar(255) DEFAULT NULL, `web_server_redirect_uri` varchar(255) DEFAULT NULL, `authorities` varchar(255) DEFAULT NULL, `access_token_validity` int(11) DEFAULT NULL, `refresh_token_validity` int(11) DEFAULT NULL, `additional_information` varchar(255) DEFAULT NULL, `autoapprove` varchar(255) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `oauth_client_token` ( `token_id` varchar(255) DEFAULT NULL, `token` longblob, `authentication_id` varchar(255) DEFAULT NULL, `user_name` varchar(255) DEFAULT NULL, `client_id` varchar(255) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `oauth_code` ( `code` varchar(255) DEFAULT NULL, `authentication` varbinary(255) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `oauth_refresh_token` ( `token_id` varchar(255) DEFAULT NULL, `token` longblob, `authentication` longblob ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
再添加client1和client2的客户端信息,如图所示:
上面的client的密码你们可使用以下代码生成:
System.out.println(new BCryptPasswordEncoder().encode("123456"));
接着添加下jdbc和mysql的依赖包
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency>
接着咱们在application.yml里面添加对mysql的配置
spring: datasource: url: jdbc:mysql://your-mysql-domain/your-database?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true&autoReconnect=true&failOverReadOnly=false username: your-username password: your-password driver-class-name: com.mysql.jdbc.Driver jpa: show-sql: true
接着改造下SsoAuthorizationServerConfig类代码
@Configuration @EnableAuthorizationServer public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { + @Autowired + private DataSource dataSource; + @Autowired + private UserDetailsService userDetailsService; @Autowired private BCryptPasswordEncoder passwordEncoder; @Override public void configure(final AuthorizationServerSecurityConfigurer oauthServer) throws Exception { oauthServer.tokenKeyAccess("isAuthenticated()") .checkTokenAccess("isAuthenticated()"); } + @Override + public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { + endpoints + .userDetailsService(userDetailsService) + .tokenStore(tokenStore()); + } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { // 配置客户端的client_id和client_secret并保存到内存中 - clients.inMemory() - .withClient("client1") - .secret(passwordEncoder.encode("123456")) - .redirectUris("http://127.0.0.1:8901/client1/login") - .authorizedGrantTypes("authorization_code") - .autoApprove(true) - .scopes("all") - .and() - .withClient("client2") - .secret(passwordEncoder.encode("123456")) - .redirectUris("http://127.0.0.1:8902/client2/login") - .authorizedGrantTypes("authorization_code") - .autoApprove(true) - .scopes("all"); + clients.jdbc(dataSource); } }
这样就完成了改造,如今咱们再从新访问客户端1或者2,输入用户名和密码,再看看数据库oauth_access_token表里面的数据,以下所示:
首先咱们添加JWTTokenStoreConfig配置类:
@Configuration public class JWTTokenStoreConfig { // 设置TokenStore为JwtTokenStore @Bean public TokenStore tokenStore() { return new JwtTokenStore(jwtAccessTokenConverter()); } // 在jwt和oauth2服务器之间充当翻译 @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); // 定义将用于签署令牌的签名密钥(自定义 存储在git上authentication.yml文件) // jwt是不保密的,因此要另外加签名验证jwt token // todo:最好不要写死,复杂点更好 converter.setSigningKey("awbeci"); return converter; } // 设置TokenEnhancer加强器中使用JWTTokenEnhancer加强器 @Bean public TokenEnhancer jwtTokenEnhancer() { return new JWTTokenEnhancer(); } // @Primary做用:若是有多个特定类型bean那么就使用被@Primary标注的bean类型进行自动注入 // @Bean // @Primary // public DefaultTokenServices tokenServices() { // // 用于从出示给服务的令牌中读取数据 // DefaultTokenServices defaultTokenServices = new DefaultTokenServices(); // defaultTokenServices.setTokenStore(tokenStore()); // defaultTokenServices.setSupportRefreshToken(true); // return defaultTokenServices; // } }
接着,咱们添加jwt的加强器JWTTokenEnhancer类,做用:扩展jwt内容信息。
// jwt token 扩展器,加进本身的数据内容 public class JWTTokenEnhancer implements TokenEnhancer { // 要进行加强须要覆盖enhance方法 @Override public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) { Map<String, Object> additionalInfo = new HashMap<>(); additionalInfo.put("enhancer_content", "there is enhancer content"); // 全部附加的属性都放到HashMap中,并设置在传入该方法的accessToken变量上 ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(additionalInfo); return oAuth2AccessToken; } }
接着,咱们修改SsoAuthorizationServerConfig类来支持jwt
@Configuration @EnableAuthorizationServer public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private DataSource dataSource; - @Autowired - private BCryptPasswordEncoder passwordEncoder; @Autowired private UserDetailsService userDetailsService; - @Bean - public TokenStore tokenStore() { - return new JdbcTokenStore(dataSource); - } + @Autowired + private AuthenticationManager authenticationManager; + @Autowired + private TokenStore tokenStore; + // 将JWTTokenStore类中的JwtAccessTokenConverter关联到OAUTH2 + @Autowired + private JwtAccessTokenConverter jwtAccessTokenConverter; + // 自动将JWTTokenEnhancer装配到TokenEnhancer类中 + // token加强类,须要添加额外信息内容的就用这个类 + @Autowired + private TokenEnhancer jwtTokenEnhancer; @Override public void configure(final AuthorizationServerSecurityConfigurer oauthServer) throws Exception { oauthServer.tokenKeyAccess("isAuthenticated()") .checkTokenAccess("isAuthenticated()"); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { + // 设置jwt签名和jwt加强器到TokenEnhancerChain + TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain(); + tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtTokenEnhancer, +jwtAccessTokenConverter)); endpoints.tokenStore(tokenStore) + // 在jwt和oauth2服务器之间充当翻译(签名) + .accessTokenConverter(jwtAccessTokenConverter) + // 令牌加强器类:扩展jwt token + .tokenEnhancer(tokenEnhancerChain) //JWT .userDetailsService(userDetailsService) + .authenticationManager(authenticationManager) } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.jdbc(dataSource); } }
接着,咱们再来改造下客户端1和2下的application.yml
security: oauth2: client: client-id: client2 client-secret: 123456 user-authorization-uri: http://127.0.0.1:8900/sso/oauth/authorize access-token-uri: http://127.0.0.1:8900/sso/oauth/token + resource: + jwt: + key-uri: http://127.0.0.1:8900/sso/oauth/token_key - resource: - token-info-uri: http://127.0.0.1:8900/sso/oauth/check_token
添加获取用户信息Controller
@SpringBootApplication @EnableOAuth2Sso public class SsoClient2Application { + @GetMapping("/user") + public Authentication user(Authentication user) { + return user; + } public static void main(String[] args) { SpringApplication.run(SsoClient2Application.class, args); } }
如今咱们再从新登陆试试,并试着访问/user。
至此,单体架构的单点登陆就完成了,下节咱们讲解分布式架构下先后端分离项目的单点登陆。
Oauth2受权模式访问之受权码模式(authorization_code)访问
SpringCloud OAuth2实现单点登陆以及OAuth2源码原理解析
Spring Security OAUTH2 获取用户信息
SpringBoot配置属性之Security
Spring Security OAuth2 配置注意点