通常的,单体架构,我们会采用Shiro对系统做防护以及权限控制。在搭建微服务系统时,同样也要对资源做保护,只有通过认证的资源才能被访问。下面,我们将借助Spring Cloud OAuth和Spring Cloud Security搭建一个统一给微服务发放访问令牌的认证服务器elsa-auth。
在微服务架构下,我们通常根据不同的业务来构建不同的微服务子系统,各个子系统对外提供相应的服务。客户端除了浏览器外,还可能是手机App,小程序等。在微服务架构出现之前,我们的系统一般为单体模式,客户端只是单一的浏览器,所以通常情况下都是通过Session进行客户端,服务端通信,Session模式有个弊端,就是在一般存在于应用内,而随着客户端种类越来越多,这种交互方式变得越来越困难(当然可以通过Session缓存化的方式来解决),于是OAuth协议应运而生。
OAuth是一种用来规范令牌(Token)发放的授权机制,目前最新版本为2.0,其主要包含了四种授权模式:授权码模式、简化模式、密码模式和客户端模式。Spring Cloud OAuth对这四种授权模式进行了实现。如有不理解的,可以访问如下阮一峰介绍的Oauth2。
由于我们的前端系统是通过用户名和密码来登录系统的,所以我们选用密码模式。
File==>新建==>Other==>搜索Maven,选择Maven Module,然后Next
填写Module Name:elsa-auth,点击Next
一直Next至FInish为止,创建完成,项目结构如下
右键点击Elsa-Auth项目:点击Java Build Path,在Resouce资源下创建资源目录resources。
Elsa-Auth完整目录结构
认证服务器项目已经创建完成,下面我们做相关依赖和配置。
<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.0modelVersion>
<parent>
<groupId>com.elsagroupId>
<artifactId>elsa-cloudartifactId>
<version>1.0-SNAPSHOTversion>
parent>
<artifactId>elas-authartifactId>
<name>Elsa-Authname>
<description>Elsa-Cloud认证服务器description>
<dependencies>
<dependency>
<groupId>com.elsagroupId>
<artifactId>elsa-commonartifactId>
<version>1.0-SNAPSHOTversion>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>
project>
在elsa-common模块引入相关依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-netflix-eureka-clientartifactId>
dependency>
spring-boot-starter-data-redis
-因为后续我们需要将认证服务器生成的Token存储到Redis中,并且Redis依赖可能会被多个微服务使用到
spring-cloud-starter-netflix-eureka-client
-因为每个微服务都可能需要通过Eureka客户端将服务注册到注册中心,所以将依赖添加到通用模块,以方便其他微服务依赖。
@EnableDiscoveryClient
@SpringBootApplication
public class ElsaAuthApp
{
public static void main(String[] args) {
SpringApplication.run(ElsaAuthApp.class, args);
}
}
@EnableDiscoveryClient注解,用于开启服务注册与发现功能
编写配置文件application.yml,Eureka相关配置的含义已通过注解体现,可自行查看。在application.yml如果没有配置Redis相关配置,则采用的是Redis默认配置,但是为了更为直观,建议还是在application.yml中添加Resis配置。
server:
port: 8101
spring:
application:
name: Elsa-Auth
# redis相关配置
redis:
database: 0
host: 127.0.0.1
port: 6379
jedis:
pool:
min-idle: 8
max-idle: 500
max-active: 2000
max-wait: 10000
timeout: 5000
eureka:
instance:
# 向Eureka 服务端发送心跳的间隔时间,单位为秒,用于服务续约。这里配置为20秒,即每隔20秒向febs-register发送心跳,表明当前服务没有宕机
lease-renewal-interval-in-seconds: 20
client:
# 为true时表示将当前服务注册到Eureak服务端
register-with-eureka: true
# 为true时表示从Eureka 服务端获取注册的服务信息
fetch-registry: true
# 新实例信息的变化到Eureka服务端的间隔时间,单位为秒
instance-info-replication-interval-seconds: 30
# 默认值为30秒,即每30秒去Eureka服务端上获取服务并缓存,这里指定为3秒的原因是方便开发时测试,实际可以指定为默认值即可;
registry-fetch-interval-seconds: 3
serviceUrl:
# 指定Eureka服务端地址
defaultZone: http://elsa:123456@localhost:8001/register/eureka/
首先我们需要定义一个WebSecurity类型的认证安全配置类ElsaSecurityConfigure,在com.elsa.auth路径下新增configure包,然后在configure包下新增ElsaSecurityConfigure类,代码如下所示:
@Order(2) // 增加过滤链的优先级,因为ElsaResourceServerConfigure的优先级为3
@EnableWebSecurity // 开启和Web相关的安全配置
public class ElsaSecurityConfigure extends WebSecurityConfigurerAdapter {
@Autowired
private ElsaUserDetailService userDetailService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();//一个相同的密码,每次加密出来的加密串都不同
}
// 一个相同的密码,每次加密出来的加密串都不同
public static void main(String[] args) {
String password = "123456";
PasswordEncoder encoder = new BCryptPasswordEncoder();
System.out.println(encoder.encode(password));
System.out.println(encoder.encode(password));
}
//密码模式需要使用到这个Bean:AuthenticationManager
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.requestMatchers()
.antMatchers("/oauth/**") //安全配置类只对/oauth/开头的请求有效
.and()
.authorizeRequests()
.antMatchers("/oauth/**").authenticated()
.and()
.csrf().disable();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());
}
}
// 一个相同的密码,每次加密出来的加密串都不同
public static void main(String[] args) {
String password = "123456";
PasswordEncoder encoder = new BCryptPasswordEncoder();
System.out.println(encoder.encode(password));
System.out.println(encoder.encode(password));
}
运行该main方法,可以看到两次输出的结果并不一样:
$2a$10$CztjcNZW8xMlol4EAN/L8eroQly7NZfZe5lNcih.arCEd9MDwkHAi
$2a$10$Jstxp5K0rsp6xocA70M.aOfCYkrdZFV/6mIacOKb6ZtnpBN.r1waK
虽然我们现在正在搭建的是一个认证服务器,但是认证服务器本身也可以对外提供REST服务,比如通过Token获取当前登录用户信息,注销当前Token等,所以它也是一台资源服务器。于是我们需要定义一个资源服务器的配置类,在com.elsa.auth.configure包下新建ElsaResourceServerConfigure类:
@Configuration
@EnableResourceServer //开启资源服务器相关配置
public class ElsaResourceServerConfigure extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.requestMatchers().antMatchers("/**") //表明该安全配置对所有请求都生效
.and()
.authorizeRequests()
.antMatchers("/**").authenticated();
}
}
相信看到这里的人会发现,ElsaSecurityConfigure和ElsaResourceServerConfigure两个配置的功能似乎是一样的,都是对请求过滤的。
ElsaSecurityConfigure对/oauth/开头的请求生效,而ElsaResourceServerConfigure对所有请求都生效,那么当一个请求进来时,到底哪个安全配置先生效?其实并没有哪个配置先生效这么一说,当在Spring Security中定义了多个过滤器链的时候,根据其优先级,只有优先级较高的过滤器链会先进行匹配。
那么ElsaSecurityConfigure和ElsaResourceServerConfigure的优先级是多少?首先我们查看ElsaSecurityConfigure继承的类WebSecurityConfigurerAdapter的源码:
@Order(100)
public abstract class WebSecurityConfigurerAdapter implements WebSecurityConfigurer<WebSecurity> {
......
}
可以看到类上使用了@Order(100)标注,说明其顺序是100。
再来看看ElsaResourceServerConfigure类上@EnableResourceServer注解源码:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({ResourceServerConfiguration.class})
public @interface EnableResourceServer {
}
该注解引入了ResourceServerConfiguration配置类,查看ResourceServerConfiguration源码:
@Configuration
public class ResourceServerConfiguration extends WebSecurityConfigurerAdapter implements Ordered {
private int order = 3;
......
}
所以ElsaResourceServerConfigure的顺序是3。在Spring中,数字越小,优先级越高,也就是说ElsaResourceServerConfigure的优先级要高于ElsaSecurityConfigure,这也就意味着所有请求都会被ElsaResourceServerConfigure过滤器链处理,包括/oauth/开头的请求。这显然不是我们要的效果,我们原本是希望以/oauth/开头的请求由ElsaSecurityConfigure过滤器链处理,剩下的其他请求由ElsaResourceServerConfigure过滤器链处理。
为了解决上面的问题,我们可以手动指定这两个类的优先级,让ElsaSecurityConfigure的优先级高于ElsaResourceServerConfigure。在ElsaSecurityConfigure类上使用Order(2)注解标注即可:
@Order(2)
@EnableWebSecurity
public class ElsaSecurityConfigure extends WebSecurityConfigurerAdapter {
......
}
ElsaSecurityConfigure和ElsaResourceServerConfigure的区别:
ElsaSecurityConfigure用于处理/oauth开头的请求,Spring Cloud OAuth内部定义的获取令牌,刷新令牌的请求地址都是以/oauth/开头的,也就是说FebsSecurityConfigure用于处理和令牌相关的请求;
ElsaResourceServerConfigure用于处理非/oauth/开头的请求,其主要用于资源的保护,客户端只能通过OAuth2协议发放的令牌来从资源服务器中获取受保护的资源。
接着我们定义一个和认证服务器相关的授权配置类。在configure包下新建ElsaAuthorizationServerConfigure,配置的解释在代码中体现,代码如下所示:
@Configuration
@EnableAuthorizationServer //开启认证服务器相关配置
public class ElsaAuthorizationServerConfigure extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Autowired
private ElsaUserDetailService userDetailService;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 1.客户端从认证服务器获取令牌的时候,必须使用client_id为elsa,client_secret为123456的标识来获取;
* 2. 该client_id支持password模式获取令牌,并且可以通过refresh_token来获取新的令牌;
* 3. 在获取client_id为elsa的令牌的时候,scope只能指定为all,否则将获取失败
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
// 需要指定多个client,可以继续使用withClient配置
.withClient("elsa")
.secret(passwordEncoder.encode("123456"))
.authorizedGrantTypes("password", "refresh_token")
.scopes("all");
}
// tokenStore使用的是RedisTokenStore,认证服务器生成的令牌将被存储到Redis中
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.tokenStore(tokenStore())
.userDetailsService(userDetailService)
.authenticationManager(authenticationManager)
.tokenServices(defaultTokenServices());
}
// 认证服务器生成的令牌将被存储到Redis中
@Bean
public TokenStore tokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}
@Primary
@Bean
public DefaultTokenServices defaultTokenServices() {
DefaultTokenServices tokenServices = new DefaultTokenServices();
tokenServices.setTokenStore(tokenStore());
// 设置为true表示开启刷新令牌的支持
tokenServices.setSupportRefreshToken(true);
// 指定了令牌的基本配置,比如令牌有效时间为60 * 60 * 24秒,刷新令牌有效时间为60 * 60 * 24 * 7秒
tokenServices.setAccessTokenValiditySeconds(60 * 60 * 24);
tokenServices.setRefreshTokenValiditySeconds(60 * 60 * 24 * 7);
return tokenServices;
}
}
ElsaSecurityConfigure及ElsaAuthorizationServerConfigure用到的ElsaUserDetailService。在com.elsa.auth路径下新增service包,然后在service包下新增ElsaUserDetailService类,代码如下所示:
@Service
public class ElsaUserDetailService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
ElsaAuthUser user = new ElsaAuthUser();
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"));
}
}
ElsaUserDetailService实现了UserDetailsService接口的loadUserByUsername方法,主要用于校验用户账号和密码,以及授权等,我们模拟了一个用户,用户名为用户输入的用户名,密码为123456(后期再改造为从数据库中获取用户),然后返回org.springframework.security.core.userdetails.User。这里使用的是User类包含7个参数的构造器,其还包含一个三个参数的构造器User(String username, String password,Collection extends GrantedAuthority> authorities),由于权限参数不能为空,所以这里先使用AuthorityUtils.commaSeparatedStringToAuthorityList方法模拟一个user:add权限。
loadUserByUsername方法返回一个UserDetails对象,该对象也是一个接口,包含一些用于描述用户信息的方法,源码如下:
public interface UserDetails extends Serializable {
//获取用户包含的权限,返回权限集合,权限是一个继承了GrantedAuthority的对象;
Collection<? extends GrantedAuthority> getAuthorities();
//用于获取密码和用户名;
String getPassword();
//方法返回boolean类型,用于判断账户是否未过期,未过期返回true反之返回false;
String getUsername();
//方法用于判断账户是否未锁定;
boolean isAccountNonExpired();
//用于判断用户凭证是否没过期,即密码是否未过期;
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
//方法用于判断用户是否可用。
boolean isEnabled();
}
实际中我们可以自定义UserDetails接口的实现类,也可以直接使用Spring Security提供的UserDetails接口实现类org.springframework.security.core.userdetails.User。
ElsaUserDetailService中ElsaAuthUser为我们自定义的用户实体类,代表我们从数据库中查询出来的用户。我们在febs-common中定义该实体类,在elsa-cmmon模块下新增com.elsa.common.entity包,然后在entity包下新增ElsaAuthUser:
public class ElsaAuthUser implements Serializable {
private static final long serialVersionUID = -1748289340320186418L;
private String username;
private String password;
private boolean accountNonExpired = true;
private boolean accountNonLocked= true;
private boolean credentialsNonExpired= true;
private boolean enabled= true;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public boolean isAccountNonExpired() {
return accountNonExpired;
}
public void setAccountNonExpired(boolean accountNonExpired) {
this.accountNonExpired = accountNonExpired;
}
public boolean isAccountNonLocked() {
return accountNonLocked;
}
public void setAccountNonLocked(boolean accountNonLocked) {
this.accountNonLocked = accountNonLocked;
}
public boolean isCredentialsNonExpired() {
return credentialsNonExpired;
}
public void setCredentialsNonExpired(boolean credentialsNonExpired) {
this.credentialsNonExpired = credentialsNonExpired;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
最后定义一个Controller,对外提供一些REST服务。在com.elsa.auth路径下新增controller包,在controller包下新增SecurityController:
@RestController
public class SecurityController {
@Autowired
private ConsumerTokenServices consumerTokenServices;
@GetMapping("oauth/test")
public String testOauth() {
return "oauth";
}
//currentUser用户获取当前登录用户
@GetMapping("user")
public Principal currentUser(Principal principal) {
return principal;
}
//signout方法通过ConsumerTokenServices来注销当前Token
@DeleteMapping("signout")
public ElsaResponse signout(HttpServletRequest request) throws ElsaAuthException {
String authorization = request.getHeader("Authorization");
String token = StringUtils.replace(authorization, "bearer ", "");
ElsaResponse elsaResponse = new ElsaResponse();
if (!consumerTokenServices.revokeToken(token)) {
throw new ElsaAuthException("退出登录失败");
}
return elsaResponse.message("退出登录成功");
}
}
ElsaResponse为系统的统一相应格式,我们在elsa-common模块中定义它,在elsa-common模块的com.elsa.common.entity路径下新增ElsaResponse类:
public class ElsaResponse extends HashMap<String, Object> {
private static final long serialVersionUID = -8713837118340960775L;
public ElsaResponse message(String message) {
this.put("message", message);
return this;
}
public ElsaResponse data(Object data) {
this.put("data", data);
return this;
}
@Override
public ElsaResponse put(String key, Object value) {
super.put(key, value);
return this;
}
public String getMessage() {
return String.valueOf(get("message"));
}
public Object getData() {
return get("data");
}
}
ElsaAuthException为自定义异常,在elsa-common模块com.elsa.common路径下新增exception包,然后在该包下新增ElsaAuthException:
public class ElsaAuthException extends Exception{
private static final long serialVersionUID = -6916154462432027437L;
public ElsaAuthException(String message){
super(message);
}
}
分别启动如下应用
1.redis
2.ElsaRegesterApp
3.ElsaAuthApp
grant_type填password,表示密码模式,然后填写用户名和密码,根据我们定义的ElsaUserDetailService逻辑,这里用户名随便填,密码必须为123456。
除了这几个参数外,我们需要在请求头中配置Authorization信息,否则请求将返回401:
值为Basic加空格加client_id:client_secret(就是在ElsaAuthorizationServerConfigure类configure(ClientDetailsServiceConfigurer clients)方法中定义的client和secret)经过base64加密后的值(可以使用http://tool.oschina.net/encrypt?type=3):
点击Send结果如下
查看Redis
我们已经成功获取了访问令牌access_token,接下来使用这个令牌去获取/user资源。
使用PostMan发送 localhost:8101/user GET请求,带上令牌,可以看到已经成功返回了数据。
接着我们使用PostMan发送 localhost:8101/oauth/test GET请求:
可以看到,虽然我们在请求头中已经带上了正确的令牌,但是并没有成功获取到资源,正如前面所说的那样,/oauth/开头的请求由ElsaSecurityConfigure定义的过滤器链处理,它不受资源服务器配置管理,所以使用令牌并不能成功获取到资源。
然后使用refresh_token去换取新的令牌,使用PostMan发送 localhost:8101/oauth/token POST请求,请求参数如下:
刷新令牌在Headers添加参数:
Authorization=Basic ZWxzYToxMjM0NTY=
Params中添加两个参数:
grant_type=refresh_token
refresh_token=登录时得到的refresh_token
可以看到,成功获取到了新的令牌。
源码地址:认证服务器