后端实现
用户登录
1、用户登录之后可以使用很多方法来保存登录态,这里使用JWT
引入依赖:
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
2、封装 JWT 用到的常量
package pers.qianyucc.qblog.constants;
public class JwtConstants {
public static final String ROLE_CLAIMS = "role";
/**
* 过期时间:24小时
*/
public static final long EXPIRATION = 60 * 60*24L;
/**
* 私钥
*/
public static final String JWT_SECRET_KEY = "uED44b4NzOQlodczbRDMvbtjDMNCiS7BUpe39Mcq";
/**
* JWT 前缀
*/
public static final String TOKEN_PREFIX = "zhulinbimo_";
/**
* token 类型
*/
public static final String TOKEN_TYPE = "JWT";
/**
* 签发者
*/
public static final String TOKEN_ISSUER = "QianYu";
/**
* 加在响应头的哪个字段
*/
public static final String TOKEN_HEADER = "Authorization";
}
3、封装 Jwt 工具类
package pers.qianyucc.qblog.utils;
import cn.hutool.crypto.*;
import io.jsonwebtoken.*;
import pers.qianyucc.qblog.constants.*;
import javax.crypto.*;
import java.util.*;
/**
* JWT 工具类
*/
public class JwtUtils {
private static final SecretKey secretKey = SecureUtil.generateKey("HS256", JwtConstants.JWT_SECRET_KEY.getBytes());
/**
* 生成 JwtToken
* @param subject token 中要保存的内容
* @param roles 角色
* @return JwtToken
*/
public static String createToken(String subject, List<String> roles) {
final Date createdDate = new Date();
final Date expirationDate = new Date(createdDate.getTime() + JwtConstants.EXPIRATION * 1000);
String tokenPrefix = Jwts.builder()
.setHeaderParam("type", JwtConstants.TOKEN_TYPE)
.signWith(SignatureAlgorithm.HS256, secretKey)
.claim(JwtConstants.ROLE_CLAIMS, String.join(",", roles))
.setIssuer(JwtConstants.TOKEN_ISSUER)
.setIssuedAt(createdDate)
.setSubject(subject)
.setExpiration(expirationDate)
.compact();
return JwtConstants.TOKEN_PREFIX + tokenPrefix;
}
/**
* 判断 JwtToken 是否已经过期
* @param token JwtToken
* @return 是否过期,true:已经过期
*/
public static boolean isTokenExpired(String token) {
Date expiredDate = getTokenBody(token).getExpiration();
return expiredDate.before(new Date());
}
/**
* 获取 JwtToken 中存储的内容
* @param token JwtToken
* @return JwtToken 中存储的内容
*/
public static String getSubject(String token) {
return getTokenBody(token).getSubject();
}
public static Claims getTokenBody(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
}
}
4、为了简单就直接在配置文件中配置用户名和使用加密算法加密后密码(这里为了简单就使用MD5,百度搜索“MD5在线加密”即可)
[]
title = 竹林客栈
desc = 不求风度翩翩,但求风骨立世;没有书生意气,也要为义为仁。
covers = https://tvax1.sinaimg.cn/mw1024/bfe05ea9ly1fxgu8jys3fj21hc0u0k0j.jpg,https://tvax1.sinaimg.cn/large/bfe05ea9ly1fxgunx09dtj21hc0u0q81.jpg,https://tvax1.sinaimg.cn/large/bfe05ea9ly1fxgv2t92yyj21hc0u0qb9.jpg
avatar = https://portrait.gitee.com/uploads/avatars/user/772/2317865_qianyucc_1589023575.png
nickname = 竹林笔墨
username = admin
password = 827ccb0eea8a706c4c34a16891f84e7b
5、创建 UserService 并编写登录方法,在登录方法中校验用户名的密码
package pers.qianyucc.qblog.service;
import cn.hutool.core.collection.*;
import cn.hutool.crypto.digest.*;
import cn.hutool.setting.*;
import org.springframework.beans.factory.annotation.*;
import org.springframework.stereotype.*;
import pers.qianyucc.qblog.exception.*;
import pers.qianyucc.qblog.model.dto.*;
import pers.qianyucc.qblog.utils.*;
import java.util.*;
import static pers.qianyucc.qblog.model.enums.ErrorInfoEnum.*;
@Service
public class UserService {
@Autowired
private Setting setting;
/**
* 校验用户名和密码
* @param userDTO 用户对象
* @return 校验成功就返回token
*/
public String checkUsernamePassword(UserDTO userDTO) {
String username = setting.getStr("username");
String password = setting.getStr("password");
if (Objects.equals(username, userDTO.getUsername()) &&
Objects.equals(password, MD5.create().digestHex(password))) {
return JwtUtils.createToken(username, CollUtil.newArrayList(UserRoleEnum.ADMIN.getValue()));
} else {
throw new BlogException(USERNAME_PASSWORD_ERROR);
}
}
}
说明:
- 用户名密码校验完成之后,将 ADMIN 角色存入 JWT 中
- 上面代码中用到的错误信息:
USERNAME_PASSWORD_ERROR(4009, "用户名或密码错误"),
以及 UserDTO
package pers.qianyucc.qblog.model.dto;
import io.swagger.annotations.*;
import lombok.*;
import javax.validation.constraints.*;
@Data
@ApiModel(value = "用户登录信息类", description = "前端传入的用户登录信息")
public class UserDTO {
@NotEmpty(message = "用户名不能为空")
@ApiModelProperty(notes = "用户名", example = "admin")
private String username;
@NotEmpty(message = "用户密码不能为空")
@ApiModelProperty(notes = "用户密码", example = "123")
private String password;
}
以及角色枚举:
package pers.qianyucc.qblog.model.enums;
public enum UserRoleEnum {
ADMIN("ADMIN");
UserRoleEnum(String value) {
this.value = value;
}
private final String value;
public String getValue() {
return value;
}
}
6、编写 UserController 实现登录接口
package pers.qianyucc.qblog.controller;
import io.swagger.annotations.*;
import org.springframework.beans.factory.annotation.*;
import org.springframework.web.bind.annotation.*;
import pers.qianyucc.qblog.constants.*;
import pers.qianyucc.qblog.model.comm.*;
import pers.qianyucc.qblog.model.dto.*;
import pers.qianyucc.qblog.service.*;
import javax.servlet.http.*;
import javax.validation.*;
@Api("与用户相关的api接口")
@RestController
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/login")
@ApiOperation("用户登录")
public Results<Map<String, Object>> login(@ApiParam(name = "用户登录信息", value = "传入json格式", required = true)
@RequestBody @Valid UserDTO userDTO) {
String token = userService.checkUsernamePassword(userDTO);
return Results.ok("登录成功", MapUtil.of("token", token));
}
}
此时使用 knief4j 测试登录
可以发现返回值里面有我们自定义的JWT
身份验证
登录成功之后,前端就拿到了 JWT,每次请求的时候需要带上 JWT 。后端可以使用拦截器进行拦截,判断 JWT 是否过期,是否非法
1、自定义拦截器
package pers.qianyucc.qblog.interceptor;
import cn.hutool.core.util.*;
import io.jsonwebtoken.*;
import lombok.extern.slf4j.*;
import org.springframework.stereotype.*;
import org.springframework.web.servlet.*;
import pers.qianyucc.qblog.constants.*;
import pers.qianyucc.qblog.exception.*;
import pers.qianyucc.qblog.model.enums.*;
import pers.qianyucc.qblog.utils.*;
import javax.servlet.http.*;
import java.util.*;
@Slf4j
@Component
public class JwtTokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception {
String token = req.getHeader(JwtConstants.TOKEN_HEADER);
if (token == null || !token.startsWith(JwtConstants.TOKEN_PREFIX)) {
throw new BlogException(ErrorInfoEnum.NOT_LOGIN);
}
token = token.replace(JwtConstants.TOKEN_PREFIX, StrUtil.EMPTY);
if (JwtUtils.isTokenExpired(token)) {
throw new BlogException(ErrorInfoEnum.TOKEN_EXPIRED);
}
Claims claims = JwtUtils.getTokenBody(token);
String[] roles = Optional.ofNullable(claims.get(JwtConstants.ROLE_CLAIMS))
.map(r -> r.toString().split(","))
.orElse(new String[0]);
if (!ArrayUtil.contains(roles, UserRoleEnum.ADMIN.getValue())) {
throw new BlogException(ErrorInfoEnum.NO_AUTHORITY);
}
return true;
}
}
说明:
- 拦截器主要检查:
- 1、token 的前缀是否正确。
- 2、token 是否过期。
- 3、token 中的角色是否包含 ADMIN
- 错误信息枚举如下:
TOKEN_EXPIRED(4001, "您的token已过期"),
NOT_LOGIN(4002, "请先登录再进行访问"),
NO_AUTHORITY(4005, "对不起,您没有权限访问该接口"),
2、配置拦截器,拦截/auth/**
类型的请求,即以/auth
开头的请求
package pers.qianyucc.qblog.config;
import org.springframework.beans.factory.annotation.*;
import org.springframework.context.annotation.*;
import org.springframework.web.servlet.config.annotation.*;
import pers.qianyucc.qblog.interceptor.*;
/**
* @author lijing
* @e-mail 1413979079@qq.com
* @date 2020-06-28 22:49
* @description Web 配置类
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private JwtTokenInterceptor jwtTokenInterceptor;
/**
* 解决 swagger静态资源无法访问的问题
*
* @param registry 资源处理程序注册表
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("doc.html")
.addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtTokenInterceptor)
.addPathPatterns("/auth/**");
}
}
3、修改请求地址,在需要登录才能访问的地址前面加上 /auth
package pers.qianyucc.qblog.controller;
//......
@Api("与文章相关的api接口")
@RestController
public class ArticleController {
@Autowired
private ArticleService articleService;
//......
@PostMapping("/auth/articles")
@ApiOperation("新增文章")
public Results<Map<String, Object>> postArticles(@ApiParam(name = "文章信息", value = "传入json格式", required = true)
@RequestBody @Valid ArticleDTO articleDTO) {
String id = articleService.insArticle(articleDTO);
return Results.ok(MapUtil.of("id", id));
}
//......
@DeleteMapping("/auth/article/{id}")
@ApiOperation("根据id删除文章")
@ApiImplicitParam(name = "id", value = "文章id", required = true, dataType = "String", paramType = "path")
public Results deleteArticle(@PathVariable String id) {
articleService.deleteArticle(id);
return Results.ok("删除成功", null);
}
@PutMapping("/auth/article/{id}")
@ApiOperation("修改文章")
@ApiImplicitParam(name = "id", value = "文章id", required = true, dataType = "String", paramType = "path")
public Results<Map<String, Object>> putArticle(@ApiParam(name = "要修改的文章信息", value = "传入json格式", required = true)
@RequestBody ArticleDTO articleDTO,
@PathVariable String id) {
articleService.updateArticle(articleDTO, id);
return Results.ok("更新成功", MapUtil.of("id", id));
}
//......
}
4、然后需要处理全局异常,JwtToken 验证失败的时候会抛出 SignatureException 异常
@ResponseBody
@ExceptionHandler(value = SignatureException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Results signatureExceptionHandler(SignatureException exception) {
log.error("SignatureException:{}", exception.getMessage());
return Results.fromErrorInfo(ErrorInfoEnum.TOKEN_INVALID);
}
用到的枚举:
TOKEN_INVALID(4004, "无效的token"),
5、然后我们修改 SwaggerConfig
中的配置,使其调试的时候可以传递自定义请求头
@Bean
public Docket docket(Environment environment) {
// 如果在dev环境(开发环境)就开启Swagger
boolean isDev = environment.acceptsProfiles(Profiles.of("dev"));
ParameterBuilder ticketPar = new ParameterBuilder();
List<Parameter> pars = new ArrayList<>();
// 调试中可以修改请求头
ticketPar.name(JwtConstants.TOKEN_HEADER)
.modelRef(new ModelRef("string")).parameterType("header")
.required(false).build();
pars.add(ticketPar.build());
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.groupName("默认接口")
.enable(isDev)
.select()
.apis(RequestHandlerSelectors.basePackage("pers.qianyucc.qblog.controller"))
.paths(PathSelectors.any())
.build()
.globalOperationParameters(pars);
}
6、当未登录的时候访问 api,提示需要登录
7、这个时候可以先登录,从响应头中拿到 token,在请求头中传入 token
这次删除成功
8、这个时候,如果我们修改了token的内容的话
提供一个可以很方便的修改JwtToken的网站:https://www.jsonwebtoken.io/
我们把签发人修改为QianYu11
然后再次请求接口,发现这里已经被我们自定义的异常处理拦截,无法访问该接口:
(这就是 Jwt 的神奇作用,虽然我们可以看到 Jwt 中存的信息,但是是无法修改)
前端
前端的登录功能 vue-template-admin 已经写好了,我们只需要分析源码,稍作改动即可
1、在 vue.config.js
中把 mock 去掉,这时候请求将不会再走前端的假数据
devServer: {
port: port,
open: true,
overlay: {
warnings: false,
errors: true
},
// before: require('./mock/mock-server.js'),
proxy: {
'/api/v1': {
target: process.env.VUE_APP_LOCAL_ADDR,
}
}
},
2、修改@/api/user.js
中的登录方法
export function login(data) {
return request({
url: '/login',
method: 'post',
data
})
}
3、修改@/views/login/index.vue
3.1、首先修改用户名和密码的校验器
const validateUsername = (rule, value, callback) => {
if (!value) {
callback(new Error("用户名不能为空"));
} else {
callback();
}
};
const validatePassword = (rule, value, callback) => {
if (value.length < 5) {
callback(new Error("密码不能小于5个字符"));
} else {
callback();
}
};
说明:
- 校验用户名原本使用的是
@/utils/validate.js
,中的validUsername
方法,这里我们不需要使用这个方法
3.2、分析登录方法
handleLogin() {
this.$refs.loginForm.validate((valid) => {
if (valid) {
// 显示 loading
this.loading = true;
this.$store
// 执行 vuex 中的登录方法
.dispatch("user/login", this.loginForm)
.then(() => {
// this.redirect 为本来要跳转的页面,如果有值的话登录之后直接跳转到本来要跳转的页面,否则跳转到首页
this.$router.push({ path: this.redirect || "/" });
// 关闭 loading
this.loading = false;
})
.catch(() => {
// 关闭 loading
this.loading = false;
});
} else {
console.log("error submit!!");
return false;
}
});
},
4、打开@/store/modules/user.js
,分析 actions 中登录方法
// user login
login({ commit }, userInfo) {
const { username, password } = userInfo
return new Promise((resolve, reject) => {
// 用户名前后去掉空格
login({ username: username.trim(), password: password }).then(response => {
// 获取返回值中的 data
const { data } = response
// 调用 mutations 中 SET_TOKEN 方法,将 token 存到 vuex 中
commit('SET_TOKEN', data.token)
// 使用 js-cookie 插件,将 token 存入本地cookie中
setToken(data.token)
resolve()
}).catch(error => {
reject(error)
})
})
},
说明:
- 这里 actions 中调用的 login 方法其实是
@/api/user
中的 login 方法 - setToken 方法在
@/utils/auth.js
中,使用 js-cookie 插件为我们提供了一种方便操作 token 的方法,如下:
import Cookies from 'js-cookie'
const TokenKey = 'vue_admin_template_token'
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}
5、修改 @/api/user.js
中的 getInfo 方法,直接调用之前写好的接口
export function getInfo(token) {
return request({
url: '/info',
method: 'get'
})
}
6、修改 @/store/modules/user.js
中 getInfo 方法
getInfo({ commit, state }) {
return new Promise((resolve, reject) => {
getInfo(state.token).then(response => {
const { data } = response
if (!data) {
return reject('认证失败,请重新登录')
}
// 将 name 修改为 nickname ,因为我们自定义的接口返回信息为 nickname
const { nickname, avatar } = data
// 登录成功之后把 nickname 和 avatar 存到 vuex 中,这样所有页面都可以访问了
commit('SET_NAME', nickname)
commit('SET_AVATAR', avatar)
resolve(data)
}).catch(error => {
reject(error)
})
})
},
说明:
- 上面的 getInfo 方法在
@/permission.js
中调用,下面稍作分析
// vue-router 的路由守卫,每次路由跳转之前执行
router.beforeEach(async (to, from, next) => {
// start progress bar
NProgress.start()
// set page title
document.title = getPageTitle(to.meta.title)
// determine whether the user has logged in
const hasToken = getToken()
if (hasToken) {
if (to.path === '/login') {
// if is logged in, redirect to the home page
next({ path: '/' })
NProgress.done()
} else {
// vuex 中有token 并且跳转的不是登录页,执行下面操作
const hasGetUserInfo = store.getters.name
if (hasGetUserInfo) {
next()
} else {
// 如果vuex 中没有用户信息,就尝试拿token去获取用户信息,我们这里获取用户信息不需要token,因为是直接调用的博客前台的接口
try {
// get user info
await store.dispatch('user/getInfo')
next()
} catch (error) {
// remove token and go to login page to re-login
await store.dispatch('user/resetToken')
Message.error(error || '出现错误,请重新登录')
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
}
} else {
/* has no token*/
// 如果没有登录态标识(token),但是需要访问的页面不需要登录,就直接跳转
if (whiteList.indexOf(to.path) !== -1) {
// in the free login whitelist, go directly
next()
} else {
// other pages that do not have permission to access are redirected to the login page.
// 这里在路由后面加上参数,是为了实现登录之后跳转回原来页面的功能
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
})
7、最后我们还需要修改一下@/utils/requets.js
import axios from 'axios'
import { MessageBox, Message } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
timeout: 5000, // request timeout
})
service.defaults.headers.post['Content-Type'] = 'application/json;charset=UTF-8';
service.interceptors.request.use(
config => {
if (store.getters.token) {
// ['Authorization'] 为JwtToken在headers中的key
config.headers['Authorization'] = getToken()
}
return config
},
error => {
// do something with request error
console.log(error) // for debug
return Promise.reject(error)
}
)
// response interceptor
service.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 0) {
Message({
message: res.msg || '网络忙,请稍后再试',
type: 'error',
duration: 5 * 1000
})
// 4004: 非法token; 4001: token 过期;
if (res.code === 4004 || res.code === 4001) {
// to re-login
MessageBox.confirm('您已经退出登录,您可以取消继续留在此页面,也可以重新登录', '确认退出', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
store.dispatch('user/resetToken').then(() => {
location.reload()
})
})
}
return Promise.reject(new Error(res.message || 'Error'))
} else {
return res
}
},
error => {
console.log('err' + error) // for debug
Message({
message: error.message,
type: 'error',
duration: 5 * 1000
})
return Promise.reject(error)
}
)
export default service
说明:主要修改两个地方
- 请求拦截中请求头加上 Authorization 字段
- 响应拦截中加上对过期 token 和非法 token 的拦截
8、此时,我们在登录页面输入用户名和密码就可以登录了,并且可以看到cookie
参考代码:https://gitee.com/qianyucc/QBlog2/tree/v-9.0