1. 前言

技術這東西吧,看別人寫的好像很簡單似的,到自己去寫的時候就各種問題,「一看就會,一做就錯」。網上關於實現SSO的文章一大堆,但是當你真的照著寫的時候就會發現根本不是那麼回事兒,簡直讓人抓狂,尤其是對於我這樣的菜鳥。幾經曲折,終於搞定了,決定記錄下來,以便後續查看。先來看一下效果:

2. 準備

2.1. 單點登錄

最常見的例子是,我們打開淘寶APP,首頁就會有天貓、聚划算等服務的鏈接,當你點擊以後就直接跳過去了,並沒有讓你再登錄一次

下面這個圖是我再網上找的,我覺得畫得比較明白:

可惜有點兒不清晰,於是我又畫了個簡版的:

重要的是理解:

  • SSO服務端和SSO客戶端直接是通過授權以後發放Token的形式來訪問受保護的資源
  • 相對於瀏覽器來說,業務系統是服務端,相對於SSO服務端來說,業務系統是客戶端
  • 瀏覽器和業務系統之間通過會話正常訪問
  • 不是每次瀏覽器請求都要去SSO服務端去驗證,只要瀏覽器和它所訪問的服務端的會話有效它就可以正常訪問

2.2. OAuth2

推薦以下幾篇博客

OAuth 2.0

Spring Security對OAuth2的支持

3. 利用OAuth2實現單點登錄

接下來,只講跟本例相關的一些配置,不講原理,不講為什麼

眾所周知,在OAuth2在有授權伺服器、資源伺服器、客戶端這樣幾個角色,當我們用它來實現SSO的時候是不需要資源伺服器這個角色的,有授權伺服器和客戶端就夠了。

授權伺服器當然是用來做認證的,客戶端就是各個應用系統,我們只需要登錄成功後拿到用戶信息以及用戶所擁有的許可權即可

之前我一直認為把那些需要許可權控制的資源放到資源伺服器里保護起來就可以實現許可權控制,其實是我想錯了,許可權控制還得通過Spring Security或者自定義攔截器來做

3.1. Spring Security 、OAuth2、JWT、SSO

在本例中,一定要分清楚這幾個的作用

首先,SSO是一種思想,或者說是一種解決方案,是抽象的,我們要做的就是按照它的這種思想去實現它

其次,OAuth2是用來允許用戶授權第三方應用訪問他在另一個伺服器上的資源的一種協議,它不是用來做單點登錄的,但我們可以利用它來實現單點登錄。在本例實現SSO的過程中,受保護的資源就是用戶的信息(包括,用戶的基本信息,以及用戶所具有的許可權),而我們想要訪問這這一資源就需要用戶登錄並授權,OAuth2服務端負責令牌的發放等操作,這令牌的生成我們採用JWT,也就是說JWT是用來承載用戶的Access_Token的

最後,Spring Security是用於安全訪問的,這裡我們我們用來做訪問許可權控制

4. 認證伺服器配置

4.1. Maven依賴

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.cjs.sso</groupId>
<artifactId>oauth2-sso-auth-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>oauth2-sso-auth-server</name>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.3.RELEASE</version>
</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>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</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-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.8.1</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.56</version>
</dependency>

</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

這裡面最重要的依賴是:spring-security-oauth2-autoconfigure

4.2. application.yml

spring:
datasource:
url: jdbc:mysql://localhost:3306/permission
username: root
password: 123456
driver-class-name: com.mysql.jdbc.Driver
jpa:
show-sql: true
session:
store-type: redis
redis:
host: 127.0.0.1
password: 123456
port: 6379
server:
port: 8080

4.3. AuthorizationServerConfig(重要)

package com.cjs.sso.config;

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.core.token.DefaultToken;
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.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

import javax.sql.DataSource;

/**
* @author ChengJianSheng
* @date 2019-02-11
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

@Autowired
private DataSource dataSource;

@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients();
security.tokenKeyAccess("isAuthenticated()");
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.accessTokenConverter(jwtAccessTokenConverter());
endpoints.tokenStore(jwtTokenStore());
// endpoints.tokenServices(defaultTokenServices());
}

/*@Primary
@Bean
public DefaultTokenServices defaultTokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(jwtTokenStore());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}*/

