JWT认证登陆,通过token和refreshToken达到前端无感刷新!

878 阅读9分钟

JWT认证登陆,通过token和refreshToken达到前端无感刷新

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

1 JWT简介

JWT(全称:Json Web Token)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。简而言之,它用在认证机制时,后端依此判断请求是否合法。类似于token

1.1 JWT数据结构

JWT一般是这样一个xxxxx.yyyyy.zzzzz字符串,分为三个部分,以"."隔开,按次序分别是:

  • Header
  • Payload
  • Signature

1.1.1 Header

它是一个描述JWT元数据的Json对象,通常由令牌的类型,即JWT,和常用的散列算法,如HMAC、SHA256或RSA组成。例如:

{
  "alg": "HS256",
  "typ": "JWT"
}

Header部分的JSON会被Base64Url编码。

1.1.2 Payload

它也是一个Json对象,主要包含两部分内容,一是默认的可选择字段,分别是:iss:发行人、exp:到期时间、sub:主题、aud:用户、nbf:在此之前不可用、iat:发布时间、jti:JWT ID用于标识该JWT。另一部分是用户自定义内容,例如用户id和用户名等。

Payload部分也会被Base64Url编码,但没有经过加密,并不安全。因此在存储用户相关信息时不能存储密码等敏感内容。

1.1.3 Signature

它是JWT第一部分和第二部分的签名。通过一个密钥,使用Header指定的算法对Header和Payload进行计算,然后就得出一个签名哈希字符串,也就是Signature。验证时,通过同一密钥,再对Header和Payload进行计算,得出的签名与JWT的第三段Signature相比较看是否相同。相同时则说明认证通过。

Signature可以保证JWT内容的正确性和完整性,避免人为的解析和对前两部分内容的篡改。

1.2 JWT登陆认证优缺点分析

使用JWT Token做认证登陆最主要优点是token中包含用户信息,并存储在客户端,服务端不需要使用session存储用户信息,这也避免了跨域问题。但是在实际中存在诸多问题:

  1. 用户信息可能存在敏感信息,因为Payload非加密的特性,敏感数据不能放在里面。
  2. 用户信息过多时,token占用空间大,每次接口访问时,请求头都要携带大量数据,后端解密也费时。
  3. 客户端token不能及时感应到服务端用户的状态变化。例如这个账户已经被管理员禁止登陆了,但是客户端之前的token假如没有过期,则还可以正常访问。

思来想去,在比较复杂的登陆业务中,服务端session貌似是不可或缺的,其在安全性、方便性和可扩展性都有优势。jwt+session虽然不符合jwt设计理念,但或许是比较好的登陆方案。

2 登陆认证流程

下面纯使用JWT做登陆与认证,不存session,只做代码实现验证和个人小项目使用。

  1. 浏览器输入用户名和密码登陆。
  2. 后端校验账户与密码成功后,返回通过JWT生成的token和refreshToken。前端则把两个token都存在cookie中。token有效期半个小时,包含用户的一些简单的账户信息,但不能存账户私密信息,用来进行每次接口调用时的认证。refreshToken有效期三天,只存用户id,当token过期失效时用其来刷新token和refreshToken,避免token固定过期时间失效后的多次登陆。
  3. 前端访问后端接口时在请求头带上token。
  4. 若在某次接口访问时token已经过期失效了,则返回特定状态码,前端此时带上参数refreshToken调用刷新token方法去获取新的token和refreshToken。若此时refreshToken也过期失效则返回另一状态码,前端获取后跳转到登陆页面。
  5. 在步骤4中若获取到了新的token,重新调用之前的接口去获取业务结果值。注意在4和5过程中用户是无感的。

3 后端代码

3.1 JWT工具类

使用gradle引入包 implementation group: 'com.auth0', name: 'java-jwt', version: '3.18.2'

@Slf4j
public class JwtUtil {

    private static final String jwtSecret = "dawang123@1,.sWDsLOI_US";
    private static final String jwtIssuer = "banXian";
    // token持续时间,分钟
    private static final long TOKEN_DURING = 30L;
    private static final long REFRESH_TOKEN_DURING = 3L * 24 * 60;

