写点什么

Oauth2 的认证实战 -HA 篇

用户头像
Damon
关注
发布于: 2020 年 04 月 24 日
Oauth2的认证实战-HA篇

1、 前言



微服务经过几年一跃成为 IT 领域炙手可热的话题,很多大厂,很早就已经开始了微服务的实践和应用。那么如何做好微服务的安全架构设计工作。这是一个架构师需要面临的、并且需要解决的问题。



2、Oauth2 简介



2.1 什么是 Oauth2

Oauth 是一个开放标准,假设有这样一种场景:一个 QQ 应用,希望让一个第三方的(慕课网)应用,能够得到关于自身的一些信息(唯一用户标识,比如说 QQ 号,用户个人信息、一些基础资料,昵称和头像等)。但是在获得这些资料的同时,却又不能提供用户名和密码之类的信息。​

​​



而 Oatuh 就是实现上述目标的一种规范。OAuth2.0 是 OAuth 协议的延续版本,但不兼容 OAuth1.0,即完全废弃了 OAuth1.0。



OAuth2.0 有这么几个术语:客户凭证、令牌、作用域。

  1. 客户凭证:客户的 clientId 和密码用于认证客户。

  2. 令牌:授权服务器在接收到客户请求后颁发的令牌。

  3. 作用域:客户请求访问令牌时,由资源拥有者额外指定的细分权限。

2.2 Oauth2 提供的几种安全认证模式

OAuth2.0 有这么几个授权模式:授权码模式、简化模式、密码模式、客户端凭证模式。

  1. 授权码模式:(authorization_code)是功能最完整、流程最严密的授权模式,code 保证了 token 的安全性,即使 code 被拦截,由于没有 client_secret,也是无法通过 code 获得 token 的。

  2. 简化模式:和授权码模式类似,只不过少了获取 code 的步骤,是直接获取令牌 token 的,适用于公开的浏览器单页应用,令牌直接从授权服务器返回,不支持刷新令牌,且没有 code 安全保证,令牌容易因为被拦截窃听而泄露。

  3. 密码模式:使用用户名/密码作为授权方式从授权服务器上获取令牌,一般不支持刷新令牌。

  4. 客户端凭证模式:一般用于资源服务器是应用的一个后端模块,客户端向认证服务器验证身份来获取令牌。



2.3 实战 Oauth2 的密码模式

本次结合 Spring Cloud Alibaba 组件,实现微服务的安全系统体系,本文主要讲解 Oauth2 的部分。

先来看鉴权中心,鉴权中心需要做到提供单点服务,为所有的客户端微服务的安全保驾护航。下面首先看依赖:



<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- 对redis支持,引入的话项目缓存就支持redis了,所以必须加上redis的相关配置,否则操作相关缓存会报异常 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>




如果需要使用 redis 来存储 token,则可以加入 reids 依赖,如果使用 jwt,则使用:



<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>




当然,本次的项目模块引入的是比较新的 Spring Boot:



<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.13.RELEASE</version>
<relativePath/>
</parent>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<swagger.version>2.6.1</swagger.version>
<xstream.version>1.4.7</xstream.version>
<pageHelper.version>4.1.6</pageHelper.version>
<fastjson.version>1.2.51</fastjson.version>
<springcloud.version>Greenwich.SR3</springcloud.version>
<mysql.version>5.1.46</mysql.version>

