背景
考虑到Vue本身是一个渐进式的前端框架,在被案例中并没有使用Vue的页面组件化和路由,而是采用html页面+iframe(代替路由)。
同时,为了省去手写可复用Vue组件的工作量,所以使用ElementUI作为UI组件。
最后,ajax采用的是axios组件。
注意:即便后端采用的不是springsecurity进行登录,也有很多知识是相通的。
阳了三天,基本不烧了,只是听觉丧失50%,味觉丧失70%,献给CJ104那些爱卷的猴崽子们吧:)
全景代码
先不解释概念直接上代码
后端实现
从数据库中查询用户和密码信息,用于替换SpringSecurity自动产生密码
@Component
public class OAUserDetailService implements UserDetailsService {
// 鉴于篇幅的原因,就不贴UserService的代码了,具体实现就是根据用户名查用户记录并返回
@Resource
private UserService userService;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
User user = userService.getById(s);
if (user == null) throw new UsernameNotFoundException(String.format("不存在%s用户", s));
return new OAUserDetail(user);
}
public class OAUserDetail implements UserDetails{
private User user;
public OAUserDetail(User user){
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return new ArrayList<>();
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getId();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
}
SpringSecurity配置
@Configuration
@Slf4j
public class OASecurityConfiguration extends WebSecurityConfigurerAdapter {
// 临时方案,实战中不可取
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.successForwardUrl("/login/success") // 登录成功跳转到controller处理
.failureForwardUrl("/login/failure");
http.cors();
http.csrf().disable();
http.authorizeRequests().anyRequest().authenticated(); // 没有这一句,将会直接绕过登录可以访问到controller
http.exceptionHandling().authenticationEntryPoint(new OAAuthenticationEntryPoint());
}
@Bean
CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration corsConfiguration = new CorsConfiguration();
// 允许跨域访问的站点
corsConfiguration.setAllowedOrigins(Arrays.asList("http://127.0.0.1:5500"));
//允许跨域访问的methods
corsConfiguration.setAllowedMethods(Arrays.asList("GET","POST"));
// 允许携带凭证
corsConfiguration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
//对所有URL生效
source.registerCorsConfiguration("/**",corsConfiguration);
return source;
}
}
登录处理Controller
省略了演示过很多遍的R对象的代码,就是一个类包含了code、message、data三个属性的前后端分离响应类
@RestController
@RequestMapping("/login")
public class LoginController {
@RequestMapping(value = "/success", method = {RequestMethod.GET, RequestMethod.POST})
public R loginSuccess(){
return R.error(401, "登录成功");
}
@RequestMapping(value = "/failure", method = {RequestMethod.GET, RequestMethod.POST})
public R loginFailure(){
return R.ok("登录失败");
}
}
未登录异常处理类
public class OAAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
PrintWriter out = response.getWriter();
R r = R.error(401, "未登录");
out.write(new ObjectMapper().writeValueAsString(r));
out.flush();
out.close();
}
}
前端实现
登录页
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<script src="https://unpkg.com/vue@2/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script type="text/javascript" src="https://cdn.bootcss.com/qs/6.7.0/qs.min.js"></script>
<title>登录奇豆OA</title>
</head>
<body>
<div id="app">
<el-form style="width: 268px; margin: 0 auto; border: thin solid #87CEFA; padding: 20px;" ref="form" :model="form" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="form.username"></el-input>
</el-form-item>
<el-form-item label="密 码">
<el-input v-model="form.password"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="login">登录</el-button>
</el-form-item>
</el-form>
</div>
</body>
</html>
<script>
new Vue({
el: '#app',
data: {
form: {
username: '',
password: ''
}
},
methods: {
login(){
axios({
method: 'post',
url: 'http://127.0.0.1:8080/login',
withCredentials: true,
// 由于axios通过request body传参默认的Content-Type为Json
// 而SpringSecurity无法识别json的参数,所以借助Qs可以将参数自动转换为
// application/x-www-form-urlencoded的编码
data: Qs.stringify({
username: this.form.username,
password: this.form.password
})
}).then((response) => {
console.log(JSON.stringify(response))
if (response.data.code === 200)
window.location.href = 'index.html'
})
}
}
})
</script>
首页
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<script src="https://unpkg.com/vue@2/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<!-- <script src="main.js" type="module"></script> -->
<title>奇豆OA</title>
</head>
<body>
<div id="app">
<el-container> <!-- 外层主容器 -->
<el-header> <!-- 头部:logo、菜单、搜索、个人信息;默认60px高度 -->
<el-container> <!-- 内部主容器 -->
<el-aside width="288px" style="height:600px">
<el-menu class="el-menu-vertical-demo">
<el-submenu v-for="(item, i) of menus" :index="item.id">
<template slot="title">{{item.name}}</template>
<el-menu-item v-for="(sitem, j) of item.subMenus" :index="sitem.id">{{sitem.name}}</el-menu-item>
</el-submenu>
</el-menu>
</el-aside>
<el-main>
<iframe name="mainFrame" src="" frameborder="0" width="100%" height="600px">
</iframe>
</el-main>
</el-container>
</el-container>
</div>
</body>
</html>
<script>
var vm = new Vue({
el: '#app',
data: {
menus: [
{id: '1', name: '系统管理', subMenus: [
{id: '2', name: '用户管理'},
{id: '3', name: '角色管理'},
{id: '4', name: '菜单管理'},
]}
]
},
methods: {
},
created(){
axios({
method: 'get',
// 本案例并没有贴出获取菜单的的Controller代码,请自行实现
url: 'http://127.0.0.1:8080/menu/getByParent',
withCredentials: true,
headers: {'Content-Type':'application/x-www-form-urlencoded'},
params: {parentId: 0}
}).then((response) => {
console.log(JSON.stringify(response))
})
}
})
</script>
前后端分离
前后端分离的的具体表现有两点
1、前端采用html技术来开发页面
2、前端页面与服务端Servlet分开部署在不同的web容器中
好处很明显:前端工程师不需要学习Servlet技术、前后端并行开发、减轻服务端Servlet容器的访问压力...
但带来的问题就包括:
跨域访问、登录session、Json响应,接下来将基于之前的代码针对这几个问题进行探讨
跨域描述
跨域是指在不同的域之间进行访问存在安全隐患,所以对请求进行了限制。跨域分为以下三种情况
http://127.0.0.1:8080 --> https://127.0.0.1:8080 协议跨域
http://127.0.0.1:8080 --> http://127.0.0.2:8080 IP跨域
http://127.0.0.1:8080 --> http://127.0.0.1:8081
注意,如下两种情况不涉及跨域
1 如果是前后端不分离,即将页面和服务端代码运行在同一个Servlet容器中自然不涉及跨域
2 直接在浏览器中输入服务端Controller的URL,相当于local --> server 也谈不上跨域
解决跨域
解决跨域的办法有好几种,基于之前的案例代码,我们采用服务端解决跨域
非SpringSecurity的场景
- 最简单粗暴的做法为Controller添加 @CrossOrigin注解,意味着允许跨域访问。 缺点很明显,服务端接口完全暴露在了危险的网络环境中
- 精细化控制跨域访问,可以通过自定义Cors过滤器,具体实现如下
import org.springframework.http.HttpHeaders;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
// 只允许127.0.0.1:5500跨域访问
corsConfiguration.addAllowedOrigin("http://127.0.0.1:5500");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.addExposedHeader(HttpHeaders.COOKIE);
corsConfiguration.setAllowCredentials(true);
source.registerCorsConfiguration("/**", corsConfiguration);
return new CorsFilter(source);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://127.0.0.1:5500")
.allowedMethods("GET","POST")
.allowCredentials(true)
.allowedHeaders("*");
}
}
注意:需要引入的依赖包org.springframework.web.cors.xxx,所以特意贴了import代码
SpringSecurity的场景
在使用了springSecurity的情况下你会发现之前的@CrossOrigin注解也解决不了跨域问题了,原因是springsecurity默认情况下并不允许跨域访问,通过如下设置可以解决该问题
@Configuration
@Slf4j
public class OASecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 开启允许跨域访问
http.cors();
}
@Bean
CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration corsConfiguration = new CorsConfiguration();
// 允许跨域访问的站点
corsConfiguration.setAllowedOrigins(Arrays.asList("http://127.0.0.1:5500"));
//允许跨域访问的methods
corsConfiguration.setAllowedMethods(Arrays.asList("GET","POST"));
// 允许携带凭证
corsConfiguration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
//对所有URL生效
source.registerCorsConfiguration("/**",corsConfiguration);
return source;
}
}
至此,跨域问题基本可以解决了
那么接下来就会发现一个问题,登录成功后index页面仍然无法发起请求(根据ParentID获取菜单列表)
该问题就是没有session ID导致的
session ID的描述
session是一种用来解决http协议无状态的重要技术。具体是如何解决无状态的呢?
首先,当浏览器访问服务端,且使用的session时,servlet容器(Tomcat)会为其创建一个Session对象,并为其生存一个session id(JSESSIONID),并通过写cookie的方式将sesssion id写入到浏览器的cookie中,如下图
然后,浏览器将自动保存session ID
如下图
最后,当浏览器再次发起其他请求时,将会自动的携带该session ID,如下图(总算把线划直了)
结论:session + cookie解决http无状态的问题
嗯.... 这和登不登录有和关系???
session的创建并不是用来解决登录问题的,但是,我们可以通过这一技术来简单的实现登录,这取决于登录时创建session,并将用户信息存入session,来点伪代码吧
// 由于是伪代码所以就没有写注解了,方法的返回值就随意些一下,不要照抄
public class LoginController{
public String login(HttpServletRequest request, String username, String password){
// 执行这句将会创建Session
Session session = request.getSession()
// 根据用户名获取用户信息
User use = getUser(username)
// 验证密码
boolean result = verifyLogin(use, password)
// 保存用户信息到session,注意不要保存密码等敏感信息到Session
session.setAttribute("user", username)
}
}
这样一来,我们就可以在拦截器中取检查对于session id对于的session中是否有user属性
解决Session ID未保存的问题
经过上一小节的说明,相信大家对session id与登录的关系了。但是,在跨域的情况下浏览器并不能正常的保存session id。
注意:session id的话题与springsecurity无关,无论采用什么方式实现登录都一样。除非采用Token记录登录的方式
浏览器在跨域的情景下不能正常保存session id,其根本原因是ajax的异步请求,需要通过在异步回调中为浏览器保存session id。
在全景代码中通过Axios发起ajax请求有如下一段代码
new Vue({
el: '#app',
data: {...},
methods: {
login(){
axios({
method: 'post',
url: 'http://127.0.0.1:8080/login',
// 就是他啦~~~~~~~~~~~~
withCredentials: true,
data: Qs.stringify({
username: this.form.username,
password: this.form.password
})
}).then((response) => {...})
}
}
})
withCredentials: true,的选项则意味着浏览器保存认证信息(即保存session id),同时,也会在发起请求时自动带上seesion id。所以,所有的请求的需要加上该选项。
至此,session id的问题可以解决
但是,前后端分离的一大特性就是,前后端之间通过JSON来传递响应结果,那么,在spring security接管登录处理、登录失败、登录成功等处理之后如何替换掉用默认的页面跳转,而改为返回Json响应呢。
Json响应
- 首先,Spring Security在用户未登录时访问资源会将请求重定向到登录页,解决该问题可以如下:
对于全局代码中Security配置类关键代码为:
@Configuration
@Slf4j
public class OASecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 就他啦~~~~
http.exceptionHandling().authenticationEntryPoint(new OAAuthenticationEntryPoint());
}
}
http.exceptionHandling().authenticationEntryPoint(new OAAuthenticationEntryPoint());
意味着,当未登录时会触发异常,并触发EntryPoint(即,跳转登录页),只需要自己定义该EntryPoint便可以接管默认行为,改为返回Json响应了。
至于代码中的OAAuthenticationEntryPoint,在全景代码部分已经有贴过,就不重复了
- 其次Spring Security登录成功与失败的处理可以设置返回结果为Json响应
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.successForwardUrl("/login/success")
.failureForwardUrl("/login/failure");
}
successForwardUrl与failureForwardUrl
可以指定Controller来处理登录成功与失败。至于该Controller也在全景代码部分已经有贴过,就不重复了
至此,前后端分离的登录的问题已经解决。
注意:前端部分axios代码,需自行优化