SSO(Single Sign On)即单点登录,效果是多个系统间,只要登录了其中一个系统,别的系统不用登录操作也能访问。比如在浏览器上同时打开天猫和淘宝页面,在天猫页面进行登录,然后回到淘宝页面刷新后会发现淘宝也已经是登录状态了。这节将介绍如何使用Spring Security OAuth2实现单点登录。
框架搭建
我们需要创建一个maven多模块项目,包含认证服务器和两个客户端。
新建一个maven项目,作为项目的父模块,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>
<packaging>pom</packaging>
<modules>
<module>sso-application-one</module>
<module>sso-application-two</module>
<module>sso-server</module>
</modules>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>cc.mrbird</groupId>
<artifactId>sso</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>sso</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR1</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
然后在该maven项目下新建一个module,artifactId为sso-server(作为认证服务器),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>sso</artifactId>
<groupId>cc.mrbird</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>sso-server</artifactId>
</project>
接着继续新增一个module模块,artifactId为sso-application-one(作为客户端一),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>sso</artifactId>
<groupId>cc.mrbird</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>sso-application-one</artifactId>
</project>
另外一个客户端和sso-application-one一致,只不过artifactId为sso-application-two。
至此,项目的基本框架搭建好了,结构如下所示:
认证服务器配置
认证服务器作用就是作为统一令牌发放并校验的地方,所以我们先要编写一些基本的Spring Security 安全配置的代码指定如何进行用户认证。
新建一个Spring Security配置类,继承WebSecurityConfigurerAdapter:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.and()
.authorizeRequests()
.anyRequest()
.authenticated();
}
}
上面简单配置了密码加密使用bcrypt方式,并且所有请求都需要认证,认证方式为Spring Security自带的登录页面认证(也可以根据前面教程来自定义登录页面,这里为了简单起见,就直接用自带的登录页了)。
接着需要定义一个自定义用户登录认证的服务:
@Configuration
public class UserDetailService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MyUser user = new MyUser();
user.setUserName(username);
user.setPassword(this.passwordEncoder.encode("123456"));
return new User(username, user.getPassword(), user.isEnabled(),
user.isAccountNonExpired(), user.isCredentialsNonExpired(),
user.isAccountNonLocked(), AuthorityUtils.commaSeparatedStringToAuthorityList("user:add"));
}
}
基本逻辑是用户名随便写,密码为123456,并且拥有user:add权限。这些前面都介绍过了,就不再详细说明了。MyUser的代码如下:
public class MyUser implements Serializable {
private static final long serialVersionUID = 3497935890426858541L;
private String userName;
private String password;
private boolean accountNonExpired = true;
private boolean accountNonLocked= true;
private boolean credentialsNonExpired= true;
private boolean enabled= true;
// get,set略
}
接着开始编写认证服务器配置。
新建SsoAuthorizationServerConfig,继承AuthorizationServerConfigurerAdapter:
@Configuration
@EnableAuthorizationServer
public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserDetailService userDetailService;
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
accessTokenConverter.setSigningKey("test_key");
return accessTokenConverter;
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 见下方
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.tokenStore(jwtTokenStore())
.accessTokenConverter(jwtAccessTokenConverter())
.userDetailsService(userDetailService);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security.tokenKeyAccess("isAuthenticated()"); // 获取密钥需要身份认证
}
}
Token使用JWT,这些配置都在前面几节OAuth2教程里介绍过了就不再赘述了,这里详细说下configure(ClientDetailsServiceConfigurer clients)的配置:
...
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("app-a")
.secret(passwordEncoder.encode("app-a-1234"))
.authorizedGrantTypes("refresh_token","authorization_code")
.accessTokenValiditySeconds(3600)
.scopes("all");
.and()
.withClient("app-b")
.secret(passwordEncoder.encode("app-b-1234"))
.authorizedGrantTypes("refresh_token","authorization_code")
.accessTokenValiditySeconds(7200)
.scopes("all");
}
...
这里分配了两个客户端配置,分别为app-a和app-b,因为使用默认的Spring Security登录页面来进行认证,所以需要开启authorization_code类型认证支持。
认证服务器的application.yml配置如下:
server:
port: 8080
servlet:
context-path: /server
认证服务器的搭建就告一段落了,接下来开始客户端代码编写。
客户端配置
两个客户端的代码基本一致,所以这里只介绍其中一个,另一个可以参考源码。
在客户端SpringBoot入口类上添加@EnableOAuth2Sso注解,开启SSO的支持:
@EnableOAuth2Sso
@SpringBootApplication
public class SsoApplicaitonOne {
public static void main(String[] args) {
new SpringApplicationBuilder(SsoApplicaitonOne.class).run(args);
}
}
接下来的重点是配置文件application.yml的配置:
security:
oauth2:
client:
client-id: app-a
client-secret: app-a-1234
user-authorization-uri: http://127.0.0.1:8080/server/oauth/authorize
access-token-uri: http://127.0.0.1:8080/server/oauth/token
resource:
jwt:
key-uri: http://127.0.0.1:8080/server/oauth/token_key
server:
port: 9090
servlet:
context-path: /app1
security.oauth2.client.client-id和security.oauth2.client.client-secret指定了客户端id和密码,这里和认证服务器里配置的client一致(另外一个客户端为app-b);user-authorization-uri指定为认证服务器的/oauth/authorize地址,access-token-uri指定为认证服务器的/oauth/token地址,jwt.key-uri指定为认证服务器的/oauth/token_key地址。
这里端口指定为9090,context-path为app1,另一个客户端端口指定为9091,context-path为app2。
接着在resources/static下新增一个index.html页面,用于跳转到另外一个客户端:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>管理系统一</title>
</head>
<body>
<h1>管理系统一</h1>
<a href="http://127.0.0.1:9091/app2/index.html">跳转到管理系统二</a>
</body>
</html>
为了验证是否认证成功,我们新增一个控制器:
@RestController
public class UserController {
@GetMapping("user")
public Principal user(Principal principal) {
return principal;
}
}
另外一个客户端的代码略,可以参考源码。
测试效果
先启动认证服务器,任何在启动两个客户端。启动后,访问http://127.0.0.1:9090/app1/index.html:
可以看到页面被重定向到认证服务器的登录页面,根据我们定义的UserDetailService,用户名随便填,密码为123456。登录后页面跳转到:
页面提示的意思是:非法请求,至少需要一个重定向URL被注册到client。从URL中可以看出,redirect_uri为http://127.0.0.1:9090/app1/login,所以我们修改认证服务器client相关配置如下:
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("app-a")
.secret(passwordEncoder.encode("app-a-1234"))
.authorizedGrantTypes("refresh_token","authorization_code")
.accessTokenValiditySeconds(3600)
.scopes("all")
.redirectUris("http://127.0.0.1:9090/app1/login")
.and()
.withClient("app-b")
.secret(passwordEncoder.encode("app-b-1234"))
.authorizedGrantTypes("refresh_token","authorization_code")
.accessTokenValiditySeconds(7200)
.scopes("all")
.redirectUris("http://127.0.0.1:9091/app2/login");
}
重启认证服务器,重复上面的过程,这次登录后,页面跳转到了授权页面:
点击Authorize:
这时候app-a对应的客户端已经登录了,点击跳转到系统二:
页面直接来到授权页,而不需要重新输入用户名密码,继续点击Authorize:
系统二也已经成功登录,访问http://127.0.0.1:9091/app2/user看是否能成功获取到用户信息:
到这里我们已经实现了单点登录的基本功能了。
但是在这个过程中需要用户点击Authorize授权,体验并不是很好,我们可以去掉它。修改认证服务器Client配置如下:
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("app-a")
.secret(passwordEncoder.encode("app-a-1234"))
.authorizedGrantTypes("refresh_token","authorization_code")
.accessTokenValiditySeconds(3600)
.scopes("all")
.autoApprove(true)
.redirectUris("http://127.0.0.1:9090/app1/login")
.and()
.withClient("app-b")
.secret(passwordEncoder.encode("app-b-1234"))
.authorizedGrantTypes("refresh_token","authorization_code")
.accessTokenValiditySeconds(7200)
.scopes("all")
.autoApprove(true)
.redirectUris("http://127.0.0.1:9091/app2/login");
}
autoApprove(true)自动授权。修改后重启即可看到效果。
权限校验
Spring Security权限校验前面介绍过了,这里看下在单点登录模式下如何进行权限校验。
在客户端控制器里加入如下代码:
@GetMapping("auth/test1")
@PreAuthorize("hasAuthority('user:add')")
public String authTest1(){
return "您拥有'user:add'权限";
}
@GetMapping("auth/test2")
@PreAuthorize("hasAuthority('user:update')")
public String authTest2(){
return "您拥有'user:update'权限";
}
在客户端新增Spring Security配置类:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
}
改完后,先启动认证服务器,在启动客户端。
在启动客户端的时候出现异常:
Caused by: java.lang.IllegalStateException: @Order on WebSecurityConfigurers must be unique. Order of 100 was already used on cc.mrbird.sso.client.config.WebSecurityConfigurer$$EnhancerBySpringCGLIB$$aa470b71@34d45ec0, so it cannot be used on org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2SsoDefaultConfiguration$$EnhancerBySpringCGLIB$$6f69df92@18137eab too.
at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration.setFilterChainProxySecurityConfigurer(WebSecurityConfiguration.java:148) ~[spring-security-config-5.1.5.RELEASE.jar:5.1.5.RELEASE]
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_171]
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_171]
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_171]
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_171]
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredMethodElement.inject(AutowiredAnnotationBeanPostProcessor.java:708) ~[spring-beans-5.1.8.RELEASE.jar:5.1.8.RELEASE]
at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:90) ~[spring-beans-5.1.8.RELEASE.jar:5.1.8.RELEASE]
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:374) ~[spring-beans-5.1.8.RELEASE.jar:5.1.8.RELEASE]
... 16 common frames omitted
大致意思是,认证服务器已经配置了Spring Security配置,并且顺序为100,和客户端的Spring Security配置冲突了。所以我们修改下客户端的Spring Security配置顺序:
@Order(101)
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
}
让它的优先级小于认证服务器的Spring Security配置。
重新启动客户端,进行单点登录操作,登录成功后,访问http://127.0.0.1:9090/app1/auth/test1:
访问http://127.0.0.1:9090/app1/auth/test2:
返回403,没权限,说明权限注解生效了。