Spring Boot:基于JWT和Spring Security的登录验证

Spring Security是Spring全家桶中基于Web Filter实现的提供安全认证服务的框架。JWT(JSON Web Token)是一个跨域身份验证解决方案,可脱离Session进行身份认证,也可以同时为多系统间提供统一身份认证。下面主要介绍如何在SpringBoot后端项目中集成JWT和Spring Security实现用户登录。

  • Spring Boot版本:2.2.6
  • IDE:IntelliJ IDEA 2019.3.3 (Ultimate Edition)

如果Springboot项目未初始化,可参考<<Spring Boot:Idea从零开始初始化后台项目>>``

1 依赖引用

Spring Security和JWT需要引用相关包,在项目的build.gradle文件中dependencies节点下添加如下引用

implementation "org.springframework.boot:spring-boot-starter-security"
implementation 'io.jsonwebtoken:jjwt:0.9.1'

2 创建用户相关表和操作类

创建用户表h_user,用户实体User及实体数据持久操作类UserRepository,具体定义可参照上一篇<<Spring Boot:Spring Data JPA开启审计功能,自动保存操作人操作时间>>

3 JWT配置

application.properties文件中添加以下配置信息

  • jwt.httpHeader:http请求中用于传输token的头名称
  • jwt.tokenHead:token前缀
  • jwt.secret:token秘钥
  • jwt.expiration:token过期时间
    在这里插入图片描述

4 JWT实现

这里划分为5个类实现JWT的核心功能部分,JwtUserJwtTokenUtilJwtUserDetailsServiceJwtTokenFilterJwtWebSecurityConfig

4.1 JwtUser

实现于Spring Security的UserDetails类,用于存储认证用户的基本信息。代码如下:

public class JwtUser implements UserDetails {
    private final Integer id;
    private final String username;
    private final String password;
    public JwtUser(
            Integer id,
            String username,
            String password) {
        this.id = id;
        this.username = username;
        this.password = password;
    }
    public Integer getId() {
        return id;
    }
    @Override
    public String getUsername() {
        return username;
    }
    @JsonIgnore
    @Override
    public String getPassword() {
        return password;
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }
    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @JsonIgnore
    @Override
    public boolean isEnabled() {
        return true;
    }
}

4.2 JwtTokenUtil

JWT工具类,基于全局配置,用于token的生成、刷新、过期检测等等,具体代码如下:

@Component
public class JwtTokenUtil {
    @Value("${jwt.secret}")
    private String jwtSecret;

    @Value("${jwt.expiration}")
    private Long expirationSeconds;

    /**
     * 从token获取JWT声明信息
     *
     * @param token
     * @return
     */
    private Claims getClaimsFromToken(String token) {
        try {
            return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody();
        } catch (Exception e) {
            throw new RuntimeException(String.format("Invalid Jwt Token:%s ======> %s", token, e.getMessage()));
        }
    }

    /**
     * 从token获取用户名
     *
     * @param token
     * @return
     */
    private String getUsernameFromToken(String token) {
        return getClaimsFromToken(token).getSubject();
    }

    /**
     * 从token获取过期时间
     *
     * @param token
     * @return
     */
    private Date getExpirationDateFromToken(String token) {
        return getClaimsFromToken(token).getExpiration();
    }

    /**
     * 从token获取Jwt用户对象
     *
     * @param token
     * @return
     */
    public JwtUser getJwtUserFromToken(String token) {
        if (isTokenExpired(token)) {
            throw new RuntimeException(String.format("user token expired:%s!", token));
        }
        Claims claims = getClaimsFromToken(token);
        return new JwtUser(Integer.valueOf(claims.getId()), claims.getSubject(), null);
    }

    /**
     * 检查token是否过期
     *
     * @param token
     * @return
     */
    private Boolean isTokenExpired(String token) {
        return getExpirationDateFromToken(token).before(new Date());
    }

    /**
     * 根据配置计算过期时间
     *
     * @return
     */
    private Date genExpirationDate() {
        return new Date(System.currentTimeMillis() + expirationSeconds * 1000);
    }

    /**
     * 根据JWT声明生成token
     *
     * @param claims
     * @return
     */
    private String genToken(Map<String, Object> claims) {
        return Jwts.builder().setClaims(claims).setExpiration(genExpirationDate()).signWith(SignatureAlgorithm.HS512, jwtSecret).compact();
    }

    /**
     * 根据Jwt用户对象生成token
     *
     * @param user
     * @return
     */
    public String genToken(JwtUser user) {
        return genToken(new HashMap<String, Object>() {{
            put(Claims.SUBJECT, user.getUsername());
            put(Claims.ID, user.getId());
        }});
    }

