背景

前段时间做了一个项目, 因为涉及到权限认证, 所以分别调研了 SpringSecurity 和 Apache Shiro. 最后选择使用了 SpringSecurity + JWT做权限认证, 现在项目已经结束, 总相关笔记.
项目下载地址 jwt-demo

  1. 使用JWT生成token
  2. token存储在数据库中
  3. 使用 application/json 登录
  4. 使用手机号进行登录
  5. URI动态拦截

配置过程

添加依赖

  1. 分别添加 SpringSecurity JWT 和 fastjson 依赖
    <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-security</artifactId>
       </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.0</version>
    </dependency>
    <!--json-->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.60</version>
    </dependency>

基础准备对象

  • 主要是在用户登录成功handle时使用JWT生成Token返回给客户端.

基础使用dto

请求返回基类

@Data
public class BaseReqDto implements Serializable {

    private String version;

}

@Data
public class BaseRespDto implements Serializable {

    private String resultCode;

    private String resultMsg;

    private String resultTime;

}

登录请求返回对象

@Data
public class LoginReqDto {

    private String username;

    private String token;

}

@Data
public class LoginRespDto extends BaseRespDto {

    private String token;

}

用于验证的用户

package com.liuzhihang.demo.bean;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.io.Serializable;
import java.util.Collection;

/**
 * 用户信息校验验证码
 *
 * @author liuzhihang
 */
public class UserDetailsImpl implements UserDetails, Serializable {
    /**
     * 用户名
     */
    private String username;
    /**
     * 密码
     */
    private String password;
    /**
     * 权限集合
     */
    private Collection<? extends GrantedAuthority> authorities;

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

    public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
        this.authorities = authorities;
    }

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

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

    public void setUsername(String username) {
        this.username = username;
    }

    public void setPassword(String password) {
        this.password = password;
    }

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

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

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

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

用户未登录handle


/**
 * 用户登录认证, 未登录返回信息
 *
 * @author liuzhihang
 * @date 2019-06-04 13:52
 */
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException {

        response.setContentType("application/json;charset=UTF-8");

        LoginRespDto respDto = new LoginRespDto();
        respDto.setResultCode("0001");
        respDto.setResultMsg("用户未登录");
        respDto.setResultTime(LocalDateTime.now().format(FORMATTER));

        response.getWriter().write(JSON.toJSONString(respDto));
    }
}

用户登录验证失败handle

/**
 * 用户登录认证失败返回的信息
 *
 * @author liuzhihang
 * @date 2019-06-04 13:57
 */
@Component
public class AuthenticationFailureHandlerImpl implements AuthenticationFailureHandler {

    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {

        response.setContentType("application/json;charset=UTF-8");

        LoginRespDto respDto = new LoginRespDto();
        respDto.setResultCode("0001");
        respDto.setResultMsg("用户登录认证失败");
        respDto.setResultTime(LocalDateTime.now().format(FORMATTER));

        response.getWriter().write(JSON.toJSONString(respDto));
    }
}

用户无权访问handle

/**
 * 当用户访问无权限页面时, 返回信息
 *
 * @author liuzhihang
 * @date 2019-06-04 14:03
 */
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {

    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {

        response.setContentType("application/json;charset=UTF-8");

        LoginRespDto respDto = new LoginRespDto();
        respDto.setResultCode("0002");
        respDto.setResultMsg("用户无权访问");
        respDto.setResultTime(LocalDateTime.now().format(FORMATTER));

        response.getWriter().write(JSON.toJSONString(respDto));

    }
}

用户登录成功handle


/**
 * 用户登录成功之后的返回信息
 *
 * @author liuzhihang
 * @date 2019-06-04 14:20
 */
@Slf4j
@Component
public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {

    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

    @Resource
    private JwtTokenUtil jwtTokenUtil;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {

        UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();

        String jwtToken = jwtTokenUtil.generateToken(userDetails);

        // 把生成的token更新到数据库中
        // 更新DB操作 ...

        response.setContentType("application/json;charset=UTF-8");

        LoginRespDto respDto = new LoginRespDto();
        respDto.setToken(jwtToken);
        respDto.setResultCode("0000");
        respDto.setResultMsg("登录成功");
        respDto.setResultTime(LocalDateTime.now().format(FORMATTER));

        response.getWriter().write(JSON.toJSONString(respDto));

    }
}

JwtTokenUtil