    private static final Algorithm DEFAULT_ALGORITHM = Algorithm.HMAC256(jwtSecret);

    /**
     * 对称加密算法,HMAC256创建JWT
     */
    public static String createJWT(Object data, Long during) {
        String token = "";
        try {
            token = JWT.create()
                    .withIssuer(jwtIssuer)   //发布者
//                    .withSubject("subject")    //主题
//                    .withAudience("王大")     //观众,相当于接受者
//                    .withJWTId(UUID.randomUUID().toString())    //编号
                    .withIssuedAt(new Date())   // 生成签名的时间
                    .withNotBefore(new Date())  //生效时间
                    .withExpiresAt(new Date(System.currentTimeMillis() + during * 60 * 1000))    // 生成签名过期时间
                    .withClaim("data", JacksonUtil.toJson(data)) //自定义数据
                    .sign(DEFAULT_ALGORITHM);
        } catch (JWTCreationException e) {
            log.error("createJWT error:", e);
        }
        return token;
    }

    public static String createJWT(Object data) {
        return createJWT(data, TOKEN_DURING);
    }

    public static String createRefreshJWT(Object data) {
        return createJWT(data, REFRESH_TOKEN_DURING);
    }

    public static <T> T parseToken(String token, Class<T> clazz) {
        Map<String, Claim> claims = getClaims(token);
        if (claims == null || claims.isEmpty()) {
            return null;
        }
        return JacksonUtil.parse(claims.get("data").asString(), clazz);
    }


    private static Map<String, Claim> getClaims(String token) {
        DecodedJWT decodedJWT = getDecodedJWT(token);
        if (decodedJWT == null) {
            return null;
        }
        return decodedJWT.getClaims();
    }


    /**
     * 获取DecodedJWT
     *
     * @param token token令牌
     * @return DecodedJWT
     */
    private static DecodedJWT getDecodedJWT(String token) {
        if (token == null || token.isEmpty()) {
            return null;
        }
        DecodedJWT decodedJWT = null;
        try {
            decodedJWT = JWT.require(DEFAULT_ALGORITHM).build().verify(token);
        } catch (AlgorithmMismatchException e) {
            log.error("decodedJWT error:{}", "token算法不一致");
        } catch (InvalidClaimException e) {
            log.error("decodedJWT error:{}", "无效的token声明");
        } catch (JWTDecodeException e) {
            log.error("decodedJWT error:{}", "token解码异常");
        } catch (SignatureVerificationException e) {
            log.error("decodedJWT error:{}", "token签名无效");
        } catch (TokenExpiredException e) {
            log.warn("decodedJWT error:{}", "token已过期");
        } catch (Exception e) {
            log.error("decodedJWT error:{}", "其他异常");
        }
        return decodedJWT;
    }

3.2 登陆方法与刷新token方法

  • refreshToken方法,校验refreshToken失败(可能过期了)或出现别的异常时直接扔出RefreshTokenInvalidException,全局异常处理捕获后返回code为1002,前端获取后直接跳转到登陆页面。
 @Override
    public JwtToken loginJwt(LoginParam loginParam) {
        // 判断登陆密码
        Admin admin = validAccountPwd(loginParam);
        return createJwtToken(admin);
    }

    @Override
    public JwtToken refreshToken(JwtToken jwtToken) {
        try {
            UserInfo userInfo = JwtUtil.parseToken(jwtToken.getRefreshToken(), UserInfo.class);
            Admin admin = adminService.getById(userInfo.getUserId());
            return createJwtToken(admin);
        }catch (Exception e){
            throw new RefreshTokenInvalidException("RefreshToken已失效或无效,请重新登录");
        }
    }

