Spring Boot之前后端分离(三):登录、登出、页面认证
前言
来啦老铁!
笔者学习Spring Boot有一段时间了,附上Spring Boot系列学习文章,欢迎取阅、赐教:
- 5分钟入手Spring Boot;
- Spring Boot数据库交互之Spring Data JPA;
- Spring Boot数据库交互之Mybatis;
- Spring Boot视图技术;
- Spring Boot之整合Swagger;
- Spring Boot之junit单元测试踩坑;
- 如何在Spring Boot中使用TestNG;
- Spring Boot之整合logback日志;
- Spring Boot之整合Spring Batch:批处理与任务调度;
- Spring Boot之整合Spring Security: 访问认证;
- Spring Boot之整合Spring Security: 授权管理;
- Spring Boot之多数据库源:极简方案;
- Spring Boot之使用MongoDB数据库源;
- Spring Boot之多线程、异步:@Async;
- Spring Boot之前后端分离(一):Vue前端;
- Spring Boot之前后端分离(二):后端、前后端集成
在前2篇文章,我们一起学习了Spring Boot之前后端分离的前端、后端、前后端集成,不过前后端集成的时候没有演示登录成功后的页面跳转。
这两天从登录后页面跳转出发,准备来个前后端交互升级版,却发现登出API没有工作正常,几经琢磨后终于解决,决定再写一篇文章,主要介绍:
1. 后端登出API的具体配置及实现;
2. JSESSIONID失效设置;
3. 登录后页面跳转、页面访问认证、登出实现;
4. 演示登录后页面跳转、页面访问认证、登出;
项目代码已更新至Git Hub仓库,欢迎取阅:
- 前端:https://github.com/dylanz666/spring-boot-vue-frontend
- 后端:https://github.com/dylanz666/spring-boot-vue-backend
1. 后端登出API的具体配置及实现;
我们之前直接使用Spring Security的默认登出API:/logout,感觉应该不会有问题,也没试过,可是当我真正使用它的时候却发现,翻车了???
检查了一下后端config包内的WebSecurityConfig.java类,关于登出的配置如下:
...
.and()
.logout()
.permitAll()
...
后经查资料和亲自实践,发现加上logoutSuccessHandler就成功解决了,具体步骤如下:
1). domain包内创建SignOutResponse.java实体类,定义登出API的返回;
package com.github.dylanz666.domain;
import com.alibaba.fastjson.JSONArray;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Component;
import java.io.Serializable;
/**
* @author : dylanz
* @since : 10/10/2020
*/
@NoArgsConstructor
@AllArgsConstructor
@Data
@Component
public class SignOutResponse implements Serializable {
private static final long serialVersionUID = 1L;
private int code;
private String status;
private String message;
@Override
public String toString() {
return "{" +
"\"code\":" + code + "," +
"\"status\":\"" + status + "\"," +
"\"message\":\"" + message + "\"" +
"}";
}
}
2). 修改config包内WebSecurityConfig.java类的登出配置部分;
...
.logout()
.deleteCookies("JSESSIONID")
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType("application/json");
response.setStatus(200);
SignOutResponse signOutResponse = new SignOutResponse();
signOutResponse.setCode(200);
signOutResponse.setStatus("success");
signOutResponse.setMessage("success");
PrintWriter out = response.getWriter();
out.write(signOutResponse.toString());
out.flush();
out.close();
})
.permitAll()
...
这里主要做了2件事,第一个,删除cookie中的JSESSIONID,第二个,声明logoutSuccessHandler;
在验证登出API之前,我们先另外建一个需要访问认证的API,我称之为认证API,步骤如下:
1). 在controller包内创建AuthController.java类;
2). 在AuthController.java类中创建/api/auth API,代码如下:
package com.github.dylanz666.controller;
import com.alibaba.fastjson.JSONArray;
import com.github.dylanz666.domain.SignInResponse;
import com.github.dylanz666.domain.SignOutResponse;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collection;
/**
* @author : dylanz
* @since : 10/09/2020
*/
@RestController
public class AuthController {
@GetMapping("/api/auth")
public SignInResponse getAuth() {
Collection<? extends GrantedAuthority> authorities = SecurityContextHolder.getContext().getAuthentication().getAuthorities();
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
String username = userDetails.getUsername();
SignInResponse signInResponse = new SignInResponse();
signInResponse.setCode(200);
signInResponse.setStatus("success");
signInResponse.setMessage("success");
signInResponse.setUsername(username);
JSONArray userRoles = new JSONArray();
for (GrantedAuthority authority : authorities) {
String userRole = authority.getAuthority();
if (!userRole.equals("")) {
userRoles.add(userRole);
}
}
signInResponse.setUserRoles(userRoles);
return signInResponse;
}
}
以上步骤完成后,我们再次用postman验证一下后端的登录、登出过程:
我们会发现登出后,Cookie中的JSESSIONID已经变了,这是由于后端把原有的JSESSIONID删除了,并自动分配了另外的JSESSIONID,且是个没有权限的JSESSIONID。不仅如此,如果我们用原有的JSESSIONID再次访问API,也是没有权限的!
因此,我们完成了登出的配置,登出功能已实现!!!
2. JSESSIONID失效设置;
通常,处于安全考虑,我们希望如果一个用户在一定时间内对网站没有任何操作,那么关闭与该用户的会话,即将该用户的JSESSIONID设置为失效,那么这个问题在Spring Security中该如何做到呢?
其实这块Spring Security已经帮我们做好了,默认情况下,用户60秒无操作,则该用户的JSESSIONID将失效,如果我们想更改该时间,也很简单,只需要在项目的resources下的application.properties文件中加入如下配置:
server.servlet.session.timeout=600
这代表,用户600秒无操作,则该用户的JSESSIONID将失效(注意单位为秒)!
JSESSIONID失效后,用户再次登录后才能继续访问我们的应用!
后端再一次准备好了,接下来我打算做一下登录后的页面跳转和前端页面的认证。
3. 登录后页面跳转、页面访问认证、登出实现;
1). 前端基于axios编写logout API调用方法和认证API的调用方法;
import request from '@/utils/request'
export function getAuth() {
return request({
url: '/api/auth',
method: 'get',
params: {}
})
}
export function logout() {
return request({
url: '/logout',
method: 'get',
params: {}
})
}
2). 修改前端config/index.js文件中的proxyTable,如下:
...
const backendBaseUrl = 'http://127.0.0.1:8080/';
...
proxyTable: {
'/api': {
target: backendBaseUrl,
changeOrigin: true,
pathRewrite: {
'^/api': '/api'
}
},
'/login': {
target: backendBaseUrl,
changeOrigin: true,
pathRewrite: {
'^/login': '/login'
}
},
'/logout': {
target: backendBaseUrl,
changeOrigin: true,
pathRewrite: {
'^/logout': '/logout'
}
},
}
3). 前端src/views底下创建home文件夹,home文件夹内创建index.vue文件,代码如下:
(我们暂时不考虑将某些功能组件化,此处只做演示)
<template>
<el-container class="tac">
<el-menu
mode="vertical"
unique-opened
default-active="1"
class="el-menu-vertical-demo"
@open="handleOpen"
@close="handleClose"
background-color="#006699"
text-color="#fff"
active-text-color="#ffd04b"
v-bind:style="menuStyle"
>
<el-submenu index="1">
<template slot="title">
<i class="el-icon-location"></i>
<span>Navigator One</span>
</template>
<el-menu-item-group title="Group One">
<el-menu-item index="1-1">item one</el-menu-item>
<el-menu-item index="1-2">item one</el-menu-item>
</el-menu-item-group>
<el-menu-item-group title="Group Two">
<el-menu-item index="1-3">item three</el-menu-item>
</el-menu-item-group>
<el-submenu index="1-4">
<template slot="title">item four</template>
<el-menu-item index="1-4-1">item one</el-menu-item>
</el-submenu>
</el-submenu>
<el-menu-item index="2">
<i class="el-icon-menu"></i>
<span>Navigator Two</span>
</el-menu-item>
<el-menu-item index="3" disabled>
<i class="el-icon-document"></i>
<span>Navigator Three</span>
</el-menu-item>
<el-menu-item index="4">
<i class="el-icon-setting"></i>
<span>Navigator Four</span>
</el-menu-item>
</el-menu>
<el-main>
<el-row>
<el-col :span="18" align="left">
<el-page-header @back="goBack" title="HOME"></el-page-header>
</el-col>
<el-col :span="2" style="margin-top: -10px">
<!-- 当前角色 -->
<el-button
type="text"
icon="el-icon-user-solid"
disabled
size="medium"
>{{ username }}({{ currentUserRole }})</el-button
>
</el-col>
<el-col :span="2" style="margin-top: -10px">
<!-- 登出 -->
<el-button
type="text"
icon="el-icon-caret-right"
size="medium"
@click="logout"
>Sign Out</el-button
>
</el-col>
<el-col :span="2" style="margin-top: -10px">
<!-- 角色下拉框 -->
<el-dropdown @command="changeRole">
<span class="el-dropdown-link">
<el-button type="text" size="medium" icon="el-icon-s-tools"
>User Role</el-button
>
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<div v-for="(item, index) in userRoles" :key="item">
<el-dropdown-item
icon="el-icon-user"
size="medium"
:command="userRoles[index]"
>{{ userRoles[index] }}</el-dropdown-item
>
</div>
</el-dropdown-menu>
</el-dropdown>
</el-col>
</el-row>
<hr />
<!-- demo form -->
<div style="width: 45%">
<el-form
ref="form"
:model="form"
label-width="120px"
label-position="left"
align="left"
>
<el-form-item label="Activity name">
<el-input v-model="form.name"></el-input>
</el-form-item>
<el-form-item label="Activity zone">
<el-select
v-model="form.region"
placeholder="please select your zone"
>
<el-option label="Zone one" value="shanghai"></el-option>
<el-option label="Zone two" value="beijing"></el-option>
</el-select>
</el-form-item>
<el-form-item label="Activity time" align="center">
<el-col :span="11">
<el-date-picker
type="date"
placeholder="Pick a date"
v-model="form.date1"
style="width: 100%"
></el-date-picker>
</el-col>
<el-col class="line" :span="2">-</el-col>
<el-col :span="11">
<el-time-picker
placeholder="Pick a time"
v-model="form.date2"
style="width: 100%"
></el-time-picker>
</el-col>
</el-form-item>
<el-form-item label="Instant delivery">
<el-switch v-model="form.delivery"></el-switch>
</el-form-item>
<el-form-item label="Activity type">
<el-checkbox-group v-model="form.type">
<el-checkbox label="Online activities" name="type"></el-checkbox>
<el-checkbox
label="Promotion activities"
name="type"
></el-checkbox>
<el-checkbox label="Offline activities" name="type"></el-checkbox>
<el-checkbox
label="Simple brand exposure"
name="type"
></el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="Resources">
<el-radio-group v-model="form.resource">
<el-radio label="Sponsor"></el-radio>
<el-radio label="Venue"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="Activity form">
<el-input type="textarea" v-model="form.desc"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">Create</el-button>
<el-button>Cancel</el-button>
</el-form-item>
</el-form>
</div>
</el-main>
</el-container>
</template>
<script>
import { getAuth, logout } from "@/api/auth";
export default {
name: "home",
data() {
return {
username: "",
userRoles: [],
currentUserRole: "",
menuStyle: {
height: null,
},
form: {
name: "",
region: "",
date1: "",
date2: "",
delivery: false,
type: [],
resource: "",
desc: "",
},
};
},
created() {
this.menuStyle.height = document.documentElement.clientHeight + "px";
getAuth().then((response) => {
if (response.code == 200 && response.message == "success") {
this.username = response.username;
this.userRoles = response.userRoles;
this.currentUserRole = this.userRoles[0]
? this.userRoles[0].substring(5, this.userRoles[0].length)
: "";
}
});
},
methods: {
logout() {
logout().then((response) => {
if (response.code == 200 && response.status == "success") {
window.location.href = "/#/login.html";
}
});
},
changeRole(role) {
this.currentUserRole = role
? role.substring(5, this.userRoles[0].length)
: "";
},
goBack() {
console.log("back to home");
},
onSubmit() {
this.$notify({
title: "submit",
message: "success",
type: "success",
offset: 100,
});
},
handleOpen(key, keyPath) {
console.log(key, keyPath);
},
handleClose(key, keyPath) {
console.log(key, keyPath);
},
},
};
</script>
<style rel="stylesheet/scss" lang="scss" scoped>
.app-breadcrumb.el-breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 10px;
.no-redirect {
color: #97a8be;
cursor: text;
}
.right-menu {
float: right;
height: 100%;
&:focus {
outline: none;
}
.right-menu-item {
display: inline-block;
margin: 0 8px;
}
.screenfull {
height: 20px;
}
.international {
vertical-align: top;
}
.theme-switch {
vertical-align: 15px;
}
.avatar-container {
height: 50px;
margin-right: 30px;
.avatar-wrapper {
cursor: pointer;
margin-top: 5px;
position: relative;
.user-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
}
.el-icon-caret-bottom {
position: absolute;
right: -20px;
top: 25px;
font-size: 12px;
}
}
}
}
}
</style>
稍微解读一下:
- 关注点一:我们created()方法中调用后端的/api/auth方法,验证用户是否已登录,如果已登录,则展示用户的角色信息和当前角色信息。如果未登录,则src/utils/request.js中会将页面重定向到登录页面,src/utils/request.js的改动见下文;
- 关注点二:我们在页面上设置了一个Sign Out入口,点击Sign Out则调用后端/logout API,并且在调用成功后重定向到登录页面;
4). 修改src/utils/request.js文件,如下:
import axios from 'axios'
import { Message, MessageBox } from 'element-ui'
// 创建axios实例
const service = axios.create({
baseURL: process.env.BASE_API, // api的base_url
timeout: 15000 // 请求超时时间
})
// request拦截器
/*
service.interceptors.request.use(config => {
if (store.getters.token) {
config.headers['X-Token'] = getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
}
return config
}, error => {
// Do something with request error
console.log(error) // for debug
Promise.reject(error)
})
*/
// respone拦截器
service.interceptors.response.use(
response => {
/**
* code为非200是抛错 可结合自己业务进行修改
*/
const res = response.data;
if (res.code !== 200) {
Message({
message: res.message,
type: 'error',
duration: 5 * 1000
})
}
return res;
},
error => {
Message({
message: error.message,
type: 'error',
duration: 5 * 1000
});
window.location.href = "/#/login.html";
return Promise.reject(error)
}
)
export default service
唯一变化是在error内增加了window.location.href = "/#/login.html"; 这行代码,这是使得访问后端API的时候,如果遇到400,401,500等错误,直接跳转到登录页面(可另外设置页面,此处只做演示),是全局的设置,避免每个页面都写一遍类似的代码(非必需,可根据实际情况设置)。
5). 修改src/views/login/index.vue中methods的login方法,如下:
login(formName) {
const from = this.$route.query.from || "home.html";
this.$refs[formName].validate((valid) => {
if (!valid) {
return false;
}
login(this.ruleForm.username, this.ruleForm.password).then(
(response) => {
this.showSuccessNotify(response);
this.ruleForm.username = "";
this.ruleForm.password = "";
if (response.code == 200 && response.status == "success") {
this.$router.push({ path: `/${from}` });
}
}
);
});
}
这是为了让从其他页面跳转到登录页面,登录成功后可直接跳转回原有页面,是个小技巧,非必需。如果不是从其他页面跳转到登录页面的,则登录成功后默认跳转至127.0.0.1/#/home.html页面。
6). 设置访问前端路由外的报错页面,即404页面;
-
src/assets内添加404页面用到的图片资源:
- 新建src/views/404.vue文件并编写404页面;
<template>
<div style="background:#f0f2f5;margin-top: -20px;">
<div class="wscn-http404">
<div class="pic-404">
<img class="pic-404__parent" :src="img_404" alt="404">
<img class="pic-404__child left" :src="img_404_cloud" alt="404">
<img class="pic-404__child mid" :src="img_404_cloud" alt="404">
<img class="pic-404__child right" :src="img_404_cloud" alt="404">
</div>
<div class="bullshit">
<div class="bullshit__oops">OOPS!</div>
<div class="bullshit__info">版权所有<a class="link-type" href="https://wallstreetcn.com" target='_blank'>华尔街见闻</a></div>
<div class="bullshit__headline">{{ message }}</div>
<div class="bullshit__info">请检查您输入的网址是否正确,请点击以下按钮返回主页或者发送错误报告</div>
<a href="/" class="bullshit__return-home">返回首页</a>
</div>
</div>
</div>
</template>
<script>
import img_404 from '@/assets/404_images/404.png'
import img_404_cloud from '@/assets/404_images/404_cloud.png'
export default {
data() {
return {
img_404,
img_404_cloud
}
},
computed: {
message() {
return '特朗普说这个页面你不能进......'
}
}
}
</script>
<style rel="stylesheet/scss" lang="scss" scoped>
.wscn-http404 {
position: relative;
width: 1200px;
margin: 20px auto 60px;
padding: 0 100px;
overflow: hidden;
.pic-404 {
position: relative;
float: left;
width: 600px;
padding: 150px 0;
overflow: hidden;
&__parent {
width: 100%;
}
&__child {
position: absolute;
&.left {
width: 80px;
top: 17px;
left: 220px;
opacity: 0;
animation-name: cloudLeft;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1s;
}
&.mid {
width: 46px;
top: 10px;
left: 420px;
opacity: 0;
animation-name: cloudMid;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1.2s;
}
&.right {
width: 62px;
top: 100px;
left: 500px;
opacity: 0;
animation-name: cloudRight;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1s;
}
@keyframes cloudLeft {
0% {
top: 17px;
left: 220px;
opacity: 0;
}
20% {
top: 33px;
left: 188px;
opacity: 1;
}
80% {
top: 81px;
left: 92px;
opacity: 1;
}
100% {
top: 97px;
left: 60px;
opacity: 0;
}
}
@keyframes cloudMid {
0% {
top: 10px;
left: 420px;
opacity: 0;
}
20% {
top: 40px;
left: 360px;
opacity: 1;
}
70% {
top: 130px;
left: 180px;
opacity: 1;
}
100% {
top: 160px;
left: 120px;
opacity: 0;
}
}
@keyframes cloudRight {
0% {
top: 100px;
left: 500px;
opacity: 0;
}
20% {
top: 120px;
left: 460px;
opacity: 1;
}
80% {
top: 180px;
left: 340px;
opacity: 1;
}
100% {
top: 200px;
left: 300px;
opacity: 0;
}
}
}
}
.bullshit {
position: relative;
float: left;
width: 300px;
padding: 150px 0;
overflow: hidden;
&__oops {
font-size: 32px;
font-weight: bold;
line-height: 40px;
color: #1482f0;
opacity: 0;
margin-bottom: 20px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-fill-mode: forwards;
}
&__headline {
font-size: 20px;
line-height: 24px;
color: #1482f0;
opacity: 0;
margin-bottom: 10px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.1s;
animation-fill-mode: forwards;
}
&__info {
font-size: 13px;
line-height: 21px;
color: grey;
opacity: 0;
margin-bottom: 30px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.2s;
animation-fill-mode: forwards;
}
&__return-home {
display: block;
float: left;
width: 110px;
height: 36px;
background: #1482f0;
border-radius: 100px;
text-align: center;
color: #ffffff;
opacity: 0;
font-size: 14px;
line-height: 36px;
cursor: pointer;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.3s;
animation-fill-mode: forwards;
}
@keyframes slideUp {
0% {
transform: translateY(60px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
}
}
</style>
项目启动后,访问未在前端设置路由的路径,如http://127.0.0.1:9528/#/test.html或http://127.0.0.1:9528/#/test,则跳转到http://127.0.0.1:9528/#/404,页面长这样:
怎么样,404页面还是挺好看的吧!
7). 将src/views/home/index.vue和src/views/404.vue加入前端路由配置src/router/index.js中:
import Vue from 'vue'
import Router from 'vue-router'
import login from '@/views/login/index'
import home from '@/views/home/index'
import notFoundPage from '@/views/404'
Vue.use(Router)
export default new Router({
routes: [
{
path: '/',
name: 'index',
component: login
},
{
path: '/login.html',
name: 'login',
component: login
},
{
path: '/home.html',
name: 'home',
component: home
},
{
path: '/404',
component: notFoundPage,
hidden: true
},
{
path: '*',
redirect: '/404',
hidden: true
}
]
})
4. 演示登录后页面跳转、页面访问认证、登出;
1). 启动前端:
npm start
2). 启动后端:
3). 登录前访问home页面:http://127.0.0.1:9528/#/home.html;
我们会发现页面自动重定向到登录页面,并且显示了一个401方面的错误信息,这是我们事先在src/utils/request.js内写好的哟,该行为比较符合实际使用需求!
4). 前端登录及登录跳转:
我们会发现:
- 只有正确的用户登录信息才能登录,错误的用户登录信息是无法登录的,这也是符合符合实际情况的!
- 登录后成功跳转至home页面,home页面进行了访问认证,并且认证通过,页面正常展示,符合实际情况!
5). 登出操作、登出后访问home页面:http://127.0.0.1:9528/#/home.html;
- 点击页面右上角的Sign Out按钮进行登出;
- 登出时调用后端logout API;
- 登出后访问home页面;
我们会发现,登出后home页面已无权限访问!
从JSESSIONID的角度来看:
- 登录后站点Cookies中JSESSIONID的情况:
- 登出后站点Cookies中JSESSIONID的情况:
可见,登出后,站点Cookies中JSESSIONID的确被删除掉了,这是后端WebSecurityConfig.java类中写好的,符合我们的预期!
至此,我们实现了登录成功后的跳转,登出功能、页面认证,并完整地做了演示。由于同时涉及前后端,整个过程稍微有点长而复杂,完整看完的话需要点耐心,我鼓励大家动手跟着实现一遍!
而我也将不断学习,不断更新Spring Boot相关知识,以及Spring Boot前后端分离相关内容。道阻且长,但不忘初心!
如果本文对您有帮助,麻烦点赞、关注!
谢谢!
作者:狄仁杰666
原文链接:https://www.jianshu.com/p/3d9778acdfb5