Spring Security 基于 JWT Token 认证
在开始这篇文章之前,我们似乎应该思考下为什么需要搞清楚 Spring Security
的内部工作原理?
按照第二篇文章中的配置,一个简单的表单认证不就达成了吗?
更有甚者,为什么我们不自己写一个表单认证,用过滤器即可完成,大费周章引入Spring Security
,看起来也并没有方便多少。
对的,在引入 Spring Security
之前,我们得首先想到,是什么需求让我们引入了 Spring Security
,以及为什么是 Spring Security
,而不是 shiro
等等其他安全框架。我的理解是有如下几点:
- 在前文的介绍中,
Spring Security
支持防止csrf
攻击,session-fixation protection
,支持表单认证,basic
认证,rememberMe
…等等一些特性,有很多是开箱即用的功能,而大多特性都可以通过配置灵活的变更,这是它的强大之处。 Spring Security
的兄弟的项目Spring Security SSO,OAuth2
等支持了多种协议,而这些都是基于Spring Security
的,方便了项目的扩展。SpringBoot
的支持,更加保证了Spring Security
的开箱即用。- 为什么需要理解其内部工作原理?一个有自我追求的程序员都不会满足于浅尝辄止,如果一个开源技术在我们的日常工作中十分常用,那么我偏向于阅读其源码,这样可以让我们即使排查不期而至的问题,也方便日后需求扩展。
Spring
及其子项目的官方文档是我见过的最良心的文档!相比较于Apache
的部分文档
这一节,为了对之前分析的 Spring Security
源码和组件有一个清晰的认识,我实现一个 Restful JWT Token
的认证。
定义需求
在表单登录中,一般使用数据库中配置的用户表,权限表,角色表,权限组表…这取决于你的权限粒度,但本质都是借助了一个持久化存储,维护了用户的角色权限,而后给出一个 /login
作为登录端点,Restful
接口提交用户名和密码,验证用户名密码后返回一个 accessToken
,和Refresh Token
,在一定时间内可以使用 Refresh Token
获取新的 accessToken
。
在我们的登录 demo
中,也是类似的,我们把用户的信息,用户名、密码、权限存储在内存中(ConcurrentHashMap
)来模拟数据库存储。
设计概述
整个认证流程如下图:
整个 Spring Security
都是围绕以上这张架构图展开的,最顶层为最核心最抽象的 AuthenticationManager
接口, ProviderManager
为 AuthenticationManager
的一个具体实现,功能如其名字他的作用是管理 Provider
的, AuthenticationProvider
才是真正认证的接口,因此我们在实践中要实现我们自己的认证方式,也就是 AuthenticationProvider
的一个具体实现。当然可以实现多个,如果有多种认证方式,现实中往往也是有多种认证方式。
在此 Demo
中一共涉及两个 Filter
,LoginProcessingFilter
处理登录请求获取Access Token 和 Refresh Token
的过滤器。ApiAuthenticationProcessingFilter
认证 Token 是否有效以及用户是否有资源访问权限的过滤器。
这两个 Filter
的都分别对应自己的 Token 、Provider
实现类,整个逻辑和上图如出一辙。
简单做一个类比:
LoginProcessingFilter –> UsernamePasswordAuthenticationFilter
JwtUsernamePasswordAuthenticationToken –> UsernamePasswordAuthenticationToken
ProviderManager –> ProviderManager
JwtAuthenticationProvider –> DaoAuthenticationProvider
MemoryUserService –> UserDetailsService
JwtUsernamePasswordAuthenticationToken
主要用来存储认证用户名、密码、Refresh Token
,这里扩展了 details
字段来存储 Refresh Token
/**
* @author Created by https://zhangaoo.com.<br/>
* @version Version: 0.0.1
* @date DateTime: 2019/06/22 16:06:00<br/>
*/
public class JwtUsernamePasswordAuthenticationToken extends UsernamePasswordAuthenticationToken {
/**
* 用来传递刷新 token
*/
private Object details;
public JwtUsernamePasswordAuthenticationToken(Object principal, Object credentials,Object details) {
super(principal, credentials);
//for password refresh token
this.details = details;
}
public JwtUsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(principal, credentials, authorities);
}
@Override
public Object getDetails(){
return this.details;
}
}
LoginProcessingFilter
此 Filter
主要是对登录用户信息或 Refresh Token
的简单 check
,具体检查在对应的 Provider
中
/**
* @author Created by https://zhangaoo.com.<br/>
* @version Version: 0.0.1
* @date DateTime: 2019/06/22 16:02:00<br/>
*/
public class LoginProcessingFilter extends AbstractAuthenticationProcessingFilter {
private final ObjectMapper objectMapper;
private final AuthenticationFailureHandler failureHandler;
private final AuthenticationSuccessHandler successHandler;
public LoginProcessingFilter(String defaultFilterProcessesUrl,
ObjectMapper objectMapper,
AuthenticationSuccessHandler successHandler,
AuthenticationFailureHandler failureHandler) {
super(defaultFilterProcessesUrl);
this.objectMapper = objectMapper;
this.failureHandler = failureHandler;
this.successHandler = successHandler;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
LoginRequest loginRequest;
try {
loginRequest = objectMapper.readValue(request.getReader(), LoginRequest.class);
} catch (Exception e){
throw new BadCredentialsException("Username or Password or refresh_token is invalid");
}
//对用户名密码或刷新 token 做初步的认证
if(!checkUserInfo(loginRequest)){
throw new BadCredentialsException("Username or Password or refresh_token is invalid");
}
JwtUsernamePasswordAuthenticationToken token = new JwtUsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword(),
loginRequest.getRefreshToken());
return this.getAuthenticationManager().authenticate(token);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
successHandler.onAuthenticationSuccess(request,response,authResult);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed)
throws IOException, ServletException {
SecurityContextHolder.clearContext();
failureHandler.onAuthenticationFailure(request, response, failed);
}
private boolean checkUserInfo(LoginRequest loginRequest){
//check refresh token first
if(StringUtils.isNotBlank(loginRequest.getRefreshToken())){
return true;
} else {
//check username or password
if (StringUtils.isBlank(loginRequest.getUsername()) || StringUtils.isBlank(loginRequest.getPassword())) {
return false;
} else {
return true;
}
}
}
}
defaultFilterProcessesUrl
:用来传递登录接口地址,改过滤器只处理登录和刷新 Token
这两个操作,这里的登录接口的地址是 /api/tokens
。
AuthenticationSuccessHandler
:认证成功的自定义处理接口,具体的操作就是颁发 Token
AuthenticationFailureHandler
:认证失败的处理方法,返回友好的提示信息给调用者,具体实现可参考代码。
JwtAuthenticationProvider
登录具体的认证,用户密码检查,用户状态检查或 Refresh Token
检查.
这里注意返回的是也是 Authentication
Token
自定义实现类,包括 LoginProcessingFilter
也是,注意两个地方返回是调用的是是两个不同的方法 LoginProcessingFilter
返回是认证还没结束父类方法中会执行 setAuthenticated(false);
,等JwtAuthenticationProvider
认证结束后才是完整的认证结束,其父类方法中会调用 super.setAuthenticated(true);
,标识已经认证通过了。
/**
* @author Created by https://zhangaoo.com.<br/>
* @version Version: 0.0.1
* @date DateTime: 2019/06/22 16:23:00<br/>
*/
@Slf4j
@Component
public class JwtAuthenticationProvider implements AuthenticationProvider {
@Autowired
private JwtTokenHelper tokenHelper;
@Autowired
private MemoryUserService memoryUserService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.notNull(authentication, "No authentication data provided");
//username and password
String username = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
String refreshToken = (String) authentication.getDetails();
//当本次请求是刷新 Token 时
if (StringUtils.isNotBlank(refreshToken)) {
// decode and verify refresh token is valid
Jws<Claims> jwsClaims = tokenHelper.parseClaims(refreshToken);
//check token type only access_token
String tokenType = jwsClaims.getBody().get("type").toString();
if (!REFRESH_TOKEN.equals(tokenType)) {
log.warn("Invalid token type,must be a refresh token,token:" + refreshToken + ",type:" + tokenType);
throw new InvalidJwtToken("Invalid token type,must be a refresh token");
}
UserContext userContext = UserContext.create(jwsClaims.getBody().get("userId", String.class),
jwsClaims.getBody().getSubject(), Collections.emptyList(),
Constant.REFRESH_TOKEN);
return new JwtUsernamePasswordAuthenticationToken(userContext, null, Collections.emptyList());
} else {
//本次请求是获取 Access Token 和 Refresh Token
MyUserDetails userDetails = memoryUserService.loadUserByUsername(username);
//Check password
if (!userDetails.getPassword().equals(password)) {
throw new BadCredentialsException("Authentication Failed. Invalid username or password.");
}
//检查用户状态
if (!userDetails.isAccountNonExpired() || !userDetails.isAccountNonLocked() || !userDetails.isCredentialsNonExpired() || !userDetails.isEnabled()) {
log.warn("User was deleted please contact administrator: " + userDetails.getUsername());
throw new UserDisabledException("User was disabled please contact administrator");
}
UserContext userContext = UserContext.create(userDetails.getUserId(), userDetails.getUsername(), userDetails.getAuthorities(), Constant.ACCESS_TOKEN);
//认证通过,传递自定义的用户上下文(注意两个构造器的区别)
return new JwtUsernamePasswordAuthenticationToken(userContext, null, Collections.emptyList());
}
}
// 标识该 Provider 只处理 JwtUsernamePasswordAuthenticationToken 一种 Token
@Override
public boolean supports(Class<?> authentication) {
return (JwtUsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
}
配置 RestSecurityConfig
/**
* @author Created by https://zhangaoo.com.<br/>
* @version Version: 0.0.1
* @date DateTime: 2019/06/28 10:01:00<br/>
*/
@Slf4j
@Configuration
@EnableWebSecurity
public class RestSecurityConfig extends WebSecurityConfigurerAdapter {
//认证用户身份的provider(处理登录的provider)
@Autowired
private JwtAuthenticationProvider jwtAuthenticationProvider;
//访问控制,检查 Token 权限的Provider
@Autowired
private ApiAuthenticationProvider apiAuthenticationProvider;
//认证通过的自定义处理方法
@Autowired
private AuthenticationSuccessHandler successHandler;
//认证失败的自定义处理方法
@Autowired
private AuthenticationFailureHandler failureHandler;
//jackson 序列化用的工具类
@Autowired
private ObjectMapper objectMapper;
//Spring Security 默认帮我们实现好的Provider Manager
@Autowired
private AuthenticationManager authenticationManager;
//解析、认证、生成Token的工具类
@Autowired
private JwtTokenHelper jwtTokenHelper;
//获取所有接口权限的服务
@Autowired
private AuthorityService authorityService;
//权限不足访问拒绝处理类
@Autowired
private AjaxAccessDeniedHandler ajaxAccessDeniedHandler;
/**
* 注意不要忘记注入这个 Bean ,否则做具体认证时加载不到实现认证的Provider,比如这里的 jwtAuthenticationProvider
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* Filter for login API
* @param loginEntryPoint
* @return
* @throws Exception
*/
protected LoginProcessingFilter buildLoginProcessingFilter(String loginEntryPoint) throws Exception {
LoginProcessingFilter filter = new LoginProcessingFilter(loginEntryPoint, objectMapper,successHandler, failureHandler );
filter.setAuthenticationManager(this.authenticationManager);
return filter;
}
/**
* Filter for resource request API
* @param pathsToSkip
* @param pattern
* @return
* @throws Exception
*/
protected ApiAuthenticationProcessingFilter buildApiAuthenticationProcessingFilter(List<String> pathsToSkip, List<String> pattern) throws Exception {
SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, pattern);
ApiAuthenticationProcessingFilter filter
= new ApiAuthenticationProcessingFilter(failureHandler, matcher, jwtTokenHelper);
filter.setAuthenticationManager(this.authenticationManager);
return filter;
}
//注入我们自己实现的 Provider
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(jwtAuthenticationProvider);
auth.authenticationProvider(apiAuthenticationProvider);
}
//核心配置
@Override
protected void configure(HttpSecurity http) throws Exception {
List<String> permitAllEndpointList = Arrays.asList(AUTHENTICATION_URL);
//模拟从数据库获取接口的访问权限,并初始化
List<ApiAuthority> authorities = authorityService.getAllAuthority();
for(ApiAuthority rule : authorities){
http
.authorizeRequests()
.antMatchers(HttpMethod.resolve(rule.getMethod()), rule.getApiUrl()).hasAuthority(rule.getAuthority());
}
http
.csrf().disable()
.exceptionHandling().accessDeniedHandler(ajaxAccessDeniedHandler)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)//使用 JWT Token 接口无状态
.and()
.authorizeRequests()
.antMatchers(permitAllEndpointList.toArray(new String[permitAllEndpointList.size()])).permitAll()//登录接口不需要认证
.and()
.authorizeRequests()
.antMatchers(API_ROOT_URL).authenticated()//除了登录接口,所有满足/api/**都需要认证
.and()
.addFilterBefore(new CustomCorsFilter(), UsernamePasswordAuthenticationFilter.class)
//注册 LoginProcessingFilter 注意放置的顺序 这很关键
.addFilterBefore(buildLoginProcessingFilter(AUTHENTICATION_URL), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildApiAuthenticationProcessingFilter(permitAllEndpointList,Arrays.asList(API_ROOT_URL)), UsernamePasswordAuthenticationFilter.class);
}
}
WebSecurityConfigAdapter
提供了我们很大的便利,不需要关注 AuthenticationManager
什么时候被创建,只需要使用其暴露的configure(AuthenticationManagerBuilder auth)
便可以添加我们自定义的 Provider
。剩下的一些细节,注释中基本都写了出来。
运行效果
获取 Access Token 和 Refresh Token
➜ ~ curl -H 'Content-Type: application/json' -X POST -d '{"username": "admin","password":"admin123456"}' http://127.0.0.1:8808/api/tokens
{"code":200,"data":{"access_token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsInNjb3BlcyI6W10sInVzZXJJZCI6IjEiLCJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiaXNzIjoiemhhbmdhb28uY29tIiwiaWF0IjoxNTYxOTA0MTM2LCJleHAiOjE1NjE5MDc3MzZ9.OLD1IcMG4pLRzW4uQtDs4TCF18lsBSB7-BJAbEaZStOeFMxGHSfyG2D3G960R7Qn0i7SKq8ryxc-FrqiwOMsVg","refresh_token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsInVzZXJJZCI6IjEiLCJ0eXBlIjoicmVmcmVzaF90b2tlbiIsImlzcyI6InpoYW5nYW9vLmNvbSIsImp0aSI6IjliMDBiNzAwLTRmNDYtNDczZS04ODk3LTBkY2U4YzE5ZDRkYiIsImlhdCI6MTU2MTkwNDEzNiwiZXhwIjoxNTYxOTMyOTM2fQ.WRoQkZE8-bP6ENUCKqbUTO5DNo0gu7z7J-EnLqC9cDjgh6EfopOx0wM0QDCp7iU_B1186tfAXYw-tczdbHEFDw"}}%
刷新 Token
➜ ~ curl -H 'Content-Type: application/json' -X POST -d '{"refresh_token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsInVzZXJJZCI6IjEiLCJ0eXBlIjoicmVmcmVzaF90b2tlbiIsImlzcyI6InpoYW5nYW9vLmNvbSIsImp0aSI6IjliMDBiNzAwLTRmNDYtNDczZS04ODk3LTBkY2U4YzE5ZDRkYiIsImlhdCI6MTU2MTkwNDEzNiwiZXhwIjoxNTYxOTMyOTM2fQ.WRoQkZE8-bP6ENUCKqbUTO5DNo0gu7z7J-EnLqC9cDjgh6EfopOx0wM0QDCp7iU_B1186tfAXYw-tczdbHEFDw"}' http://127.0.0.1:8808/api/tokens
{"code":200,"data":{"access_token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsInNjb3BlcyI6W10sInVzZXJJZCI6IjEiLCJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiaXNzIjoiemhhbmdhb28uY29tIiwiaWF0IjoxNTYxOTA0MzE4LCJleHAiOjE1NjE5MDc5MTh9.TJ-0SrIbiVW81nA9Ep91Wq1GEGVhMqFIq9ppi9ZRiYkbk88W1DG84YhA1m9KIvWgOmb6tYxPVIvLsM_9QSH06Q"}}%
接口权限测试
测试代码在内存中写死了两个用户,admin/admin123456
,testUser/testUser123456
,admin
同时具有 admin
和 user
两个权限,testUser
只有 user
权限。
访问 /api/user/all
和 /api/user/admin
需要 admin
权限,而访问 /api/user/testUser
只需要 user
权限。
正常测试
admin
但访问/api/user/all
➜ ~ curl -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsInNjb3BlcyI6W10sInVzZXJJZCI6IjEiLCJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiaXNzIjoiemhhbmdhb28uY29tIiwiaWF0IjoxNTYxOTA0MzE4LCJleHAiOjE1NjE5MDc5MTh9.TJ-0SrIbiVW81nA9Ep91Wq1GEGVhMqFIq9ppi9ZRiYkbk88W1DG84YhA1m9KIvWgOmb6tYxPVIvLsM_9QSH06Q' http://127.0.0.1:8808/api/user/all
{"code":200,"data":{"admin":{"id":"1","username":"admin","password":"admin123456","accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true},"testUser":{"id":"2","username":"testUser","password":"testUser123456","accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true}}}%
testUser
访问/api/user/testUser
➜ ~ curl -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0VXNlciIsInNjb3BlcyI6W10sInVzZXJJZCI6IjIiLCJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiaXNzIjoiemhhbmdhb28uY29tIiwiaWF0IjoxNTYxOTA0NzU3LCJleHAiOjE1NjE5MDgzNTd9.BIFKr7yznjVClWeiamiB8WfkYk3iVoGDEM0f90Hy7idrTOJ8udBkTLtAIPrnMgg1w_Hk_60EEPx00lNXDb337Q' http://127.0.0.1:8808/api/user/testUser
{"code":200,"data":{"id":"2","username":"testUser","password":"testUser123456","accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true}}%
拒绝访问测试
curl -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0VXNlciIsInNjb3BlcyI6W10sInVzZXJJZCI6IjIiLCJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiaXNzIjoiemhhbmdhb28uY29tIiwiaWF0IjoxNTYxOTA0NzU3LCJleHAiOjE1NjE5MDgzNTd9.BIFKr7yznjVClWeiamiB8WfkYk3iVoGDEM0f90Hy7idrTOJ8udBkTLtAIPrnMgg1w_Hk_60EEPx00lNXDb337Q' http://127.0.0.1:8808/api/user/all
{
"code": 5000,
"data": "Access is denied"
}
如果要自定义拒绝访问返回信息,一个例子如下:
@Slf4j
@Component
public class AjaxAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper mapper;
@Autowired
public AjaxAccessDeniedHandler(ObjectMapper mapper) {
this.mapper = mapper;
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
mapper.writeValue(response.getWriter(), new ResponseData(ResponseData.FAIL, AuthMessages.AUTH_009));
}
}
以上代码完整的实现了一个Spring Security
整合 JWT Token
例子,上述 Demo
未详细介绍的 Spring Cloud
相关的技术还包括 zuul 网关 Feign Client 、Eureka等,后续会继续介绍。
源代码参考 GitHub
本文由 zealzhangz 创作,采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
最后编辑时间为:
2019/06/30 22:49