手把手撸一个wiki系统(7):用户管理、单点登录

崩天的勾玉 2021年7月8日12:25:34
评论
152 12745字

用户表

#用户表
drop table if exists `user`;
create table `user`(
    `id` bigint not null comment '用户id',
    `login_name` varchar(50) not null comment '登录名',
    `name` varchar(50) not null comment '昵称',
    `password` char(32) not null comment '密码',
    primary key (`id`),
    unique key `login_name_unique` (`login_name`)
)engine=innodb default charset=utf8mb4 comment '用户';

insert into `user` (id, login_name, name, password) VALUES (1,'tangssst','一号','123456')

使用mybatisX插件生成代码。

并实现前后端基本功能,见代码提交的7-1(内容较多)。

用户名重复校验、自定义异常

service:

/**
 * 通过loginName获取用户信息
 * @param loginName
 */
public User selectByLoginName(String loginName){
    QueryWrapper<User> userQueryWrapper = new QueryWrapper<User>()
            .like("login_name",loginName);
    List<User> userList = userMapper.selectList(userQueryWrapper);
    if (CollectionUtils.isEmpty(userList)){
        return null;
    }else {
        return userList.get(0);
    }
}

然后调用:

if (!ObjectUtils.isEmpty(req.getLoginName())) {
    //有loginName则模糊查询
    wrapper.like("name", req.getLoginName());
    IPage<User> userIPage = userMapper.selectPage(page, wrapper);
    userList = userIPage.getRecords();
}else {
    //无参时查全部
    userList = userMapper.selectPage(page, null).getRecords();
}

创建异常包

新建BusinessException类:

  private BusinessExceptionCode code;

    public BusinessException (BusinessExceptionCode code) {
        super(code.getDesc());
        this.code = code;
    }

    public BusinessExceptionCode getCode() {
        return code;
    }

    public void setCode(BusinessExceptionCode code) {
        this.code = code;
    }

    /**
     * 不写入堆栈信息,提高性能
     */
    @Override
    public Throwable fillInStackTrace() {
        return this;
    }
}

对应的异常枚举类BusinessExceptionCode:

/**
 * 异常的枚举类,定义了常见异常
 * Created by tangssst@qq.com on 2021/07/02
 */
public enum BusinessExceptionCode {

    USER_LOGIN_NAME_EXIST("登录名已存在"),
    LOGIN_USER_ERROR("用户名不存在或密码错误"),
    VOTE_REPEAT("您已点赞过"),
    ;

    private String desc;

    BusinessExceptionCode(String desc) {
        this.desc = desc;
    }

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }
}

使用:

//新增
//判断用户名是否已存在
if (ObjectUtils.isEmpty(selectByLoginName(saveReq.getLoginName()))){
    user.setId(snowFlake.nextId());
    userMapper.insert(user);
}else {
    throw new BusinessException(BusinessExceptionCode.USER_LOGIN_NAME_EXIST);
}

异常统一处理

使用ExceptionHandler拦截器对代码抛出的异常进行统一处理,包括业务异常、系统异常,分类处理。

/**
 * 统一异常处理、数据预处理
 * Created by tangssst@qq.com on 2021/06/14
 */
@ControllerAdvice
public class ControllerExceptionHandler {

    private static final Logger LOG = LoggerFactory.getLogger(ControllerExceptionHandler.class);

    /**
     * 校验异常统一处理
     * @param e
     * @return
     */
    @ExceptionHandler(value = BindException.class)
    @ResponseBody
    public CommonResp validExceptionHandler(BindException e) {
        CommonResp commonResp = new CommonResp();
        LOG.warn("参数校验失败:{}", e.getBindingResult().getAllErrors().get(0).getDefaultMessage());
        commonResp.setSuccess(false);
        commonResp.setMessage(e.getBindingResult().getAllErrors().get(0).getDefaultMessage());
        return commonResp;
    }

    /**
     * 业务异常统一处理
     * @param e
     * @return
     */
    @ExceptionHandler(value = BusinessException.class)
    @ResponseBody
    public CommonResp validExceptionHandler(BusinessException e) {
        CommonResp commonResp = new CommonResp();
        LOG.warn("业务异常:{}", e.getCode().getDesc());
        commonResp.setSuccess(false);
        commonResp.setMessage(e.getCode().getDesc());
        return commonResp;
    }