<alibaba-cloud.version>2.1.1.RELEASE</alibaba-cloud.version>
<springcloud.alibaba.version>0.9.0.RELEASE</springcloud.alibaba.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${alibaba-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<!-- <dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${springcloud.alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency> -->

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${springcloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>




剩下的,像数据库、持久化等,其他的可以根据需要添加。



配置完成后,我们需要写一个认证服务器的配置:



package com.damon.config;

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

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
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.error.WebResponseExceptionTranslator;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;

import com.damon.component.JwtTokenEnhancer;
import com.damon.login.service.LoginService;

/**
*
* 认证服务器配置
* @author Damon
* @date 2020年1月13日 下午3:03:30
*
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

@Autowired
private PasswordEncoder passwordEncoder;

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private LoginService loginService;

@Autowired
//@Qualifier("jwtTokenStore")
@Qualifier("redisTokenStore")
private TokenStore tokenStore;
/*@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Autowired
private JwtTokenEnhancer jwtTokenEnhancer;*/

@Autowired
private Environment env;


@Autowired
private DataSource dataSource;

@Autowired
private WebResponseExceptionTranslator userOAuth2WebResponseExceptionTranslator;

/**
* redis token 方式
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//验证时发生的情况处理
endpoints.authenticationManager(authenticationManager) //支持 password 模式
.exceptionTranslator(userOAuth2WebResponseExceptionTranslator)//自定义异常处理类添加到认证服务器配置
.userDetailsService(loginService)
.tokenStore(tokenStore);

}

/**
* 客户端配置(给谁发令牌)
* 不同客户端配置不同
*
* authorizedGrantTypes 可以包括如下几种设置中的一种或多种:
authorization_code:授权码类型。需要redirect_uri
implicit:隐式授权类型。需要redirect_uri
password:资源所有者(即用户)密码类型。
client_credentials:客户端凭据(客户端ID以及Key)类型。
refresh_token:通过以上授权获得的刷新令牌来获取新的令牌。

accessTokenValiditySeconds:token 的有效期
scopes:用来限制客户端访问的权限,在换取的 token 的时候会带上 scope 参数,只有在 scopes 定义内的,才可以正常换取 token。
* @param clients
* @throws Exception
* @author Damon
* @date 2020年1月13日
*
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("provider-service")
.secret(passwordEncoder.encode("provider-service-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")//配置申请的权限范围
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2001/login")//授权码模式开启后必须指定


.and()
.withClient("consumer-service")
.secret(passwordEncoder.encode("consumer-service-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")//配置申请的权限范围
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2005/login")//授权码模式开启后必须指定


.and()
.withClient("resource-service")
.secret(passwordEncoder.encode("resource-service-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")//配置申请的权限范围
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2006/login")//授权码模式开启后必须指定

.and()
.withClient("test-sentinel")
.secret(passwordEncoder.encode("test-sentinel-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")//配置申请的权限范围
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2008/login")//授权码模式开启后必须指定

.and()
.withClient("test-sentinel-feign")
.secret(passwordEncoder.encode("test-sentinel-feign-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")//配置申请的权限范围
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2010/login")//授权码模式开启后必须指定

.and()
.withClient("customer-service")
.secret(passwordEncoder.encode("customer-service-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2012/login")//授权码模式开启后必须指定
;
}

@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security.allowFormAuthenticationForClients();//是允许客户端访问 OAuth2 授权接口,否则请求 token 会返回 401
security.checkTokenAccess("isAuthenticated()");//是允许已授权用户访问 checkToken 接口
security.tokenKeyAccess("isAuthenticated()"); // security.tokenKeyAccess("permitAll()");获取密钥需要身份认证,使用单点登录时必须配置,是允许已授权用户获取 token 接口
}
}





Redis 配置:



package com.damon.config;

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.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

/**
* 使用redis存储token的配置
* @author Damon
* @date 2020年1月13日 下午3:03:19
*
*/
@Configuration
public class RedisTokenStoreConfig {

@Autowired
private RedisConnectionFactory redisConnectionFactory;

@Bean
public TokenStore redisTokenStore (){
//return new RedisTokenStore(redisConnectionFactory);
return new MyRedisTokenStore(redisConnectionFactory);
}
}




后面接下来需要配置安全访问的拦截,这时候需要 SpringSecurity:



package com.damon.config;

import javax.servlet.http.HttpServletResponse;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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;

/**
*
* SpringSecurity配置
* @author Damon
* @date 2020年1月13日 下午3:03:55
*
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf()
.disable()

.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPointHandle())
//.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()

.authorizeRequests()
.antMatchers("/oauth/**", "/login/**")//"/logout/**"
.permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll();
}

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

@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/css/**", "/js/**", "/plugins/**", "/favicon.ico");
}
}




再者,就是需要配置资源拦截:



package com.damon.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

/**
*
* 资源服务器配置
* @author Damon
* @date 2020年1月13日 下午3:03:48
*
*/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {


@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()

.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPointHandle())
//.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()

.requestMatchers().antMatchers("/api/**")
.and()
.authorizeRequests()
.antMatchers("/api/**").authenticated()
.and()
.httpBasic();
}
}




其中,在上面我们配置了资源拦截、权限拦截的统一处理配置:



package com.damon.config;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import com.alibaba.fastjson.JSON;
import com.damon.commons.Response;

/**
*
* 统一结果处理
*
* @author Damon
* @date 2020年1月16日 上午11:11:44
*
*/

public class AuthenticationEntryPointHandle implements AuthenticationEntryPoint {
/**
*
* @author Damon
* @date 2020年1月16日
*
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {

//response.setStatus(HttpServletResponse.SC_FORBIDDEN);
//response.setStatus(HttpStatus.OK.value());

//response.setHeader("Access-Control-Allow-Origin", "*"); //gateway已加,无需再加
//response.setHeader("Access-Control-Allow-Headers", "token");
//解决低危漏洞点击劫持 X-Frame-Options Header未配置
response.setHeader("X-Frame-Options", "SAMEORIGIN");
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");

response.getWriter()
.write(JSON.toJSONString(Response.ok(response.getStatus(), -2, authException.getMessage(), null)));
/*response.getWriter()
.write(JSON.toJSONString(Response.ok(200, -2, "Internal Server Error", authException.getMessage())));*/
}
}




