基于cas实现的sso虽然实现比较简单,但功能实在是单一,性能(每个请求都有验证ticket),可靠性(必须保证认证服务器高可用)都得打个问号,而oauth2功能强大,整合spring security,jwt,能实现一套用户session管理,token刷新,权限控制,跨一级域名单点登录等,是一套强大的组合拳,oauth2也是第三方微信,QQ授权登录的核心。
一、基本原理-参考
同域单点登录原理
- 门户系统设置
Cookie
的domain
为一级域名也就是zlt.com
,这样就可以共享门户的Cookie
给所有的使用该域名(xxx.zlt.com
)的系统 - 使用
Spring Session
等技术让所有系统共享Session
- 这样只要门户系统登录之后无论跳转应用1或者应用2,都能通过门户
Cookie
中的sessionId
读取到Session
中的登录信息实现单点登录
Oauth2跨域单点登录
- 访问系统1判断未登录,则跳转到UAA系统请求授权
- 在UAA系统域名
sso.com
下的登录地址中输入用户名/密码完成登录 - 登录成功后UAA系统把登录信息保存到
Session
中,并在浏览器写入域为sso.com
的Cookie
- 访问系统2判断未登录,则跳转到UAA系统请求授权
- 由于是跳转到UAA系统的域名
sso.com
下,所以能通过浏览器中UAA的Cookie
读取到Session
中之前的登录信息完成单点登录
二、认证服务器雏形
1.依赖
<?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 https://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.2.1.RELEASE</version>
<relativePath/>
</parent>
<groupId>com.jwolf</groupId>
<artifactId>oauth2sso-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>oauth2sso-server</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR6</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-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</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>
2.spring security相关配置
@Service
public class UserService implements UserDetailsService {
private List<User> userList=new ArrayList<>(8);
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 这里为了方便这里就不查数据库了
*/
@PostConstruct
public void initData() {
userList.add(new User("user1",passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("super")));
userList.add(new User("user2",passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("admin")));
userList.add(new User("user3",passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("common")));
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<User> optionalUser = userList.stream().filter(item -> item.getUsername().equals(username)).findAny();
if (optionalUser==null) {
throw new UsernameNotFoundException("用户名或密码错误");
}
//这里把查到的用户返回就可以了,spring security内部会进行密码比对
return optionalUser.get();
}
}
神坑预警:这里的用户列表要么从DB查,要么写的方法内每次重新创建,否则会出现调用localhost:9402/logout或清除cookie后再次登录报警告“encoded password empty。。”导致无法再次登录,重启认证服务器才能登录
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin().and().authorizeRequests().anyRequest().authenticated();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
}
}
3.oauth2相关配置
@Configuration
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("client1")
.secret(passwordEncoder().encode("secret1"))
.authorizedGrantTypes("authorization_code")
.scopes("all")
//.accessTokenValiditySeconds(10)
//.refreshTokenValiditySeconds(864000)
.redirectUris("http://baidu.com","http://localhost:9502/login") // 授权成功后运行跳转的url,sso客户端默认/login,可在client端通过security.oauth2.sso.login-path修改为其它
.autoApprove(false) // true则自动授权,跳过授权页面点击步骤
.and()
.withClient("client2")
.secret(passwordEncoder().encode("secret2"))
.authorizedGrantTypes("authorization_code")
.scopes("all")
//.accessTokenValiditySeconds(10)
//.refreshTokenValiditySeconds(864000)
.redirectUris("http://localhost:9602/login")
.autoApprove(false);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(jwtTokenStore()).accessTokenConverter(jwtAccessTokenConverter());
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
// 要访问认证服务器tokenKey的时候需要经过身份认证
security.tokenKeyAccess("isAuthenticated()");
}
/**
* 可以使用jdbc,redis,jwt,memory等方式存储token
* @return
*/
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
// JWT签名用的key,泄漏会导致JWT被伪造
converter.setSigningKey("dev");
return converter;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
4.application.yml及启动类
server:
port: 9402
spring:
application:
name: oauth2-server
/**
* <p>
* Description: 认证服务器
* </p>
*
* @author majun
* @version 1.0
* @date 2020-10-28 20:59
*/
@SpringBootApplication
@EnableAuthorizationServer
@EnableWebSecurity
public class Oauth2ssoApplication {
public static void main(String[] args) {
SpringApplication.run(Oauth2ssoApplication.class, args);
}
}
三、SSO client端(ssclient1,client2)
1.依赖同认证服务器,可增加一个解析JWT的工具包
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
2.测试接口 ,注:用户信息,jwt,权限可以从SecurityContextHolder.getContext()获取
@RestController
@RequestMapping("/user")
public class UserController {
/**
* 测试接口-获取用户信息
* @param authentication
* @return
*/
@GetMapping("/getCurrentUser")
//@PreAuthorize("hasAuthority('super')")
//@PreAuthorize("hasAnyRole('ROLE_NORMAL')")
//@PreAuthorize("hasPermission('')")
public Object getCurrentUser(Authentication authentication) {
//解析看看JWT内容
OAuth2AuthenticationDetails details =(OAuth2AuthenticationDetails) authentication.getDetails();
Claims claims = Jwts.parser()
.setSigningKey("dev".getBytes(StandardCharsets.UTF_8))
.parseClaimsJws(details.getTokenValue())
.getBody();
System.out.println(claims);
return authentication;
}
}
3. client1,client2的application.yml ,启动类 @EnableOAuth2Sso需要开启SSO
server:
port: 9502
servlet:
session:
cookie:
# 防止cookie冲突,冲突会导致登录验证不通过,如果不同client端使用相同的cookie名会导致挤掉另一client授权的cookie被覆盖
name: OAUTH2-CLIENT1-SESSIONID
spring:
application:
name: oauth2-client1
oauth2-service-url: http://localhost:9402
security:
oauth2:
client:
client-id: client1
client-secret: secret1
user-authorization-uri: ${oauth2-service-url}/oauth/authorize
access-token-uri: ${oauth2-service-url}/oauth/token
resource:
jwt:
key-uri: ${oauth2-service-url}/oauth/token_key
key-value: dev #对应认证服务器jwt key的签名
server:
port: 9502
servlet:
session:
cookie:
# 防止cookie冲突,冲突会导致登录验证不通过,如果不同client端使用相同的cookie名会导致挤掉另一client授权的cookie被覆盖
name: OAUTH2-CLIENT1-SESSIONID
spring:
application:
name: oauth2-client2
oauth2-service-url: http://localhost:9402
security:
oauth2:
client:
client-id: client1
client-secret: secret1
user-authorization-uri: ${oauth2-service-url}/oauth/authorize
access-token-uri: ${oauth2-service-url}/oauth/token
resource:
jwt:
key-uri: ${oauth2-service-url}/oauth/token_key
key-value: dev #对应认证服务器jwt key的签名
四、测试
访问client2业务接口:http://localhost:9602/user/getCurrentUser
因为没登录重定向到认证服务器去登录,user1/123456
点击授权后跳回client2的业务接口
然后访问client1业务接口:http://localhost:9502/user/getCurrentUser,因为client2已登录,client1就不用再登录了,直接跳到授权页面,点击同意授权直接跳回业务接口
五、系统优化及进阶使用
1.页面美化—认证服务器新增自定义登录页面mylogin.html,自定义认证页面mygrant.html及一个控制视图跳转的CustomAuthController,然后SecurityConfig修改相关配置即可。走起>>>
新增thymeleaf依赖及配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
spring:
thymeleaf:
prefix: classpath:/templates/
suffix: .html
cache: false
自定义登录页与授权页面
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<div class="login-container">
<div class="form-container">
<p class="title">用户登录</p>
<form name="loginForm" method="post" action="/authentication/form" >
<input type="text" name="username" placeholder="用户名"/>
<br>
<input type="password" name="password" placeholder="密码"/>
<br>
<button type="submit" class="btn">登 录</button>
</form>
<p style="color: red" th:if="${param.error}">用户名或密码错误</p>
</div>
</div>
</body>
<style>
.login-container {
margin: 50px;
width: 100%;
}
.form-container {
margin: 0px auto;
width: 50%;
text-align: center;
box-shadow: 1px 1px 10px #888888;
height: 300px;
padding: 5px;
}
input {
margin-top: 10px;
width: 350px;
height: 30px;
border-radius: 3px;
border: 1px #E9686B solid;
padding-left: 2px;
}
.btn {
width: 350px;
height: 35px;
line-height: 35px;
cursor: pointer;
margin-top: 20px;
border-radius: 3px;
background-color: #E9686B;
color: white;
border: none;
font-size: 15px;
}
.title{
margin-top: 5px;
font-size: 18px;
color: #E9686B;
}
</style>
</html>
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>授权</title>
</head>
<body style="margin: 0px">
<div class="container">
<h3 th:text="${clientId}+' 请求授权,该应用将获取你的以下信息'"></h3>
<p>昵称,头像和性别</p>
授权后表明你已同意 <a href="#boot" style="color: #E9686B" th:text="${clientId}+'服务协议'"></a>
<form method="post" action="/oauth/authorize">
<input type="hidden" name="user_oauth_approval" value="true">
<div th:each="item:${scopes}">
<input type="radio" th:name="'scope.'+${item}" value="true" hidden="hidden" checked="checked"/>
</div>
<button class="btn" type="submit"> 同意/授权</button>
</form>
</div>
</body>
<style>
html{
padding: 0px;
margin: 0px;
}
.title-left a{
color: white;
}
.container{
clear: both;
text-align: center;
}
.btn {
width: 350px;
height: 35px;
line-height: 35px;
cursor: pointer;
margin-top: 20px;
border-radius: 3px;
background-color: #E9686B;
color: white;
border: none;
font-size: 15px;
}
</style>
</html>
页面跳转的控制
@Controller
@SessionAttributes("authorizationRequest")
public class CustomAuthController {
/**
* 跳到自定义登录页面,需要在security配置该path
* @return
*/
@RequestMapping("/mylogin")
public String getMyLogin(){
return "mylogin";
}
/**
* 跳到自定义授权页面(oauth2默认使用该path)
* @param model
* @param request
* @return
* @throws Exception
*/
@RequestMapping("/oauth/confirm_access")
public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
ModelAndView mv = new ModelAndView();
mv.setViewName("mygrant");
mv.addObject("clientId", authorizationRequest.getClientId());
mv.addObject("scopes",authorizationRequest.getScope());
return mv;
}
}
SecurityConfig配置修改
package com.jwolf.oauth2ssoserver.config;
import org.springframework.beans.factory.annotation.Autowired;
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.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
protected void configure(HttpSecurity http) throws Exception {
// http.formLogin().and().authorizeRequests().anyRequest().authenticated()
http.formLogin()
.loginPage("/mylogin") //登录页view,默认/login
.loginProcessingUrl("/authentication/form")//与登录页form提交的url一致,
.and()
.authorizeRequests().antMatchers("/user/xxx", "/mylogin").permitAll()
.anyRequest().authenticated()
.and()
.logout()
.logoutUrl("/logout") //默认logout
//.logoutSuccessUrl("/xxxx")
.deleteCookies("OAUTH2-CLIENT2-SESSIONID","OAUTH2-CLIENT1-SESSIONID")
.and()
.csrf().disable();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
}
}
美化效果
2.jwt增强——默认只有client_id,user_name,exp,权限等字段,可以通过jwt增强
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> delegates = new ArrayList<>();
delegates.add((oAuth2AccessToken, oAuth2Authentication) -> {
Map<String, Object> info = new HashMap<>();
info.put("info1", "111111");
info.put("info2", "222222");
((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);
return oAuth2AccessToken;
});
//注意jwtAccessTokenConverter一定要在其它增强链最后,它的作用是生成jwt字符串
delegates.add(jwtAccessTokenConverter());
tokenEnhancerChain.setTokenEnhancers(delegates);
endpoints.tokenStore(jwtTokenStore())
.userDetailsService(userDetailsService)
.tokenEnhancer(tokenEnhancerChain);
}
3.SSO client端权限
启动类开启权限开关(多种),默认关闭
@EnableGlobalMethodSecurity(prePostEnabled = true)
业务接口新增权限注解
@PreAuthorize("hasAuthority('super')")
//@PreAuthorize("hasAnyRole('ROLE_NORMAL')")
//@PreAuthorize("hasPermission('')")。。。。。
只有有对应authority的user才能访问
4.微信,QQ,github等第三方认证登录的秘密
较大的几个社交平台都提供了oauth2授权登录(用的最严格也是最常用的授权码授权,如果是第二方可考虑简单一点的授权方式),其实是它们提供了一个较大的授权服务器,成为它们的开发者后申请到的appid,appsecret同自己的授权服务器Oauth2配置(如图AuthorizitionServerConfig里配置的client和secret),授权回调url等同于oauth2配置的redirectUrl,但它们的配置一定inMemory,而是jdbc存储,不用重启实时生效的,第三方授权登录本质也是sso,一种跨域的sso. 集成第三方授权配置参考:
security:
oauth2:
client:
client-id: VXXXXXXXX
client-secret: FgSvYXXdDFgSvYXXdD
access-token-uri: http://openapi.baidu.com/oauth/2.0/token
user-authorization-uri: http://openapi.baidu.com/oauth/2.0/authorize
resource:
userInfoUri: https://openapi.baidu.com/rest/2.0/passport/users/getInfo
5.oauth2繁杂的授权码认证流程框架内部直接走完了,现在来看细节。其它授权方式及刷新令牌参考,简要步骤
- 用户先在授权服务器进行认证
- 认证成功后用户再进行授权
- 授权之后授权服务器返回给客户端一个授权码
- 客户端再使用获取到的授权码到授权服务器换取访问令牌
- 客户端使用访问令牌到资源服务器获取用户资源
5.1 oauth2配置client1新增一个允许的redirectUrl,如https://jd.com
5.2.获取授权码
这里有个坑:
这里测试一般使用http://baidu.com测试,使用http://jd.com测试回调后授权码code没有,注意单点登录登录后授权页面这个链接http://localhost:9402/oauth/authorize?client_id=client1&redirect_uri=http://localhost:9502/user/mylogin&response_type=code&scope=all&state=F0ECjt(FOECjt不是授权码)
登录并同意授权后从响应的链接百度一下,你就知道 获取的授权码
5.3.postman发送POST请求,注意相关参数与授权链接的一直,并且只能访问一次
使用access_token访问资源服务器资源,可以使用postman Auth功能,其实就是通过1,2,3步骤添加了一个Authorization请求头,也可以直接http://localhost:9502/user/getCurrentUser?access_token=上面的access_token. 注意@EnableOAuth2Sso不是@EnableResourceServer复合注解,需要通过access_token手动访问资源服务器接口必须启动类加上@EnableResourceServer但是加上后所有的sso客户端所有资源都会被保护,不能直接访问了,需要另外的处理。。。(资源服务器与sso客户端区别??)
刷新令牌:http://localhost:9402/oauth/token?grant_type=refresh_token&client_id=client1&client_secret=secret1&scope=all&refresh_token=上面的freshToken,可获取新的access_token及新的refresh_token。
刷新令牌(refresh_token)也可以用来获取访问令牌(access_token),使用刷新令牌获取访问令牌的过程并不是一种独立的授权方式。刷新令牌只是授权之后随着访问令牌一起返回来的结果而已,而且只有授权码授权方式和密码授权方式才会有刷新令牌,而简化授权方式和客户端授权方式都没有刷新令牌。访问令牌一般都有一个较短的有效期,在有效期内,用户可以使用访问令牌到资源服务器获取用户资源,但是过了有效期,就需要用户重新进行认证并授权,为了减少用户重复认证并授权的复杂过程,就引入了刷新令牌。刷新令牌一般也有一个有效期,但比访问令牌的有效期要长一些,在刷新令牌的有效期之内,可以使用刷新令牌到授权服务器重新获取访问令牌(同时重新获取刷新令牌),但如果刷新令牌过期了,就无法使用刷新令牌来重新获取访问令牌了,此时必须通过认证并授权的方式来获取。
TODO: access_token濒临过期,如何利用refresh_token获取新的access_token让用户无感知????