前言

这一篇文章是项目框架的重头戏,在找了多方文章后才从这一系列博客–>Spring Cloud实战 | 第六篇:Spring Cloud Gateway + Spring Security OAuth2 + JWT实现微服务统一认证鉴权成功的搭建了OAuth2框架。

跟随上面介绍的博客,使用Gateway作为资源服务器,OAuth2作为认证服务器,使用JWT作为token。搭建了这个框架。在搭建完成后写下这一篇博客记录项目搭建过程和所遇到的问题。

概念介绍

这一模块留待后续补充,目前先介绍项目搭建过程。

认证服务器

Spring Security OAuth过时

在最开始使用OAuth2时,我才发现Spring Security OAuth已过时,Spring官方已经OAuth2整合到Spring Security中,但是经过多次搜索,我依旧没找到基于Spring Security如何使用OAuth2,所以决定使用spring-cloud-starter-oauth2先用着,等日后博客更多或者我的技术更好再进行更换,也算留个坑。

20210124123038

Spring Security OAuth

导入依赖

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>

编写配置文件

和其他微服务相同,通用配置就够了不需要添加特殊的配置

1
2
3
4
5
6
7
8
9
spring.application.name=@project.artifactId@
spring.profiles.active=@profiles.active@
spring.main.allow-bean-definition-overriding=true
# nacos
spring.cloud.nacos.discovery.server-addr=@profiles.nacos@
spring.cloud.nacos.config.server-addr=@profiles.nacos@
spring.cloud.nacos.config.file-extension=properties
spring.cloud.nacos.config.shared-dataids=application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
spring.cloud.nacos.config.refreshable-dataids=application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}

认证服务器配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
/**
* 认证服务配置
*
*/
@Configuration
@EnableAuthorizationServer
@AllArgsConstructor
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

private AuthenticationManager authenticationManager;
private UserDetailServiceImpl userDetailsService;

/**
* 客户端信息配置
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
// 为了方便,使用本地内存存储
.inMemory()
// 客户端id
.withClient(AuthConstants.ADMIN_CLIENT_ID)
// 客户端密码
.secret(new BCryptPasswordEncoder().encode(AuthConstants.ADMIN_CLIENT_SECRET))
// 该客户端允许授权的类型
.authorizedGrantTypes("password", "refresh_token")
// 该客户端允许授权的范围
.scopes("all")
// false跳转到授权页面,true不跳转,直接发令牌
.autoApprove(false);
}

/**
* 配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> tokenEnhancers = new ArrayList<>();
tokenEnhancers.add(tokenEnhancer());
tokenEnhancers.add(jwtAccessTokenConverter());
tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);

endpoints.authenticationManager(authenticationManager)
.accessTokenConverter(jwtAccessTokenConverter())
.tokenEnhancer(tokenEnhancerChain)
.userDetailsService(userDetailsService)
// refresh_token有两种使用方式:重复使用(true)、非重复使用(false),默认为true
// 1.重复使用:access_token过期刷新时, refresh token过期时间未改变,仍以初次生成的时间为准
// 2.非重复使用:access_token过期刷新时, refresh_token过期时间延续,在refresh_token有效期内刷新而无需失效再次登录
.reuseRefreshTokens(false);
}

/**
* 允许表单认证
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
CustomClientCredentialsTokenEndpointFilter endpointFilter = new CustomClientCredentialsTokenEndpointFilter(security);
endpointFilter.afterPropertiesSet();
endpointFilter.setAuthenticationEntryPoint(authenticationEntryPoint());
security.addTokenEndpointAuthenticationFilter(endpointFilter);

security.authenticationEntryPoint(authenticationEntryPoint())
.tokenKeyAccess("isAuthenticated()")
.checkTokenAccess("permitAll()");
}

/**
* 使用非对称加密算法对token签名
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(keyPair());
return converter;
}

/**
* 从classpath下的密钥库中获取密钥对(公钥+私钥)
*/
@Bean
public KeyPair keyPair() {
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(
new ClassPathResource("shiming.jks"), "123456".toCharArray());
KeyPair keyPair = factory.getKeyPair(
"shiming", "123456".toCharArray());
return keyPair;
}

/**
* JWT内容增强
*/
@Bean
public TokenEnhancer tokenEnhancer() {
return (accessToken, authentication) -> {
Map<String, Object> map = new HashMap<>(2);
OAuthUser user = (OAuthUser) authentication.getUserAuthentication().getPrincipal();
map.put(AuthConstants.JWT_USER_ID_KEY, user.getId());
map.put(AuthConstants.JWT_CLIENT_ID_KEY, user.getClientId());
map.put(AuthConstants.JWT_NICKNAME,user.getUser().getNickname());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
return accessToken;
};
}