最后,自定义异常处理类添加到认证服务器配置:



package com.damon.config;

import java.io.IOException;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.common.DefaultThrowableAnalyzer;
import org.springframework.security.oauth2.common.exceptions.InsufficientScopeException;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.security.web.util.ThrowableAnalyzer;
import org.springframework.stereotype.Component;
import org.springframework.web.HttpRequestMethodNotSupportedException;

import com.damon.exception.UserOAuth2Exception;

/**
*
* 自定义异常转换类
* @author Damon
* @date 2020年2月27日 上午10:28:19
*
*/

@Component("userOAuth2WebResponseExceptionTranslator")
public class UserOAuth2WebResponseExceptionTranslator implements WebResponseExceptionTranslator {
private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();

@Override
public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception {
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(e);
Exception ase = (OAuth2Exception)this.throwableAnalyzer.getFirstThrowableOfType(OAuth2Exception.class, causeChain);
//异常链中有OAuth2Exception异常
if (ase != null) {
return this.handleOAuth2Exception((OAuth2Exception)ase);
}
//身份验证相关异常
ase = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (ase != null) {
return this.handleOAuth2Exception(new UserOAuth2WebResponseExceptionTranslator.UnauthorizedException(e.getMessage(), e));
}
//异常链中包含拒绝访问异常
ase = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
if (ase instanceof AccessDeniedException) {
return this.handleOAuth2Exception(new UserOAuth2WebResponseExceptionTranslator.ForbiddenException(ase.getMessage(), ase));
}
//异常链中包含Http方法请求异常
ase = (HttpRequestMethodNotSupportedException)this.throwableAnalyzer.getFirstThrowableOfType(HttpRequestMethodNotSupportedException.class, causeChain);
if(ase instanceof HttpRequestMethodNotSupportedException){
return this.handleOAuth2Exception(new UserOAuth2WebResponseExceptionTranslator.MethodNotAllowed(ase.getMessage(), ase));
}
return this.handleOAuth2Exception(new UserOAuth2WebResponseExceptionTranslator.ServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), e));
}