@Bean
public JwtTokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setSigningKey("cjs"); // Sets the JWT signing key
return jwtAccessTokenConverter;
}

}

說明:

  1. 別忘了@EnableAuthorizationServer
  2. Token存儲採用的是JWT
  3. 客戶端以及登錄用戶這些配置存儲在資料庫,為了減少資料庫的查詢次數,可以從資料庫讀出來以後再放到內存中

4.4. WebSecurityConfig(重要)

package com.cjs.sso.config;

import com.cjs.sso.service.MyUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;

/**
* @author ChengJianSheng
* @date 2019-02-11
*/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private MyUserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}

@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/assets/**", "/css/**", "/images/**");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login")
.and()
.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest()
.authenticated()
.and().csrf().disable().cors();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

}

4.5. 自定義登錄頁面(一般來講都是要自定義的)

package com.cjs.sso.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

/**
* @author ChengJianSheng
* @date 2019-02-12
*/
@Controller
public class LoginController {

@GetMapping("/login")
public String login() {
return "login";
}

@GetMapping("/")
public String index() {
return "index";
}

}

自定義登錄頁面的時候,只需要準備一個登錄頁面,然後寫個Controller令其可以訪問到即可,登錄頁面表單提交的時候method一定要是post,最重要的時候action要跟訪問登錄頁面的url一樣

千萬記住了,訪問登錄頁面的時候是GET請求,表單提交的時候是POST請求,其它的就不用管了

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Ela Admin - HTML5 Admin Template</title>
<meta name="description" content="Ela Admin - HTML5 Admin Template">
<meta name="viewport" content="width_=device-width, initial-scale=1">

<link type="text/css" rel="stylesheet" th:href="@{/assets/css/normalize.css}">
<link type="text/css" rel="stylesheet" th:href="@{/assets/bootstrap-4.3.1-dist/css/bootstrap.min.css}">
<link type="text/css" rel="stylesheet" th:href="@{/assets/css/font-awesome.min.css}">
<link type="text/css" rel="stylesheet" th:href="@{/assets/css/style.css}">

</head>
<body class="bg-dark">

<div class="sufee-login d-flex align-content-center flex-wrap">
<div class="container">
<div class="login-content">
<div class="login-logo">
<h1 style="color: #57bf95;">歡迎來到王者榮耀</h1>
</div>
<div class="login-form">
<form th:action="@{/login}" method="post">
<div class="form-group">
<label>Username</label>
<input type="text" class="form-control" name="username" placeholder="Username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" class="form-control" name="password" placeholder="Password">
</div>
<div class="checkbox">
<label>
<input type="checkbox"> Remember Me
</label>
<label class="pull-right">
<a href="#">Forgotten Password?</a>
</label>
</div>
<button type="submit" class="btn btn-success btn-flat m-b-30 m-t-30" style="font-size: 18px;">登錄</button>
</form>
</div>
</div>
</div>
</div>

<script type="text/javascript" th:src="@{/assets/js/jquery-2.1.4.min.js}"></script>
<script type="text/javascript" th:src="@{/assets/bootstrap-4.3.1-dist/js/bootstrap.min.js}"></script>
<script type="text/javascript" th:src="@{/assets/js/main.js}"></script>

</body>
</html>

4.6. 定義客戶端

4.7. 載入用戶

登錄賬戶

package com.cjs.sso.domain;

import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

/**
* 大部分時候直接用User即可不必擴展
* @author ChengJianSheng
* @date 2019-02-11
*/
@Data
public class MyUser extends User {

private Integer departmentId; // 舉個例子,部門ID

private String mobile; // 舉個例子,假設我們想增加一個欄位,這裡我們增加一個mobile表示手機號

public MyUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
}

public MyUser(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
}
}

載入登錄賬戶

package com.cjs.sso.service;

import com.alibaba.fastjson.JSON;
import com.cjs.sso.domain.MyUser;
import com.cjs.sso.entity.SysPermission;
import com.cjs.sso.entity.SysUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.List;

