SpringBoot使用Spring Security实现JwtToken验证
导入Spring Security依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
启动项目,控制台会打印 Spring Security 自动生成的密码
![/images/all/image-20230308165824883.png](/images/all/image-20230308165824883.png)
这时访问接口会自动跳转到登录界面,默认用户名为user
, 初始密码为上面的图片中自动生成的密码
![/images/all/image-20230308170119488.png](/images/all/image-20230308170119488.png)
注意: 这是一种传统的基于Session-Cookie
的用户认证机制 !
接入自定义用户表
上面使用的是Spring Security默认的用户和密码,那么如何接入自己创建的用户表呢?
User
类如下:
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "user")
public class User{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "name")
private String name;
private Integer age;
private String email;
private String password;
}
接着创建一个实现了Spring Security
中的UserDetails
接口的实现类,注意修改重写的方法
@Data
@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {
private User user; // 获取用户信息,注意添加构造函数
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword(); // 注意这里用于获取用户密码
}
@Override
public String getUsername() {
return user.getName(); // 注意这里用于获取用户名
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
再创建一个实现了Spring Security
中的UserDetailsService
接口的实现类
@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper; // 导入mapper层或者Dao层
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.getUserByName(username); // 根据用户名获取用户信息,用户名需要唯一
if (user==null) {
throw new RuntimeException("用户不存在");
}
return new UserDetailsImpl(user);
}
}
这样配置完成之后就接入用户表了,但是这时候只能如果使用明文密码登录的话,需要在真正的密码前加上{noop}
用于识别
比如有一个admin
用户,密码为admin
,那么在数据库中password
字段需要存为{noop}admin
![/images/all/image-20230308182354086.png](/images/all/image-20230308182354086.png)
![/images/all/image-20230308182226058.png](/images/all/image-20230308182226058.png)
存储密文密码
前面直接将明文密码直接存入数据库中,这样是很不安全的,一般会将加密后的密文密码存入数据库中。
我们可以创建一个config
文件夹,再创建一个配置文件SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* 注入密码解析器到IOC容器中
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
BCryptPasswordEncoder
类实现了PasswordEncoder
接口,这个接口中定义了三个方法
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
-
encode(rawPassword)
对字符串进行加密的方法 -
matches(rawPassword,encodedPassword)
校验传入的明文密码rawPassword
是否和加密密码encodedPassword
相匹配 -
upgradeEncoding(String encodedPassword)
如果应该再次对编码后的密码进行编码以提高安全性,则返回true,否则返回false。
写一个测试类测试一下加密后的效果
@SpringBootTest
class SecurityConfigTest {
@Test
void passwordEncoder() {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// 明文加密
System.out.println(passwordEncoder.encode("123456"));
System.out.println(passwordEncoder.encode("123456"));
System.out.println(passwordEncoder.encode("123456"));
System.out.println(passwordEncoder.encode("123456"));
System.out.println(passwordEncoder.encode("123456"));
// 明文和密文匹配
// true
System.out.println(passwordEncoder.matches("123456",
"$2a$10$8ew9orusgSjo4fRBODHgBug6aOkJwaq3ikAOVRAtwPkCqVMVLJI.O"));
// false
System.out.println(passwordEncoder.matches("1234567",
"$2a$10$8ew9orusgSjo4fRBODHgBug6aOkJwaq3ikAOVRAtwPkCqVMVLJI.O"));
}
输出结果:
$2a$10$m8cJpTd5IGKiaNEV60DJu.VraepJ8zkCdWXZhi4vjcxsyjDvPS1G.
$2a$10$JRhXG799rFQx/mdcmgTKye9kSBpcGpSLkDxFAwfXf/dZUra8YFA5S
$2a$10$6tnqqQapA4kJShVR23TEKONfsIiAcnoQ8WDhXaDwGCJ76TMhkavXO
$2a$10$sUSFPa3dbaYghraLzJoWWOoT9INQ8/dVEgLRU6pkm2qMZI0PAuLTO
$2a$10$vLfl/HUjUTJY4ieeQTJIyeTZv4.PW8JhJv.h38.4gpVqRElw.4yjK
true
false
即使是同样的密码每次生成的密文也不同,这样我们就可以将这些密文密码代替明文密码存入数据库中,登录的时候还是使用原来的明文。
![/images/all/image-20230308183219903.png](/images/all/image-20230308183219903.png)
Session-Cookie用户认证机制
工作流程
- 用户输入登录信息
- 服务端验证登录信息是否正确,如果正确就在服务器端为这个用户创建一个 Session,并把 Session 存入数据库
- 服务器端会向客户端返回带有 sessionID 的 Cookie
- 客户端接收到服务器端发来的请求之后,看见响应头中的
Set-Cookie
字段,将 Cookie 保存起来- 接下来的请求中都会带上这个 Cookie,服务器将 sessionID 和 数据库中的相匹配,如果有效则处理该请求
- 如果用户登出,Session 会在客户端和服务器端都被销毁
缺陷
-
扩展性不好,当拥有多台服务器的情况下,如何共享 Session 会成为一个问题,也就是说,用户第一个访问的时候是服务器 A,而第二个请求被转发给了服务器 B,那服务器 B 无法得知其状态。
-
安全性不好,攻击者可以利用本地 Cookie 进行欺骗和 CSRF 攻击。
-
Session 保存在服务器端,如果短时间内有大量用户,会影响服务器性能。
-
存在跨域问题。Cookie 属于同源策略限制的内容之一。
举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?
一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。
另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。
JwtToken用户认证
工作流程
前端将自己的用户名和密码发送到后端的接口
后端核对用户名和密码之后,将用户的一些信息作为 payload,生成 jwt
后端将 jwt 作为登录成功的返回结果返回给前端。前端可以将其结果保存在
localStorage/sessionStorage
中,登出时删除 jwt 即可。每一次请求都将 jwt 放在 HTTP 请求头中的
Authorization
位 (Authorization: Bearer <token>
),这样相比放在 Cookie 中可以实现跨域。服务器解码 jwt,如果 token 有效,那么处理这个请求
用户登出,在客户端删除 token 即可,与服务端无关
特点
-
jwt 默认是不加密的
-
jwt 的目的是用来验证来源可靠性,并不是保护数据和防止未经授权的访问。(可以类比成一张电影票,只能验证电影票是否是真的,电影票也有一些基本信息,但是他人也可以使用你的电影票,如果有可能的话)一旦暴露,任何人都可以获得权限。为了减少盗用,JWT 的有效期应该设置得比较短,对于一些比较重要的权限,使用时应该再次对用户进行认证。
-
最大的缺点是 token 过期处理问题,由于服务器不保存 Session 状态,因此无法在使用过程中废止或者更改权限。也就是说,一旦 jwt 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑
基于JwtToken验证实现用户登录注册
导入依赖
直接加入最新的依赖即可
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
添加配置
创建一个jwt 工具类: JwtUtil
,用来创建、解析jwt token
@Component
public class JwtUtil {
public static final long JWT_TTL = 60 * 60 * 1000L * 24 * 14; // 有效期14天
public static final String JWT_KEY = "SDFGjhdsfalshdfHFdsjkdsfds121232131afasdfac"; // 随机的字符串,设置的长一些
public static String getUUID() {
return UUID.randomUUID().toString().replaceAll("-", "");
}
public static String createJWT(String subject) {
JwtBuilder builder = getJwtBuilder(subject, null, getUUID());
return builder.compact();
}
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if (ttlMillis == null) {
ttlMillis = JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setId(uuid)
.setSubject(subject)
.setIssuer("sg")
.setIssuedAt(now)
.signWith(signatureAlgorithm, secretKey)
.setExpiration(expDate);
}
public static SecretKey generalKey() {
byte[] encodeKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
return new SecretKeySpec(encodeKey, 0, encodeKey.length, "HmacSHA256");
}
public static Claims parseJWT(String jwt) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(jwt)
.getBody();
}
}
实现JwtAuthenticationTokenFilter
类,用来验证jwt token
,如果验证成功,则将 User 信息注入上下文中。
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private UserMapper userMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");
if (!StringUtils.hasText(token) || !token.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
token = token.substring(7);
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
throw new RuntimeException(e);
}
// 这里不同的ORM实现可能不同,这里用的是JPA
// 就是根据用户id获取user
User user = userMapper.findUserById(Integer.parseInt(userid));
if (user == null) {
throw new RuntimeException("用户名未登录");
}
UserDetailsImpl loginUser = new UserDetailsImpl(user);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
修改SecurityConfig
类代码如下:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
/**
* 注入密码解析器到IOC容器中
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/user/account/token/", "/user/account/register/").permitAll() // 写上你需要放行的接口
.antMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
}
实现接口
/user/account/token/
:验证用户名密码,验证成功后返回jwt token
(令牌)/user/account/info/
:根据令牌返回用户信息/user/account/register/
:注册账号
编写UserController
,映射以上接口
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private LoginServiceImpl loginService;
@Autowired
private UserInfoServiceImpl infoService;
@Autowired
private RegisterServiceImpl registerService;
@PostMapping("/account/token/")
public Map<String, String> getToken(@RequestParam Map<String, String> map) {
String username = map.get("username");
String password = map.get("password");
System.out.println(username + ' ' + password);
return loginService.getToken(username, password);
}
@GetMapping("/account/info/")
public Map<String, String> getInfo() {
return infoService.getInfo();
}
@PostMapping("/account/register/")
public Map<String, String> register(@RequestParam Map<String, String> map) {
String username = map.get("username");
String password = map.get("password");
String confirmedPassword = map.get("confirmedPassword");
return registerService.register(username, password, confirmedPassword);
}
}
实现LoginServiceImpl
, 用户输入用户名和密码后,登录成功返回一个Jwt_token
@Service
public class LoginServiceImpl {
@Autowired
private AuthenticationManager authenticationManager;
public Map<String, String> getToken(String username, String password) {
// 根据用户名和密码进行验证
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
// 验证成功后进入下一步,否则报错
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
UserDetailsImpl loginUser = (UserDetailsImpl) authenticate.getPrincipal();
// 获取登录用户信息
User user = loginUser.getUser();
// 利用用户id生成token
String jwt = JwtUtil.createJWT(user.getId().toString());
Map<String, String> map = new HashMap<>();
map.put("error_message", "success");
map.put("token", jwt);
return map;
}
}
输入用户名、密码、确认密码,注册一个新用户。设置了一系列校验。
@Service
public class RegisterServiceImpl {
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
public Map<String, String> register(String username, String password, String confirmedPassword) {
Map<String, String> map = new HashMap<>();
if (username == null) {
map.put("error_message", "用户名不能为空");
return map;
}
if (password == null || confirmedPassword == null) {
map.put("error_message", "密码不能为空");
return map;
}
//删除首尾的空白字符
username = username.trim();
if (username.length() == 0) {
map.put("error_message", "用户名不能为空");
return map;
}
if (password.length() == 0 || confirmedPassword.length() == 0) {
map.put("error_message", "密码不能为空");
return map;
}
if (username.length() > 100) {
map.put("error_message", "用户名长度不能大于100");
return map;
}
if (password.length() > 100 || confirmedPassword.length() > 100) {
map.put("error_message", "密码不能大于100");
return map;
}
if (!password.equals(confirmedPassword)) {
map.put("error_message", "两次输入的密码不一致");
return map;
}
//查询用户名是否重复
User user = userMapper.getUserByName(username);
if (user != null) {
map.put("error_message", "用户名已存在");
return map;
}
// 用户密码加密存储
String encodedPassword = passwordEncoder.encode(password);
// 添加一个新用户
User u = new User();
u.setName(username);
u.setAge(18); // 这里age和email先设定一个默认值
u.setEmail("xxxxx");
u.setPassword(encodedPassword);
// 存入数据库
userMapper.save(u);
map.put("error_message", "success");
return map;
}
}
验证发起请求用户的token
,返回用户的部分信息
@Service
public class UserInfoServiceImpl {
/**
* 根据token返回用户信息
* @return map存储的信息
*/
public Map<String, String> getInfo() {
UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
UserDetailsImpl loginUser = (UserDetailsImpl) authentication.getPrincipal();
User user = loginUser.getUser();
Map<String, String> map = new HashMap<>();
map.put("error_message", "success");
map.put("id", user.getId().toString());
map.put("name", user.getName());
return map;
}
}
接口测试:
![/images/all/image-20230308223730819.png](/images/all/image-20230308223730819.png)
![/images/all/image-20230308232040930.png](/images/all/image-20230308232040930.png)
![/images/all/image-20230308230524237.png](/images/all/image-20230308230524237.png)