private ResponseEntity<OAuth2Exception> handleOAuth2Exception(OAuth2Exception e) throws IOException {
int status = e.getHttpErrorCode();
HttpHeaders headers = new HttpHeaders();
headers.set("Cache-Control", "no-store");
headers.set("Pragma", "no-cache");
if (status == HttpStatus.UNAUTHORIZED.value() || e instanceof InsufficientScopeException) {
headers.set("WWW-Authenticate", String.format("%s %s", "Bearer", e.getSummary()));
}
UserOAuth2Exception exception = new UserOAuth2Exception(e.getMessage(),e);
ResponseEntity<OAuth2Exception> response = new ResponseEntity(exception, headers, HttpStatus.valueOf(status));
return response;
}


private static class MethodNotAllowed extends OAuth2Exception {
public MethodNotAllowed(String msg, Throwable t) {
super(msg, t);
}
@Override
public String getOAuth2ErrorCode() {
return "method_not_allowed";
}
@Override
public int getHttpErrorCode() {
return 405;
}
}

private static class UnauthorizedException extends OAuth2Exception {
public UnauthorizedException(String msg, Throwable t) {
super(msg, t);
}
@Override
public String getOAuth2ErrorCode() {
return "unauthorized";
}
@Override
public int getHttpErrorCode() {
return 401;
}
}

private static class ServerErrorException extends OAuth2Exception {
public ServerErrorException(String msg, Throwable t) {
super(msg, t);
}
@Override
public String getOAuth2ErrorCode() {
return "server_error";
}
@Override
public int getHttpErrorCode() {
return 500;
}
}

private static class ForbiddenException extends OAuth2Exception {
public ForbiddenException(String msg, Throwable t) {
super(msg, t);
}
@Override
public String getOAuth2ErrorCode() {
return "access_denied";
}
@Override
public int getHttpErrorCode() {
return 403;
}
}
}





最后,我们可能需要配置一些请求客户端的配置,以及变量配置:



@Configuration
public class BeansConfig {
@Resource
private Environment env;

@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setReadTimeout(env.getProperty("client.http.request.readTimeout", Integer.class, 15000));
requestFactory.setConnectTimeout(env.getProperty("client.http.request.connectTimeout", Integer.class, 3000));
RestTemplate rt = new RestTemplate(requestFactory);
return rt;
}

}




package com.damon.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;

/**
* 配置信息
* @author Damon
* @date 2019年10月25日 下午1:54:01
*
*/

@Component
@RefreshScope
public class EnvConfig {

@Value("${jdbc.driverClassName:}")
private String jdbc_driverClassName;

@Value("${jdbc.url:}")
private String jdbc_url;

@Value("${jdbc.username:}")
private String jdbc_username;

@Value("${jdbc.password:}")
private String jdbc_password;

public String getJdbc_driverClassName() {
return jdbc_driverClassName;
}

public void setJdbc_driverClassName(String jdbc_driverClassName) {
this.jdbc_driverClassName = jdbc_driverClassName;
}

public String getJdbc_url() {
return jdbc_url;
}

public void setJdbc_url(String jdbc_url) {
this.jdbc_url = jdbc_url;
}

public String getJdbc_username() {
return jdbc_username;
}

public void setJdbc_username(String jdbc_username) {
this.jdbc_username = jdbc_username;
}

public String getJdbc_password() {
return jdbc_password;
}

public void setJdbc_password(String jdbc_password) {
this.jdbc_password = jdbc_password;
}

}




最后需要配置一些环境配置:



spring:
application:
name: oauth-cas
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
config:
server-addr: 127.0.0.1:8848
refreshable-dataids: actuator.properties,log.properties

redis: #redis相关配置
database: 8
host: 127.0.0.1 #10.10.1.5 #localhost
port: 6379
password: sofawang #xylx1.t123 #sofawang #有密码时设置
jedis:
pool:
max-active: 8
max-idle: 8
min-idle: 0
timeout: 10000ms




