天天看点

SpringBoot + spring security + JWT实现前后端分离

作者:大遥老师信息奥赛

概述

越来越多的网站开发都采用前后端分离架构。典型的像React\vue等。本篇文章会介绍下基于SpringBoot架构后端的实现。

JWT

JWT原理

SpringBoot + spring security + JWT实现前后端分离

JWT通讯原理

具体认证流程:

1、输入用户/密码,服务端认证成功后。会返回客户端一个JWT Token。

格式:

IUzUxMiJ9.eyJzdWIiOiJoMS.PqZg8jCah0hgj           

2、客户端将token保存到本地(可以放LocalStorage,也可以放Cookie)

3、当前端访问一个受保护的路由或资源,需要在HTTP请求头的Authorization字段中使用Bearer模式添加JWT。格式如下:Authorization: Bearer

4、服务端利用Secrity过滤器,获取HTTP头里面的这个token值,解析是否为有效的Token。如果是则放行。

Spring Security框架

SpringSecurity 采用的是责任链的设计模式,是一堆过滤器链的组合,它有一条很长的过滤器链。简化一下:

SpringBoot + spring security + JWT实现前后端分离

登录流程过滤器链

示例代码目录:

SpringBoot + spring security + JWT实现前后端分离

示例工程

跟Security相关的几个文件代码如下:

WebSecurityConfig.java 配置文件

package com.aliyun.agp.webcommon.security;

import com.aliyun.agp.webcommon.security.jwt.JwtAuthenticationEntryPoint;
import com.aliyun.agp.webcommon.security.jwt.JwtRequestFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Configurable;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configurable
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Autowired
    private UserDetailsService jwtUserDetailsService;

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    @Value("${jwt.get.token.uri}")
    private String authenticationPath;

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .userDetailsService(jwtUserDetailsService)
                .passwordEncoder(passwordEncoderBean());
    }
    @Bean
    public PasswordEncoder passwordEncoderBean() {
        return new BCryptPasswordEncoder();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .authorizeRequests()
                // 指定的接口直接放行
                .antMatchers(HttpMethod.POST,"/api/login").permitAll()
                .antMatchers(HttpMethod.GET,"/api/refresh").permitAll()
                // 其他接口需要认证才能请求
                .anyRequest().authenticated()
                .and()
                .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .and()
                // 不需要创建session,前后端分离
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
           

这个文件里面要注意配置哪些URI是不需要认证的。没有认证的会到哪个类处理。以及自定义过滤器。

JwtAuthenticationEntryPoint.java这个类的作用就是没有登录的处理逻辑。

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {

    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        AgpWebResult agpWebResult = AgpWebResult.buildFailWithErrCode(HttpServletResponse.SC_UNAUTHORIZED,"Unauthorized");

        httpServletResponse.getWriter().write(AgpUtils.toJsonString(agpWebResult));
        httpServletResponse.flushBuffer();

    }
}
           

再有一个就是过滤器了。这块跟采用JWT还是用其他协议有关。需要拿到前端提交过来的请求信息。

JwtRequestFilter.java自定义过滤器

@Component
public class JwtRequestFilter extends OncePerRequestFilter {
    private static final Logger logger = LoggerFactory.getLogger(LoggerConstants.AGP_APPEND);

    @Autowired
    private UserDetailsService jwtUserDetailsService;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    public static final String TOKEN_PREFIX = "Bearer ";
    public static final String HEADER_STRING = "Authorization";

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        final  String requestTokenHeader = httpServletRequest.getHeader(HEADER_STRING);
        String username = null;
        String jwtToken = null;
        if (requestTokenHeader != null && requestTokenHeader.startsWith(TOKEN_PREFIX)) {
            jwtToken = requestTokenHeader.replace(TOKEN_PREFIX,"");
            try {
                username = jwtTokenUtil.getUsernameFromToken(jwtToken);
            }catch (IllegalArgumentException e) {
                logger.error("JWT_TOKEN_UNABLE_TO_GET_USERNAME", e);
            }  catch (ExpiredJwtException e) {
                logger.warn("the token is expired and not valid anymore", e);
            } catch (SignatureException e) {
                logger.error("Authentication Failed. Username or Password not valid.");
            } catch (MalformedJwtException exception) {
                logger.warn("Request to parse invalid JWT : failed : {}", exception.getMessage());
            }
        } else {
            logger.warn("JWT_TOKEN_DOES_NOT_START_WITH_BEARER_STRING");
        }
        logger.debug("JWT_TOKEN_USERNAME_VALUE '{}'", username);
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = jwtUserDetailsService.loadUserByUsername(username);
            if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails,null,userDetails.getAuthorities()
                );
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
                logger.info("authenticated user " + username + ", setting security context");
                // 设置成全局ThreadLocal级别的上下文,用于获取当前登录用户
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(httpServletRequest,httpServletResponse);
    }           

用户密码认证实现。Security是将用户的密码与输入的密码在内存中比较是否Match的。

JwtUserDetailsService.java提供loadUserByUsername方法

@Service
public class JwtUserDetailsService implements UserDetailsService {
    @Autowired
    private UserService userService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        User user = userService.findByUsername(s).orElseThrow(() -> new UsernameNotFoundException("user not found with username:" + s));

