徒手撸springcloud+VUE--Oauth2篇
上一篇写了父工程和eureka、gateway服务的搭建和配置,基本上采用默认的配置就可完成基本的搭建,比较简单。本篇重点写点Oauth2的搭建和踩过的各种坑
一、引入相关jar包
1、oauth2的jar包和自动化配置的jar包
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.2.RELEASE</version>
2、引入eureka客户端jar包
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
3、本例采用数据库存储token和客户端信息,所以需要引入数据库相关jar包
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.35</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.23</version>
</dependency>
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
<version>2.1.5</version>
</dependency>
本例采用的是tk.mybatis,这个jar包基本包含了常用的增删改查功能,而且很方便和Page插件结合,一行代码实现分页查询,感兴趣的小伙伴可以去通用mapper详细了解。
4、本例使用openfeign实现各服务之间互相通信,所以需要引入相应jar包
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.land</groupId>
<artifactId>util</artifactId>
<version>1.0</version>
<scope>compile</scope>
</dependency>
使用缓存来控制token的刷新,使用util服务中的一些通用类满足各服务之间的用户数据,所以也需要相应的引入其jar包
至此相应jar包都已经加入,后续其他的jar包在需要使用时加入。
二、配置WebSecurityConfigurerAdapter
1、新建WebSecurityConfig类继承WebSecurityConfigurerAdapter,代码如下
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserServiceDetail userServiceDetail;
@Override
protected void configure(HttpSecurity http) throws Exception {
//禁用跨站伪造
http
.authorizeRequests().anyRequest().authenticated();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userServiceDetail)
.passwordEncoder(new BCryptPasswordEncoder());
}
}
配置所有的访问都必须经过验证,并设置了获取用户的service和密码验证方式。
其中UserServiceDetail为验证用户的方法,需要继承UserDetailsService,代码如下
@Service
public class UserServiceDetail implements UserDetailsService {
@Autowired
private SysFeignService sysFeignService;
@Override
public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
UserDto userDto = sysFeignService.loadUserByUsername(userName);
if(null != userDto){
User user = new User(userDto);
return user;
}else {
throw new UsernameNotFoundException("账号不存在");
}
}
}
此处采用openfeign的方式访问远程sys服务,获取用户的相关信息,代码如下
@FeignClient(name = "sys",configuration = FeignConfig.class)
public interface SysFeignService {
@GetMapping(value = "/common/loadUserByUsername")
UserDto loadUserByUsername(@RequestParam("userName") String userName);
@GetMapping(value = "/common/findByName")
UserDto findByName(@RequestParam("userName") String userName);
}
两个获取方式,其中一个是获取管理员的,一个是获取普通用户的,稍后再详细说明
使用@FeignClient注解启用远程访问,其中name参数指远程服务的名称,configuration指配置文件,我的配置文件采用的是默认的,代码如下
@Configuration
public class FeignConfig {
@Bean
public Retryer feignRetryer(){
return new Retryer.Default();
}
}
三、配置AuthorizationServerConfigurerAdapter
新建Oauth2Config继承AuthorizationServerConfigurerAdapter
1、创建tokenStore方法,并注册为bean
@Bean
public TokenStore tokenStore(){
//使用数据库存储token信息
return new JdbcTokenStore(dataSource);
// return new JwtTokenStore(jwtAccessTokenConverter());
}
tokenStore有4种方式存储,分别是内存、数据库、jwt和Redis,具体可打开TokenStore源码,点击其实现类查看,如下图
本例采用数据的存储方式,主要是为了后期能在管理端直接控制用户token
2、配置客户端存储方式,代码如下
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//使用数据库存储客户端信息
clients.jdbc(dataSource);
}
客户端的存储方式有两种,数据库和内存存储,可见下方源码
public class ClientDetailsServiceConfigurer extends SecurityConfigurerAdapter<ClientDetailsService, ClientDetailsServiceBuilder<?>> {
public ClientDetailsServiceConfigurer(ClientDetailsServiceBuilder<?> builder) {
this.setBuilder(builder);
}
public ClientDetailsServiceBuilder<?> withClientDetails(ClientDetailsService clientDetailsService) throws Exception {
this.setBuilder(((ClientDetailsServiceBuilder)this.getBuilder()).clients(clientDetailsService));
return (ClientDetailsServiceBuilder)this.and();
}
public InMemoryClientDetailsServiceBuilder inMemory() throws Exception {
InMemoryClientDetailsServiceBuilder next = ((ClientDetailsServiceBuilder)this.getBuilder()).inMemory();
this.setBuilder(next);
return next;
}
public JdbcClientDetailsServiceBuilder jdbc(DataSource dataSource) throws Exception {
JdbcClientDetailsServiceBuilder next = ((ClientDetailsServiceBuilder)this.getBuilder()).jdbc().dataSource(dataSource);
this.setBuilder(next);
return next;
}
public void init(ClientDetailsServiceBuilder<?> builder) throws Exception {
}
public void configure(ClientDetailsServiceBuilder<?> builder) throws Exception {
}
}
本例采用的同样是数据库方式,原因也是为了能动态的控制各客户端的访问权限,例如账号密码、资源ID、scope和token有效期等。但需要配合token管理,因为修改客户端信息后不会马上生效,已获取token的客户端已经将相关信息存放到token中,在token失效之前不会再访问数据库进行验证。因此如果想修改立马生效,可以将已生成的token全部清除。
3、配置获取token的策略
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()")
.allowFormAuthenticationForClients()
.passwordEncoder(new BCryptPasswordEncoder());
}
4、配置AuthorizationServerEndpointsConfigurer
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore()).authenticationManager(authenticationManager);
endpoints.userDetailsService(userServiceDetail);
}
四、扩展自定义登录方式
1、在配置AuthorizationServerEndpointsConfigurer中设置tokenGranter为自定义tokenGranter
endpoints.tokenGranter(tokenGranter(endpoints));
2、自定义tokenGranter方法
private TokenGranter tokenGranter(AuthorizationServerEndpointsConfigurer endpoints) {
TokenGranter tokenGranter = new TokenGranter() {
private CompositeTokenGranter delegate;
@Override
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
if (delegate == null) {
delegate = new CompositeTokenGranter(getDefaultTokenGranters(endpoints));
}
return delegate.grant(grantType, tokenRequest);
}
};
return tokenGranter;
}
通过getDefaultTokenGranters方法重写默认TokenGranter
3、自定义getDefaultTokenGranters方法
private List<TokenGranter> getDefaultTokenGranters(AuthorizationServerEndpointsConfigurer endpoints) {
AuthorizationServerTokenServices tokenService = endpoints.getTokenServices();
OAuth2RequestFactory requestFactory = endpoints.getOAuth2RequestFactory();
AuthorizationCodeServices authorizationCodeServices = endpoints.getAuthorizationCodeServices();
List<TokenGranter> tokenGranters = new ArrayList<>();
tokenGranters.add(new ClientCredentialsTokenGranter(tokenService, clientDetailsService, requestFactory));
tokenGranters.add(new RefreshTokenGranter(tokenService, clientDetailsService, requestFactory));
tokenGranters.add(new AuthorizationCodeTokenGranter(tokenService, authorizationCodeServices, clientDetailsService, requestFactory));
tokenGranters.add(new ImplicitTokenGranter(tokenService, clientDetailsService, requestFactory));
tokenGranters.add(new ResourceOwnerPasswordTokenGranter(authenticationManager, tokenService, clientDetailsService, requestFactory));
//短信验证码授权
tokenGranters.add(new SmsCodeTokenGranter(tokenService, clientDetailsService, requestFactory,sysFeignService));
//管理用户账号密码登录
tokenGranters.add(new PasswordTokenGranter(authenticationManager,tokenService, clientDetailsService, requestFactory,sysFeignService));
//普通用户账号密码登录
tokenGranters.add(new UserPasswordTokenGranter(authenticationManager,tokenService, clientDetailsService, requestFactory,sysFeignService));
return tokenGranters;
}
首先在tokenGranters 列表中加入默认的tokenGranter,包括客户端认证、token刷新等,在下方加入自定义的登录方法。
4、自定义登录方式
以管理员账号登录为例,继承AbstractTokenGranter ,代码如下
public class PasswordTokenGranter extends AbstractTokenGranter {
private static final String GRANT_TYPE = "sys_password";
private final AuthenticationManager authenticationManager;
private SysFeignService sysFeignService;
protected PasswordTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, SysFeignService sysFeignService) {
super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
this.sysFeignService = sysFeignService;
this.authenticationManager = authenticationManager;
}
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
Map<String, String> parameters = new LinkedHashMap(tokenRequest.getRequestParameters());
String username = parameters.get("username");
String password = parameters.get("password");
UserDto userDto = sysFeignService.loadUserByUsername(username);
if(userDto == null) {
throw new InvalidGrantException("用户不存在");
}
if(!new BCryptPasswordEncoder().matches(password,userDto.getPassword())){
throw new InvalidGrantException("密码不正确");
}
User user = new User(userDto);
Authentication userAuth = new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities());
((AbstractAuthenticationToken)userAuth).setDetails(userDto);
OAuth2Request storedOAuth2Request = this.getRequestFactory().createOAuth2Request(client, tokenRequest);
return new OAuth2Authentication(storedOAuth2Request, userAuth);
}
}
自定义GRANT_TYPE 为sys_password,即在登录时GRANT_TYPE 参数为sys_password时会自动进入到此方法进行登录处理
重写getOAuth2Authentication方法,进行登录逻辑处理
这里有三个坑,一个是密码验证方式,一个是bean注入方式,一个是客户端只能获取用户名,其他信息无法获取
- 密码验证方式
密码验证方式和常规的MD5验证方式不同,只能使用BCryptPasswordEncoder内置的matches方法进行验证,因为BCryptPasswordEncoder是不对称加密,同样的明文每次加密后的密文都是不同的。这个问题是对BCryptPasswordEncoder的不了解,坑了我好长时间,最后发现登陆传的密码和数据库密码不一致,然后自己再次加密的时候,发现还是跟数据库不同,才找到问题所在,说出来有点丢人。。。
- bean注入方式
必须在Oauth2Config类中注入,然后在自定义方法中将bean传到这里,然后初始化的时候赋值,否则bean会一直为null,具体原因未知。在这里被坑了整整一天。。。。
1、在Oauth2Config中注入sysFeignService
@Autowired
private SysFeignService sysFeignService;
2、在自定义方法中将bean传过去
tokenGranters.add(new PasswordTokenGranter(authenticationManager,tokenService, clientDetailsService, requestFactory,sysFeignService));
2、在PasswordTokenGranter初始化方法中赋值
private final AuthenticationManager authenticationManager;
private SysFeignService sysFeignService;
protected PasswordTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, SysFeignService sysFeignService) {
super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
this.sysFeignService = sysFeignService;
this.authenticationManager = authenticationManager;
}
3、扩展客户端获取用户信息
User user = new User(userDto);
Authentication userAuth = new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities());
((AbstractAuthenticationToken)userAuth).setDetails(userDto);
其中User继承了security的UserDetails,通过user进行验证方便后期扩展,不过这是我的强迫症,实际上通过userDto也可以。然后通过setDetails方法将用户信息放到token中,在资源服务中就可以通过下面的方法获取userDto数据
public static UserDto getUser(){
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
JSONObject authJson = JSONObject.parseObject(JSONObject.toJSONString(authentication));
JSONObject userAuthJson = authJson.getJSONObject("userAuthentication");
JSONObject detailsJson = userAuthJson.getJSONObject("details");
JSONObject principanJson = detailsJson.getJSONObject("principal");
UserDto userDto = new UserDto();
userDto.setUsername(principanJson.getString("username"));
userDto.setUserType(principanJson.getString("userType"));
userDto.setEnabled(principanJson.getBoolean("enabled"));
userDto.setAuthList(principanJson.getJSONArray("authorities").toJavaList(String.class));
userDto.setBirthday(principanJson.getDate("birthday"));
userDto.setSex(principanJson.getString("sex"));
userDto.setMail(principanJson.getString("mail"));
userDto.setRealName(principanJson.getString("realName"));
userDto.setNickName(principanJson.getString("nickName"));
userDto.setId(principanJson.getInteger("id"));
userDto.setPhone(principanJson.getString("phone"));
return userDto;
}catch (Exception e){
return null;
}
}
当然也可以在controller中直接将Principal 、 OauthApplication 、 Authentication 对象传进去直接获取,我这里因为使用自定义注解实现日志服务,这几个对象解析是会报错,所以就写了一个工具类来获取,后文在介绍sys服务时会说。
五、token自动刷新
客户端信息已经配置了token的有效期,本例采用数据存储,数据库必须通过官方的语句进行生成,数据语句可以在官网和网上获取
数据库结构如下图
本例只使用oauth_client_details,oauth_access_token两张表
oauth_access_token表如下
oauth_client_details表如下
其中access_token_validity即是token有效期
但是token到期后还得引导用户重新进行登录,体验非常不好,如果能像session一样多长时间不访问才会过期就好了,基于这个想法,进行了各种实验,最终找到一种方法,具体做法如下
1、自定义OauthTokenInterceptor拦截器继承HandlerInterceptorAdapter
@Component
public class OauthTokenInterceptor extends HandlerInterceptorAdapter {
@Autowired
private TokenStore tokenStore;
// @Autowired
// private RedisTemplate<String,String> redisTemplate;
private final Cache cache = Cache.newHardMemoryCache(0,3*60);
private final long hour = 2;
//拦截请求,如果存在token,自动重置token有效期
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String authorization = request.getHeader("Authorization");
if(StringUtils.isNotBlank(authorization)){
authorization = authorization.substring(authorization.indexOf("Bearer")+6).trim();
DefaultOAuth2AccessToken defaultOAuth2AccessToken = (DefaultOAuth2AccessToken) tokenStore.readAccessToken(authorization);
if(!Objects.isNull(defaultOAuth2AccessToken) && defaultOAuth2AccessToken.isExpired()){
//token已过期,返回
return super.preHandle(request,response,handler);
}
if(null == cache.get(authorization)){
//将token放到缓存中,有效期设置为3分钟,即3分钟之刷新一次
cache.put(authorization,new Date());
System.out.println("============================更新token:"+authorization+"("+new Date()+")===============================");
//获取到期时间
Date expiration = defaultOAuth2AccessToken.getExpiration();
//给到期时间重置为两个小时
expiration.setTime(new Date().getTime() + hour*60*60*1000L);
defaultOAuth2AccessToken.setExpiration(expiration);
//重新设置token
OAuth2Authentication oauthApplication = tokenStore.readAuthentication(defaultOAuth2AccessToken);
tokenStore.storeAccessToken(defaultOAuth2AccessToken,oauthApplication);
}
}
return super.preHandle(request,response,handler);
}
}
重写preHandle方法
首先判断header中是否有token,header中的token是放在Authorization参数中,这个前后端要统一,token前面会加上token类型Bearer,这里也要前后端统一,然后进行截取,获取token
如果没有token直接放行
如果有token,判断token是否过期,如果过期直接放行
如果token未过期,解析token,并重新设置token有效期,本例每次都将token有效期重置为两个小时,实际情况可以根据业务进行调整
这里有个坑,就是重新设置token的时候会报错,原因有以下几个
- 重新设置token,会先删除原token,然后在插入新的token,源码如下
public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
String refreshToken = null;
if (token.getRefreshToken() != null) {
refreshToken = token.getRefreshToken().getValue();
}
if (this.readAccessToken(token.getValue()) != null) {
this.removeAccessToken(token.getValue());
}
this.jdbcTemplate.update(this.insertAccessTokenSql, new Object[]{this.extractTokenKey(token.getValue()), new SqlLobValue(this.serializeAccessToken(token)), this.authenticationKeyGenerator.extractKey(authentication), authentication.isClientOnly() ? null : authentication.getName(), authentication.getOAuth2Request().getClientId(), new SqlLobValue(this.serializeAuthentication(authentication)), this.extractTokenKey(refreshToken)}, new int[]{12, 2004, 12, 12, 12, 2004, 12});
}
insertAccessTokenSql 语句
private String insertAccessTokenSql = "insert into oauth_access_token (token_id, token, authentication_id, user_name, client_id, authentication, refresh_token) values (?, ?, ?, ?, ?, ?, ?)";
removeAccessToken方法
public void removeAccessToken(String tokenValue) {
this.jdbcTemplate.update(this.deleteAccessTokenSql, new Object[]{this.extractTokenKey(tokenValue)});
}
deleteAccessTokenSql语句
private String deleteAccessTokenSql = "delete from oauth_access_token where token_id = ?";
这里删除的时候是直接将token删除了,但是如果同时使用refreshToken 的话会造成oauth_refresh_token表外键找不到
oauth_refresh_token表结构如下
所以需要放弃使用refreshToken (自认为,不知道对不对,结果就是我不使用refreshToken 后这个问题解决了。。。)
- 频繁刷新脏数据问题
上面的代码中,各位小伙伴可以看到里面有一个判断缓存中是否有token的步骤,主要就是解决这个问题的。原因是我登录的时候需要获取token,获取当前登录用户,获取菜单、权限等等,所以会在登录的一瞬间多次访问,导致解析的时候token已经不存在了(原来的已经删除,新的还未生成,导致token解析失败)
为了解决这个问题,刚开始是采用Redis存储的方式,每次刷新token之后将token存放到Redis中,每次更新前先判断Redis中是否已经存在,如果存在就不再更新了。首先考虑Redis的原因是可以通过Redis客户端控制刷新,而且Redis支持自定义有效期,这样才能控制token的刷新频率。 - Redis读取太慢
使用Redis之后上述的问题得到了改善,但是偶尔还是会出问题,而且并发高峰期出问题的频率比较高,问题个人怀疑是Redis读取速度的问题(我的Redis是放在外网服务器上的,个人在本地调试,存在一定的网络时间),所以将Redis更换为cache缓存,至此个人测试阶段未再出现这个问题。
不过这个解决办法并不是最好的,因为如果要实现oauth服务高可用如果设置全局,这个问题留待以后有时间再考虑。
2、将自定义拦截器加入到SpringMVC拦截器中
新建WebMvcConfig类继承WebMvcConfigurer
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private OauthTokenInterceptor oauthTokenInterceptor;
//添加自定义拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(oauthTokenInterceptor).addPathPatterns("/**");
}
}
这里踩了个坑,自定义的OauthTokenInterceptor 拦截器需要用注入bean的方式使用,不能用new的方式,否则OauthTokenInterceptor 类会报空指针。网上看的一些方法直接new就可以用,我的不可以,具体原因未深入探究。
六、配置文件
所有的配置如下:
server:
port: 8768
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka
spring:
application:
name: oauth
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/longmao-oauth?serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8
username: root
password: root
# 连接池配置
druid:
# 初始化大小,最小,最大
initial-size: 5
min-idle: 5
max-active: 20
# 配置获取连接等待超时的时间
max-wait: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位毫秒
time-between-eviction-runs-millis: 60000
# 配置一个连接在池中最小生存时间
min-evictable-idle-time-millis: 300000
validation-query: SELECT 1 FROM sys_user
test-while-idle: true
test-on-borrow: false
test-on-return: false
# 打开 PSCache,并且指定每个连接上 PSCache 的大小
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
# 配置监控统计拦截的 Filter,去掉后监控界面 SQL 无法统计,wall 用于防火墙
filters: stat,wall
# 通过 connection-properties 属性打开 mergeSql 功能;慢 SQL 记录
connection-properties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
# 配置 DruidStatFilter
web-stat-filter:
enabled: true
url-pattern: /*
exclusions: .js,*.gif,*.jpg,*.bmp,*.png,*.css,*.ico,/druid/*
# 配置 DruidStatViewServlet
stat-view-servlet:
enabled: true
url-pattern: /druid/*
# IP 白名单,没有配置或者为空,则允许所有访问
# allow: 127.0.0.1
# IP 黑名单,若白名单也存在,则优先使用
# deny: 192.168.31.253
# 禁用 HTML 中 Reset All 按钮
reset-enable: false
# 登录用户名/密码
login-username: root
login-password: 123
redis:
database: 0
host: localhost
password: test123
port: 6379
timeout: 1000
七、总结
oauth2对我来说还是个新东西,刚开始写的时候下手太急了,导致后续进行了多次更改。对于新技术最好还是通过几个步骤来学习会更好些
- 通过网上查询相关资料,了解技术背景、主要解决方向和实现逻辑
- 查询相关案例,读代码
- 查看源码,了解实现的具体方法
- 下手尝试,深入理解
以上代码都可以直接拿来用,已经经过个人测试,基本解决了我现在的所有需求,有问题可以留言讨论。