    private JwtToken createJwtToken(Admin admin) {
        UserInfo userInfo = new UserInfo();
        userInfo.setUserId(admin.getId());
        // refreshToken默认三天
        String refreshToken = JwtUtil.createRefreshJWT(userInfo);
        // token默认时间30分钟
        userInfo.setUserName(admin.getAdminName());
        userInfo.setAvatar(admin.getAvatar());
        userInfo.setNickname(admin.getNickname());
        String token = JwtUtil.createJWT(userInfo);

        JwtToken jwtToken = new JwtToken();
        jwtToken.setToken(token);
        jwtToken.setRefreshToken(refreshToken);
        return jwtToken;
    }

3.3 登陆拦截器

  • 拦截器校验token失败后,直接扔出TokenInvalidException,全局异常处理捕获后返回code为1001,前端获取后调用refreshToken接口去刷新token和refreshToken。
  • 若token未失效,则把获取的UserInfo放到ThreadlLocal中,方便获取用户信息,这就不要session了,嘿嘿。
public class LoginInterceptor implements HandlerInterceptor {

    public static final Logger log = LoggerFactory.getLogger(LoginInterceptor.class);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {

        // 可以获取该方法上的自定义注解,然后通过注解来判断该方法是否要被拦截
        // @ExcludedPath 自定义注解,不被拦截
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        ExcludedPath excludedPath = method.getAnnotation(ExcludedPath.class);
        if (excludedPath != null) {
            return true;
        }

        // token验证
        String token = request.getHeader("Authorization").substring("Bearer".length() + 1).trim();
        log.info("AuthInterceptor:token is {}", token);
        UserInfo userInfo = JwtUtil.parseToken(token, UserInfo.class);
        if (userInfo == null) {
            throw new TokenInvalidException("token已失效或无效");
        }
        UserInfoHelper.set(userInfo);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
//        log.info("执行完方法之后进行(Controller方法调用之后),但是此时还没有进行视图渲染");
        UserInfoHelper.clear();
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
//        log.info("整个请求都处理完毕,DispatcherServlet也渲染了对应的视图,此时可以做一些清理的工作等等");
    }

}

4 前端代码

  • 难点一是在一个业务接口返回token失效时,需要携带refershToken去调用刷新token接口,在成功的返回新的token后,这个业务接口再携带新的token去获取业务数据,这个过程用户是无感的。
  • 难点二是若一个页面多个业务接口同时返回token失效,则只需要第一个接口去刷新token,别的接口等待第一个接口刷新完成后直接使用新的token去获取业务数据。
  • 两个难点最后解决参考文章。# axios封装,无感刷新token
  • 前端使用Vue,框架模板使用的是GitHub - PanJiaChen/vue-admin-template: a vue2.0 minimal admin template

4.1 axios封装

  1. 发送每一次请求时,都在请求拦截器中,先获取最新token,然后设置请求头Authorization。
  2. response拦截器中,code=1001 在刷新token时,旧请求通过await先挂起,返回新token后,使用旧请求的接口配置发起新的请求,并把结果返回给旧的请求。
  3. response拦截器中,code=1002 则重新登陆。
import axios from 'axios'
import { Message, MessageBox } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'

// create an axios instance
const instance = axios.create({
  baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
  // withCredentials: true, // send cookies when cross-domain requests
  timeout: 20000 // request timeout
})

// request interceptor
instance.interceptors.request.use(
  config => {
    // do something before request is sent
    if (store.getters.token) {
      config.headers['Authorization'] = 'Bearer ' + getToken()
    }
    return config
  },
  error => {
    // do something with request error
    console.log(error) // for debug
    return Promise.reject(error)
  }
)

// response interceptor
instance.interceptors.response.use(
  async response => {
    const { data } = response
    // 0:正常返回 1:系统错误
    if (data.code !== 0) {
      // token失效尝试通过refreshToken去刷新token
      if (data.code === 1001) {
        await store.dispatch('user/refreshToken')
        return Promise.resolve(instance(response.config))
      } else if (data.code === 1002) {
        // refreshtoken也失效,去重新登录
        MessageBox.confirm('你的登陆状态已失效,请重新登陆', '确认登录', {
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          store.dispatch('user/resetToken').then(() => {
            location.reload()
          })
        })
      } else {
        Message({ message: data.msg || '系统出错了',
          type: 'error',
          duration: 5 * 1000
        })
      }
    } else {
      return data
    }
    return Promise.reject(new Error(data.msg || data || 'Error'))
  },
  error => {
    Message({
      message: error.msg || error,
      type: 'error',
      duration: 5 * 1000
    })
    return Promise.reject(error)
  }
)

export default instance

4.2 user.js