server:
port: 2000
undertow:
uri-encoding: UTF-8
accesslog:
enabled: false
pattern: combined
#这里我们使用了SpringBoot2.x,注意session与1.x不同
servlet:
session:
timeout: PT120M
cookie:
name: OAUTH-CAS-SESSIONID #防止Cookie冲突,冲突会导致登录验证不通过


client:
http:
request:
connectTimeout: 8000
readTimeout: 30000

mybatis:
mapperLocations: classpath:mapper/*.xml
typeAliasesPackage: com.damon.*.model


spring:
profiles:
active: dev




最后,我们添加启动类:



@Configuration
@EnableAutoConfiguration
@ComponentScan(basePackages = {"com.damon"})
@EnableDiscoveryClient
public class CasApp {
public static void main(String[] args) {
SpringApplication.run(CasApp.class, args);
}
}




以上,一个认证中心的代码实战逻辑就完成了。



接下来,我们看一个客户端如何去认证,首先还是依赖:



<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>




在客户端,我们也需要配置一个资源配置与权限配置:



package com.damon.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

/**
*
*
* @author Damon
* @date 2020年1月16日 下午6:28:35
*
*/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()

.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPointHandle())
//.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()

.requestMatchers().antMatchers("/api/**")
.and()
.authorizeRequests()
.antMatchers("/api/**").authenticated()
.and()
.httpBasic();
}
}




当然,权限拦截可能就相对简单了:

package com.damon.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

/**
*
* 在接口上配置权限时使用
* @author Damon
* @date 2020年1月13日 下午3:29:49
*
*/
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Order(101)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}




同样,这里也需要一个统一结果处理类,这里就不展示了。



接下来,我们主要看配置:



cas-server-url: http://oauth-cas #http://localhost:2000#设置可以访问的地址

security:
oauth2: #与cas对应的配置
client:
client-id: provider-service
client-secret: provider-service-123
user-authorization-uri: ${cas-server-url}/oauth/authorize #是授权码认证方式需要的
access-token-uri: ${cas-server-url}/oauth/token #是密码模式需要用到的获取 token 的接口
resource:
loadBalanced: true
#jwt: #jwt存储token时开启
#key-uri: ${cas-server-url}/oauth/token_key
#key-value: test_jwt_sign_key
id: provider-service
#指定用户信息地址
user-info-uri: ${cas-server-url}/api/user #指定user info的URI,原生地址后缀为/auth/user
prefer-token-info: false
#token-info-uri:
authorization:
check-token-access: ${cas-server-url}/oauth/check_token #当此web服务端接收到来自UI客户端的请求后,需要拿着请求中的 token 到认证服务端做 token 验证,就是请求的这个接口




在上面的配置里,我们看到了各种注释了,讲得很仔细,但是我要强调下:为了高可用,我们的认证中心可能多个,所以需要域名来作 LB。同时,开启了 loadBalanced=true。最后,如果是授权码认证模式,则需要 "user-authorization-uri",如果是密码模式,需要 "access-token-uri" 来获取 token。我们通过它 "user-info-uri" 来获取认证中心的用户信息,从而判断该用户的权限,从而访问相应的资源。另外,上面的配置需要在bootstrap文件中,否则可能失败,大家可以试试。

接下来,我们添加一般配置:



server:
port: 2001
undertow:
uri-encoding: UTF-8
accesslog:
enabled: false
pattern: combined
servlet:
session:
timeout: PT120M
cookie:
name: PROVIDER-SERVICE-SESSIONID #防止Cookie冲突,冲突会导致登录验证不通过


backend:
ribbon:
client:
enabled: true
ServerListRefreshInterval: 5000

ribbon:
ConnectTimeout: 3000
# 设置全局默认的ribbon的读超时
ReadTimeout: 1000
eager-load:
enabled: true
clients: oauth-cas,consumer-service
MaxAutoRetries: 1 #对第一次请求的服务的重试次数
MaxAutoRetriesNextServer: 1 #要重试的下一个服务的最大数量(不包括第一个服务)
#listOfServers: localhost:5556,localhost:5557
#ServerListRefreshInterval: 2000
OkToRetryOnAllOperations: true
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RoundRobinRule


