diff --git a/ruoyi-common/ruoyi-common-security/src/main/java/com/ruoyi/common/security/config/ResourceServerConfig.java b/ruoyi-common/ruoyi-common-security/src/main/java/com/ruoyi/common/security/config/ResourceServerConfig.java index 6aa3ba89..9f48f8db 100644 --- a/ruoyi-common/ruoyi-common-security/src/main/java/com/ruoyi/common/security/config/ResourceServerConfig.java +++ b/ruoyi-common/ruoyi-common-security/src/main/java/com/ruoyi/common/security/config/ResourceServerConfig.java @@ -1,5 +1,6 @@ package com.ruoyi.common.security.config; +import com.ruoyi.common.security.handler.AuthExceptionEntryPoint; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.security.oauth2.OAuth2ClientProperties; import org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerProperties; @@ -77,6 +78,6 @@ public class ResourceServerConfig extends ResourceServerConfigurerAdapter @Override public void configure(ResourceServerSecurityConfigurer resources) { - resources.tokenServices(tokenServices()); + resources.tokenServices(tokenServices()).authenticationEntryPoint(new AuthExceptionEntryPoint()); } } diff --git a/ruoyi-common/ruoyi-common-security/src/main/java/com/ruoyi/common/security/handler/AuthExceptionEntryPoint.java b/ruoyi-common/ruoyi-common-security/src/main/java/com/ruoyi/common/security/handler/AuthExceptionEntryPoint.java new file mode 100644 index 00000000..f634c37e --- /dev/null +++ b/ruoyi-common/ruoyi-common-security/src/main/java/com/ruoyi/common/security/handler/AuthExceptionEntryPoint.java @@ -0,0 +1,38 @@ +package com.ruoyi.common.security.handler; + +import com.alibaba.fastjson.JSON; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.common.core.constant.HttpStatus; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.utils.ServletUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * 自定义访问401返回值 + * + * @author tanran + */ +public class AuthExceptionEntryPoint implements AuthenticationEntryPoint { + + private final Logger logger = LoggerFactory.getLogger(AuthExceptionEntryPoint.class); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException){ + + logger.info("token已失效,跳转登录页面 {}", request.getRequestURI()); + + String msg = authException.getMessage(); + ServletUtils.renderString(response, JSON.toJSONString(R.fail(HttpStatus.UNAUTHORIZED, msg))); + } +} \ No newline at end of file diff --git a/ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/ValidateCodeFilter.java b/ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/ValidateCodeFilter.java index e2cbd0bb..8dbd7a09 100644 --- a/ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/ValidateCodeFilter.java +++ b/ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/ValidateCodeFilter.java @@ -11,8 +11,12 @@ import com.alibaba.fastjson.JSON; import com.ruoyi.common.core.utils.StringUtils; import com.ruoyi.common.core.web.domain.AjaxResult; import com.ruoyi.gateway.service.ValidateCodeService; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; import reactor.core.publisher.Mono; +import java.util.List; + /** * 验证码过滤器 * @@ -38,8 +42,13 @@ public class ValidateCodeFilter extends AbstractGatewayFilterFactory return (exchange, chain) -> { ServerHttpRequest request = exchange.getRequest(); - // 非登录请求,不处理 - if (!StringUtils.containsIgnoreCase(request.getURI().getPath(), AUTH_URL)) + MultiValueMap queryParams = request.getQueryParams(); + // todo 下述常量写入SecurityConstants.java + List grant_typeS = queryParams.get("grant_type"); + + // 非登录请求,不处理 刷新access_token,不处理 + boolean isLogin = StringUtils.containsIgnoreCase(request.getURI().getPath(),AUTH_URL); + if (!isLogin || (isLogin && !ObjectUtils.isEmpty(queryParams) && !ObjectUtils.isEmpty(grant_typeS) && grant_typeS.contains("refresh_token"))) { return chain.filter(exchange); } diff --git a/ruoyi-ui/src/api/login.js b/ruoyi-ui/src/api/login.js index 9aa93ca9..bc81ed15 100644 --- a/ruoyi-ui/src/api/login.js +++ b/ruoyi-ui/src/api/login.js @@ -1,39 +1,50 @@ -import request from '@/utils/request' - -const client_id = 'web' -const client_secret = '123456' -const grant_type = 'password' -const scope = 'server' - -// 登录方法 -export function login(username, password, code, uuid) { - return request({ - url: '/auth/oauth/token', - method: 'post', - params: { username, password, code, uuid, client_id, client_secret, grant_type, scope } - }) -} - -// 获取用户详细信息 -export function getInfo() { - return request({ - url: '/system/user/getInfo', - method: 'get' - }) -} - -// 退出方法 -export function logout() { - return request({ - url: '/auth/token/logout', - method: 'delete' - }) -} - -// 获取验证码 -export function getCodeImg() { - return request({ - url: '/code', - method: 'get' - }) -} \ No newline at end of file +import request from '@/utils/request' + +const client_id = 'web' +const client_secret = '123456' +let grant_type = 'password' +const scope = 'server' + +// 刷新方法 +export function refreshToken( refresh_token ) { + grant_type = `refresh_token` + return request({ + url: '/auth/oauth/token', + method: 'post', + params: { client_id, client_secret, grant_type, scope, refresh_token } + }) +} + +// 登录方法 +export function login(username, password, code, uuid) { + grant_type = 'password' + return request({ + url: '/auth/oauth/token', + method: 'post', + params: { username, password, code, uuid, client_id, client_secret, grant_type, scope } + }) +} + +// 获取用户详细信息 +export function getInfo() { + return request({ + url: '/system/user/getInfo', + method: 'get' + }) +} + +// 退出方法 +export function logout() { + return request({ + url: '/auth/token/logout', + method: 'delete' + }) +} + +// 获取验证码 +export function getCodeImg() { + return request({ + url: '/code', + method: 'get' + }) +} diff --git a/ruoyi-ui/src/store/modules/user.js b/ruoyi-ui/src/store/modules/user.js index 5c8a33ff..4459e3cd 100644 --- a/ruoyi-ui/src/store/modules/user.js +++ b/ruoyi-ui/src/store/modules/user.js @@ -1,96 +1,161 @@ -import { login, logout, getInfo } from '@/api/login' -import { getToken, setToken, removeToken } from '@/utils/auth' - -const user = { - state: { - token: getToken(), - name: '', - avatar: '', - roles: [], - permissions: [] - }, - - mutations: { - SET_TOKEN: (state, token) => { - state.token = token - }, - SET_NAME: (state, name) => { - state.name = name - }, - SET_AVATAR: (state, avatar) => { - state.avatar = avatar - }, - SET_ROLES: (state, roles) => { - state.roles = roles - }, - SET_PERMISSIONS: (state, permissions) => { - state.permissions = permissions - } - }, - - actions: { - // 登录 - Login({ commit }, userInfo) { - const username = userInfo.username.trim() - const password = userInfo.password - const code = userInfo.code - const uuid = userInfo.uuid - return new Promise((resolve, reject) => { - login(username, password, code, uuid).then(res => { - setToken(res.access_token) - commit('SET_TOKEN', res.access_token) - resolve() - }).catch(error => { - reject(error) - }) - }) - }, - - // 获取用户信息 - GetInfo({ commit, state }) { - return new Promise((resolve, reject) => { - getInfo(state.token).then(res => { - const user = res.user - const avatar = user.avatar == "" ? require("@/assets/image/profile.jpg") : process.env.VUE_APP_BASE_API + user.avatar; - if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组 - commit('SET_ROLES', res.roles) - commit('SET_PERMISSIONS', res.permissions) - } else { - commit('SET_ROLES', ['ROLE_DEFAULT']) - } - commit('SET_NAME', user.userName) - commit('SET_AVATAR', avatar) - resolve(res) - }).catch(error => { - reject(error) - }) - }) - }, - - // 退出系统 - LogOut({ commit, state }) { - return new Promise((resolve, reject) => { - logout(state.token).then(() => { - commit('SET_TOKEN', '') - commit('SET_ROLES', []) - commit('SET_PERMISSIONS', []) - removeToken() - resolve() - }).catch(error => { - reject(error) - }) - }) - }, - - // 前端 登出 - FedLogOut({ commit }) { - return new Promise(resolve => { - commit('SET_TOKEN', '') - removeToken() - resolve() - }) - } - } -} - -export default user +import { login, logout, getInfo, refreshToken as refreshTokenFunc } from '@/api/login' +import { getToken, setToken, removeToken, + setRefreshToken, removeRefreshToken, + setExpiresIn, removeExpiresIn +} from '@/utils/auth' + +/** + * 存储token + * @param commit + * @param res + */ +function storeToken(commit, resolve, res) { + setToken(res.access_token) + commit('SET_TOKEN', res.access_token) + + // 存储refresh_token expires_in + // console.log(`获取[刷新令牌]成功了 === `, res.refresh_token) + setRefreshToken(res.refresh_token) + commit('SET_REFRESH_TOKEN', res.refresh_token) + + const expires_in_time = new Date().getTime() + res.expires_in * 1000 + // console.log(`获取[访问令牌]成功了,过期日期 === `, new Date(expires_in_time)) + setExpiresIn(expires_in_time) + commit('SET_EXPIRES_IN', expires_in_time) + + resolve() +} + +const user = { + state: { + token: getToken(), + name: '', + avatar: '', + roles: [], + permissions: [] + }, + + mutations: { + SET_EXPIRES_IN: (state, v) => { + state.expires_in = v + }, + SET_REFRESH_TOKEN: (state, v) => { + state.refresh_token = v + }, + SET_TOKEN: (state, token) => { + state.token = token + }, + SET_NAME: (state, name) => { + state.name = name + }, + SET_AVATAR: (state, avatar) => { + state.avatar = avatar + }, + SET_ROLES: (state, roles) => { + state.roles = roles + }, + SET_PERMISSIONS: (state, permissions) => { + state.permissions = permissions + } + }, + + + actions: { + + // 刷新 + RefreshToken({ commit }, refreshTokenParams) { + // console.log(`进入src/store/modules/user.js执行[刷新token]`) + const refreshToken = refreshTokenParams.refreshToken + return new Promise((resolve, reject) => { + refreshTokenFunc(refreshToken).then(res => { + debugger + // console.log(`调用[刷新token]接口,返回参数 === `, res) + + storeToken(commit, resolve, res) + + }).catch(error => { + reject(error) + + // console.log(`可能refresh_token已过期!`, error) + + // 清空 + + // console.log(`清空鉴权信息`) + commit('SET_TOKEN', '') + commit('SET_REFRESH_TOKEN', '') + commit('SET_EXPIRES_IN', 0) + commit('SET_ROLES', []) + commit('SET_PERMISSIONS', []) + removeToken() + removeRefreshToken() + removeExpiresIn() + + }) + }) + }, + + // 登录 + Login({ commit }, userInfo) { + const username = userInfo.username.trim() + const password = userInfo.password + const code = userInfo.code + const uuid = userInfo.uuid + return new Promise((resolve, reject) => { + login(username, password, code, uuid).then(res => { + + storeToken(commit, resolve, res) + + }).catch(error => { + reject(error) + }) + }) + }, + + // 获取用户信息 + GetInfo({ commit, state }) { + return new Promise((resolve, reject) => { + getInfo(state.token).then(res => { + const user = res.user + const avatar = user.avatar == "" ? require("@/assets/image/profile.jpg") : process.env.VUE_APP_BASE_API + user.avatar; + if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组 + commit('SET_ROLES', res.roles) + commit('SET_PERMISSIONS', res.permissions) + } else { + commit('SET_ROLES', ['ROLE_DEFAULT']) + } + commit('SET_NAME', user.userName) + commit('SET_AVATAR', avatar) + resolve(res) + }).catch(error => { + reject(error) + }) + }) + }, + + // 退出系统 + LogOut({ commit, state }) { + return new Promise((resolve, reject) => { + logout(state.token).then(() => { + commit('SET_TOKEN', '') + commit('SET_ROLES', []) + commit('SET_PERMISSIONS', []) + removeToken() + resolve() + }).catch(error => { + reject(error) + }) + }) + }, + + // 前端 登出 + FedLogOut({ commit }) { + return new Promise(resolve => { + commit('SET_TOKEN', '') + removeToken() + resolve() + }) + } + } +} + +export default user diff --git a/ruoyi-ui/src/utils/auth.js b/ruoyi-ui/src/utils/auth.js index 88d7b6cc..ec26552b 100644 --- a/ruoyi-ui/src/utils/auth.js +++ b/ruoyi-ui/src/utils/auth.js @@ -1,15 +1,57 @@ -import Cookies from 'js-cookie' - -const TokenKey = 'Admin-Token' - -export function getToken() { - return Cookies.get(TokenKey) -} - -export function setToken(token) { - return Cookies.set(TokenKey, token) -} - -export function removeToken() { - return Cookies.remove(TokenKey) -} +import Cookies from 'js-cookie' + +const suffix = `ruoyi` + +const TokenKey = 'Admin-Token' + suffix +const RefreshTokenKey = 'Admin-Refresh-Token' + suffix +const ExpiresInKey = 'Admin-Expires-In' + suffix + +export function getToken() { + return Cookies.get(TokenKey) +} + +export function setToken(v) { + return Cookies.set(TokenKey, v) +} + +export function removeToken() { + return Cookies.remove(TokenKey) +} + +/** + * 存储令牌信息 refresh_token expires_in 等等 + * @param token + * @returns {*} + */ +export function getRefreshToken() { + // console.log(`从Cookie获取refresh_token`) + return Cookies.get(RefreshTokenKey) || `` +} + +export function setRefreshToken(v) { + return Cookies.set(RefreshTokenKey, v) +} + +export function removeRefreshToken() { + return Cookies.remove(RefreshTokenKey) +} + +/** + * + * @returns {*} + */ +export function getExpiresIn() { + + const time = Cookies.get(ExpiresInKey) || -1 // -1说明cookie没有过期时间,用户还没有登录或者准备登录 + + // // console.log(`从Cookie获取token过期时间 === `, new Date(parseInt(time))) + return time +} + +export function setExpiresIn(v) { + return Cookies.set(ExpiresInKey, v) +} + +export function removeExpiresIn() { + return Cookies.remove(ExpiresInKey) +} diff --git a/ruoyi-ui/src/utils/request.js b/ruoyi-ui/src/utils/request.js index 5d1cfd36..f753ce3a 100644 --- a/ruoyi-ui/src/utils/request.js +++ b/ruoyi-ui/src/utils/request.js @@ -1,102 +1,186 @@ -import axios from 'axios' -import { Notification, MessageBox, Message } from 'element-ui' -import store from '@/store' -import { getToken } from '@/utils/auth' -import errorCode from '@/utils/errorCode' -import { tansParams } from "@/utils/ruoyi"; - -axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8' -// 创建axios实例 -const service = axios.create({ - // axios中请求配置有baseURL选项,表示请求URL公共部分 - baseURL: process.env.VUE_APP_BASE_API, - // 超时 - timeout: 10000 -}) - -// request拦截器 -service.interceptors.request.use(config => { - const isToken = (config.headers || {}).isToken === false - if (getToken() && !isToken) { - config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改 - } - return config -}, error => { - console.log(error) - Promise.reject(error) -}) - -// 响应拦截器 -service.interceptors.response.use(res => { - const code = res.data.code || 200; - const message = errorCode[code] || res.data.msg || errorCode['default'] - if (code === 401) { - MessageBox.confirm( - '登录状态已过期,您可以继续留在该页面,或者重新登录', - '系统提示', - { - confirmButtonText: '重新登录', - cancelButtonText: '取消', - type: 'warning' - } - ).then(() => { - store.dispatch('LogOut').then(() => { - location.reload() // 为了重新实例化vue-router对象 避免bug - }) - }) - } else if (code === 500) { - Message({ - message: message, - type: 'error' - }) - return Promise.reject(new Error(message)) - } else if (code !== 200) { - Notification.error({ - title: message - }) - return Promise.reject('error') - } else { - return res.data - } - }, - error => { - console.log('err' + error) - Message({ - message: error.message, - type: 'error', - duration: 5 * 1000 - }) - return Promise.reject(error) - } -) - -// 通用下载方法 -export function download(url, params, filename) { - return service.post(url, params, { - transformRequest: [(params) => { - return tansParams(params) - }], - responseType: 'blob' - }).then((data) => { - const content = data - const blob = new Blob([content]) - if ('download' in document.createElement('a')) { - const elink = document.createElement('a') - elink.download = filename - elink.style.display = 'none' - elink.href = URL.createObjectURL(blob) - document.body.appendChild(elink) - elink.click() - URL.revokeObjectURL(elink.href) - document.body.removeChild(elink) - } else { - navigator.msSaveBlob(blob, filename) - } - }).catch((r) => { - console.error(r) - }) -} - - - -export default service +import axios from 'axios' +import { Notification, MessageBox, Message } from 'element-ui' +import store from '@/store' +import { getToken, getRefreshToken, getExpiresIn,removeToken} from '@/utils/auth' +import errorCode from '@/utils/errorCode' +import { tansParams } from "@/utils/ruoyi"; +var refreshCount = 0 +// token将在这个时间以后过期 毫秒 +// const EXPIRED_IN_THIS_SECONDS = 6000 +const EXPIRED_IN_THIS_SECONDS = 100000 +const CODE_PATH = `/code` +window.isRefreshing = false + +axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8' +// 创建axios实例 +const service = axios.create({ + // axios中请求配置有baseURL选项,表示请求URL公共部分 + baseURL: process.env.VUE_APP_BASE_API, + // 超时 + timeout: 10000 +}) + +/** + * 增加令牌刷新功能 + * qq:8416837 + * author:学生宫布 + */ +// request拦截器 +service.interceptors.request.use(config => { + + // 令牌维护 - start + // 获取当前时间戳,与过期时间比对,如果即将过期或已经过期,则调/auth/oauth/token API刷新token + // isRefreshing 检测是否正在刷新,如果正在刷新,则阻塞,直到获取新token + const beLogining = config.url === CODE_PATH // 正在登录 + // console.log(`【请求拦截器执行中】`) + const futureTime = getExpiresIn() + // console.log(`令牌到期时间(long)`, futureTime) + // console.log(`令牌到期时间 === `, new Date(parseInt(futureTime))) + const itsTimeToRrefresh = futureTime != -1 && !window.isRefreshing && ((futureTime - new Date().getTime() ) <= EXPIRED_IN_THIS_SECONDS) + if (itsTimeToRrefresh) { // 如果expires_in_time eq 0,则很可能是初次登陆,从而勿须刷新令牌 假如设置还差6秒过期 + // 锁 避免多个调用重复刷新 + window.isRefreshing = true + + // console.log(`当前时间 === `, new Date()); + + // console.log(`令牌`, (futureTime - new Date().getTime())/1000, `秒后过期,因此现在刷新令牌`); + + // 刷新令牌 将新令牌更新到存储或本地 + return refresh(config); + + } + // 令牌维护 - end + + else { + // console.log(`令牌正常或者还未登录【或者正在刷新】,因此暂不刷新它,请求url === `, config.url); + + if(beLogining) { // 如果正在登录,那就清空token相关的cookie + // console.log(`准备登录,请求url === `, config.url) + removeToken() + return config + }else { // 如果在调业务接口,则授权 + return auth(config); // 给请求添加token + } + } + +}, error => { + // console.log(error) + Promise.reject(error) +}) + +/** + * 同步刷新令牌并更新到ajax配置 + * @param config + * @returns {Promise<*>} + */ +async function refresh(config) { + const refreshTokenParams = {} + const refreshToken = getRefreshToken() + refreshTokenParams.refreshToken = refreshToken + // 调API刷新令牌 + await store.dispatch("RefreshToken", refreshTokenParams).then(() => { + + auth(config) + + }) + .catch((e) => { + // console.log(`刷新失败`, e, `,可能refresh_token已过期~`) + }); + + refreshCount ++ + + // console.log(`刷新页面之前,当前第几次刷新token === `, refreshCount) + + window.isRefreshing = false + return config +} + +/** + * 给请求授权 + * @param config + * @returns {{headers}} + */ +function auth(config) { + const isToken = (config.headers || {}).isToken === false + if (getToken() && !isToken) { + + // console.log(`访问`, config.url, `之前,给请求头附加token`) + + config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改 + } + + return config +} + +// 响应拦截器 +service.interceptors.response.use(res => { + const code = res.data.code || 200 || 0; + const message = errorCode[code] || res.data.errMsg || res.data.msg || errorCode['default'] + if (code === 401) { + MessageBox.confirm( + '登录状态已过期,您可以继续留在该页面,或者重新登录', + '系统提示', + { + confirmButtonText: '重新登录', + cancelButtonText: '取消', + type: 'warning' + } + ).then(() => { + store.dispatch('LogOut').then(() => { + location.reload() // 为了重新实例化vue-router对象 避免bug + }) + }) + } else if (code === 500) { + Message({ + message: message, + type: 'error' + }) + return Promise.reject(new Error(message)) + } else if (code !== 200) { + Notification.error({ + title: message + }) + return Promise.reject('error') + } else { + return res.data + } + }, + error => { + // console.log('err' + error) + Message({ + message: error.message, + type: 'error', + duration: 5 * 1000 + }) + return Promise.reject(error) + } +) + +// 通用下载方法 +export function download(url, params, filename) { + return service.post(url, params, { + transformRequest: [(params) => { + return tansParams(params) + }], + responseType: 'blob' + }).then((data) => { + const content = data + const blob = new Blob([content]) + if ('download' in document.createElement('a')) { + const elink = document.createElement('a') + elink.download = filename + elink.style.display = 'none' + elink.href = URL.createObjectURL(blob) + document.body.appendChild(elink) + elink.click() + URL.revokeObjectURL(elink.href) + document.body.removeChild(elink) + } else { + navigator.msSaveBlob(blob, filename) + } + }).catch((r) => { + console.error(r) + }) +} + +export default service