@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, e) -> {
response.setStatus(HttpStatus.HTTP_OK);
response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Cache-Control", "no-cache");
ResultDto result = ResultDto.error(ErrorCodeEnum.USER_ERROR_A0303);
response.getWriter().print(JSONUtil.toJsonStr(result));
response.getWriter().flush();
};
}
}

生成密钥

1
2
3
4
5
6
7
8
9
10
11
12
13
keytool -genkey -alias shiming -keyalg RSA -keypass 123456 -keystore shiming.jks -storepass 123456

-genkey 生成密钥

-alias 别名

-keyalg 密钥算法

-keypass 密钥口令

-keystore 生成密钥库的存储路径和名称

-storepass 密钥库口令
编写获取公钥接口
1
2
3
4
5
6
7
8
@ApiOperation("获取公钥")
@GetMapping("getPublicKey")
public Map<String, Object> getPublicKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}

安全配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* security 安全相关配置类
*
* @author: YoCiyy
* @date: 2020/6/22
*/
@Configuration
@AllArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

/**
* 安全拦截机制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
// 放行
.antMatchers("/oauth/**","/v2/api-docs")
.permitAll()
// 其他请求必须认证通过
.anyRequest().authenticated()
.and()
.formLogin() // 允许表单登录
.and()
.csrf().disable();

}

/**
* 如果不配置SpringBoot会自动配置一个AuthenticationManager,覆盖掉内存中的用户
*/
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

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


}