hystrix.command.BackendCall.execution.isolation.thread.timeoutInMilliseconds: 5000
hystrix.threadpool.BackendCallThread.coreSize: 5




这里,我们使用了 Ribbon 来做 LB,hystrix 来作熔断,最后需要注意的是:加上了 cookie name,防止 Cookie 冲突,冲突会导致登录验证不通过



配置启动类:



@Configuration
@EnableAutoConfiguration
@ComponentScan(basePackages = {"com.damon"})
@EnableDiscoveryClient
@EnableOAuth2Sso
public class ProviderApp {

public static void main(String[] args) {
SpringApplication.run(ProviderApp.class, args);
}

}




我们在上面配置了所有带有 "/api/**" 的路径请求,都会加以拦截,根据用户的信息来判断其是否有权限访问。



写一个简单的测试类:



@RestController
@RequestMapping("/api/user")
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
@Autowired
private UserService userService;
@PreAuthorize("hasAuthority('admin')")
@GetMapping("/auth/admin")
public Object adminAuth() {
logger.info("test password mode");
return "Has admin auth!";
}
}




上面的代码表示:如果用户具有 "admin" 的权限,则能够访问该接口,否则会被拒绝。

本文用的是 alibaba 的组件来作 LB,在 Spring Cloud Alibaba 实战 一文中讲过相关组件。启动认证中心、客户端、网关以及相关注册中心后,我们可以在 Nacos 界面看到服务列表:





最后,我们先来通过密码模式来进行认证吧:



curl -i -X POST -d "username=admin&password=123456&grant_type=password&client_id=provider-service&client_secret=provider-service-123" http://localhost:5555/oauth-cas/oauth/token




认证成功后,会返回如下结果:



{"access_token":"d2066f68-665b-4038-9dbe-5dd1035e75a0","token_type":"bearer","refresh_token":"44009836-731c-4e6a-9cc3-274ce3af8c6b","expires_in":3599,"scope":"all"}




接下来,我们通过 token 来访问接口:



curl -i -H "Accept: application/json" -H "Authorization:bearer d2066f68-665b-4038-9dbe-5dd1035e75a0" -X GET http://localhost:5555/provider-service/api/user/auth/admin




成功会返回结果:



Has admin auth!




token 如果失效,会返回:



{"error":"invalid_token","error_description":"d2066f68-665b-4038-9dbe-5dd1035e75a01"}




结束福利

  开源实战利用 k8s 作微服务的架构设计代码:https://github.com/damon008/spring-cloud-k8shttps://gitee.com/damon_one/spring-cloud-k8s,欢迎大家 star,多多指教。



关于作者

  笔名:Damon,技术爱好者,长期从事 Java 开发、Spring Cloud 的微服务架构设计,以及结合 docker、k8s 做微服务容器化,自动化部署等一站式项目部署、落地。Go 语言学习,k8s 研究,边缘计算框架 KubeEdge 等。公众号 程序猿Damon 发起人。个人微信 MrNull008,欢迎來撩。



欢迎关注 InfoQ: https://www.infoq.cn/profile/1905020/following/user



关注公众号





发布于: 2020 年 04 月 24 日阅读数: 421
用户头像

Damon

关注

God bless the fighters. 2020.03.11 加入

欢迎关注公众号:程序猿Damon,长期从事Java开发,研究Springcloud的微服务架构设计,Hadoop资源调度,以及结合k8s做微服务容器化,自动化部署等一站式项目部署、落地。学习Go,研究边缘计算KubeEdge等。

评论 (2 条评论)

发布
用户头像
作者有没有试过 连续两次请求/oauth/token接口
2020 年 10 月 22 日 15:24
回复
两次之后呢?
2020 年 12 月 22 日 13:53
回复
没有更多了
Oauth2的认证实战-HA篇