主要用来生成token和通过token解析对象等操作.

package com.liuzhihang.demo.utils;

import com.liuzhihang.demo.bean.UserDetailsImpl;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.Date;

/**
 * 使用 java-jwt jwt类库
 *
 * @author liuzhihang
 * @date 2019-06-05 09:22
 */
@Component
public class JwtTokenUtil {

    private static final SignatureAlgorithm SIGN_TYPE = SignatureAlgorithm.HS256;

    public static final String SECRET = "jwt-secret";

    /**
     * JWT超时时间
     */
    public static final long EXPIRED_TIME = 7 * 24 * 60 * 60 * 1000L;

    /**
     * claims 为自定义的私有声明, 要放在前面
     * <p>
     * 生成token
     */
    public String generateToken(UserDetails userDetails) {

        long instantNow = Instant.now().toEpochMilli();

        Claims claims = Jwts.claims();
        claims.put(Claims.SUBJECT, userDetails.getUsername());

        return Jwts.builder().setClaims(claims).setIssuedAt(new Date(instantNow))
                .setExpiration(new Date(instantNow + EXPIRED_TIME))
                .signWith(SIGN_TYPE, SECRET).compact();
    }

    /**
     * claims 为自定义的私有声明, 要放在前面
     * <p>
     * 生成token
     */
    public String generateToken(String userName) {

        long instantNow = Instant.now().toEpochMilli();

        Claims claims = Jwts.claims();
        claims.put(Claims.SUBJECT, userName);

        return Jwts.builder().setClaims(claims).setIssuedAt(new Date(instantNow))
                .setExpiration(new Date(instantNow + EXPIRED_TIME))
                .signWith(SIGN_TYPE, SECRET).compact();
    }

    /**
     * 将token解析, 映射为 UserDetails
     *
     * @param jwtToken
     * @return
     */
    public UserDetails getUserDetailsFromToken(String jwtToken) {

        Claims claimsFromToken = getClaimsFromToken(jwtToken);

        String userName = claimsFromToken.get(Claims.SUBJECT, String.class);

        UserDetailsImpl userDetails = new UserDetailsImpl();
        userDetails.setUsername(userName);

        return userDetails;
    }

    /**
     * 验证token
     */
    public Boolean validateToken(String token, UserDetails userDetails) {
        UserDetailsImpl user = (UserDetailsImpl) userDetails;
        String username = getPhoneNoFromToken(token);

        return (username.equals(user.getUsername()) && !isTokenExpired(token));
    }

    /**
     * 刷新令牌
     *
     * @param token 原令牌
     * @return 新令牌
     */
    public String refreshToken(String token) {
        String refreshedToken;
        try {
            Claims claims = getClaimsFromToken(token);

            long instantNow = Instant.now().toEpochMilli();

            refreshedToken = Jwts.builder().setClaims(claims).setIssuedAt(new Date(instantNow))
                    .setExpiration(new Date(instantNow + EXPIRED_TIME))
                    .signWith(SIGN_TYPE, SECRET).compact();
        } catch (Exception e) {
            refreshedToken = null;
        }
        return refreshedToken;
    }

    /**
     * 获取token是否过期
     */
    public Boolean isTokenExpired(String token) {
        Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    /**
     * 根据token获取username
     */
    public String getPhoneNoFromToken(String token) {
        return getClaimsFromToken(token).getSubject();
    }

    /**
     * 获取token的过期时间
     */
    public Date getExpirationDateFromToken(String token) {
        return getClaimsFromToken(token).getExpiration();
    }

    /**
     * 解析JWT
     */
    private Claims getClaimsFromToken(String token) {
        return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
    }

}

WebSecurityConfig 核心配置

package com.liuzhihang.demo.config;

import com.liuzhihang.demo.filter.CustomizeAuthenticationFilter;
import com.liuzhihang.demo.filter.JwtPerTokenFilter;
import com.liuzhihang.demo.service.UserDetailServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.annotation.Resource;

/**
 * @author liuzhihang
 * @date 2019-06-03 14:25
 */
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailServiceImpl userDetailServiceImpl;

    @Resource
    private JwtPerTokenFilter jwtPerTokenFilter;

    @Resource(name = "authenticationEntryPointImpl")
    private AuthenticationEntryPoint authenticationEntryPoint;

    @Resource(name = "authenticationSuccessHandlerImpl")
    private AuthenticationSuccessHandler authenticationSuccessHandler;

