这里我们用到了一个Result的类,这个用于我们的异步统一返回的结果封装。一般来说,结果里面有几个要素必要的
package com.ryan.common.lang;
import lombok.Data;
import org.springframework.stereotype.Component;
import java.io.Serializable;
@Data
public class Result implements Serializable {
//状态码,200一般表示正常,非200表示异常
private int code;
//信息
private String msg;
//数据
private Object data;
//操作成功的结果封装
//对于前端来说,操作成功一般都是200,他们一般比较注重我data,所以我们需要进一步封装
public static Result success(Object data){
return success(200,"操作成功",data);
}
//方法封装,方便别处调用
public static Result success(int code,String msg,Object data){
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
//往往操作失败之后,没有什么数据返回的,看中的是msg,所以我们可以再进一层封装
public static Result fail(String msg){
return success(400, msg,null);
}
//操作失败的状态码一般是400,msg一般是错误原因什么的,所以这里最好也加上
public static Result fail(String msg, Object data){
return success(400,msg,data);
}
//操作失败的结果封装
public static Result fail(int code,String msg,Object data){
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
}
考虑到后面可能需要做集群、负载均衡等,所以就需要会话共享,而shiro的缓存和会话信息,我们一般考虑使用redis来存储这些数据,所以,我们不仅仅需要整合shiro,同时也需要整合redis。在开源的项目中,我们找到了一个starter可以快速整合shiro-redis,配置简单,这里也推荐大家使用。
而因为我们需要做的是前后端分离项目的骨架,所以一般我们会采用token或者jwt作为跨域身份验证解决方案。所以整合shiro的过程中,我们需要引入jwt的身份验证过程。
第一步:导入shiro-redis的starter包:还有jwt的工具包,以及为了简化开发,我引入了hutool工具包。
<dependency>
<groupId>org.crazycakegroupId>
<artifactId>shiro-redis-spring-boot-starterartifactId>
<version>3.2.1version>
dependency>
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.3.3version>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwtartifactId>
<version>0.9.1version>
dependency>
第二部:配置shiro
package com.ryan.config;
import com.ryan.shiro.AccountRealm;
import com.ryan.shiro.JwtFilter;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* shiro启用注解拦截控制器
*/
@Configuration
public class ShiroConfig {
@Autowired
JwtFilter jwtFilter;
@Bean
public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionDAO(redisSessionDAO);
return sessionManager;
}
@Bean
public DefaultWebSecurityManager securityManager(AccountRealm accountRealm,
SessionManager sessionManager,
RedisCacheManager redisCacheManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm);
securityManager.setSessionManager(sessionManager);
securityManager.setCacheManager(redisCacheManager);
/*
* 关闭shiro自带的session,详情见文档
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
//shiro过滤器链的定义
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
Map<String, String> filterMap = new LinkedHashMap<>();
//所有的链接都会经过我们jwt
filterMap.put("/**", "jwt"); // 主要通过注解方式校验权限
chainDefinition.addPathDefinitions(filterMap);
return chainDefinition;
}
@Bean("shiroFilterFactoryBean")
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
ShiroFilterChainDefinition shiroFilterChainDefinition) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
Map<String, Filter> filters = new HashMap<>();
filters.put("jwt", jwtFilter);
shiroFilter.setFilters(filters);
Map<String, String> filterMap = shiroFilterChainDefinition.getFilterChainMap();
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
// 开启注解代理(默认好像已经开启,可以不要)
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
@Bean
public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
return creator;
}
}
第三步:导入jwt工具类
package com.ryan.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* jwt工具类
*/
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "ryan.jwt")
public class JwtUtils {
private String secret;
private long expire;
private String header;
/**
* 生成jwt token
*/
public String generateToken(long userId) {
Date nowDate = new Date();
//过期时间
Date expireDate = new Date(nowDate.getTime() + expire * 1000);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(userId+"")
.setIssuedAt(nowDate)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Claims getClaimByToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}catch (Exception e){
log.debug("validate is token error ", e);
return null;
}
}
/**
* token是否过期
* @return true:过期
*/
public boolean isTokenExpired(Date expiration) {
return expiration.before(new Date());
}
}
第四步:编写jwtFilter类
这个过滤器是我们的重点,这里我们继承的是Shiro内置的AuthenticatingFilter,一个可以内置了可以自动登录方法的的过滤器,有些同学继承BasicHttpAuthenticationFilter也是可以的。
我们需要重写几个方法:
package com.ryan.shiro;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.ryan.common.lang.Result;
import com.ryan.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.ExpiredCredentialsException;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
//定义jwt的过滤器JwtFilter
/*
这个过滤器是我们的重点,这里我们继承的是Shiro内置的AuthenticatingFilter,
一个可以内置了可以自动登录方法的的过滤器
*/
@Component
public class JwtFilter extends AuthenticatingFilter {
@Autowired
JwtUtils jwtUtils;
//实现登录,我们需要生成我们自定义支持的JwtToken
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
//获取Token
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwt = request.getHeader("Authorization");//获取jwt,,jwt是放在header里面的
if (StringUtils.isEmpty(jwt)){
return null;//jwt为空的话就没必要登陆,直接跳过
}
//有的话,封装成token
return new JwtToken(jwt);
/*
思考:这里的createToken有什么用呢?
底层就是调用了subject.login(token)
然后再交给realm进行处理
*/
}
//拦截校验,当头部没有Authorization时候,我们直接通过,不需要自动登录;
// 当带有的时候,首先我们校验jwt的有效性,没问题我们就直接执行executeLogin方法实现自动登录
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwt = request.getHeader("Authorization");//获取jwt,,jwt是放在header里面的
if (StringUtils.isEmpty(jwt)){
return true;//没jwt,直接跳过,进入接口注解过滤
}else {
//如果有,则需要校验jwt
Claims claim = jwtUtils.getClaimByToken(jwt);
//如果claim为空或者token过期了,则抛出异常
if (claim == null || jwtUtils.isTokenExpired(claim.getExpiration())){
throw new ExpiredCredentialsException("token已过期,请重新登录");
}
//执行登陆,接下来就是交给realm处理的
return executeLogin(servletRequest,servletResponse);
}
}
//从executeLogin方法原码可以看到,登录异常时候进入onLoginFailure方法,我们直接把异常信息封装然后抛出
//因为是前后端分离,当出现登陆异常时,我们应该封装结果集
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
Throwable throwable = e.getCause() == null ? e : e.getCause();
Result result = Result.fail(throwable.getMessage());
String json = JSONUtil.toJsonStr(result);
try {
httpServletResponse.getWriter().print(json);
} catch (IOException ex) {
ex.printStackTrace();
}
return false;
}
}
第四步:新建一个jwtToken类
package com.ryan.shiro;
import org.apache.shiro.authc.AuthenticationToken;
public class JwtToken implements AuthenticationToken {
private String token;
public JwtToken(String jwt){
this.token = jwt;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
第五步:新建一个AccountProfile类
package com.ryan.shiro;
import lombok.Data;
import java.io.Serializable;
@Data
public class AccountProfile implements Serializable {
private Long id;
private String username;
private String avatar;
private String email;
}
第六步:新建AccountRealm类
AccountRealm是shiro进行登录或者权限校验的逻辑所在,算是核心了,我们需要重写3个方法,分别是
其实主要就是doGetAuthenticationInfo登录认证这个方法,可以看到我们通过jwt获取到用户信息,判断用户的状态,最后异常就抛出对应的异常信息,否者封装成SimpleAuthenticationInfo返回给shiro。 接下来我们逐步分析里面出现的新类:
1、shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,所以这里我们自定义一个JwtToken,来完成shiro的supports方法。
package com.ryan.shiro;
import cn.hutool.core.bean.BeanUtil;
import com.ryan.entity.User;
import com.ryan.service.UserService;
import com.ryan.utils.JwtUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class AccountRealm extends AuthorizingRealm {
@Autowired
JwtUtils jwtUtils;
@Autowired
UserService userService;
//需要声明支持什么token
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
//登陆处理
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
//强转
JwtToken jwtToken = (JwtToken) token;
String userId = jwtUtils.getClaimByToken((String) jwtToken.getPrincipal()).getSubject();
User user = userService.getById(Long.valueOf(userId));
if(user == null){
throw new UnknownAccountException("账户不存在");
}
if (user.getStatus() == -1){
throw new LockedAccountException("账户已被锁定");
}
AccountProfile profile = new AccountProfile();
BeanUtil.copyProperties(user, profile);
return new SimpleAuthenticationInfo(profile, jwtToken.getCredentials(),getName());
}
}
基本的校验的路线完成之后,我们需要少量的基本信息配置:
shiro-redis:
enabled: true
redis-manager:
host: 127.0.0.1:6379
ryan:
jwt:
# 加密秘钥
secret: f4e2e52034348f86b67cde581c0f9eb5
# token有效时长,7天,单位秒
expire: 604800
header: token
另外,如果你项目有使用spring-boot-devtools,需要添加一个配置文件,在resources目录下新建文件夹META-INF,然后新建文件spring-devtools.properties,这样热重启时候才不会报错。
restart.include.shiro-redis=/shiro-[\\w-\\.]+jar
新建一个exception包,然后新建一个全局异常处理的类GlobalExceptionHandler
package com.ryan.exception;
import com.ryan.common.lang.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.ShiroException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
//除了runtime还有shiro
@ResponseStatus(HttpStatus.UNAUTHORIZED)//未授权
@ExceptionHandler(value = ShiroException.class)
public Result handler(ShiroException e){
log.error("运行时异常:----------------{}",e);
return Result.fail(401,e.getMessage(),null);
}
//大异常RuntimeException
@ResponseStatus(HttpStatus.BAD_REQUEST)//返回状态码,让前端知道
@ExceptionHandler(value = RuntimeException.class)
public Result handler(RuntimeException e){
log.error("运行时异常:----------------{}",e);
return Result.fail(e.getMessage());
}
}
接口添加一个要求认证后才能访问的注解
@RequiresAuthentication
当我们表单数据提交的时候,前端的校验我们可以使用一些类似于jQuery Validate等js插件实现,而后端我们可以使用Hibernate validatior来做校验。
我们使用springboot框架作为基础,那么就已经自动集成了Hibernate validatior。
那么用起来啥样子的呢?
第一步:首先在实体的属性上添加对应的校验规则,比如:
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@NotBlank(message = "昵称不能为空")
private String username;
private String avatar;
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
private String password;
private Integer status;
private LocalDateTime created;
private LocalDateTime lastLogin;
第二步 :这里我们使用@Validated注解方式,如果实体不符合要求,系统会抛出异常,那么我们的异常处理中就捕获到MethodArgumentNotValidException。
@PostMapping("/save")
public Result save(@Validated @RequestBody User user){
//@Validated注解方式,如果实体不符合要求,系统会抛出异常MethodArgumentNotValidException
//这就要求我们需要在全局异常处理中捕捉他
return Result.success(user);
第三步:全局异常中处理实体校验异常
@ResponseStatus(HttpStatus.UNAUTHORIZED)//未授权
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result handler(MethodArgumentNotValidException e){
log.error("实体校验异常:----------------{}",e);
ObjectError objectError = e.getBindingResult().getAllErrors().stream().findFirst().get();
return Result.fail(objectError.getDefaultMessage());
}
第四步:使用postman测试下
因为是前后端分析,所以跨域问题是避免不了的,我们直接在后台进行全局跨域处理:
第一步:跨域配置CorsConfig
package com.ryan.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 解决跨域问题
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true)
.maxAge(3600)
.allowedHeaders("*");
}
}
第二步:在jwtfilter中提供跨域的支持
/**
* 对跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
登录的逻辑其实很简答,只需要接受账号密码,然后把用户的id生成jwt,返回给前段,为了后续的jwt的延期,所以我们把jwt放在header上。具体代码如下:
package com.ryan.controller;
import cn.hutool.core.map.MapUtil;
import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.ryan.common.dto.LoginDto;
import com.ryan.common.lang.Result;
import com.ryan.entity.User;
import com.ryan.service.UserService;
import com.ryan.utils.JwtUtils;
import org.apache.catalina.security.SecurityUtil;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
/**
* 默认账号密码:ryan / 111111
*
*/
@RestController
public class AccountController {
@Autowired
JwtUtils jwtUtils;
@Autowired
UserService userService;
@PostMapping("/login")
public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response){
//1.用户名验证
User user = userService.getOne(new QueryWrapper<User>().eq("username", loginDto.getUsername()));
//断言username不为空
Assert.notNull(user, "用户不存在");
//2.密码验证
if(!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))){
return Result.fail("密码不正确");
}
//3.生成jwt并放到header当中
String jwt = jwtUtils.generateToken(user.getId());
response.setHeader("Authorization",jwt);
response.setHeader("Access-control-Expose-Header","Authorization");
//4.返回用户信息
return Result.success(MapUtil.builder()
.put("id",user.getId())
.put("username",user.getUsername())
.put("avatar",user.getAvatar())
.put("email",user.getEmail())
.map()
);
}
@RequiresAuthentication
@GetMapping("/logout")
public Result logout(){
SecurityUtils.getSubject().logout();
return Result.success(null);
}
}
Assert.notNull(user, “用户不存在”);执行可能会抛出异常,需要在全局异常中处理
@ResponseStatus(HttpStatus.UNAUTHORIZED)//未授权
@ExceptionHandler(value = IllegalArgumentException.class)
public Result handler(IllegalArgumentException e){
log.error("Assert异常:----------------{}",e);
return Result.fail(e.getMessage());
}
10、博客接口开发
我们的骨架已经完成,接下来,我们就可以添加我们的业务接口了,下面我以一个简单的博客列表、博客详情页、博客编辑/新增、博客删除为例子开发:
第一步:写一个工具类ShiroUtil
package com.ryan.utils;
import com.ryan.shiro.AccountProfile;
import org.apache.shiro.SecurityUtils;
public class ShiroUtil {
public static AccountProfile getProfile() {
return (AccountProfile) SecurityUtils.getSubject().getPrincipal();
}
}
第二步:接口开发
package com.ryan.controller;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ryan.common.lang.Result;
import com.ryan.entity.Blog;
import com.ryan.service.BlogService;
import com.ryan.utils.ShiroUtil;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
/**
*
* 前端控制器
*
*
* @author Ryan
* @since 2020-06-11
*/
@RestController
public class BlogController {
//详情(list)、detail(通过id)、编辑/新建、删除
@Autowired
BlogService blogService;
@GetMapping("/blogs")
public Result list(@RequestParam(defaultValue = "1") Integer currentPage){//默认是第一页,确保不是空
Page page = new Page(currentPage, 5);//每页5条记录
IPage pageData = blogService.page(page, new QueryWrapper<Blog>().orderByDesc("created"));
return Result.success(pageData);
}
@GetMapping("/blog/{id}")
public Result detail(@PathVariable(value = "id")Long id){
Blog blog = blogService.getById(id);
//blog不应该为空
Assert.notNull(blog, "该博客不存在或已被删除");
return Result.success(blog);
}
@RequiresAuthentication//需要认证才能编辑
@PostMapping("/blog/edit")
public Result edit(@Validated @RequestBody Blog blog){
//分为两种情况,编辑已有博客和新建博客
Blog temp = null;
if(blog.getId() != null){//表示编辑已有博客
temp = blogService.getById(blog.getId());
//注意,只能编辑自己的博客
Assert.isTrue(temp.getUserId().longValue() == ShiroUtil.getProfile().getId().longValue(), "抱歉,没有权限编辑他人的博客");
}else{//表示新建博客
temp = new Blog();
temp.setUserId(ShiroUtil.getProfile().getId());
temp.setCreated(LocalDateTime.now());
temp.setStatus(0);
}
BeanUtil.copyProperties(blog, temp, "id","userId","created","status");
blogService.saveOrUpdate(temp);
return Result.success(null);
}
@GetMapping("/blog/remove/{id}")
public Result delete(@PathVariable(value = "id")Long id){
blogService.removeById(id);
return Result.success(null);
}
}
测试:略
1.1、node.js:之前都安装过了,这里就不演示了
1.2、vue cli:为了vue可视化操作,我这里升级了vue3.6.3的版本,注意是3.6+的版本,不然会踩到一些坑,然后控制台输入vue cli即可弹出vue的可视化界面,在工程根目录下新建一个vueblog-vue项目
1.3、下一步中,项目文件夹中输入项目名称“vueblog-vue”,其他不用改,点击下一步,选择【手动】,再点击下一步,如图点击按钮,勾选上路由Router、状态管理Vuex,去掉js的校验。
下一步中,也选上【Use history mode for router】,点击创建项目,然后弹窗中选择按钮【创建项目,不保存预设】,就进入项目创建啦。
稍等片刻之后,项目就初始化完成了。上面的步骤中,我们创建了一个vue项目,并且安装了Router、Vuex。这样我们后面就可以直接使用。
1.4、安装element-ui:略
1.5、安装axios:略
接下来,我们先定义好路由和页面,因为我们只是做一个简单的博客项目,页面比较少,所以我们可以直接先定义好,然后在慢慢开发,这样需要用到链接的地方我们就可以直接可以使用:
我们在views文件夹下定义几个页面:
然后在路由中心配置:
import Vue from 'vue'
import VueRouter from 'vue-router'
import Login from "../views/Login";
import Blogs from "../views/Blogs";
import Detail from "../views/Detail";
import Edit from "../views/Edit";
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'index',
component: Blogs
},{
path: '/login',
name: 'Login',
component: Login
},{
path: '/blogs',
name: 'Blogs',
component: Blogs
},{
path: '/blog/add',
name: 'Add',
meta: {
requireAuth: true
},
component: Edit
},{
path: '/blog/:blogId/detail',
name: 'Detail',
component: Detail
},{
path: '/blog/:blogId/edit',
name: 'Edit',
meta: {
requireAuth: true
},
component: Edit
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
接下来我们去开发我们的页面。其中,带有meta:requireAuth: true说明是需要登录字后才能访问的受限资源,后面我们路由权限拦截时候会用到。
Login.vue
接下来,我们来搞一个登陆页面,表单组件我们直接在element-ui的官网上找就行了,登陆页面就两个输入框和一个提交按钮,相对简单,然后我们最好带页面的js校验。
立即登录
重置
上面的代码主要做了两件事情:
表单校验规则还好,比较固定写法,查一下element-ui的组件就知道了,我们来分析一下发起登录之后的代码:
_this.$store.commit("SET_TOKEN", jwt)
_this.$store.commit("SET_USERINFO", userInfo)
_this.$router.push("/blogs")
从返回的结果请求头中获取到token的信息,然后使用store提交token和用户信息的状态。完成操作之后,我们调整到了/blogs路由,即博客列表页面。
需要在store/index.js中配置
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
token: "",
userInfo: JSON.parse(sessionStorage.getItem("userInfo"))//反序列化
},
mutations: {
//相当于setter
SET_TOKEN: (state, token) => {
state.token = token
localStorage.setItem("token", token)
},
SET_USERINFO: (state, userInfo) => {
state.userInfo = userInfo
sessionStorage.setItem("userInfo", JSON.stringify(userInfo))
//说明:JSON.stringify()的作用是将 JavaScript 对象转换为 JSON 字符串,而JSON.parse()可以将JSON字符串转为一个对象。
},
REMOVE_INFO: (state) => {
state.token = ""
state.userInfo = {}
localStorage.setItem("token","")
sessionStorage.setItem("userInfo", JSON.stringify(""))
}
},
getters: {
//getter
getUser: state => {
return state.userInfo
}
},
actions: {
},
modules: {
}
})
存储token,我们用的是localStorage,存储用户信息,我们用的是sessionStorage。毕竟用户信息我们不需要长久保存,保存了token信息,我们随时都可以初始化用户信息。
点击登录按钮发起登录请求,成功时候返回了数据,如果是密码错误,我们是不是也应该弹窗消息提示。为了让这个错误弹窗能运用到所有的地方,所以我对axios做了个后置拦截器,就是返回数据时候,如果结果的code或者status不正常,那么我对应弹窗提示。
src目录下新建一个axios.js文件(与main.js同级)
import axios from 'axios'
import ElementUI from 'element-ui';
import store from "./store";
import router from "./router";
//url前缀
axios.defaults.baseURL = "http://localhost:8001"
//前置拦截
axios.interceptors.request.use(config => {
return config
});
//后置拦截
axios.interceptors.response.use(response => {
let res = response.data;
console.log("==========================")
console.log(res)
console.log("==========================")
if (res.code === 200){
return response
}else {//这是针对shiroException的情况,其他的属于报错了
//因为此axios是要挂载到main.js中的,所以这里不能使用this.$message
ElementUI.Message.error('密码错误,请重新输入', {duration: 3*1000});//弹窗3秒后消失
//同时我们要拦截Login.vue中的正确逻辑,并弹窗
return Promise.reject(response.data.msg)
}
},
error => {
console.log(error);
if(error.response.data){//当返回的数据不为空的时候,就将msg赋值给error的message,给下面的弹窗使用
error.message = error.response.data.msg
}
//报错的话我们应该重新跳转到登陆界面
if(error.response.status === 401){
//先清楚token和userInfo
store.commit('REMOVE_INFO');//注意store不要导错包了
router.push("/login")
}
//除了401,我们直接弹窗即可
ElementUI.Message.error(error.message, {duration: 3*1000});
return Promise.reject(error)
}
)
前置拦截,其实可以统一为所有需要权限的请求装配上header的token信息,这样不需要在使用是再配置,我的小项目比较小,所以,还是免了吧~
然后再main.js中导入axios.js
import './axios.js' // 请求拦截
后端因为返回的实体是Result,success时候code为200,fail时候返回的是400,所以可以根据这里判断结果是否是正常的。另外权限不足时候可以通过请求结果的状态码来判断结果是否正常。这里都做了简单的处理。
登录完成之后直接进入博客列表页面,然后加载博客列表的数据渲染出来。同时页面头部我们需要把用户的信息展示出来,因为很多地方都用到这个模块,所以我们把页面头部的用户信息单独抽取出来作为一个组件。
那么,我们先来完成头部的用户信息,应该包含三部分信息:id,头像、用户名,而这些信息我们是在登录之后就已经存在了sessionStorage。因此,我们可以通过store的getters获取到用户信息。
在component文件夹中新建Header.vue
欢迎来到Ryan的博客
{{user.username}}
主页
编写博客
登陆
退出
然后需要头部用户信息的页面只需要几个步骤:
import Header from "@/components/Header";
data() {
components: {Header}
}
# 然后模板中调用组件
接下来就是列表页面,需要做分页,列表我们在element-ui中直接使用时间线组件来作为我们的列表样式,还是挺好看的。还有我们的分页组件。
需要几部分信息:
Blogs.vue
{{blog.title}}
{{blog.description}}
data()中直接定义博客列表blogs、以及一些分页信息。methods()中定义分页的调用接口page(currentPage),参数是需要调整的页码currentPage,得到结果之后直接赋值即可。然后初始化时候,直接在mounted()方法中调用第一页this.page(1)。
我们点击发表博客链接调整到/blog/add页面,这里我们需要用到一个markdown编辑器,在vue组件中,比较好用的是mavon-editor,那么我们直接使用哈。先来安装mavon-editor相关组件:
安装mavon-editor
基于Vue的markdown编辑器mavon-editor
npm install mavon-editor --save
然后在main.js中全局注册:
import mavonEditor from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'
Vue.use(mavonEditor);
ok,那么我们去定义我们的博客表单:
Edit.vue
提交
清空
逻辑依然简单,校验表单,然后点击按钮提交表单,注意头部加上Authorization信息,返回结果弹窗提示操作成功,然后跳转到博客列表页面。
然后因为编辑和添加是同一个页面,所以有了create()方法,比如从编辑连接/blog/7/edit中获取blogId为7的这个id。然后回显博客信息。获取方式是const blogId = this.$route.params.blogId。
对了,mavon-editor因为已经全局注册,所以我们直接使用组件即可:
博客详情中需要回显博客信息,然后有个问题就是,后端传过来的是博客内容是markdown格式的内容,我们需要进行渲染然后显示出来,这里我们使用一个插件markdown-it,用于解析md文档,然后导入github-markdown-c,所谓md的样式。
方法如下:
# 用于解析md文档
npm install markdown-it --save
# md样式
npm install github-markdown-css
然后就可以在需要渲染的地方使用:
Detail.vue
{{blog.title}}
编辑
删除
具体逻辑还是挺简单,初始化create()方法中调用getBlog()方法,请求博客详情接口,返回的博客详情content通过markdown-it工具进行渲染。同时还添加了“编辑”和“删除”两个按钮
别忘了导入github的样式哦
import "github-markdown-css/github-markdown.css"
另外标题下添加了个小小的编辑按钮,通过isOwnBlog (判断博文作者与登录用户是否同一人)来判断按钮是否显示出来。
页面已经开发完毕之后,我们来控制一下哪些页面是需要登录之后才能跳转的,如果未登录访问就直接重定向到登录页面,因此我们在src目录下定义一个js文件:
permission.js
import router from "./router";
// 路由判断登录 根据路由配置文件的参数
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requireAuth)) { // 判断该路由是否需要登录权限
const token = localStorage.getItem("token")
console.log("------------" + token)
if (token) { // 判断当前的token是否存在 ; 登录存入的token
if (to.path === '/login') {
} else {
next()
}
} else {
next({
path: '/login'
})
}
} else {
next()
}
})
通过之前我们再定义页面路由时候的的meta信息,指定requireAuth: true,需要登录才能访问,因此这里我们在每次路由之前(router.beforeEach)判断token的状态,觉得是否需要跳转到登录页面。
然后我们再main.js中import我们的permission.js
import './permission.js' // 路由拦截
注:本项目来自B站up注MarkerHub教程,感兴趣的同学可以去看看练手