    /**
     * 其余的异常
     * @return
     */
    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    public CommonResp validExceptionHandler(Exception e){
        CommonResp commonResp = new CommonResp();
        LOG.error("系统异常:", e);
        commonResp.setSuccess(false);
        commonResp.setMessage("系统异常,请联系管理员");
        return commonResp;
    }

}

此时新建用户如果用户名重复的话就会提示异常。

见7-2

用户名在不可选状态

新增时可操作,编辑时用户名不能被操作:

<a-form-item label="用户名">
  <a-input v-model:value="user.loginName" :disabled="user.id"/>
</a-form-item>

这是前段,后端UserService.java也要做校验:

    //更新
    //设置为null后不会更新
    user.setLoginName(null);
    userMapper.updateById(user);

见7-3

密码加密

md5存储:

在UserController.java里:

@PostMapping("/save")
public CommonResp save(@Valid @RequestBody UserSaveReq req){
    //对密码做md5加密
    req.setPassword(DigestUtils.md5DigestAsHex(req.getPassword().getBytes()));
    CommonResp commonResp = new CommonResp<>();
    userService.save(req);
    return commonResp;
}

public下新建js文件夹,放入Md5.js。

在index.html里引入:

<script src="<%= BASE_URL %>js/Md5.js"></script>

在userAdmin的save方法使用:

user.value.password = hexMd5(user.value.password + KEY);

会爆红,我们将对应的变量定义一下,告诉vue他们是存在的:

declare let hexMd5:any;
declare let KEY:any;

此时后端的正则校验没用了,我们在请求封装类的去掉:

@Pattern(regexp = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,32}$", message = "【密码】至少包含 数字和英文,长度6-32")

前端编辑框隐藏密码选项:

<a-form-item label="密码">
  <a-input v-model:value="user.password" type="password" v-show="!user.id"/>
</a-form-item>

后端save时把密码也清空:

else {
    //保存更新时不接受loginName和password
    user.setLoginName(null);
    user.setPassword(null);
    userMapper.updateById(user);
}

此时点编辑就不会显示密码了,后端也不接受密码传参。

见7-5。

重置密码

新建UserResetPasswordReq.java,内容:

@Data
public class UserResetPasswordReq {

    /**
     * 用户id
     */
    private Long id;

    /**
     * 密码
     */
    @NotNull(message = "密码不能为空")
    private String password;
}

UserController:

@PostMapping("/resetPassword")
public CommonResp resetPassword(@Valid UserResetPasswordReq passwordReq){
    //对密码做md5加密
    passwordReq.setPassword(DigestUtils.md5DigestAsHex(passwordReq.getPassword().getBytes()));
    CommonResp commonResp = new CommonResp<>();
    userService.resetPassword(passwordReq);
    return commonResp;
}

UserService:

/**
 * 重置密码
 * @param passwordReq
 */
public void resetPassword(UserResetPasswordReq passwordReq){
    User user = CopyUtil.copy(passwordReq,User.class);
    userMapper.updateById(user);
}

前端:

新增modal:

<a-modal
    title="密码修改"
    v-model:visible="resetModalVisible"
    :confirm-loading="resetModalLoading"
    @ok="handleResetModalOk"
>
  <a-form :model="user" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
    <a-form-item label="密码" >
      <a-input v-model:value="user.password" type="password" />
    </a-form-item>
  </a-form>
</a-modal>

函数:

/**
 * 密码重置
 */
const resetModalVisible = ref(false);
const resetModalLoading = ref(false);

const handleResetModalOk = () => {
  resetModalLoading.value = true;
  axios.post("/user/resetPassword", user.value).then((response) => {
    resetModalLoading.value = false;
    //前端对密码进行一次加密,KEY为盐值
    user.value.password = hexMd5(user.value.password + KEY);
    const data = response.data; // data = commonResp
    if (data.success) { //如果成功
      resetModalVisible.value = false;
      // 重新加载列表
      handleQuery({
        page: pagination.value.current,
        size: pagination.value.pageSize,
      });
    } else {
      message.error(data.message);
    }
  });
};

/**
 * 编辑
 */
const resetPassword = (record: any) => {
  resetModalVisible.value = true;
  user.value = Tool.copy(record); //对象复制
         user.value.password = null;
};

增加按钮:

<a-button type="primary" @click="resetPassword(record)">
  密码
</a-button>

单点登录

token官网:https://jwt.io/