    @Resource(name = "authenticationFailureHandlerImpl")
    private AuthenticationFailureHandler authenticationFailureHandler;

    @Resource(name = "accessDeniedHandlerImpl")
    private AccessDeniedHandler accessDeniedHandler;

    /**
     * 创建用于认证授权的用户
     *
     * @param auth
     * @throws Exception
     */
    @Autowired
    public void configureUserInfo(AuthenticationManagerBuilder auth) throws Exception {

        // 放入自己的认证授权用户, 内部逻辑需要自己实现
        // UserDetailServiceImpl implements UserDetailsService
        auth.userDetailsService(userDetailServiceImpl);

    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 使用JWT, 关闭session
                .csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and().httpBasic().authenticationEntryPoint(authenticationEntryPoint)

                // 登录的权限, 成功返回信息, 失败返回信息
                .and().formLogin().permitAll()

                .loginProcessingUrl("/login")

                // 配置url 权限 antMatchers: 匹配url 权限
                .and().authorizeRequests()
                .antMatchers("/login", "/getVersion")
                .permitAll()
                // 其他需要登录才能访问
                .anyRequest().access("@dynamicAuthorityService.hasPermission(request,authentication)")

                // 访问无权限 location 时
                .and().exceptionHandling().accessDeniedHandler(accessDeniedHandler)

                // 自定义过滤
                .and().addFilterAt(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtPerTokenFilter, UsernamePasswordAuthenticationFilter.class)

                .headers().cacheControl();

    }


    /**
     * 密码加密器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        /**
         * BCryptPasswordEncoder:相同的密码明文每次生成的密文都不同,安全性更高
         */
        return new BCryptPasswordEncoder();
    }

    @Bean
    CustomizeAuthenticationFilter customAuthenticationFilter() throws Exception {
        CustomizeAuthenticationFilter filter = new CustomizeAuthenticationFilter();
        filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        filter.setAuthenticationFailureHandler(authenticationFailureHandler);
        filter.setAuthenticationManager(authenticationManagerBean());
        return filter;
    }

}

登录校验过程

{% mermaid %}
graph TD;
A(请求登录) --> B(CustomizeAuthenticationFilter#attemptAuthentication 解析请求的json);
B --> C(UserDetailServiceImpl#loadUserByUsername 验证用户名密码);
C --> D(AuthenticationSuccessHandlerImpl#onAuthenticationSuccess 构建返回参数 包括token);
D --> E(返回结果)

自定义拦截器解析 json 报文

前端请求登录报文类型为 application/json 需要后端增加拦截器, 对登录请求报文进行解析

package com.liuzhihang.demo.filter;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONException;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;

/**
 *
 * 自定义拦截器, 重写UsernamePasswordAuthenticationFilter 从而可以处理 application/json 中的json请求报文
 *
 * @author liuzhihang
 * @date 2019-06-12 19:04
 */
@Slf4j
public class CustomizeAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException {

        // attempt Authentication when Content-Type is json
        if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_UTF8_VALUE)
            || request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {
            try {
                BufferedReader br = request.getReader();
                String str;
                StringBuilder jsonStr = new StringBuilder();
                while ((str = br.readLine()) != null) {
                    jsonStr.append(str);
                }

                log.info("本次登录请求参数:{}", jsonStr);

                JSONObject jsonObject = JSON.parseObject(jsonStr.toString());

                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                    jsonObject.getString("username"), jsonObject.getString("password"));
                setDetails(request, authRequest);
                return this.getAuthenticationManager().authenticate(authRequest);
            } catch (IOException e) {
                log.info("用户登录, 请求参数 不正确");
                throw new AuthenticationServiceException("获取报文请求参数失败");
            } catch (JSONException e) {
                log.info("用户登录, 请求报文格式 不正确");
                throw new AuthenticationServiceException("请求报文, 转换Json失败");
            }
        } else {
            log.error("用户登录, contentType 不正确");
            throw new AuthenticationServiceException(
                "请求 contentType 不正确, 请使用 application/json;charset=UTF-8 或者 application/json;");
        }

    }

}

