vue+spring-security前后端分离登录实现
1.shiro和spring-security区别
首先Shiro
较之Spring Security
,Shiro
在保持强大功能的同时,还在简单性和灵活性方面拥有巨大优势。Shiro
是一个强大而灵活的开源安全框架,能够非常清晰的处理认证、授权、管理会话以及密码加密。
Spring Security
除了不能脱离Spring
,Shiro
的功能它都有。Spring Security
对Spring
结合较好,如果项目用的springmvc
,使用起来很方便。
我们公司的登录认证主要使用了Shiro
,实现了登录认证以及oauth2认证。提供了接口供不同个性化登录实现,如:
IdentityBuilder
:构建登录身份,控制登录流程UserService
:查询用户真实信息CredentialsMatcher
:校验登录身份和真实信息NamedAuthenticationListener
:登录成功、失败的后处理
参照这套认证体系,我们采用Spring Security
来重构以下。
2.jwt和session的选择
基于session和基于jwt的方式的主要区别就是用户的状态保存的位置,session是保存在服务端的,而jwt是保存在客户端的。jwt的优点就不说了,主要说说缺点
- 由于jwt的payload是使用base64编码的,并没有加密,因此jwt中不能存储敏感数据。而session的信息是存在服务端的,相对来说更安全。
- jwt太长。由于是无状态使用JWT,所有的数据都被放到JWT里,如果还要进行一些数据交换,那载荷会更大,经过编码之后导致jwt非常长,cookie的限制大小一般是4k,cookie很可能放不下,所以jwt一般放在local storage里面。并且用户在系统中的每一次http请求都会把jwt携带在Header里面,http请求的Header可能比Body还要大。而sessionId只是很短的一个字符串,因此使用jwt的http请求比使用session的开销大得多。
- 无状态是jwt的特点,但也导致了这个问题,jwt是一次性的。想修改里面的内容,就必须签发一个新的jwt。
为了安全性我们也是选择session来保存用户状态,而我们公司的系统经常要在服务端根据session进行权限控制,而jwt主要用在移动端的无状态场景。
3.spring-security权限控制实现
3.1 AuthenticationBuilder对标IdentityBuilder
Spring Security
用一个类专门负责接手前端数据构建登录身份,这个类时UsernamePasswordAuthenticationFilter
,但是这个类只能接受url请求中的参数,对于请求参数不在url中的则没法解析。所以我们需要个性化这个类让他能够Request PayLoad
中的数据。
/**
* {@link UsernamePasswordAuthenticationFilter}
*/
public class FrameUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private final AuthenticationBuilder authenticationBuilder;
public FrameUsernamePasswordAuthenticationFilter(AuthenticationBuilder authenticationBuilder) {
super(new AntPathRequestMatcher("/api/login", "POST"));
this.authenticationBuilder = authenticationBuilder;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
Authentication authRequest = authenticationBuilder.build(request, response);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
另外我们在封装一个AuthenticationBuilder
接口用于个性化实现,并提供默认实现FrameAuthenticationBuilder
public class FrameAuthenticationBuilder implements AuthenticationBuilder {
@Override
public Authentication build(HttpServletRequest request, HttpServletResponse response) {
LoginVO loginVO = JSON.parseObject(getRequestPayload(request), LoginVO.class);
return new UsernamePasswordAuthenticationToken(
loginVO.getUsername(), loginVO.getPassword());
}
public String getRequestPayload(HttpServletRequest request) {
try {
return IOUtils.toString(request.getReader());
} catch (IOException ex) {
ex.printStackTrace();
}
return "";
}
}
3.2 UserDetailsService对标UserService
Spring Security
有个类专门负责通过username
查找用户信息的接口,这个接口就是UserDetailsService
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//可以从数据库里根据username去除用户信息
}
3.3 DaoAuthenticationProvider对标CredentialsMatcher
AbstractUserDetailsAuthenticationProvider
类提供了一个抽象方法additionalAuthenticationChecks
用子类DaoAuthenticationProvider
实现,UserDetails
参数为AuthenticationBuilder
构建的前端参数,UsernamePasswordAuthenticationToken
为UserDetailsService
通过前端传递的username
去数据库中查询到的用户信息,对比两个对象的密码判断是否认证通过。值得一提的时密码提供了PasswordEncoder
接口实现自定义加密类型,后续我们可以基于SM3加密算法来实现。
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
shiro
在抽象类AuthenticatingRealm
也有类似的方法,并提供了CredentialsMatcher
接口。
protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
CredentialsMatcher cm = getCredentialsMatcher();
if (cm != null) {
if (!cm.doCredentialsMatch(token, info)) {
//not successful - throw an exception to indicate this:
String msg = "Submitted credentials for token [" + token + "] did not match the expected credentials.";
throw new IncorrectCredentialsException(msg);
}
} else {
throw new AuthenticationException("A CredentialsMatcher must be configured in order to verify " +
"credentials during authentication. If you do not wish for credentials to be examined, you " +
"can configure an " + AllowAllCredentialsMatcher.class.getName() + " instance.");
}
}
3.4 AuthenticationSuccessHandler、AuthenticationFailureHandler、AccessDeniedHandler和AuthenticationEntryPoint对标NamedAuthenticationListener
AuthenticationSuccessHandler
接口主要用于实现用户登录成功后数据的返回,对应Shiro``AuthenticationListener
类的onSuccess
方法。
AuthenticationFailureHandler
接口主要用于实现用户登录失败后的数据返回,对应Shiro``AuthenticationListener
类的onFailure
方法。
AccessDeniedHandler
接口主要用于实现权限未认证数据的返回处理,也是对应Shiro``AuthenticationListener
类的onFailure
方法。
AuthenticationEntryPoint
接口主要用于实现权限未认证时或其他异常数据的返回处理,也是对应Shiro``AuthenticationListener
类的onFailure
方法。
3.5 组装起来
Spring Security
的配置方式非常优雅,通过HttpSecurity
链式调用进行配置的
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler) // 处理认证失败
.authenticationEntryPoint(authenticationEntryPoint) //数据处理终端
.and()
.formLogin()
.successHandler(authenticationSucessHandler) // 处理登录成功
.failureHandler(authenticationFailureHandler) // 处理登录失败
.apply(new FrameUsernamePasswordAuthenticationConfigurer(authenticationBuilder, authenticationSuccessHandler, authenticationFailureHandler));
}
}
3.6 如果AuthenticationBuilder有多个性化实现
Spring IOC
如果有多个实现类我们在注入引用时如果不指定bean
的名称是会报错的,本着开闭原则,注入bean
的地方不可能每次个性化都去修改名称,所以我们能否实现多实现类是根据优先级进行选举,答案是可以的。在DefaultListableBeanFactory
我们找到了determinePrimaryCandidate
选举方法,但是这个个方法只能识别@Primary
注解,对于多个实现类如果标记为@Primary
则选取有标记的实现类,但是如果又有个实现类也标记了@Primary
就又不行了,所以可以增加一个@FramePriority
注解用来增加@Primary
,并增加一个int
类型参数值用于标记优先级,如果选举是根据获取这个值最大的实现类。
public class FrameListableBeanFactory extends DefaultListableBeanFactory {
@Override
protected String determinePrimaryCandidate(Map<String, Object> candidates, @Nullable Class<?> requiredType) {
String primaryBeanName = null;
for (Map.Entry<String, Object> entry : candidates.entrySet()) {
String candidateBeanName = entry.getKey();
Object beanInstance = entry.getValue();
if (isPrimary(candidateBeanName, beanInstance)) {
if (primaryBeanName != null) {
boolean candidateLocal = containsBeanDefinition(candidateBeanName);
boolean primaryLocal = containsBeanDefinition(primaryBeanName);
if (candidateLocal && primaryLocal) {
//获取自定义选举结果
candidateBeanName = determineBidPriorityCandidate(candidates);
if (candidateBeanName != null) {
return candidateBeanName;
}
throw new NoUniqueBeanDefinitionException(requiredType, candidates.size(),
"more than one 'primary' bean found among candidates: " + candidates.keySet());
} else if (candidateLocal) {
primaryBeanName = candidateBeanName;
}
} else {
primaryBeanName = candidateBeanName;
}
}
}
return primaryBeanName;
}
protected String determineBidPriorityCandidate(Map<String, Object> candidates) {
List<Object> list = new ArrayList<>(candidates.values());
//先取出有@BidPriority注解的
list = list.stream().filter(a -> a.getClass().getAnnotation(FramePriority.class) != null).collect(Collectors.toList());
//如果没有直接返回
if (list.isEmpty()) {
return null;
}
//根据@BidPriority注解值排序一下
list.sort(Comparator.comparingInt(a -> a.getClass().getAnnotation(FramePriority.class).value()));
for (Map.Entry<String, Object> entry : candidates.entrySet()) {
String candidateBeanName = entry.getKey();
Object beanInstance = entry.getValue();
//取排序后第一个即最小的
if (beanInstance.equals(list.get(0))) {
return candidateBeanName;
}
}
return null;
}
}
4.axios封装统一请求
前端我们采用vue
,本次我们采用的时开源框架vben
,vben
是基于vue3
开发的,支持TypeScript
。前后端交互我们采用Axios
封装请求。
function createAxios(opt?: Partial<CreateAxiosOptions>) {
return new VAxios(
deepMerge(
{
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes
// authentication schemes,e.g: Bearer
// authenticationScheme: 'Bearer',
authenticationScheme: '',
timeout: 10 * 1000,
// 基础接口地址
// baseURL: globSetting.apiUrl,
// 后台增加X-Requested-With校验,有这个标记的表示前台请求 add by lurj
headers: { 'Content-Type': ContentTypeEnum.JSON, 'X-Requested-With': 'XMLHttpRequest' },
// 如果是form-data格式
// headers: { 'Content-Type': ContentTypeEnum.FORM_URLENCODED },
// 数据处理方式
transform,
// 配置项,下面的选项都可以在独立的接口请求中覆盖
requestOptions: {
// 默认将prefix 添加到url
joinPrefix: true,
// 是否返回原生响应头 比如:需要获取响应头时使用该属性
isReturnNativeResponse: false,
// 需要对返回数据进行处理
isTransformResponse: true,
// post请求的时候添加参数到url
joinParamsToUrl: false,
// 格式化提交参数时间
formatDate: true,
// 消息提示类型
errorMessageMode: 'message',
// 接口地址
apiUrl: globSetting.apiUrl,
// 接口拼接地址
urlPrefix: urlPrefix,
// 是否加入时间戳
joinTime: true,
// 忽略重复请求
ignoreCancelToken: true,
// 是否携带token
withToken: true,
},
},
opt || {},
),
);
}
export const defHttp = createAxios();
增加一个方法专门处理后端返回的对象
/**
* @description: 处理请求数据。如果数据不是预期格式,可直接抛出错误
*/
transformRequestHook: (res: AxiosResponse<Result>, options: RequestOptions) => {
const { data } = res;
if (!data) {
// return '[HTTP] Request has no return value';
throw new Error(t('sys.api.apiRequestFailed'));
}
// 这里 code,result,message为 后台统一的字段,需要在 types.ts内修改为项目自己的接口返回格式
const { code, body, msg } = data;
// 这里逻辑可以根据项目进行修改
const hasSuccess = data && Reflect.has(data, 'code') && code === ResultEnum.SUCCESS;
if (hasSuccess) {
return body;
}
// 未登录跳转登录页面
const hasForbidden = data && Reflect.has(data, 'code') && code === ResultEnum.FORBIDDEN;
if (hasForbidden) {
throw new Error('Access Denied');
}
}
在’vue-router’中进行权限控制
router.beforeEach(async (to, from, next) => {
if (whitePathList.includes(to.path as PageEnum)) {
if (to.path === LOGIN_PATH) {
const isSessionTimeout = userStore.getSessionTimeout;
try {
// 尝试自动登录,如果是已授权则直接跳转首页,未授权会被捕获异常
await userStore.afterLoginAction();
if (!isSessionTimeout) {
next((to.query?.redirect as string) || '/');
return;
}
} catch (error) {
if (error instanceof Error && error.message === 'Access Denied') {
userStore.setToken(undefined);
userStore.setSessionTimeout(false);
userStore.setUserInfo(null);
userStore.$reset;
console.log(userStore.getUserInfo);
console.log(userStore.getUserInfo.userId);
console.log(userStore.getToken);
console.log(error.message);
// 如果鉴权失败则继续跳转登录页
next();
return;
}
}
}
console.log(2);
next();
return;
}
// 非前后端分离框架未授权时直接从后台重定向到登录界面
// 前后端分离框架需要有一个全局的认证处理逻辑,处理未授权时统一跳回登录界面
// 前后端jwt模式会在前台缓存token,可以根据是否有token判断登录状态,使用session模式前台不存token,所以每次都需要访问服务端鉴权因此会降低性能
// 为了防止一直访问服务端鉴权增加LastUpdateTime,整个页面刷新才会重置store
// get userinfo while last fetch time is empty
console.log(userStore.getLastUpdateTime);
if (userStore.getLastUpdateTime === 0) {
try {
await userStore.getUserInfoAction();
} catch (error) {
if (error instanceof Error && error.message === 'Access Denied') {
userStore.setToken(undefined);
userStore.setSessionTimeout(false);
userStore.setUserInfo(null);
// 必须要充值不然有缓存
userStore.$reset;
console.log(userStore.getUserInfo);
console.log(userStore.getUserInfo.userId);
console.log(userStore.getToken);
console.log(error.message);
const redirectData: { path: string; replace: boolean; query?: Recordable<string> } = {
path: LOGIN_PATH,
replace: true,
};
if (to.path) {
redirectData.query = {
...redirectData.query,
redirect: to.path,
};
}
console.log(redirectData);
next(redirectData);
} else {
next();
}
return;
}
}
}
5.sping-data对标DAO
DAO的特性就不多说了,此次我们不引入公司框架,为了实现数据库查询我们采用了sping-data
,使用sping-data
的好处是我们可以直接封装一个领域驱动中的仓储层。而我们也是运用领域驱动的思想进行设计的。
- 仓储层
public interface FrameUserRepository extends PagingAndSortingRepository<FrameUser, String> {
Optional<FrameUser> findByUsername(String username);
Optional<FrameUser> findByUsernameOrMobile(String username, String mobile);
}
- api层
public interface FrameUserService {
FrameUser findByUsername(String username);
FrameUser findByUsernameOrMobile(String username, String mobile);
List<FrameRole> findRoleByUserguid(String userguid);
}
- 实现层
@Component
public class FrameUserServiceImpl implements FrameUserService {
private final FrameUserRepository frameUserRepository;
private final FrameRoleRepository frameRoleRepository;
private final FrameUserRoleRelationRepository frameUserRoleRelationRepository;
public FrameUserServiceImpl(FrameUserRepository frameUserRepository,
FrameRoleRepository frameRoleRepository,
FrameUserRoleRelationRepository frameUserRoleRelationRepository) {
this.frameUserRepository = frameUserRepository;
this.frameRoleRepository = frameRoleRepository;
this.frameUserRoleRelationRepository = frameUserRoleRelationRepository;
}
@Override
public FrameUser findByUsername(String username) {
return frameUserRepository.findByUsername(username).orElse(null);
}
@Override
public FrameUser findByUsernameOrMobile(String username, String mobile) {
return frameUserRepository.findByUsernameOrMobile(username, mobile).orElse(null);
}
@Override
public List<FrameRole> findRoleByUserguid(String userguid) {
List<FrameRole> list = new ArrayList<>();
Iterable<FrameUserRoleRelation> frameUserRoleRelations = frameUserRoleRelationRepository.findByUserguid(userguid);
frameUserRoleRelations.forEach(p -> frameRoleRepository.findById(p.getRoleguid()).ifPresent(list::add));
return list;
}
}
- 控制层
@Slf4j
@RestController
@RequestMapping("/api/user")
public class FrameLoginController {
private final FrameUserService frameUserService;
public FrameLoginController(final FrameUserService frameUserService) {
this.frameUserService = frameUserService;
}
@GetMapping(value = "/list")
public Result<?> searchByPid(@RequestParam(value = "pid") String pid,
Pageable pageable) {
return Result.OK(frameUserService.findByPid(pid, pageable));
}
}
- 其他领域通过api调用
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
FrameUser frameUser = frameUserService.findByUsernameOrMobile(username, username);
if (frameUser == null) {
throw new UsernameNotFoundException("该用户不存在");
}
}
多数据源实现
使用spring-jdb
的JdbcTemplate
实现,并采用druid
进行连接池管理。
@Test
protected void test() {
DruidDataSource dataSource = new DruidDataSource();
dataSource.setUrl("jdbc:mysql://192.168.220.236:3306/jeecg-boot-dev?characterEncoding=UTF-8&useUnicode=true&useSSL=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai");
dataSource.setUsername("root");
dataSource.setPassword("123456");
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
Integer count = jdbcTemplate.queryForObject("select count(*) from frame_user", Integer.class);
String sql = "select * from frame_user where username=?";
String name = "admin";
List<Map<String, Object>> list = jdbcTemplate.query(sql, (rs, rowNum) -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("username", rs.getString("username"));
return map;
}, name);
List<Map<String, Object>> list2 = jdbcTemplate.queryForList(sql, name);
System.out.println(count);
}
最后谈下mybatis和mybatis-plus
知乎上看到一条评价很有意思:mybatis-plus就仿佛,你开着一辆名叫mybatis的手动挡汽车,然后请了一个叫mybatis-plus的人坐在副驾驶帮你挂档。既然如此为什么不从一开始直接开手动挡车呢?spring data jpa
他不香吗?
主要是我不会用,也不想学。。配置太复杂