原本以为在网上找了一个spring security实现sso的demo就能顺利地实现单点登录应用,但是too young too simple,按照demo给的代码确实能通过浏览器实现sso,但是app端携带token去访问需要权限的资源时却被拒绝。
究其原因,我们首先从架构层面来分析一下:
基础架构
认证流程
1.每一次浏览器的请求访问资源服务器时,资源服务器都会检查session是否存在当前用户的认证信息。
2.如果不存在,就会跳转到认证服务器进行认证。
3.认证成功后分别会在认证服务器和当前资源服务器各存储一份用户相关的认证信息。
4.用户每次访问资源服务器时就不需要再去认证服务器进行认证了。
这个详细的认证过程在Spring security oauth2认证流程分析中有详细分析过
问题1:同一个浏览器访问不同的资源服务器,只要进行过一次认证,访问其他的资源服务器就不需要重新认证了呢?
其实不需要认证是假的。
1.假设访问资源服务器1后用户登录认证通过,认证服务器和资源服务器1上分别都存有用户的认证信息。
2.当前用户访问资源服务器2(前提是同一个浏览器),此时资源服务器2上面并没有用户的认证信息,就会自动跳转到认证服务器,跳转到认证服务器后,认证服务器发现用户已经认证(虽然用户之前认证后会跳转,但是认证服务器的cookie信息还在浏览器上),便会回调资源服务器2。
3.资源服务器根据回调信息拿到当前用户的认证信息,将其保存起来,方便用户下次访问。
问题2:App或第三方应用怎么访问?
因为有一些App或第三方应用无法基于cookie发送请求,按照上述浏览器的访问流程根本行不通。
1.App或第三方应用根据是否应用内存在token判断何时需要登录认证,不需要像浏览器那样各种重定向。
2.App或第三方应用就需要直接跳过资源服务器直接访问认证服务器进行认证并获取token
3.App或第三方应用通过认证服务器认证后获取token,再通过token向资源服务器请求服务。
此时会出现一个问题:请求被拒绝。因为资源服务器并不存在token对应的认证信息。
这样的架构显然是无法请求到资源的,因为用户的认证信息都在认证服务器中,所有需要权限的资源都是无法访问的。
为了解决我们的困境,可以把本来存储在认证服务器的认证信息共享出来:
版本1
把认证信息存储抽离出来是很有必要的,因为正常情况下,为了避免服务遭遇单点故障,都会是集群化部署,如图:
按照上图所示,
1.App在认证服务器认证后,用户认证信息是存储在类似redis或其他存储空间的。
2.App携带token访问资源服务,资源服务通过token获取用户认证信息,验证通过后提供服务,否则拒绝访问。
版本2
1.认证步骤不变,App在认证服务器认证后,用户认证信息是存储在类似redis或其他存储空间的。
2.当携带token请求资源时,资源服务器间接地通过认证服务器去验证token的有效性,验证成功后就提供服务,否则拒绝访问。
JWT
针对于JWT的实现,架构又回到了
或者
JWT的方式区别于不需要额外的存储,只需要signingKey就能对token进行解析,这就意味着只需要signingKey就可以在任意服务器对token进行验证。
认证服务器
认证服务器的核心功能就是颁发token。但是也没法避免有时候需要向认证服务器请求某些服务。
以spring boot 2.1.6.RELEASE版本为例
依赖
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.security.oauth:spring-security-oauth2:2.3.6.RELEASE'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
compile 'org.apache.commons:commons-lang3:3.9'
compile 'commons-collections:commons-collections:3.2.2'
compile 'commons-beanutils:commons-beanutils:1.9.3'
compile 'javax.xml.bind:jaxb-api:2.3.0'
compile 'com.sun.xml.bind:jaxb-impl:2.3.0'
compile 'com.sun.xml.bind:jaxb-core:2.3.0'
compile 'javax.activation:activation:1.1.1'
自定义Filter,用于认证服务器解析App请求携带的Authorization信息,也就是token信息
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Enumeration;
import java.util.Objects;
/** @author zouwei */
public class AppAuthenticationProcessFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION_KEY = "Authorization";
private TokenStore tokenStore;
public AppAuthenticationProcessFilter(TokenStore tokenStore) {
this.tokenStore = tokenStore;
}
@Override
public void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
Authentication authentication = attemptAuthentication(request);
if (Objects.nonNull(authentication)) {
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
/**
* 验证权限
*
* @param request
* @return
* @throws AuthenticationException
*/
private Authentication attemptAuthentication(HttpServletRequest request)
throws AuthenticationException {
String token = extractToken(request);
if (!StringUtils.isBlank(token)) {
return tokenStore.readAuthentication(token);
}
return null;
}
/**
* 先从请求头中获取token,没获取到就从请求体中获取
*
* @param request
* @return
*/
protected String extractToken(HttpServletRequest request) {
Enumeration<String> headers = request.getHeaders(AUTHORIZATION_KEY);
while (headers.hasMoreElements()) {
String value = parseAuthorizationValue(headers.nextElement());
if (!StringUtils.isBlank(value)) {
return value;
}
}
// 如果没有从请求头中拿到token,就从请求体中获取
return obtainRequestToken(request);
}
/**
* 从请求体中获取token
*
* @param request
* @return
*/
private String obtainRequestToken(HttpServletRequest request) {
String authorizationValue = request.getParameter(AUTHORIZATION_KEY);
return parseAuthorizationValue(authorizationValue);
}
/**
* 解析AuthorizationValue
*
* @param authorizationValue
* @return
*/
private String parseAuthorizationValue(String authorizationValue) {
if (Objects.isNull(authorizationValue)) {
return null;
}
if ((authorizationValue
.toLowerCase()
.startsWith(OAuth2AccessToken.BEARER_TYPE.toLowerCase()))) {
String authHeaderValue =
authorizationValue.substring(OAuth2AccessToken.BEARER_TYPE.length()).trim();
int commaIndex = authHeaderValue.indexOf(',');
if (commaIndex > 0) {
authHeaderValue = authHeaderValue.substring(0, commaIndex);
}
return authHeaderValue;
}
return null;
}
}
import com.example.oauth2.filter.AppAuthenticationProcessFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/** @author zouwei */
@Configuration
@EnableWebSecurity
public class ApplicationSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired private RedisConnectionFactory redisConnectionFactory;
@Override
public void configure(HttpSecurity http) throws Exception {
/** 创建自定义Filter */
AppAuthenticationProcessFilter appAuthenticationProcessFilter =
new AppAuthenticationProcessFilter(tokenStore());
http
/** 将自定义Filter加入FilterChain */
.addFilterAfter(
appAuthenticationProcessFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
.and()
.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.csrf()
.disable();
}
/**
* 密码模式要配置AuthenticationManager
*
* @return
* @throws Exception
*/
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 加密方式
*
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 指定tokenStore
*
* @return
*/
@Bean
public TokenStore tokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.password.PasswordEncoder;
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.TokenStore;
/** @author zouwei */
@Configuration
@EnableAuthorizationServer
public class ApplicationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired private PasswordEncoder passwordEncoder;
/** 用于设置token的存储方式 */
@Autowired private TokenStore tokenStore;
/** 密码模式需要 */
@Autowired private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
/** 资源服务器验证token的时候需要权限 */
security.checkTokenAccess("isAuthenticated()");
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("client1")
.secret(passwordEncoder.encode("secret1"))
.redirectUris("http://127.0.0.1:8081/client1/login")
.authorizedGrantTypes("authorization_code", "password", "refresh_token")
.scopes("all")
.and()
.withClient("client2")
.secret(passwordEncoder.encode("secret2"))
.redirectUris("http://127.0.0.1:8082/client2/login")
.authorizedGrantTypes("authorization_code", "password", "refresh_token")
.scopes("all");
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
/** 指定tokenStore */
endpoints
.tokenStore(tokenStore)
/** password模式需要 */
.authenticationManager(authenticationManager);
}
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
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.Component;
/** @author zouwei */
@Component
public class ApplicationUserDetailsService implements UserDetailsService {
@Autowired private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
/**
* 为了测试方便,暂且直接返回User
*/
return new User(
username,
passwordEncoder.encode("123456"),
AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
}
}
以上代码即可创建一个简单的认证服务器,并且可支持浏览器和App认证和访问资源。也就是说,它既是认证服务器,也是资源服务器。
至于为什么不直接使用框架自己提供的资源服务器配置和注解,是因为框架提供的不能同时支持浏览器和App。通过源码分析发现,框架自己也就是一个Filter做了相关的实现,按照这个原理,我就自定义了Filter达到双端兼容的效果。
资源服务器
下面的这个是单纯的资源服务器
依赖
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.1.6.RELEASE'
implementation 'org.springframework.security.oauth:spring-security-oauth2:2.3.6.RELEASE'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
compile 'org.apache.commons:commons-lang3:3.9'
compile 'commons-collections:commons-collections:3.2.2'
compile 'commons-beanutils:commons-beanutils:1.9.3'
compile 'javax.xml.bind:jaxb-api:2.3.0'
compile 'com.sun.xml.bind:jaxb-impl:2.3.0'
compile 'com.sun.xml.bind:jaxb-core:2.3.0'
compile 'javax.activation:activation:1.1.1'
资源服务器用于验证App请求的Authorization头信息,也就是token有效性
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Enumeration;
import java.util.Objects;
/** @author zouwei */
public class AppAuthenticationProcessFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION_KEY = "Authorization";
private TokenStore tokenStore;
private ResourceServerTokenServices tokenServices;
public AppAuthenticationProcessFilter(TokenStore tokenStore) {
this.tokenStore = tokenStore;
}
public AppAuthenticationProcessFilter(ResourceServerTokenServices tokenServices) {
this.tokenServices = tokenServices;
}
@Override
public void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
Authentication authentication = attemptAuthentication(request);
if (Objects.nonNull(authentication)) {
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
/**
* 验证权限
*
* @param request
* @return
* @throws AuthenticationException
*/
private Authentication attemptAuthentication(HttpServletRequest request)
throws AuthenticationException {
String token = extractToken(request);
if (!StringUtils.isBlank(token)) {
if (Objects.nonNull(tokenStore)) {
/** 直接访问tokenStore获取认证信息 */
return tokenStore.readAuthentication(token);
} else {
try {
/** 访问认证服务器获取认证信息 */
return tokenServices.loadAuthentication(token);
} catch (Exception e) {
return null;
}
}
}
return null;
}
/**
* 先从请求头中获取token,没获取到就从请求体中获取
*
* @param request
* @return
*/
protected String extractToken(HttpServletRequest request) {
Enumeration<String> headers = request.getHeaders(AUTHORIZATION_KEY);
while (headers.hasMoreElements()) {
String value = parseAuthorizationValue(headers.nextElement());
if (!StringUtils.isBlank(value)) {
return value;
}
}
// 如果没有从请求头中拿到token,就从请求体中获取
return obtainRequestToken(request);
}
/**
* 从请求体中获取token
*
* @param request
* @return
*/
private String obtainRequestToken(HttpServletRequest request) {
String authorizationValue = request.getParameter(AUTHORIZATION_KEY);
return parseAuthorizationValue(authorizationValue);
}
/**
* 解析AuthorizationValue
*
* @param authorizationValue
* @return
*/
private String parseAuthorizationValue(String authorizationValue) {
if (Objects.isNull(authorizationValue)) {
return null;
}
if ((authorizationValue
.toLowerCase()
.startsWith(OAuth2AccessToken.BEARER_TYPE.toLowerCase()))) {
String authHeaderValue =
authorizationValue.substring(OAuth2AccessToken.BEARER_TYPE.length()).trim();
int commaIndex = authHeaderValue.indexOf(',');
if (commaIndex > 0) {
authHeaderValue = authHeaderValue.substring(0, commaIndex);
}
return authHeaderValue;
}
return null;
}
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import sso.oauth2.client2.filter.AppAuthenticationProcessFilter;
/** @author zouwei */
@Configuration
@EnableOAuth2Sso
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired private RedisConnectionFactory redisConnectionFactory;
/**
* 用于访问认证服务器
*/
@Autowired private ResourceServerTokenServices tokenServices;
@Override
protected void configure(HttpSecurity http) throws Exception {
/**
* 创建自定义Filter,有两种构造方式,分别对应不同的架构模式
*/
AppAuthenticationProcessFilter appAuthenticationProcessFilter =
new AppAuthenticationProcessFilter(tokenServices);
http
/**
* 将自定义Filter加入FilterChain
*/
.addFilterAfter(
appAuthenticationProcessFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/", "/login**")
.permitAll()
.anyRequest()
.authenticated();
}
/**
* 定义TokenStore
* @return
*/
@Bean
public TokenStore tokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}
}
配置文件:
#client-id
security.oauth2.client.client-id=client1
security.oauth2.client.client-secret=secret1
#跳转到认证服务器的地址
security.oauth2.client.user-authorization-uri=http://127.0.0.1:8080/oauth/authorize
#请求令牌地址
security.oauth2.client.access-token-uri=http://127.0.0.1:8080/oauth/token
#JWT获取秘钥地址
#security.oauth2.resource.jwt.key-uri=http://127.0.0.1:8080/oauth/token_key
#JWT秘钥
#security.oauth2.resource.jwt.key-value=signingKey
#请求认证服务器验证token
security.oauth2.resource.token-info-uri=http://127.0.0.1:8080/oauth/check_token
server.port=8081
server.servlet.context-path=/client1
可以按照上述资源服务器代码和配置,再创建一个client2资源服务,这样就可以联合演示单点登录这个功能。
上述代码和配置中的redis配置是默认的,如有不同,需要自己进行自定义配置