赞
踩
首先来说一下身份认证,有两种技术方案,一种是基于传统的Session模式,另外一种则是基于令牌的模式,通常使用JWT。
传统的session模式使用了很多年,技术非常成熟,不过这种模式的存在一些弊端:
资源消耗:使用session模式需要在服务器上存储会话数据,这可能会导致服务器资源的消耗增加。如果同时有大量用户使用系统,这可能会导致服务器崩溃或变慢。
可扩展性:使用session模式可能会影响系统的可扩展性。如果需要将系统扩展到多个服务器上,那么需要确保所有服务器都可以访问会话数据。这可能需要使用共享存储或其他技术,这可能会增加系统的复杂性和成本。
安全性:使用session模式可能会影响系统的安全性。如果会话数据被泄露或被攻击者篡改,那么攻击者可能会访问用户的敏感信息。因此,需要确保会话数据的安全性,并采取适当的安全措施,如加密和身份验证。
可维护性:使用session模式可能会影响系统的可维护性。如果会话数据的结构或格式发生变化,那么可能需要修改系统的代码。这可能会导致系统的维护成本增加。
使用session影响最大的其实是第二点扩展性上,生产环境为了防止单点故障,保证高可用,集群部署是常规操作,如何确保用户登录后能正常操作系统,通常有几种解决方式:
1.会话粘滞:负载均衡通过反向代理的方式来实现会话粘滞,即按照某些规则,如ip地址,把某个用户始终路由到某个固定的集群节点。
2.集中存储:使用Redis、数据库等来集中存储会话信息,从而在集群多个节点下共享会话数据。
3.会话复制:将集群某个节点的会话数据,同步到其他集群节点,很少用,因为复杂,易出错,还会带来额外的网络流量。
此外,在移动端接入的情况下,没有传统web的session机制,这时候,还需要借助第三方功能组件,如Spring Session来模拟产生类似于web的session信息,进一步增加的复杂性。
随着前后端分离架构模式的出现和发展,以及微服务的流行,目前基于令牌的方式逐渐成为主流技术方案,其过程如下:
令牌模式,主要有以下优点:
前端框架vue-element-plus-admin的登录操作位于src/views/Login/components/LoginForm.vue中,默认调用的是mock数据。平台后端真实的登录API接口地址是/system/user/login,因此修改api/login/index.ts中的loginApi方法中的url地址即可
export const loginApi = (data: UserType) => {
return request.post({
url: '/system/user/login?username=' + data.username + '&password=' + data.password,
data
})
}
首先,需要将登录请求API返回的令牌,存下来。
关于登录成功后,保存用户信息,框架原先实现如下:
// 登录
const signIn = async () => {
const formRef = unref(elFormRef)
await formRef?.validate(async (isValid) => {
if (isValid) {
loading.value = true
const { getFormData } = methods
const formData = await getFormData<UserType>()
try {
const res = await loginApi(formData)
if (res) {
wsCache.set(appStore.getUserInfo, res.data)
// 是否使用动态路由
if (appStore.getDynamicRouter) {
getRole()
} else {
await permissionStore.generateRoutes('none').catch(() => {})
permissionStore.getAddRouters.forEach((route) => {
addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
})
permissionStore.setIsAddRouters(true)
push({ path: redirect.value || permissionStore.addRouters[0].path })
}
}
} finally {
loading.value = false
}
}
})
}
以上代码来源于src/views/Login/components/LoginForm.vue,关键语句是第14行,使用缓存工具类,将后端返回的数据即用户信息存到了SessionStorage中。
这个地方实际并没有使用到vue的全局状态管理,我做了改造如下:
import { useUserStore } from '@/store/modules/user'
const userStore = useUserStore()
const res = await loginApi(formData)
if (res) {
// 保存用户信息
userStore.setUserAction(res.data)
……
然后在src\store\modules目录下新增user.ts,包括标识、账号、姓名、是否强制修改密码、令牌、菜单权限数组和按钮权限数组这几个关键字段,代码如下:
import { store } from '../index'
import { defineStore } from 'pinia'
import { useCache } from '@/hooks/web/useCache'
import { USER_KEY } from '@/constant/common'
const { wsCache } = useCache()
import { setToken } from '@/utils/auth'
interface UserState {
account: string
name: string
forceChangePassword: string
id: string
token: string
buttonPermission: string[]
menuPermission: string[]
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
account: '',
name: '',
forceChangePassword: '',
id: '',
token: '',
buttonPermission: [],
menuPermission: []
}),
getters: {
getAccount(): string {
return this.account
}
},
actions: {
async setUserAction(user) {
this.account = user.account
this.name = user.name
this.forceChangePassword = user.forceChangePassword
this.id = user.id
this.token = user.token
this.buttonPermission = user.buttonPermission
this.menuPermission = user.menuPermission
// 保存用户信息
wsCache.set(USER_KEY, user)
// 保存令牌
setToken(user.token)
},
async clear() {
wsCache.clear()
this.resetState()
},
resetState() {
this.account = ''
this.name = ''
this.forceChangePassword = ''
this.id = ''
this.token = ''
this.buttonPermission = []
this.menuPermission = []
}
}
})
export const useUserStoreWithOut = () => {
return useUserStore(store)
}
43行将整个用户信息存了下来。考虑到令牌会频繁使用,每次先去取整个用户对象再获取令牌比较低效,因此将令牌又单独存了下,45行。这里封装的一个令牌的读写工具类。
import { useCache } from '@/hooks/web/useCache'
import { TOKEN_KEY } from '@/constant/common'
const { wsCache } = useCache()
// 获取token
export const getToken = () => {
return wsCache.get(TOKEN_KEY) ? wsCache.get(TOKEN_KEY) : ''
}
// 设置token
export const setToken = (token) => {
wsCache.set(TOKEN_KEY, token)
}
// 删除token
export const removeToken = () => {
wsCache.delete(TOKEN_KEY)
}
登录成功后,使用浏览器的调试功能,查看SessionStorage,会发现用户信息和令牌两项信息都保存了下来。
前端请求后端使用的axios,这就需要修改axios的配置了,在请求拦截器中增加读取令牌,并设置header的方法,对应代码位置是 src\config\axios\service.ts,具体如下:
// 创建axios实例
const service: AxiosInstance = axios.create({
baseURL: PATH_URL, // api 的 base_url
timeout: config.request_timeout // 请求超时时间
})
// request拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 读取token
const token = getToken()
if (token) {
// 若不为空,则将token放入header属性
config.headers['X-Token'] = token
}
const urlencoded = 'application/x-www-form-urlencoded'
if (
config.method === 'post' &&
(config.headers as AxiosRequestHeaders)['Content-Type'] === urlencoded
) {
config.data = qs.stringify(config.data)
}
……
return config
},
(error: AxiosError) => {
// Do something with request error
console.log(error) // for debug
Promise.reject(error)
}
)
第9-14行我增加的代码。
这时候,在登录情况下,发起任意一个请求,使用浏览器的调试功能,可以看到header中多了令牌信息。
后端收到前端请求后,会对令牌进行验证,如果验证失败,要么是令牌本身无效,要么是令牌超时,这时统一返回给前端一个401的Http状态码,前端需要根据该状态码给予用户友好提示并导向系统登录页。
这时候要调整的是axios的配置,在响应拦截器中进行状态码判断、友好提示。
// response 拦截器
service.interceptors.response.use(
(response: AxiosResponse<any>) => {
if (response.config.responseType === 'blob') {
// 如果是文件流,直接过
return response
} else if (response.status === REQUEST_SUCCESS) {
return new Promise((resolve) => {
// 若为成功请求,直接返回业务数据
if (response.status === REQUEST_SUCCESS) {
resolve(response)
}
})
return response.data
} else {
ElMessage.error(response.data.message)
}
},
(error: AxiosError) => {
if (error.response) {
if (error.response.status === UNAUTHORIZED) {
// 收到401响应时,给出友好提示
ElMessage.warning('未登录或会话超时,请重新登录')
// 清空浏览器缓存
wsCache.clear()
// 执行页面刷新
setTimeout(function () {
location.reload()
}, 2000)
} else if (error.response.status === NOT_FOUND) {
ElMessage.error('未找到服务,请确认')
} else if (error.response.status === METHOD_NOT_ALLOWED) {
ElMessage.error('请求的方法不支持,请确认')
} else {
ElMessage.error(error.response.data.message)
}
return Promise.reject(error)
} else {
ElMessage.error('请求远程服务器失败')
}
}
)
401状态码的处理参见上面21-29行,对于404和405,处理方式也类似。
当收到401请求后,会在系统顶部中央位置给出友好提示,2秒后自动跳转到登录页面。
另外,常见的http状态还有400和403,这两个状态,用户通过正常途径操作系统实际不会触发,往往是拿接口调试工具,或者把url地址直接拷贝往浏览器里粘贴引发的。后端对应这两种模式,会返回200状态码,将错误信息放到响应里,由前端给予友好提示。
在系统注销后,需要将令牌清理掉,这点框架自身已经做了处理,直接将所有缓存清空,代码位于src\components\UserInfo\src\UserInfo.vue第10行
const loginOut = () => {
ElMessageBox.confirm(t('common.loginOutMessage'), t('common.reminder'), {
confirmButtonText: t('common.ok'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
const res = await loginOutApi().catch(() => {})
if (res) {
wsCache.clear()
tagsViewStore.delAllViews()
resetRouter() // 重置静态路由表
replace('/login')
}
})
.catch(() => {})
}
平台集成了Spring Security组件来实现身份认证和权限控制,今天重点说下跟身份认证相关的功能点,关于Spring Security如何集成,内容较多,后面再开专篇介绍。
SpringSecurity组件已经内置登录功能,只需要写一个配置类,进行必要的配置即可。
如果只放代码片段,很难看清楚前后依赖和代码逻辑,先把整体配置文件放在下方,再重点介绍要说的功能。
package tech.abc.platform.framework.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.SecurityExpressionOperations;
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.builders.WebSecurity;
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.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler;
import org.springframework.security.web.access.expression.WebSecurityExpressionRoot;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsUtils;
/**
* SpringSecurity安全框架配置
*
* @author wqliu
* @date 2023-03-08
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 登录处理地址
*/
public static final String SYSTEM_USER_LOGIN = "/system/user/login";
/**
* 注销处理地址
*/
public static final String SYSTEM_USER_LOGOUT = "/system/user/logout";
/**
* 会话超时地址
*/
public static final String SYSTEM_USER_SESSION_INVALID = "/system/user/sessionInvalid";
@Autowired
private UserDetailsServiceImpl myUserService;
@Autowired
private AuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler myAuthenticationFailHandler;
@Autowired
private MyLogoutHandler myLogoutHandler;
@Autowired
private MyLogoutSuccessHandler myLogoutSuccessHandler;
@Autowired
private MyPermissionEvaluator myPermissionEvaluator;
@Autowired
private JwtFilter jwtFilter;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 设置自定义用户服务及加密方式
auth.userDetailsService(myUserService).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 允许跨域访问
http.cors();
// 禁用csrf攻击防护
http.csrf().disable();
// 登录处理
http.formLogin()
// 此处的方法实际是虚拟的,并不需要在UserControl控制器中存在,与前端请求一致即可,会被SpringSecurity截获
// 即使该方法存在,也会被SpringSecurity优先截获
// 另外,前后端分离的情况下,不需要指定登录地址loginPage参数,指定了也不起作用
.loginProcessingUrl(SYSTEM_USER_LOGIN)
// 设置自定义的身份认证成功处理器
.successHandler(myAuthenticationSuccessHandler)
// 设置自定义的身份认证失败处理器
.failureHandler(myAuthenticationFailHandler);
// 注销处理
http.logout()
// 这里新加自定义处理处理器主要是生成用户注销审计日志,如放在logoutSuccessHandler则无法取到当前用户
.addLogoutHandler(myLogoutHandler)
.logoutUrl(SYSTEM_USER_LOGOUT)
.logoutSuccessHandler(myLogoutSuccessHandler)
.invalidateHttpSession(true);
// 会话管理
http.sessionManagement()
// 使用jwt token,不需要session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 配置允许访问页面
http.authorizeRequests()
// 允许跨域请求中的Preflight请求
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
// 允许swagger文档接口匿名访问
.antMatchers("/swagger-ui.html").anonymous()
.antMatchers("/swagger-resources/**").anonymous()
.antMatchers("/webjars/**").anonymous()
.antMatchers("/*/api-docs").anonymous()
;
// 配置其他请求,需认证
http.authorizeRequests()
.anyRequest()
.authenticated();
// 配置JWT过滤器
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
public void configure(WebSecurity web) {
web.expressionHandler(new DefaultWebSecurityExpressionHandler() {
@Override
protected SecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication, FilterInvocation fi) {
WebSecurityExpressionRoot root = (WebSecurityExpressionRoot) super.createSecurityExpressionRoot(authentication, fi);
root.setPermissionEvaluator(myPermissionEvaluator);
return root;
}
});
}
}
登录操作的核心配置是如下部分:
/**
* 登录处理地址
*/
public static final String SYSTEM_USER_LOGIN = "/system/user/login";
// 登录处理
http.formLogin()
// 此处的方法实际是虚拟的,并不需要在UserControl控制器中存在,与前端请求一致即可,会被SpringSecurity截获
// 即使该方法存在,也会被SpringSecurity优先截获
// 另外,前后端分离的情况下,不需要指定登录地址loginPage参数,指定了也不起作用
.loginProcessingUrl(SYSTEM_USER_LOGIN)
// 设置自定义的身份认证成功处理器
.successHandler(myAuthenticationSuccessHandler)
// 设置自定义的身份认证失败处理器
.failureHandler(myAuthenticationFailHandler);
进行上述配置后,后端暴露给前端的登录操作的API接口地址是/system/user/login。
SpringSecurity组件会在身份认证成功后,回调一个AuthenticationSuccessHandler类的方法。登录成功后设置用户信息,包括标识、账号、姓名、是否强制修改密码、菜单数据、按钮权限,以及令牌的生成,都是在这里实现的。
package tech.abc.platform.framework.security;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import tech.abc.platform.common.annotation.SystemLog;
import tech.abc.platform.common.constant.CommonConstant;
import tech.abc.platform.common.constant.TreeDefaultConstant;
import tech.abc.platform.common.entity.MyUserDetails;
import tech.abc.platform.common.enums.LogTypeEnum;
import tech.abc.platform.common.enums.YesOrNoEnum;
import tech.abc.platform.common.utils.CacheUtil;
import tech.abc.platform.common.utils.JwtUtil;
import tech.abc.platform.common.utils.ResultUtil;
import tech.abc.platform.common.vo.Result;
import tech.abc.platform.framework.config.PlatformConfig;
import tech.abc.platform.system.entity.PermissionItem;
import tech.abc.platform.system.entity.User;
import tech.abc.platform.system.enums.PermissionTypeEnum;
import tech.abc.platform.system.service.OrganizationService;
import tech.abc.platform.system.service.UserService;
import tech.abc.platform.system.vo.MenuTreeVO;
import tech.abc.platform.system.vo.MetaVO;
import tech.abc.platform.system.vo.UserVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 自定义登录成功处理器
* 如继承父类则会内置跳转首页或权限验证失败前一页,会影响前端跳转,自己实现接口,去除了后端自动跳转
*
* @author wqliu
* @date 2023-03-08
*/
@Component
@Slf4j
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
public static final double VALIDATE_LICENCE_PERCENT = 0.2;
@Autowired
private UserService userService;
@Autowired
private OrganizationService organizationService;
@Autowired
private CacheUtil cacheUtil;
@Autowired
private PlatformConfig platformConfig;
@Autowired
private JwtUtil jwtUtil;
@Override
@SystemLog(value = "登录成功", logType = LogTypeEnum.AUDIT, logRequestParam = false, executeResult = CommonConstant.YES)
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication)
throws IOException {
MyUserDetails userDetails = (MyUserDetails) authentication.getPrincipal();
// 获取用户名
String account = userDetails.getUsername().toLowerCase();
// 查询用户信息
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.lambda().eq(User::getAccount, account);
User user = userService.getOne(userQueryWrapper);
// 重置登录失败次数
userService.resetLoginFailureCount(user.getId());
// 构造返回对象
UserVO userVO = new UserVO();
userVO.setId(user.getId());
userVO.setAccount(user.getAccount());
userVO.setName(user.getName());
// 强制修改密码标志位
userVO.setForceChangePasswordFlag(user.getForceChangePasswordFlag());
// 判断是否超出密码修改时间
if (userService.checkExceedPasswordChangeDays(user.getId())) {
userVO.setForceChangePasswordFlag(YesOrNoEnum.YES.name());
}
// 生成令牌
String token = jwtUtil.generateTokenWithSubject(user.getAccount(), platformConfig.getSystem().getTokenValidSpan() * 60);
// 设置令牌
userVO.setToken(token);
// 获取权限
List<PermissionItem> permissionList = userService.getPermission(user.getId());
if (CollectionUtils.isNotEmpty(permissionList)) {
// 获取按钮权限
List<String> buttonPermission = permissionList.stream().filter(x -> x.getType()
.equals(PermissionTypeEnum.BUTTON.toString()))
.map(PermissionItem::getPermissionCode).distinct().collect(Collectors.toList());
userVO.setButtonPermission(buttonPermission);
// 获取菜单权限
List<MenuTreeVO> moduleList = getMenu(permissionList);
userVO.setMenuPermission(moduleList);
}
// 构建返回
ResponseEntity<Result> result = ResultUtil.success(userVO, "登录成功");
ResultUtil.returnJsonToFront(response, result);
}
/**
* 获取菜单
* 目前支持两级
*
* @return
*/
public List<MenuTreeVO> getMenu(List<PermissionItem> permissionList) {
// 生成模块
List<MenuTreeVO> moduleList
= generateModule(permissionList, TreeDefaultConstant.DEFAULT_TREE_ROOT_ID);
for (MenuTreeVO module : moduleList) {
// 生成菜单
List<MenuTreeVO> menus = generateMenu(permissionList, module.getId());
module.setChildren(menus);
}
return moduleList;
}
/**
* 生成模块
*/
private List<MenuTreeVO> generateModule(List<PermissionItem> list, String parentId) {
List<MenuTreeVO> result = new ArrayList<>();
for (PermissionItem node : list) {
// 获取类型为模块权限项
if (node.getType().equals(PermissionTypeEnum.MODULE.toString())) {
if (node.getPermissionItem().equals(parentId)) {
MenuTreeVO vo = new MenuTreeVO();
vo.setId(node.getId());
vo.setParentId(node.getPermissionItem());
vo.setName(node.getCode());
vo.setPath("/" + node.getCode());
vo.setComponent(node.getComponent());
MetaVO metaVO = new MetaVO();
metaVO.setTitle(node.getName());
metaVO.setIcon(node.getIcon());
metaVO.setHidden(false);
vo.setMeta(metaVO);
result.add(vo);
}
}
}
return result;
}
/**
* 生成菜单
*/
private List<MenuTreeVO> generateMenu(List<PermissionItem> list, String parentId) {
List<MenuTreeVO> menus = new ArrayList<MenuTreeVO>();
List<PermissionItem> permissionList
= list.stream().filter(x -> x.getPermissionItem().equals(parentId)).collect(Collectors.toList());
for (PermissionItem permission : permissionList) {
if (permission.getType().equals(PermissionTypeEnum.MENU.toString())
|| permission.getType().equals(PermissionTypeEnum.PAGE.toString())) {
MenuTreeVO vo = new MenuTreeVO();
vo.setId(permission.getId());
vo.setParentId(permission.getPermissionItem());
vo.setName(permission.getCode());
vo.setPath(permission.getCode());
vo.setComponent(permission.getComponent());
MetaVO metaVO = new MetaVO();
metaVO.setTitle(permission.getName());
metaVO.setIcon(permission.getIcon());
// 如果为页面,非菜单,则设置隐藏
metaVO.setHidden(permission.getType().equals(PermissionTypeEnum.PAGE.toString()));
vo.setMeta(metaVO);
menus.add(vo);
// 查找下级
// 菜单规划为两级,因此此处未使用递归,而是再往下找一级即可
List<MenuTreeVO> children = generateMenu(list, permission.getId());
if (children != null && children.size() > 0) {
menus.addAll(children);
}
}
}
return menus;
}
}
令牌的生成、验证,封装了一个工具类。
package tech.abc.platform.common.utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import tech.abc.platform.common.exception.SessionExpiredException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* jwt工具类
*
* @author wqliu
* @date 2023-03-08
*/
@Component
public class JwtUtil {
/**
* 密钥
*/
@Value("${platform-config.system.tokenSecret}")
private String secret;
/**
* 默认超时时间 30分钟
*/
private final Long JWT_DEFAULT_EXPIRE_SECONDS = 30 * 60L;
/**
* 获取超时时间
*
* @param validSpan 时长,单位 秒
* @return
*/
private Date getExpireTime(long validSpan) {
// 生成JWT过期时间
long nowMilliSecond = System.currentTimeMillis();
if (validSpan < 0) {
validSpan = JWT_DEFAULT_EXPIRE_SECONDS;
}
long expMilliSecond = nowMilliSecond + validSpan * 1000;
Date exp = new Date(expMilliSecond);
return exp;
}
/**
* 生成带主题的令牌
*
* @param subject 主题
* @param validSpan 有效时长,单位秒
* @return jwt令牌
*/
public String generateTokenWithSubject(String subject, long validSpan) {
Algorithm algorithm = Algorithm.HMAC256(secret);
Date expireTime = getExpireTime(validSpan);
String token = JWT.create()
.withSubject(subject)
.withExpiresAt(expireTime)
.sign(algorithm);
return token;
}
/**
* 验证令牌
*
* @param token
* @return
*/
public void verifyToken(String token) {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.build();
try {
verifier.verify(token);
} catch (Exception ex) {
throw new SessionExpiredException("令牌无效或过期,请重新登录");
}
}
/**
* 解码令牌
*
* @param token
* @return
*/
public DecodedJWT decode(String token) {
return JWT.decode(token);
}
/**
* 获取主题
*
* @param token 令牌
* @return 主题
*/
public String getSubject(String token) {
return decode(token).getSubject();
}
}
接下来就是最核心部分的实现,即进行身份认证,登录成功后,后续每次请求前端都会携带令牌,后端如何来获取和验证这个令牌呢?
SpringSecurity组件自身实际并未直接提供基于令牌的登录模式,不过提供了框架,需要自己扩展下,实现一个过滤器。
package tech.abc.platform.framework.security;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;
import tech.abc.platform.common.utils.JwtUtil;
import tech.abc.platform.common.utils.ResultUtil;
import tech.abc.platform.common.vo.Result;
import tech.abc.platform.framework.config.PlatformConfig;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 基于jwt令牌的身份认证过滤器
*
* @author wqliu
* @date 2023-03-08
*/
@Slf4j
@Component
public class JwtFilter extends GenericFilterBean {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PlatformConfig platformConfig;
@Autowired
private JwtUtil jwtUtil;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
// 优先从http头中获取令牌
String token = req.getHeader("X-Token");
// 其次从cookie中获取
if (StringUtils.isBlank(token)) {
Cookie[] cookies = ((HttpServletRequest) servletRequest).getCookies();
if (cookies != null) {
for (int i = 0; i < cookies.length; i++) {
if ("token".equals(cookies[i].getName())) {
token = cookies[i].getValue();
break;
}
}
}
}
// 再次,从url地址中获取
if (StringUtils.isBlank(token)) {
token = req.getParameter("X-Token");
}
if (StringUtils.isNotBlank(token)) {
// 验证令牌
try {
jwtUtil.verifyToken(token);
} catch (Exception ex) {
ResponseEntity<Result> result = ResultUtil.error(ex.getMessage(), HttpStatus.UNAUTHORIZED);
ResultUtil.returnJsonToFront((HttpServletResponse) servletResponse, result);
return;
}
// 获取账号
String account = jwtUtil.decode(token).getSubject();
// 查询用户信息
UserDetails user = userDetailsService.loadUserByUsername(account);
// 构造SpringSecurity的认证对象后放到SecurityContextHolder中
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 执行后续过滤器
filterChain.doFilter(servletRequest, servletResponse);
}
}
需要将该过滤器配置到SpringSecurity过滤器链的合适位置
// 配置JWT过滤器
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
同时,将会话管理也配置一下,既然已经使用了令牌模式,那么就不再需要会话,设置SpringSecurity的会话管理策略为SessionCreationPolicy.STATELESS。
// 会话管理
http.sessionManagement()
// 使用jwt token,不需要session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
在令牌验证环节,如果验证失败,要么是令牌本身无效,要么是令牌超时,这时统一返回给前端一个401的Http状态码,前端根据该状态码给予用户友好提示并导向系统登录页。
// 验证令牌
try {
jwtUtil.verifyToken(token);
} catch (Exception ex) {
ResponseEntity<Result> result = ResultUtil.error(ex.getMessage(), HttpStatus.UNAUTHORIZED);
ResultUtil.returnJsonToFront((HttpServletResponse) servletResponse, result);
return;
}
跟登录API类似,同样是通过配置产生。
/**
* 注销处理地址
*/
public static final String SYSTEM_USER_LOGOUT = "/system/user/logout";
// 注销处理
http.logout()
// 这里新加自定义处理处理器主要是生成用户注销审计日志,如放在logoutSuccessHandler则无法取到当前用户
.addLogoutHandler(myLogoutHandler)
.logoutUrl(SYSTEM_USER_LOGOUT)
.logoutSuccessHandler(myLogoutSuccessHandler)
.invalidateHttpSession(true);
平台名称:一二三开发平台
简介: 企业级通用开发平台
设计资料:csdn专栏
开源地址:Gitee
开源协议:MIT
欢迎收藏、点赞、评论,你的支持是我前行的动力。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。