    /**
     * 用于当有用户操作时刷新用户token过期时间,防止用户操作过程中token过期
     *
     * @param token
     * @return
     */
    public String refreshToken(String token) {
        if (isTokenExpired(token)) {
            throw new RuntimeException(String.format("user token expired:%s!", token));
        }
        Claims claims = getClaimsFromToken(token);
        return genToken(claims);
    }
}

4.3 JwtUserDetailsService

实现与Spring Security的UserDetailsService类,用于用户的身份验证。具体代码如下:

@Service
public class JwtUserDetailsService implements UserDetailsService {
    private Logger logger = LoggerFactory.getLogger(this.getClass());
    private final UserRepository userRepository;

    public JwtUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = this.userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException(String.format("User not exist:'%s'", username)));
        logger.info("用户请求登录:{}", username);
        return new JwtUser(user.getId(), user.getUsername(), user.getPassword());
    }
}

4.4 JwtTokenFilter

Spring Security基于Web Filter,这里JwtTokenFilter实现于OncePerRequestFilter,用于拦截用户需要鉴权的请求并验证token有效性。具体代码如下:

@Component
// TODO: 2020/4/16 study OncePerRequestFilter
public class JwtTokenFilter extends OncePerRequestFilter {
    private final JwtTokenUtil jwtTokenUtil;

    @Value("${jwt.httpHeader}")
    private String tokenHttpHeader;

    @Value("${jwt.tokenHead}")
    private String tokenHead;

    private final UserRepository userRepository;

    public JwtTokenFilter(JwtTokenUtil jwtTokenUtil, UserRepository userRepository) {
        this.jwtTokenUtil = jwtTokenUtil;
        this.userRepository = userRepository;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader(this.tokenHttpHeader);
        if (authHeader != null && authHeader.startsWith(tokenHead)) {
            try {
                // This part after "Bearer "
                final String authToken = authHeader.substring(tokenHead.length());
                JwtUser jwtUser = jwtTokenUtil.getJwtUserFromToken(authToken);
                //从客户端token中获取到了用户信息,但是应用上下文中不存在登录用户,根据业务逻辑决定是否需要创建登录用户
                if (jwtUser != null && jwtUser.getUsername() != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                    User user = this.userRepository.findByUsername(jwtUser.getUsername()).orElseThrow(() -> new HException(String.format("用户不存在:%s", jwtUser.getUsername())));
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities(new ArrayList<>()));
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    //应用上下文中设置登录用户信息,此时Authentication类型为User
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            } catch (Exception e) {
                logger.error(e.getMessage(), e);
            }
        }
        chain.doFilter(request, response);
    }
}

4.5 JwtWebSecurityConfig

实现于Spring Security的WebSecurityConfigurerAdapter,用于Web安全全局配置,比如设置哪些需要验证权限、哪些需要放行、静态页面如何处理以及权限如何验证等等。具体实现如下:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class JwtWebSecurityConfig extends WebSecurityConfigurerAdapter {
    private final JwtTokenFilter jwtAuthTokenFilter;
    private final UserDetailsService userDetailsService;
    private final String[] staticResPatterns = new String[]{"/", "/*.html", "/favicon.ico", "/**/*.html", "/**/*.css", "/**/*.js"};
    private final String[] noneAuthReqPatterns = new String[]{"/public/**", "/demo/**", "/actuator/**", "/auth/**"};

    public JwtWebSecurityConfig(@Qualifier("jwtUserDetailsService") UserDetailsService userDetailsService, JwtTokenFilter jwtAuthTokenFilter) {
        this.userDetailsService = userDetailsService;
        this.jwtAuthTokenFilter = jwtAuthTokenFilter;
    }

    @Autowired
    public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        // 设置UserDetailsService并使用BCrypt进行密码的hash
        authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // 由于使用的是JWT不使用session,这里禁用csrf
                .csrf().disable()
                //禁用form登录
                .formLogin().disable()
                // 基于token,所以不需要session,禁用
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                // 允许所有HttpMethod.OPTIONS类型请求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 允许对于网站静态资源的无授权访问
                .antMatchers(HttpMethod.GET, this.staticResPatterns).permitAll()
                // 无需验证身份的请求,如登录注册等等
                .antMatchers(noneAuthReqPatterns).permitAll()
                //admin开头的请求,需要admin权限
                .antMatchers("/admin/**").hasAnyRole("ADMIN")
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated()
                //使用jwtAuthTokenFilter验证身份,身份验证失败返回403错误代码
                .and().addFilterBefore(jwtAuthTokenFilter, UsernamePasswordAuthenticationFilter.class).exceptionHandling().authenticationEntryPoint(new Http403ForbiddenEntryPoint())
                //启用跨域请求
                .and().cors();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        final CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE", "PATCH"));
        // setAllowCredentials(true) is important, otherwise:
        // The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
        configuration.setAllowCredentials(true);
        // setAllowedHeaders is important! Without it, OPTIONS preflight request
        // will fail with 403 Invalid CORS request
        configuration.setAllowedHeaders(Arrays.asList("Authorization", "X-API", "Cache-Control", "Content-Type", "CurWhseId"));
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * any GenericFilterBean (OncePerRequestFilter is one) in the context will be automatically added to the filter chain. Meaning the configuration you have above will include the same filter twice.
     * <p>
     * this is to fix the filter been called twice
     */
    @Bean(name = "authenticationFilterRegistration")
    public FilterRegistrationBean myAuthenticationFilterRegistration(final JwtTokenFilter filter) {
        final FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        filterRegistrationBean.setFilter(filter);
        filterRegistrationBean.setEnabled(false);
        return filterRegistrationBean;
    }
}

