上一篇::SpringSecurity学习二 SpringSecurity部署数据库
目录
SpringSecurity 结合jwt
前面文章搭配数据库实现了SpringSecurity的基本使用,默认使用的还是cookie/session,现在改为jwt认证。实际中根据需求不一定用到jwt,但是学习改造成jwt很能加深对SpringSecurity的理解。
项目源码gitte地址: https://gitee.com/xiang_Gitee/spring-security-learn(子工程token)
JWT
简单介绍
- JWT是Json web token的缩写,Json Web Token, 是一种JSON风格的轻量级的授权和身份认证规范,可实现无状态、分布式的Web应用授权。
- 简单来讲就是用户登录成功之后,服务器根据自己设定的密钥和jwt的规则生成一个凭证,并把这个凭证返回给用户,用户再访问时候附带上设个凭证,服务器根据自己的密钥和jwt规则解析这个凭证,判断凭证是否通过。
和传统验证的最大区别就在于服务器不需要存储任何数据(除了密钥),单纯通过字符串的编码、解码来进行认证,所以JWT是“无状态的”。
JWT数据格式
JWT包含三部分数据:
-
Header:头部,通常头部有两部分信息:
声明类型,声明这是JWT类型 算法,使用的算法名称,用于生成第三部分签名
对头部用Base64Url编码,得到第一部分数据
-
Payload:载荷,就是有效数据,在官方文档中(RFC7519),这里给了7个示例信息:
iss (issuer):表示签发人 exp (expiration time):表示token过期时间 sub (subject):主题 aud (audience):受众 nbf (Not Before):生效时间 iat (Issued At):签发时间 jti (JWT ID):编号
对这部分也用Base64Url编码,得到第二部分数据。
-
Signature:签名,是整个数据的认证信息。
一般根据前两步的数据,再加上服务的的密钥secret(密钥保存在服务端,不能泄露给客户端),通过Header中配置的加密算法生成Signature,用这个Signature验证整个数据完整和可靠性。
JWT 缺点
由于采用jwt服务器就不保存会话状态,所以
- 一旦JWT签发,在有效期内将会一直有效,不能取消,所以无法注销一个jwt,虽然可通过服务端修改secret的方法注销,但修改之前的jwt也会由于密钥不一致而全部失效。
- 传统的cookie+session的方案天然的支持续签,但是jwt的状态保存在本身,无法单纯通过后端得以续签。
- 密码更改前的jwt仍然可用,这倒是可以通过将secret设置成密码来解决。
JWT使用
以后端的角度,在用户登录成功的时候生成有效的JWT,在用户访问的时候验证前端附带的JWT是否有效,就这么简单,即 生成jwt、验证jwt。
相关依赖
较之前加入了java-jwt
<!-- jwt依赖 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.16.0</version>
</dependency>
<!-- 基本依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>RELEASE</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<!--数据库-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>
步骤
SpringSecurity的所有控制都是用过spring的Filter链实现的,也就是说所有配置主要就是通过在Filter链上加减替换Filter。
应用jwt所要进行的具体操作是:
- 禁用session。jwt是无状态的登录,依靠jwt本身携带的信息判断登录情况,这里先禁掉session排除干扰。
- 拦截登录请求,自定义实现登录过程,产生jwt返回给用户。
- 拦截所有请求,自定义验证过程,主要是检查请求附带的jwt。
按过滤器链的顺序来说,第3步拦截是在第2步拦截之前。
这里主要是按登录顺序来讲。
SecurityConfig 配置
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserService userService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf()//禁用跨域保护策略,默认开启
.disable()
.authorizeRequests()//认证需求路径
.antMatchers("/", "/home")
.permitAll()
.anyRequest()
.authenticated();
//SpringSecurity中的Session管理有几种状态,默认`ifRequired`,表示有需要就创建Session。‘STATELESS’表示从不创建和使用Session。
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
//在UsernamePasswordAuthenticationFilter这个过滤器之前添加自定义的认证拦截器TokenFilter,这个拦截器是自定义的
http.addFilterBefore(new TokenFilter(),UsernamePasswordAuthenticationFilter.class);
//用自定义的loginFilter代替UsernamePasswordAuthenticationFilter,源码注释说addFilterAt这个操作不会覆盖原来的过滤器,但实际似乎会?
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
@Bean
LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setFilterProcessesUrl("/dosignin");
loginFilter.setAuthenticationManager(authenticationManagerBean());
return loginFilter;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//用自定义的userDetailsService,并设置一个无加密的加密器
auth.userDetailsService(userService)
.passwordEncoder(NoOpPasswordEncoder.getInstance());
}
}
这里已经完成了第一步,禁用session。
需要注意的是在自定义登录认证之后SecurityConfig 里原来配置的loginProcessingUrl、failureUrl、defaultSuccessUrl就失效了,上面贴的SecurityConfig 也可以看到这几句配置已经删去。登录成功/失败的处理配置定义在了LoginFilter 过滤器中。
以及logout登出配置,使用jwt之后这个配置也没有意义了。
第二第三步的配置也紧跟其后,但是第二第三步的TokenFilter和LoginFilter 还没实现,下面贴出。
过滤器
先准备一个Jwt的工具类
这里是简单配置的,实际使用按自己的需要设置私钥、过期时间和改变生成逻辑等。
public class JwtUtils {
// 过期时间5分钟
private static final long EXPIRE_TIME = 5 * 60 * 1000;
// 私钥
public static final String SECRET = "SECRET_VALUE";
// 请求头
public static final String AUTH_HEADER = "authToken";
/**
* 验证token是否正确
*/
public static DecodedJWT verify(String token) throws JWTVerificationException {
Algorithm algorithm = Algorithm.HMAC256(SECRET);
JWTVerifier verifier = JWT.require(algorithm).build();
DecodedJWT jwt = verifier.verify(token);
return jwt;
}
/**
* 获得token中的自定义信息,无需secret解密也能获得
*/
public static String getClaimFiled(String token, String filed) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim(filed).asString();
} catch (JWTDecodeException e) {
return null;
}
}
/**
* 生成jwt
*/
public static String createToken(String username) {
try {
//过期时间
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(SECRET);
// 附带username,nickname信息
return JWT.create().withClaim("username", username).withJWTId(UUID.randomUUID().toString()).withExpiresAt(date).sign(algorithm);
} catch (JWTCreationException e) {
return null;
}
}
/**
* 获取 token的签发时间
*/
public static Date getIssuedAt(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getIssuedAt();
} catch (JWTDecodeException e) {
return null;
}
}
/**
* 验证 token是否过期
*/
public static boolean isTokenExpired(String token) {
Date now = Calendar.getInstance().getTime();
DecodedJWT jwt = JWT.decode(token);
return jwt.getExpiresAt().before(now);
}
/**
* 刷新 token的过期时间
*/
public static String refreshTokenExpired(String token, String secret) {
DecodedJWT jwt = JWT.decode(token);
Map<String, Claim> claims = jwt.getClaims();
try {
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTCreator.Builder builer = JWT.create().withExpiresAt(date);
for (Map.Entry<String, Claim> entry : claims.entrySet()) {
builer.withClaim(entry.getKey(), entry.getValue().asString());
}
return builer.sign(algorithm);
} catch (JWTCreationException e) {
return null;
}
}
/**
* 生成随机盐
*/
public static String generateSalt() {
String uuid = UUID.randomUUID().toString();
//String hex = uuid.nextBytes(16).toHex();
return uuid;
}
}
LoginFilter 登录过滤
这个过滤器用以拦截登录url,处理验证逻辑并返回一个有效的jwt。在前两篇文章中都没有主动去配置登录过滤器,是因为SpringSecurity过滤链上已经有一个默认的登录过滤器了,名称为UsernamePasswordAuthenticationFilter。
现在我们要继承这个UsernamePasswordAuthenticationFilter得到一个自定义的Filter,并且用来代替过滤链上的UsernamePasswordAuthenticationFilter,这样就可以实现我们想要的登录逻辑了。
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Resource
private UserMapper userMapper;
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException {
//User user = new ObjectMapper().readValue(httpServletRequest.getInputStream(), User.class);
User user = new User();
user.setUsername(httpServletRequest.getParameter("username"));
user.setPassword(httpServletRequest.getParameter("password"));
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authenticate = getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken);
return authenticate;
}
//登录成功
@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse resp, FilterChain chain, Authentication authResult) throws IOException, ServletException {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
User hr = (User) authResult.getPrincipal();
hr.setPassword(null);
out.write(hr.getUsername()+"登录成功!");
resp.setHeader("authToken",JwtUtils.createToken(hr.getUsername()));
out.flush();
out.close();
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse resp, AuthenticationException failed) throws IOException, ServletException {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write("登录失败!");
out.flush();
out.close();
}
}
LoginFilter 重写了UsernamePasswordAuthenticationFilter 的三个方法:
-
.attemptAuthentication
核心方法,生成凭证。
看返回值类型是Authentication,翻译过来是“认证”的意思,用来存储认证信息,作用类似于shiro中的AuthorizationInfo。
从登录参数中提取出用户名密码封装在Authentication中(演示代码用实现类UsernamePasswordAuthenticationToken ),然后调用AuthenticationManager.authenticate()方法进行自动校验。【校验逻辑其实就在前一篇文章中UserService.loadUserByUsername(String username)实现 -
.successfulAuthentication
这个方法用来处理登录成功的情况,如果attemptAuthentication的校验成功,就会调用该方法,这里就使用JwtUtils生成了一个有效的jwt放在头部返回。前端在登录成功之后的请求就需要注意带上authToken这个头部了,另一个过滤器提取jwt参数也是用这里定义的参数名。 -
.unsuccessfulAuthentication
顾名思义自然就是认证不成功的处理方法。
TokenFilter 令牌校验过滤器
这个过滤器算是普通的spring Filter了,直接继承GenericFilterBean在doFilter方法中实现jwt的校验逻辑。
public class TokenFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
//1.获取jwt
HttpServletRequest request = (HttpServletRequest) servletRequest;
String authToken = request.getHeader("authToken");
if (authToken==null||authToken.isEmpty()){
filterChain.doFilter(servletRequest,servletResponse);
return;
}
String username;
// 2.验证JWT签名
try{
DecodedJWT jwt = JwtUtils.verify(authToken);
username = jwt.getClaim("username").asString();
}catch (JWTVerificationException e){
PrintWriter writer = servletResponse.getWriter();
writer.write("token认证错误,请重新登录");
writer.flush();
writer.close();
return;
}
// 3.将认证信息封装成AuthenticationToken放进上下文,否则仍会跳转到登录链接
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null,null);
SecurityContextHolder.getContext().setAuthentication(token);
//4.继续执行过滤链
filterChain.doFilter(servletRequest,servletResponse);
}
}
注释3处的代码,生成UsernamePasswordAuthenticationToken的时候一共有三个参数,分别是用户名、密码、权限(角色)信息,由于这里还没涉及权限后两个参数都填的null,有需要的话在登录时将权限信息封装在jwt,这里解析时再取出即可。
到这里需要的配置完毕了,在basic子工程的基础上,只新增/更改了这三个文件。
演示
启动服务器,用postman简单验证效果。
用户名和密码:
-
输入错误的用户名,登录失败
-
输入正确的用户名和密码,登录成功
得到jwt
-
不附带jwt访问接口
-
带上jwt访问,访问成功
-
jwt输错或者过期