        User jwtUser = new User();
        jwtUser.setId(user.getId());
        jwtUser.setUserName(user.getUserName());
        jwtUser.setPassword(passwordEncoder.encode(user.getPassword()));
        jwtUser.setEmail(user.getEmail());
        jwtUser.setRoles(user.getRoles());
        return JwtUserDetails.build(jwtUser);
    }
}           

JwtUserDetails.java实现UserDetails接口

public class JwtUserDetails implements UserDetails {
    private Long id;

    private String username;

    private String email;

    private String password;


    private Collection<? extends GrantedAuthority> authorities;

    public JwtUserDetails(Long id, String username, String email, String password,
                          Collection<? extends GrantedAuthority> authorities) {
        this.id = id;
        this.username = username;
        this.email = email;
        this.password = password;
        this.authorities = authorities;
    }

    public static JwtUserDetails build(User user) {
        List<GrantedAuthority> authorities = user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority(role.getName().name()))
                .collect(Collectors.toList());

        return new JwtUserDetails(
                new Long(user.getId()),
                user.getUserName(),
                user.getEmail(),
                user.getPassword(),
                authorities);
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @JsonIgnore
    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }


    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        JwtUserDetails user = (JwtUserDetails) o;
        return Objects.equals(id, user.id);
    }

    @JsonIgnore
    public Long getId() {
        return id;
    }


}
           

跟JWT 其实就一个工具类,用于生成token,校验token的。

import io.jsonwebtoken.Clock;
import io.jsonwebtoken.impl.DefaultClock;
import org.springframework.beans.factory.annotation.Value;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.cglib.core.internal.Function;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.io.Serializable;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

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

    @Value("${jwt.token.expiration.in.seconds}")
    private Long expiration;

    private Clock clock = DefaultClock.INSTANCE;

    /***
     * 从token中获取用户名
     * @param token
     * @return
     */
    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }

    /**
     * 获取过期时间
     *
     * @param token
     * @return
     */
    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    public Boolean canTokenBeRefreshed(String token) {
        return (!isTokenExpired(token));
    }

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    /**
     * 验证token是否有效
     *
     * @param token
     * @param userDetails
     * @return
     */
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    /***
     * 给用户生成新的token
     * @param authentication
     * @return
     */
    public String generateToken(Authentication authentication) {
        Map<String, Object> claims = new HashMap<>();

        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String username = userDetails.getUsername();
        return doGenerateToken(claims, username);
    }

    /***
     * 当token过期之后需要刷新token
     * @param token
     * @return
     */
    public String refreshToken(String token) {
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        final Claims claims = getAllClaimsFromToken(token);
        claims.setIssuedAt(createdDate);
        claims.setExpiration(expirationDate);

        return Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, secret).compact();
    }

    private Date calculateExpirationDate(Date createdDate) {
        return new Date(createdDate.getTime() + expiration * 1000);
    }

    /***
     * 生成一个新token,顺序:
     * 1. Define claims of the token, like Issuer, Expiration, Subject, and the ID
     * 2. Sign the JWT using the HS512 algorithm and secret key.
     * 3. According to JWS Compact
     * @param claims
     * @param subject  以用户名为subject进行生成token
     * @return
     */
    private String doGenerateToken(Map<String, Object> claims, String subject) {
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);
        return Jwts.builder().setClaims(claims).setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret).compact();
    }

    /***
     * 判断当前token是否过期
     * @param token
     * @return
     */
    private Boolean isTokenExpired(String token) {
        final Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser().setSigningKey(secret)
                .parseClaimsJws(token).getBody();
    }

}
           

入口控制器

@RestController
@CrossOrigin
public class JwtAuthenticationController extends BaseController {
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Value("${jwt.http.request.header}")
    private String tokenHeader;

    @Autowired
    private JwtUserDetailsService userDetailsService;

    @RequestMapping(value = "/api/login", method = RequestMethod.POST)
    public AgpWebResult createAuthenticationToken(@RequestBody JwtRequest authenticationRequest) {
        AgpWebResult agpWebResult = null;
        try {
            // 先利用Security框架校验用户名、密码是否对的
            Authentication authentication = authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword());
            // 利用JWT工具生成token,客户端需要写到浏览器
            final String token = jwtTokenUtil.generateToken(authentication);
            agpWebResult = AgpWebResult.buildSuccess(token);
        } catch (Exception e) {
            agpWebResult = AgpWebResult.buildFailWithErrCode(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
        }

        return agpWebResult;
    }
}           

Postman效果

非登录态,访问需要授权API:

{
    "data": null,
    "errorCode": 401,
    "errorMsg": "Unauthorized",
    "statusCode": "401",
    "success": false
}           

登录接口,返回值:

{
    "data": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIifQ.gztc6-yNTmKiQ---bIsVoSa",
    "success": true,
    "errorMsg": null,
    "statusCode": null,
    "errorCode": null
}           

如果输入的用户名或密码不对,返回:

{
    "data": null,
    "success": false,
    "errorMsg": "INVALID_CREDENTIALS",
    "statusCode": "401",
    "errorCode": 401
}           

为了统一后端返回值格式,定义了一个统一的JSON结果。与前端约束好,返回的JSON格式,先看success是否为true,如果不对,再看失败的原因。

继续阅读