引入依赖:

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.0</version>
</dependency>

新建UserLoginReq,保留用户名、密码

@Data
public class UserLoginReq {

    /**
     * 登录名
     */
    @NotNull(message = "登录名不能为空")
    // @Length(min = 6, max = 20, message = "【密码】6~20位")
    @Pattern(regexp = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,32}$", message = "【密码】不符合规则!")
    private String loginName;

    /**
     * 密码
     */
    @NotNull(message = "密码不能为空")
    private String password;

}

新建UserLoginResp:

@Data
public class UserLoginResp {

    private Long id;

    private String loginName;

    private String name;

}

controller:

    /**
     * 登录login接口
     * @param userSaveReq
     * @return
     */
    @PostMapping("/login")
    public CommonResp login(@Valid @RequestBody UserLoginReq userLoginReq){
        userLoginReq.setPassword(DigestUtils.md5DigestAsHex(userLoginReq.getPassword().getBytes()));
        CommonResp<Object> commonResp = new CommonResp<>();
        UserLoginResp userLoginResp = userService.login(userLoginReq);
        commonResp.setContent(userLoginResp);
        return commonResp;
    }

增加异常:

LOGIN_USER_ERROR("用户名不存在或密码错误"),

service:

/**
 * 登录login
 * @param userLoginReq
 * @return
 */
public UserLoginResp login(UserLoginReq userLoginReq){
    User user = selectByLoginName(userLoginReq.getLoginName());
    if (ObjectUtils.isEmpty(user)){
        //用户名为空,抛出异常
        log.info("用户名不存在,{}",userLoginReq.getLoginName());
        throw new BusinessException(BusinessExceptionCode.LOGIN_USER_ERROR);
    }else {
        //比对数据库的密码和传入的密码,都是两层加密
        if (user.getPassword().equals(userLoginReq.getPassword())){
            //登录成功
            UserLoginResp loginResp = CopyUtil.copy(user, UserLoginResp.class);
            return loginResp;
        }else {
            //密码不对
            log.info("密码不对,输入的密码:{},数据库密码:{}",userLoginReq.getPassword(),user.getPassword());
            throw new BusinessException(BusinessExceptionCode.LOGIN_USER_ERROR);
        }
    }
}

前端:

增加登录a标签,增加modal,showLoginModal、login方法。

见提交7-7

bug修复

7-7.1 修复多个bug:logger、前端md5加密应在axios前、controller缺少@RequestBody导致无法接收到参数

7-7.2 将notnull改为@notEmpty,前者只能校验null,后者还能校验空字符串。

登录处理redis

token+redis

集成redis:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

在controller里注入内置的工redis具类:

@Resource
private RedisTemplate redisTemplate;

因为要将token给前端,所以在UserLoginResp增加属性:

private String token;

String更通用,可以用md5生成token。

controller代码:

@PostMapping("/login")
public CommonResp login(@Valid @RequestBody UserLoginReq userLoginReq){
    userLoginReq.setPassword(DigestUtils.md5DigestAsHex(userLoginReq.getPassword().getBytes()));
    CommonResp<Object> commonResp = new CommonResp<>();
    UserLoginResp userLoginResp = userService.login(userLoginReq);
    Long token = snowFlake.nextId();
    userLoginResp.setToken(token.toString());
    ValueOperations ops = redisTemplate.opsForValue();
    ops.set(token.toString(),userLoginResp,24, TimeUnit.HOURS);
    log.info("生成了token:{}, 并放入redis", token);
    commonResp.setContent(userLoginResp);
    return commonResp;
}

redis的value需要序列化,此处我将类实现了serializable接口。也可以用Jackson将类转为json

增加redis配置:

redis:
  host: 127.0.0.1
  port: 6379

本地我们启动redis,就能正常用了。

见7-8

引入vuex

vuex是一个全局的变量,能同时被多个组件使用

store.js:

import {createStore} from 'vuex'

declare let SessionStorage: any;
const USER = "USER";

const store = createStore({
  state: {
    user: SessionStorage.get(USER) || {}
  },
  mutations: {
    setUser (state, user) {
      console.log("store user:", user);
      state.user = user;
      SessionStorage.set(USER, user);
    }
  },
  actions: {
  },
  modules: {
  }
});

export default store;

header:

// 登录后保存
const user = computed(() => store.state.user);
……
store.commit("setUser", data.content);
……
store.commit("setUser", {});

