A place to hold mainly reading notes, and some technical stuff occasionally. 这里主要是一些读书笔记、感悟;还有部分技术相关的内容。
目录[-]
本系列教程,是作为团队内部的培训资料准备的。主要以实验的方式来体验 SpringSecurity
的各项Feature。
会话管理,是一个比较大的话题,大家熟知的Cookie-Session
模式就忽略掉,今天重点介绍无状态会话:基于令牌的JWT
(JSON Web Token),适用于微服务架构的会话管理方式;后续会涉及到Session
共享、OAuth2.0
等关于分布式集群的会话管理。
关于JWT的介绍,网上资源有很多,可参考:https://jwt.io/introduction,简单来说,JWT由三部分构成:Header、Payload、Signature,三者之间以点号. 分隔,前面两部分使用Base64编码(关于Base64编码的更多信息),具体构成如下:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
有状态服务,即服务端记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行相应的处理,HTTP
本身是无状态的,短连接,因此便有了我们传统的Cookie-Session
模式,这在单体架构中广泛使用。用户完成登录后,与用户的会话信息被保存在服务端的Session
中,然后服务端响应一个SessionID
给前端,前端将这个SessionID
存储在Cookie
中,后续请求携带Cookie
信息继续发起请求,后端再查询其对应的会话信息,完成请求响应。
这种方式在微服务架构下会带来一些问题:
明白了有状态服务,无状态服务就好理解了,无状态服务在实际比较常见的实现是采用基于令牌的方式,即:
使用JWT进行认证处理具有以下优点:
以下几点是在实施JWT安全方案时需要仔细考虑的问题:
老规矩,依然采用实验的方式来进行测试,不过这次先看效果:
新建一个 SpringBoot
项目,起名 springboot-security-jwt
,核心依赖为 Web
, SpringSecurity
及 jjwt
:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.7.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
创建资源接口: /user/add
, /user/query
,以及默认的home路径 /
,用以展示登录用户信息,若未登录,则展示匿名用户信息。
@RestController
@Slf4j
public class HelloController {
@GetMapping(value = "/user/add")
public String accessResource1() {
return " Access Resource 1: Add User";
}
@GetMapping(value = "/user/query")
public String accessResource2() {
return " Access Resource 2: Query User";
}
@GetMapping(value = "/")
public String index() {
log.info(SecurityContextHolder.getContext().getAuthentication().toString());
return "Welcome " + SecurityContextHolder.getContext().getAuthentication();
}
}
安全配置类:
@Configuration
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtAuthenticationEntryPoint authenticationErrorHandler;
public SecurityConfig(JwtAccessDeniedHandler jwtAccessDeniedHandler, JwtAuthenticationEntryPoint authenticationErrorHandler) {
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
this.authenticationErrorHandler = authenticationErrorHandler;
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// There is no PasswordEncoder mapped for the id "null"
PasswordEncoder encoder = passwordEncoder();
String yourPassword = "123";
log.info("Encoded password: " + encoder.encode(yourPassword));
// Config account info and permissions
auth.inMemoryAuthentication()
.withUser("dev").password(encoder.encode(yourPassword)).roles("dev", "test")
.and()
.withUser("test").password(encoder.encode(yourPassword)).authorities("ROLE_test");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/user/add").hasRole("dev")
.antMatchers("/user/query").hasAuthority("ROLE_test")
.antMatchers("/user/**").authenticated()
.anyRequest().permitAll() // Let other request pass
.and()
.csrf().disable() // turn off csrf, or will be 403 forbidden
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // stateless
.and()
.formLogin()
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException {
log.info("Login Successfully");
SecurityContextHolder.getContext().setAuthentication(authentication);
String token = JwtUtil.createToken(authentication);
httpServletResponse.getWriter().write(token);
}
})
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
log.info("Login Error");
httpServletResponse.getWriter().write(e.getLocalizedMessage());
}
})
.and()
.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(authenticationErrorHandler)
.accessDeniedHandler(jwtAccessDeniedHandler);
}
}
JWT过滤器类: JwtAuthenticationFilter
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 从头信息中提取token
String token = JwtUtil.resolveToken(request);
if (token != null) {
// 通过JWT工具方法解析token
Authentication authentication = JwtUtil.getAuthentication(token);
// 将认证信息设置到上下文中,注意无状态(stateless)的设置!
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
未认证、未授权拦截类: JwtAuthenticationEntryPoint
, JwtAccessDeniedHandler
@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException {
// 当用户尝试访问安全的REST资源而不提供任何凭据时,将调用此方法发送401 响应
log.info("UNAUTHORIZED");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
}
}
@Component
@Slf4j
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException {
// 当用户在没有授权的情况下访问受保护的REST资源时,将调用此方法发送403 Forbidden响应
log.info("FORBIDDEN");
response.sendError(HttpServletResponse.SC_FORBIDDEN, e.getMessage());
}
}
JWT工具类与常量类: JwtUtil
, JwtConstant
public class JwtUtil {
/**
* 生成 jwt token
*
* @param authentication
* @return
*/
public static String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + JwtConstant.VALIDITY_SECONDS * 1000);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(JwtConstant.AUTH_KEY, authorities)
.signWith(SignatureAlgorithm.HS512, JwtConstant.SECRET)
.setExpiration(validity)
.compact();
}
/**
* 解密 jwt token
*
* @param token
* @return
*/
public static Authentication getAuthentication(String token) {
Claims claims = Jwts.parser()
.setSigningKey(JwtConstant.SECRET)
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(JwtConstant.AUTH_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
/**
* 从请求头信息中解析出token
*
* @param request
* @return
*/
public static String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(JwtConstant.HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtConstant.TOKEN_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
public final class JwtConstant {
/**
* 对称加密密钥
* 仅服务端存储,生产中建议使用复杂度高的密钥或采用非对称加密eg:RSA
*/
public static final String SECRET = "heartsuit";
/**
* Token有效期
*/
public static final long VALIDITY_SECONDS = 60 * 60 * 12; // default 12 hours
/**
* 权限
*/
public static final String AUTH_KEY = "auth";
/**
* 头信息中Token的Key
*/
public static final String HEADER = "authorization";
/**
* Token前缀
*/
public static final String TOKEN_PREFIX = "Bearer ";
private JwtConstant() {
}
}
formLogin,为了方便演示,这里有点取巧,其实无法通过浏览器表单登录了,而且当今的实际项目一般都是前后端分离的。这里在登录成功后生成JWT直接放到了 formLogin
的 successHandler
中,仅供演示。
内存方式,用户信息(用户名、密码、权限)使用简单的内存型存储;实际生产应使用数据库,方便扩展;
权限硬编码,在安全配置中,权限拦截通过手动编写;实际生产应从数据库中查询出来后动态配置;
配置常量,关于JWT的配置信息,使用了一个常量类;实际中可写到配置文件(或配置中心),通过 SpringBoot
的配置属性进行读取;
对称加密,这里采用的是对称加密方式对JWT进行签名与验签;实际生产中建议使用非对称加密算法eg: RSA
等实现;
异常拦截,关于JWT过期、格式错误的异常都是JWT依赖包自动抛出的异常,实际生产中应进行拦截并进一步封装,优化接口调用体验;
如果上述每一点占10%,那么距离上生产还有一多半的工作量要完成,因此,前面的实验或代码权当是纸上谈兵,仅用于演示,意思到了就足够了。。
.csrf().disable()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
If you have any questions or any bugs are found, please feel free to contact me.
Your comments and suggestions are welcome!