5 JWT使用

5.1 用户注册和登录

创建AuthService用于登录验证,AuthController类用于用户注册和登录请求处理。代码如下:

@Service
public class AuthService {
    private final AuthenticationManager authenticationManager;
    private final JwtTokenUtil jwtTokenUtil;

    @Autowired
    public AuthService(AuthenticationManager authenticationManager,
                       JwtTokenUtil jwtTokenUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenUtil = jwtTokenUtil;
    }

    /**
     * 用户名密码登录验证,获取token
     * @param username 用户名
     * @param password 密码
     * @return
     */
    public String login(String username, String password) {
        UsernamePasswordAuthenticationToken upToken = new UsernamePasswordAuthenticationToken(username, password);
        //该方法会去调用userDetailsService.loadUserByUsername()去验证用户名和密码,如果正确,则会存储该用户名密码到“Spring Security 的 context中”
        //此时Authentication类型为JwtUser
        Authentication auth = authenticationManager.authenticate(upToken);
        return jwtTokenUtil.genToken((JwtUser) auth.getPrincipal());
    }
}

@RestController
@RequestMapping("/auth")
public class AuthController {
    private Logger logger = LoggerFactory.getLogger(this.getClass());

    private final AuthService authService;
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;

    public AuthController(AuthService authService, PasswordEncoder passwordEncoder, UserRepository userRepository) {
        this.authService = authService;
        this.passwordEncoder = passwordEncoder;
        this.userRepository = userRepository;
    }

    @PostMapping("/regUser")
    public HResponse regUser(String username, String password) {
        if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
            return HResponse.error("用户名密码不能为空!");
        }
        int userCount = this.userRepository.countAllByUsername(username);
        if (userCount > 0) {
            return HResponse.error(String.format("用户已存在:%s", userCount));
        }
        User user = new User();
        user.setUsername(username.trim());
        user.setPassword(this.passwordEncoder.encode(password));
        user.setEmail("[email protected]");
        user.setFlag(0);
        user.setMobile("18988888888");
        user.setRealName(username);
        this.userRepository.save(user);
        return HResponse.success();
    }

    @PostMapping("/login")
    public HResponse login(@Param("username") String username, @Param("password") String password) {
        try {
            User user = this.userRepository.findByUsername(username).orElseThrow(() -> new HException("用户不存在"));
            JSONObject resp = new JSONObject().fluentPut("user", user);
            resp.put("token", authService.login(username, password));
            logger.info("user login with password: {}", username);
            return HResponse.success(resp);
        } catch (HException e) {
            return HResponse.error(e.getMessage());
        } catch (Exception e) {
            logger.error(e.getMessage(), e);
        }
        return HResponse.error(String.format("用户 %s 登录失败!", username));
    }
}

使用Postman发送注册和登录请求,响应如下:
在这里插入图片描述

在这里插入图片描述

5.2 未登录鉴权模拟

<<Spring Boot:Idea从零开始初始化后台项目>>中,开发了几个common/路径的请求,比如在未登录的情况下再执行其中的common/setRedisValue请求,将返回403错误,如JwtWebSecurityConfig中配置。
在这里插入图片描述
在请求的header中附带Authorization信息,并设置为登录成功后返回的token,则请求响应成功,如下图:
在这里插入图片描述

发布了17 篇原创文章 · 获赞 6 · 访问量 780

猜你喜欢

转载自blog.csdn.net/lxh_worldpeace/article/details/105579757