携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情
前言
上一篇文章中,我们学习了使用 spring security 做最基本的拦截。因为目前流行的趋势是前后端分离,我们的登录接口也要适应,这一篇教程我们来实现登录 Restful 化和集成 Jwt。
什么是 JWT ?
英文全称 JSON Web Token,用通俗的话讲,就是使用既定规则(秘钥/公钥)生成的一串加密报文,JWT内部可以从这个报文里面解析出登录信息以及过期时间等信息。
- 优点:静态化 token,不用持久化存储;分布式的情况不用考虑 session 会话机制
- 缺点:无法注销,只能配合 redis 等工具进行一个黑名单处理
安装依赖
打开 pom.xml 文件,加入依赖
在 properties 标签中加入:
<jjwt.version>0.11.5</jjwt.version>
在 <dependencies> 标签中加入如下依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
编写 JWT 工具类
使用 jwt 的时候,需要获取一些信息,例如从 jwt 的 token 中获取用户名,根据用户名生成 token 等。我们都可以简单编写一个工具类,用来供应目前或者将来的一些需要权限框架的项目复用。
文件: JwtTokenUtils.java
package com.example.auth.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Date;
import java.util.function.Function;
@Slf4j
@Component
public class JwtTokenUtil {
// 规定 JWT_SECRET 的长度要很长
private static final String JWT_SECRET = "cuifuan@aliyun.com-cuifuan@aliyun.com-cuifuan@aliyun.com-cuifuan@aliyun.com-cuifuan@aliyun.com";
// 过期时间-毫秒计时 默认 7 天
private static final Long JWT_EXPIRATION = 7L * 24 * 60 * 1000;
private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256;
/**
* 根据用户信息生成token
*/
public String generateToken(String username) {
Claims claims = Jwts.claims().setSubject(username);
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(getSignKey())
.compact();
}
/**
* 从token中获取用户名
*/
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
/**
* 判断token是否有效
* 两方面:token是否过期
* token用户名是否和userDetails中用户名一致
*/
public boolean validateToken(String token, UserDetails userDetails) {
final String username = this.extractUsername(token);
if (null == userDetails) {
return false;
}
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
/**
* 判断token是否失效
*/
public boolean isTokenExpired(String token) {
Date expiredDate = this.getExpiredDateFromToken(token);
return expiredDate.before(new Date());
}
/**
* 从token中获取失效时间
*/
public Date getExpiredDateFromToken(String token) {
Claims claims = extractAllClaims(token);
return claims.getExpiration();
}
/**
* 从token中获取负载
*/
private Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSignKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 生成token失效时间
*/
private Date generateExpirationDate() {
return new Date(System.currentTimeMillis() + JWT_EXPIRATION);
}
private static Key getSignKey() {
// 使用我们的 JWT_SECRET 密钥签署我们的 JWT
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(JWT_SECRET);
return new SecretKeySpec(apiKeySecretBytes, SIGNATURE_ALGORITHM.getJcaName());
}
}
改造安全配置类
文件:SecurityConfiguration.java
- 增加 Bean -> AuthenticationManager,用来在等用户登录时,校验用户名与密码
- 剔除 Session 机制,改造为基于 token 机制
- 关闭 CSRF
- 根据文档变动,改动一些代码,保证项目正常运行
- 文档地址:官方变动文档,写的其实是有些乱的,国外网友已经在评论开始抡起了键盘...
package com.example.auth.config;
import com.example.auth.service.AdminService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
/**
* desc: 保护接口组件
* date 2022/7/25
*
* @author 程序员鱼丸
**/
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
private static final String[] IGNORE_API = new String[]{"/user", "/api/login"};
private AdminService userDetailsService;
@Autowired
public void setUserDetailsService(AdminService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authz ->
// 放行一些接口,在 IGNORE_API 中
authz.antMatchers(IGNORE_API).permitAll()
// 除了放行的接口其他全校验
.anyRequest().authenticated()
)
// 使用 Spring Security 提供的默认值启用安全功能
.httpBasic(withDefaults())
// 指定用户业务层,此业务层需要实现 Spring Security 官方的 UserDetailsService 接口
.userDetailsService(userDetailsService)
.authenticationProvider(authenticationProvider())
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 由于使用的是JWT,我们这里不需要csrf
.csrf().disable();
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
// 密码的加密方式
return new BCryptPasswordEncoder();
}
private AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
// 指定密码加密方式
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
/**
* desc: 代替旧版本
*
* @Override
* @Bean public AuthenticationManager authenticationManagerBean() throws Exception {
* return super.authenticationManagerBean();
* }
* <p>
* date 2022/7/26
* @author 程序员鱼丸
**/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
业务层登录逻辑与登录接口
在业务层增加登录的逻辑,代码如下,能注释的基本都加了,没加的网页搜索下具体的,都有的
@Autowired
private AuthenticationManager authenticationManager;
private JwtTokenUtil jwtTokenUtils;
@Autowired
public void setJwtTokenUtils(JwtTokenUtil jwtTokenUtils) {
this.jwtTokenUtils = jwtTokenUtils;
}
public AdminUser adminLogin(AdminUser admin) {
// 生成用户名密码身份验证令牌
UsernamePasswordAuthenticationToken upaToken = new UsernamePasswordAuthenticationToken(admin.getUsername(), admin.getPassword());
// 验证器验证令牌
authenticationManager.authenticate(upaToken);
// 根据用户名获取用户信息
final UserDetails userDetails = this.loadUserByUsername(admin.getUsername());
// 根据用户名生成 token
final String token = jwtTokenUtils.generateToken(userDetails.getUsername());
// 根据用户信息与用户权限在 spring 上下文存储用户的权限信息
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
// 封装返回前端的类信息
AdminUser userInfo = this.getCurrentUserInfo();
userInfo.setToken(token);
// 防止密码返回
userInfo.setPassword(null);
return userInfo;
}
文件: ApiController.java
@Autowired
private AdminService adminService;
@PostMapping("/api/login")
public AdminUser adminLogin(@RequestBody AdminUser admin) {
return adminService.adminLogin(admin);
}
测试登录接口
使用的工具为 Postman ,请求接口地址为:
这个/api/login我在安全配置类中已经进行了放行,因为是登录的接口,不需要鉴权
可以看到返回的 token 中已经生成了我们需要的值,后续请求其他接口所需要的就是这个 token 值
至此我们的自定义登录已经完成了。
总结
- 安全配置类中关闭 csrf 与 session
- 创建 Auth验证令牌关键 Bean
- 放行登录接口
- 引入 jwt 依赖,编写工具类生成 jwt-token
tips: 看下 github 代码,在配置文件增加了允许循环依赖,与实体类中增加了 token 属性