用户认证模块

  • 根据获取到的username从数据库中查询到密码, 将用户名密码赋值给UserDetails对象, 返回其他的框架会进行校验
  • 这边使用中是使用的手机号+验证码登录, 所以 上面json解析的也是 phoneNo+verificationCode
  • 在这块 username仅仅代指登录名, 可以是手机号可以是别的.
  • 这边使用中验证码是从redis中获取的. 获取不到返回失败, 获取到和传递的不一致也算失败.
package com.liuzhihang.demo.service;

import com.liuzhihang.demo.bean.UserDetailsImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;

/**
 * @author liuzhihang
 */
@Slf4j
@Component("userDetailServiceImpl")
public class UserDetailServiceImpl implements UserDetailsService {


    /**
     * 用来验证登录名是否有权限进行登录
     *
     * 可以通过数据库进行校验 也可以通过redis 等等
     *
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {


        UserDetailsImpl userDetailsImpl = new UserDetailsImpl();
        userDetailsImpl.setUsername("liuzhihang");
        userDetailsImpl.setPassword(new BCryptPasswordEncoder().encode("123456789"));
        return userDetailsImpl;
    }

}

请求校验过程

{% mermaid %}
graph TD;
A(请求接口) --> B(JwtPerTokenFilter#doFilterInternal 验证Header中的token);
B --> C(DynamicAuthorityService#hasPermission 验证有没有请求url权限);
C --> D(处理逻辑);
D --> E(返回结果)

JWTToken拦截器

主要是拦截请求, 验证Header中的token是否正确

package com.liuzhihang.demo.filter;

import com.liuzhihang.demo.utils.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author liuzhihang
 * @date 2019-06-05 09:09
 */
@Slf4j
@Component
public class JwtPerTokenFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    /**
     * 存放Token的Header Key
     */
    private static final String HEADER_STRING = "token";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String token = request.getHeader(HEADER_STRING);
        if (null != token && !jwtTokenUtil.isTokenExpired(token)) {
            UserDetails userDetails = jwtTokenUtil.getUserDetailsFromToken(token);
            String username = userDetails.getUsername();

            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

                // 通过 username 查询数据库 获取token 然后和库中token作比较

                if (username.equals("liuzhihang")) {

                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        filterChain.doFilter(request, response);
    }

}

URI动态校验

package com.liuzhihang.demo.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import java.util.HashSet;
import java.util.Set;

/**
 * 动态权限认证
 *
 * @author liuzhihang
 * @date 2019-06-25 15:51
 */
@Slf4j
@Component(value = "dynamicAuthorityService")
public class DynamicAuthorityService {


    public boolean hasPermission(HttpServletRequest request, Authentication authentication) {

        try {
            Object principal = authentication.getPrincipal();
            if (principal instanceof UserDetails && authentication instanceof UsernamePasswordAuthenticationToken) {
                // 本次请求的uri
                String uri = request.getRequestURI();

                // 获取当前用户
                UserDetails userDetails = (UserDetails) principal;

                String username = userDetails.getUsername();
                log.info("本次用户请求认证, username:{}, uri:{}", username, uri);

                // 从数据库取逻辑
                if (username.equals("liuzhihang")){
                    Set<String> set = new HashSet<>();
                    set.add("/homeInfo");
                    set.add("/getAllUser");
                    set.add("/editUserInfo");
                    if (set.contains(uri)) {
                        return true;
                    }
                }

            }
        } catch (Exception e) {
            log.error("用户请求登录, uri:{} error", request.getRequestURI(), e);
            return false;
        }
        return false;
    }
}

测试

脚本在 httpclient脚本

POST localhost:8080/login
Content-Type: application/json

{
  "username": "liuzhihang",
  "password": "123456789"
}
### 请求接口脚本

POST localhost:8080/homeInfo
Content-Type: application/json
token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJsaXV6aGloYW5nIiwiaWF0IjoxNTY5MDI1NjY4LCJleHAiOjE1Njk2MzA0Njh9.Kot_uLnwtcq-t5o4x3V-xBnpf-mKEi7OV2eAfgMCKLk
###

返回:

{
  "resultCode": "0000",
  "resultMsg": "登录成功",
  "resultTime": "20190920191038",
  "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJsaXV6aGloYW5nIiwiaWF0IjoxNTY4OTc3ODM4LCJleHAiOjE1Njk1ODI2Mzh9.MAS9VkFdCF3agkCgTtc0VzPMFjY42vFyIvAEzkSeAfs"
}

参考

前后端分离 SpringBoot + SpringSecurity + JWT + RBAC 实现用户无状态请求验证