整合H5缓存

vuex的组件在页面刷新后数据会丢失,通过整合h5的缓存session-storage解决。

添加session-storage.js,在index.html里引入:

<script src="<%= BASE_URL %>js/session-storage.js"></script>

登录功能前后端开发见7-9

退出登录

token增加过期时间

后端增加退出登录logout接口,退出后清除redis信息

前端增加退出按钮,退出后清除前端信息


controller:

@GetMapping("/logout/{token}")
public CommonResp logout(@PathVariable String token) {
    CommonResp resp = new CommonResp<>();
    redisTemplate.delete(token);
    log.info("从redis中删除token: {}", token);
    return resp;
}

前端:

<a-popconfirm
    title="确认退出登录?"
    ok-text="是"
    cancel-text="否"
    @confirm="logout()"
>
  <a class="login-menu" v-show="user.id">
    <span>退出登录</span>
  </a>
</a-popconfirm>
// 退出登录
const logout = () => {
  console.log("退出登录开始");
  axios.get('/user/logout/' + user.value.token).then((response) => {
    const data = response.data;
    if (data.success) {
      message.success("退出登录成功!");
      store.commit("setUser", {});
    } else {
      message.error(data.message);
    }
  });
};

登录校验

后端增加拦截器,检查token有效性

前端请求参数增加token


使用拦截器,在interceptor包下新建LoginInterceptor.java:

@Component
public class LoginInterceptor implements HandlerInterceptor {

    private static final Logger LOG = LoggerFactory.getLogger(LoginInterceptor.class);

    @Resource
    private RedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 打印请求信息
        LOG.info("------------- LoginInterceptor 开始 -------------");
        long startTime = System.currentTimeMillis();
        request.setAttribute("requestStartTime", startTime);

        // OPTIONS请求不做校验,
        // 前后端分离的架构, 前端会发一个OPTIONS请求先做预检, 对预检请求不做校验
        if(request.getMethod().toUpperCase().equals("OPTIONS")){
            return true;
        }

        String path = request.getRequestURL().toString();
        LOG.info("接口登录拦截:,path:{}", path);

        //获取header的token参数
        String token = request.getHeader("token");
        LOG.info("登录校验开始,token:{}", token);
        if (token == null || token.isEmpty()) {
            LOG.info( "token为空,请求被拦截" );
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            return false;
        }
        Object object = redisTemplate.opsForValue().get(token);
        if (object == null) {
            LOG.warn( "token无效,请求被拦截" );
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            return false;
        } else {
            LOG.info("已登录:{}", object);

            return true;
        }
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        long startTime = (Long) request.getAttribute("requestStartTime");
        LOG.info("------------- LoginInterceptor 结束 耗时:{} ms -------------", System.currentTimeMillis() - startTime);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//        LOG.info("LogInterceptor 结束");
    }
}

然后是SpringMvcConfig:

@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {

    @Resource
    LoginInterceptor loginInterceptor;

    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/test/**",
                        "/redis/**",
                        "/user/login",
                        "/category/all",
                        "/ebook/search",
                        "/doc/all/**",
                        "/doc/vote/**",
                        "/doc/getContent/**",
                        "/ebook-snapshot/**"
                );

    }
}

见7-11

路由校验

管理菜单在非登录状态不应该显示出来:

<a-menu-item key="/admin/ebook" :style="user.id? {} : {display:'none'}">
  <router-link to="/admin/ebook">电子书管理</router-link>
</a-menu-item>

此时非登录用户看不到管理菜单,但是还要避免用户直接去访问url:

在前端路由router下的index.ts里对应的路径下增加自定义数据:

meta: {
  loginRequire: true
}

底部增加:

// 路由登录拦截
router.beforeEach((to, from, next) => {
  // 要不要对meta.loginRequire属性做监控拦截
  if (to.matched.some(function (item) {
    console.log(item, "是否需要登录校验:", item.meta.loginRequire);
    return item.meta.loginRequire
  })) {
    const loginUser = store.state.user;
    if (Tool.isEmpty(loginUser)) {
      console.log("用户未登录!");
      next('/');
    } else {
      next();
    }
  } else {
    next();
  }
});

见提交7-12.

您可能感兴趣的文章

继续阅读
版权:文章来自凡蜕博客,转载请带上地址。微信公众号: 『崩天的勾玉』
匿名

发表评论

匿名网友