客户端自定义异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 重写filter实现客户端自定义异常处理
*/
public class CustomClientCredentialsTokenEndpointFilter extends ClientCredentialsTokenEndpointFilter {

private AuthorizationServerSecurityConfigurer configurer;
private AuthenticationEntryPoint authenticationEntryPoint;


public CustomClientCredentialsTokenEndpointFilter(AuthorizationServerSecurityConfigurer configurer) {
this.configurer = configurer;
}

@Override
public void setAuthenticationEntryPoint(AuthenticationEntryPoint authenticationEntryPoint) {
super.setAuthenticationEntryPoint(null);
this.authenticationEntryPoint = authenticationEntryPoint;
}

@Override
protected AuthenticationManager getAuthenticationManager() {
return configurer.and().getSharedObject(AuthenticationManager.class);
}

@Override
public void afterPropertiesSet() {
setAuthenticationFailureHandler((request, response, e) -> authenticationEntryPoint.commence(request, response, e));
setAuthenticationSuccessHandler((request, response, authentication) -> {
});
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@RestControllerAdvice
@Slf4j
public class AuthExceptionHandler {

/**
* 用户名和密码错误
*
* @param e
* @return
*/
@ExceptionHandler(InvalidGrantException.class)
public ResultDto<?> handleInvalidGrantException(InvalidGrantException e) {
return ResultDto.error(ErrorCodeEnum.USER_ERROR_A0210);
}

/**
* 账户异常(禁用、锁定、过期)
*
* @param e
* @return
*/
@ExceptionHandler({InternalAuthenticationServiceException.class})
public ResultDto<?> handleInternalAuthenticationServiceException(InternalAuthenticationServiceException e) {
return ResultDto.error(e.getMessage(),ErrorCodeEnum.USER_ERROR_A0200);
}
}

自定义token

创建用户信息类接受feign调用其他服务返回的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class User implements Serializable {

private static final long serialVersionUID = 1L;

@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;

private String account;

private String name;

private String nickname;

private String password;

private String introduction;

private Integer sex;

private String telephone;

private String email;

private String avatarUrl;

private String backgroundUrl;

private Integer pushMethod;

@TableLogic
private Integer isDeleted;

private Long orgId;

private String orgName;

private String orgPath;

@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;

private String def1;

private String def2;

private String def3;

private String def4;

private String def5;

private List<Long> roles;
}

自定义token用户类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OAuthUser implements UserDetails {
private Long id;
private String username;
private String password;
private Boolean enabled;
private String clientId;
private User user;
private Collection<SimpleGrantedAuthority> authorities;

public OAuthUser(User user) {
this.user = user;
this.setId(user.getId());
this.setUsername(user.getAccount());
this.setPassword(user.getPassword());
this.setEnabled(0 == user.getIsDeleted());
if (CollectionUtil.isNotEmpty(user.getRoles())) {
authorities = new ArrayList<>();
user.getRoles().forEach(roleId -> authorities.add(new SimpleGrantedAuthority(String.valueOf(roleId))));
}
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}

@Override
public String getPassword() {
return password;
}

@Override
public String getUsername() {
return username;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return enabled;
}
}

自定义UserDetailService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Service
@AllArgsConstructor
@Slf4j
public class UserDetailServiceImpl implements UserDetailsService {

private SystemFeign systemFeign;

private HttpServletRequest request;

@Override
public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
String clientId = request.getParameter(AuthConstants.JWT_CLIENT_ID_KEY);
OAuthUser oAuthUser = null;
switch (clientId) {
// 后台用户
case AuthConstants.ADMIN_CLIENT_ID:
ResultDto<User> res = systemFeign.getUserInfo(account);
if (!res.isSuccess()) {
throw new UsernameNotFoundException(ErrorCodeEnum.USER_ERROR_A0201.getDescription());
}
User user = res.getData();
oAuthUser = new OAuthUser(user);
oAuthUser.setClientId(clientId);
break;
default:
}
if (!oAuthUser.isEnabled()) {
throw new DisabledException("该账户已被禁用!");
} else if (!oAuthUser.isAccountNonLocked()) {
throw new LockedException("该账号已被锁定!");
} else if (!oAuthUser.isAccountNonExpired()) {
throw new AccountExpiredException("该账号已过期!");
}
return oAuthUser;
}
}

重写之后的这个方法会通过传入的username作为参数调用其他微服务的方法,最后将获取到的加密密码和传入的密码加密后比较。相同则调用接下来的方法,不同会抛出密码错误异常,而之前所写的异常处理拦截器会拦截异常将密码错误返回给前端。

自定义返回信息

1
2
3
4
5
6
7
8
9
10
11
@Data
@Builder
public class Oauth2Token {

private String token;

private String refreshToken;

private int expiresIn;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@ApiOperation("获取token")
@ApiImplicitParams({
@ApiImplicitParam(name = "grant_type", defaultValue = "password", value = "授权模式", required = true),
@ApiImplicitParam(name = "client_id", defaultValue = "shimingWeb", value = "Oauth2客户端ID", required = true),
@ApiImplicitParam(name = "client_secret", defaultValue = "shimingSecret", value = "Oauth2客户端秘钥", required =
true),
@ApiImplicitParam(name = "refresh_token", value = "刷新token"),
@ApiImplicitParam(name = "username", defaultValue = "17748725080", value = "登录账户"),
@ApiImplicitParam(name = "password", defaultValue = "123456", value = "登录密码"),

//@ApiImplicitParam(name = "code", value = "小程序code"),
//@ApiImplicitParam(name = "encryptedData", value = "包括敏感数据在内的完整用户信息的加密数据"),
//@ApiImplicitParam(name = "iv", value = "加密算法的初始向量"),
})
@PostMapping("token")
public ResultDto<?> postAccessToken(
Principal principal,
@RequestParam Map<String, String> parameters
) throws HttpRequestMethodNotSupportedException {
OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(principal, parameters).getBody();
assert oAuth2AccessToken != null;
Oauth2Token oauth2Token = Oauth2Token.builder()
.token(oAuth2AccessToken.getValue())
.refreshToken(oAuth2AccessToken.getRefreshToken().getValue())
.expiresIn(oAuth2AccessToken.getExpiresIn())
.build();
return ResultDto.success(oauth2Token);
}

注销

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@ApiOperation("注销")
@GetMapping("/logout")
public ResultDto<?> logout(HttpServletRequest request) {
String payload = request.getHeader(AuthConstants.JWT_PAYLOAD_KEY);
JSONObject jsonObject = JSONUtil.parseObj(payload);
// JWT唯一标识
String jti = jsonObject.getStr("jti");
// JWT过期时间戳(单位:秒)
long exp = jsonObject.getLong("exp");

long currentTimeSeconds = System.currentTimeMillis() / 1000;
// token已过期
if (exp < currentTimeSeconds) {
return ResultDto.error(ErrorCodeEnum.USER_ERROR_A0311);
}
RedisUtil.set(AuthConstants.TOKEN_BLACKLIST_PREFIX + jti, null, (exp - currentTimeSeconds),
TimeUnit.SECONDS);
return ResultDto.success();
}

如上,oauth2的认证服务器就完成了。

原文链接

Spring Cloud实战 | 第六篇:Spring Cloud Gateway + Spring Security OAuth2 + JWT实现微服务统一认证鉴权