/**
* @author ChengJianSheng
* @date 2019-02-11
*/
@Slf4j
@Service
public class MyUserDetailsService implements UserDetailsService {

@Autowired
private PasswordEncoder passwordEncoder;

@Autowired
private UserService userService;

@Autowired
private PermissionService permissionService;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = userService.getByUsername(username);
if (null == sysUser) {
log.warn("用戶{}不存在", username);
throw new UsernameNotFoundException(username);
}
List<SysPermission> permissionList = permissionService.findByUserId(sysUser.getId());
List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
if (!CollectionUtils.isEmpty(permissionList)) {
for (SysPermission sysPermission : permissionList) {
authorityList.add(new SimpleGrantedAuthority(sysPermission.getCode()));
}
}

MyUser myUser = new MyUser(sysUser.getUsername(), passwordEncoder.encode(sysUser.getPassword()), authorityList);

log.info("登錄成功!用戶: {}", JSON.toJSONString(myUser));

return myUser;
}
}

4.8. 驗證

當我們看到這個界面的時候,表示認證伺服器配置完成

5. 兩個客戶端

5.1. Maven依賴

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.cjs.sso</groupId>
<artifactId>oauth2-sso-client-member</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>oauth2-sso-client-member</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
<version>3.0.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

5.2. application.yml

server:
port: 8082
servlet:
context-path: /memberSystem
security:
oauth2:
client:
client-id: UserManagement
client-secret: user123
access-token-uri: http://localhost:8080/oauth/token
user-authorization-uri: http://localhost:8080/oauth/authorize
resource:
jwt:
key-uri: http://localhost:8080/oauth/token_key

這裡context-path不要設成/,不然重定向獲取code的時候回被攔截

5.3. WebSecurityConfig

package com.cjs.example.config;

import com.cjs.example.util.EnvironmentUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.context.annotation.Configuration;
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.WebSecurityConfigurerAdapter;

/**
* @author ChengJianSheng
* @date 2019-03-03
*/
@EnableOAuth2Sso
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private EnvironmentUtils environmentUtils;

@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/bootstrap/**");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
if ("local".equals(environmentUtils.getActiveProfile())) {
http.authorizeRequests().anyRequest().permitAll();
}else {
http.logout().logoutSuccessUrl("http://localhost:8080/logout")
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
}

說明:

  1. 最重要的註解是@EnableOAuth2Sso
  2. 許可權控制這裡採用Spring Security方法級別的訪問控制,結合Thymeleaf可以很容易做許可權控制
  3. 順便多提一句,如果是前後端分離的話,前端需求載入用戶的許可權,然後判斷應該顯示那些按鈕那些菜單

5.4. MemberController

package com.cjs.example.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import java.security.Principal;

/**
* @author ChengJianSheng
* @date 2019-03-03
*/
@Controller
@RequestMapping("/member")
public class MemberController {

@GetMapping("/list")
public String list() {

return "member/list";
}

@GetMapping("/info")
@ResponseBody
public Principal info(Principal principal) {
return principal;
}

@GetMapping("/me")
@ResponseBody
public Authentication me(Authentication authentication) {
return authentication;
}

@PreAuthorize("hasAuthority(member:save)")
@ResponseBody
@PostMapping("/add")
public String add() {

return "add";
}

@PreAuthorize("hasAuthority(member:detail)")
@ResponseBody
@GetMapping("/detail")
public String detail() {
return "detail";
}
}

5.5. Order項目跟它是一樣的

server:
port: 8083
servlet:
context-path: /orderSystem
security:
oauth2:
client:
client-id: OrderManagement
client-secret: order123
access-token-uri: http://localhost:8080/oauth/token
user-authorization-uri: http://localhost:8080/oauth/authorize
resource:
jwt:
key-uri: http://localhost:8080/oauth/token_key

5.6. 關於退出

退出就是清空用於與SSO客戶端建立的所有的會話,簡單的來說就是使所有端點的Session失效,如果想做得更好的話可以令Token失效,但是由於我們用的JWT,故而撤銷Token就不是那麼容易,關於這一點,在官網上也有提到:

本例中採用的方式是在退出的時候先退出業務伺服器,成功以後再回調認證伺服器,但是這樣有一個問題,就是需要主動依次調用各個業務伺服器的logout

6. 工程結構

附上源碼: github.com/chengjianshe

7. 演示

作者:廢物大師兄

鏈接:cnblogs.com/cjsblog/p/1來源:博客園

推薦閱讀:

相关文章