SpringBoot使用Spring Security实现JwtToken验证

警告
本文最后更新于 2023-03-08,文中内容可能已过时,请谨慎使用。
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

启动项目,控制台会打印 Spring Security 自动生成的密码

/images/all/image-20230308165824883.png

这时访问接口会自动跳转到登录界面,默认用户名为user, 初始密码为上面的图片中自动生成的密码

/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-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
  1. 用户输入登录信息
  2. 服务端验证登录信息是否正确,如果正确就在服务器端为这个用户创建一个 Session,并把 Session 存入数据库
  3. 服务器端会向客户端返回带有 sessionID 的 Cookie
  4. 客户端接收到服务器端发来的请求之后,看见响应头中的 Set-Cookie 字段,将 Cookie 保存起来
  5. 接下来的请求中都会带上这个 Cookie,服务器将 sessionID 和 数据库中的相匹配,如果有效则处理该请求
  6. 如果用户登出,Session 会在客户端和服务器端都被销毁
  • 扩展性不好,当拥有多台服务器的情况下,如何共享 Session 会成为一个问题,也就是说,用户第一个访问的时候是服务器 A,而第二个请求被转发给了服务器 B,那服务器 B 无法得知其状态。

  • 安全性不好,攻击者可以利用本地 Cookie 进行欺骗和 CSRF 攻击。

  • Session 保存在服务器端,如果短时间内有大量用户,会影响服务器性能。

  • 存在跨域问题。Cookie 属于同源策略限制的内容之一。

举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?

一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。

另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。

  1. 前端将自己的用户名和密码发送到后端的接口

  2. 后端核对用户名和密码之后,将用户的一些信息作为 payload,生成 jwt

  3. 后端将 jwt 作为登录成功的返回结果返回给前端。前端可以将其结果保存在localStorage/sessionStorage 中,登出时删除 jwt 即可。

  4. 每一次请求都将 jwt 放在 HTTP 请求头中的 Authorization 位 (Authorization: Bearer <token>),这样相比放在 Cookie 中可以实现跨域。

  5. 服务器解码 jwt,如果 token 有效,那么处理这个请求

  6. 用户登出,在客户端删除 token 即可,与服务端无关

  • jwt 默认是不加密

  • jwt 的目的是用来验证来源可靠性,并不是保护数据和防止未经授权的访问。(可以类比成一张电影票,只能验证电影票是否是真的,电影票也有一些基本信息,但是他人也可以使用你的电影票,如果有可能的话)一旦暴露,任何人都可以获得权限。为了减少盗用,JWT 的有效期应该设置得比较短,对于一些比较重要的权限,使用时应该再次对用户进行认证。

  • 最大的缺点是 token 过期处理问题,由于服务器不保存 Session 状态,因此无法在使用过程中废止或者更改权限。也就是说,一旦 jwt 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑

直接加入最新的依赖即可

<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
用户登录成功后发送一个token
/images/all/image-20230308232040930.png
用户注册成功
/images/all/image-20230308230524237.png
返回用户部分信息

相关文章