  • 包含用户登陆、退出、刷新token等的一些方法,重要的是refreshToken方法。
  • refreshPromise为真则说明某一请求正在刷新token,别的刷新请求则直接返回refreshPromise。调用完成后,refreshPromise要重新设置为underfine,以免下次刷新token时为真无法去刷新。
  • auth.js就是操作token在cookie中放置或移除。
import { login, refreshToken, logout, getInfo } from '@/api/user'
import * as auth from '@/utils/auth'
import { resetRouter } from '@/router'

const getDefaultState = () => {
  return {
    token: auth.getToken(),
    refreshToken: auth.getRefreshToken(),
    userName: '',
    nickname: '',
    avatar: ''
  }
}

const state = getDefaultState()

// 是否刷新token标志
let refreshPromise

const mutations = {
  RESET_STATE: (state) => {
    Object.assign(state, getDefaultState())
  },
  SET_TOKEN: (state, token) => {
    state.token = token
  },
  SET_REFRESH_TOKEN: (state, refreshToken) => {
    state.refreshToken = refreshToken
  },
  SET_NAME: (state, userName) => {
    state.userName = userName
  },
  SET_NICKNAME: (state, nickname) => {
    state.nickname = nickname
  },
  SET_AVATAR: (state, avatar) => {
    state.avatar = avatar || 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif'
  }
}

const actions = {
  // user login
  login({ commit }, userInfo) {
    const { username, password } = userInfo
    return new Promise((resolve, reject) => {
      login({ adminName: username.trim(), password: password }).then(response => {
        const { data } = response
        commit('SET_TOKEN', data.tokens.token)
        commit('SET_REFRESH_TOKEN', data.tokens.refreshToken)
        auth.setToken(data.tokens.token)
        auth.setRefreshToken(data.tokens.refreshToken)
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  },
  // refresh token
  refreshToken({ commit, state }) {
    if (!refreshPromise) {
      refreshPromise = new Promise((resolve, reject) => {
        refreshToken({ refreshToken: state.refreshToken }).then(response => {
          const { data } = response
          commit('SET_TOKEN', data.tokens.token)
          commit('SET_REFRESH_TOKEN', data.tokens.refreshToken)
          auth.setToken(data.tokens.token)
          auth.setRefreshToken(data.tokens.refreshToken)
          resolve()
        }).catch(error => {
          reject(error)
          // eslint-disable-next-line no-return-assign
        }).finally(() => refreshPromise = undefined)
      })
    }
    return refreshPromise
  },
  // get user info
  getInfo({ commit, state }) {
    return new Promise((resolve, reject) => {
      getInfo().then(response => {
        const { data } = response
        if (!data) {
          return reject('Verification failed, please Login again.')
        }
        const { userName, nickname, avatar } = data.info
        commit('SET_NAME', userName)
        commit('SET_AVATAR', avatar)
        commit('SET_NICKNAME', nickname)
        resolve(data)
      }).catch(error => {
        reject(error)
      })
    })
  },

  // user logout
  logout({ commit, state }) {
    return new Promise((resolve, reject) => {
      logout(state.token).then(() => {
        auth.removeToken() // must remove  token  first
        auth.removeRefreshToken()
        resetRouter()
        commit('RESET_STATE')
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  },

  // remove token
  resetToken({ commit }) {
    return new Promise(resolve => {
      auth.removeToken() // must remove  token  first
      auth.removeRefreshToken()
      commit('RESET_STATE')
      resolve()
    })
  }

}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

5.总结

JWT登陆个人使用感觉还是挺不错的,免登陆时间长,不占用服务器空间等。在小项目或某些场景下或许有较大的用处。后面再持续探索吧!

技术有限,不足之处望各位大佬多多指教!一起进步!