前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >SpringBoot 实现用户登录,分布式Session功能

SpringBoot 实现用户登录,分布式Session功能

作者头像
Java帮帮
发布2020-02-11 17:33:35
3.8K0
发布2020-02-11 17:33:35
举报

之前介绍过不少关于登录功能的代码,本文介绍一下关于分布式Session 的功能实现,

完整代码(以后写博客,尽量给 git 地址)在 https://github.com/saysky/sensboot

通常,我们的项目都是多实例部署的,生产环境通常一个模块可能都会部署四五台服务器。

我们知道用户登录后,需要存储 session 信息,session 信息通常是存储在服务器的内存中的,不能持久化(服务器重启失效),多台服务器也不能共存。为了解决这个问题,我们可以将 session 存到几个服务器共享的地方里去,比如 Redis,只要在一个内网中,几台服务器可以共享 Redis (Redis本质也是装在某台服务器中)。

具体怎么实现呢?这里简单描述下:

  1. 用户登录成功,通过UUID生成一个随机唯一字符串,命名为 token,通过向 redis 中 set 一个值,key 为 token 字符串,value 为用户对象序列化后的字符串。
  2. 当用户访问其他页面,请求方法时,检验请求参数或 cookie 中是否有 token
  3. 如果有,则从 redis 查询 token,验证 token 是否有效
  4. 如果没有,则抛出异常 “用户未登录”

关于参数验证,这里可以通过 SpringMVC 的 resolveArgument 方法来统一解决,即所有方法参数验证时都会验证用户名是否登录。而不需要在每个方法里都写一段检查用户名是否登录,这样就太冗余了。

下面是具体实现,由上到下(重要到次要)贴代码,完整代码在 GitHub 中可以获取。

一、基本登录

LoginController

登录的实现在 UserServiceImpl 中

代码语言:javascript
复制
package com.liuyanzhao.sens.controller;
import com.liuyanzhao.sens.result.Result;
import com.liuyanzhao.sens.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletResponse;
/**
 * @author 言曌
 * @date 2019-07-22 14:07
 */
@Controller
@Slf4j
public class LoginController {
    @Autowired
    private UserService userService;
    /**
     * 登录功能
     * 验证用户名和密码,登录成功,生成token,存入到redis中
     * 登录成功
     *
     * @param response
     * @param username
     * @param password
     * @return
     */
    @PostMapping("/doLogin")
    @ResponseBody
    public Result<String> doLogin(HttpServletResponse response,
                                  @RequestParam("username") String username,
                                  @RequestParam("password") String password) {
        //登录
        log.info("用户登录:username:{}, password:{}", username, password);
        //判断用户名是否存在
        String token = userService.login(response, username, password);
        return Result.success(token);
    }
}

UserServiceImpl

为了代码简洁,UserService 接口这里就不贴了

代码语言:javascript
复制
package com.liuyanzhao.sens.service.impl;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.plugins.Page;
import com.liuyanzhao.sens.entity.User;
import com.liuyanzhao.sens.exception.GlobalException;
import com.liuyanzhao.sens.mapper.UserMapper;
import com.liuyanzhao.sens.result.CodeMsg;
import com.liuyanzhao.sens.service.UserService;
import com.liuyanzhao.sens.utils.RedisUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;
/**
 * 用户业务逻辑实现类
 * MyBatis Plus 版本
 */
@Service
public class UserServiceImpl implements UserService {
    public static final String COOKIE_NAME_TOKEN = "token";
    /**
     * token过期时间,2天
     */
    public static final int TOKEN_EXPIRE = 3600 * 24 * 2;
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private RedisUtil redisUtil;
    @Override
    public User findByUsername(String username) {
        return userMapper.findByUsername(username);
    }
    @Override
    public String login(HttpServletResponse response, String username, String password) {
        //判断用户名是否存在
        User user = findByUsername(username);
        if (user == null) {
            throw new GlobalException(CodeMsg.USERNAME_NOT_EXIST);
        }
        //验证密码,这里为了例子简单,密码没有加密
        String dbPass = user.getPassword();
        if (!password.equals(dbPass)) {
            throw new GlobalException(CodeMsg.PASSWORD_ERROR);
        }
        //生成cookie
        String token = UUID.randomUUID().toString().replace("-", "");
        addCookie(response, token, user);
        return token;
    }
    @Override
    public User getByToken(HttpServletResponse response, String token) {
        if (StringUtils.isEmpty(token)) {
            return null;
        }
        User user = JSON.parseObject(redisUtil.get(COOKIE_NAME_TOKEN + "::" + token), User.class);
        //重置有效期
        if (user == null) {
            throw new GlobalException(CodeMsg.USER_NOT_LOGIN);
        }
        addCookie(response, token, user);
        return user;
    }
    private void addCookie(HttpServletResponse response, String token, User user) {
        //将token存入到redis
        redisUtil.set(COOKIE_NAME_TOKEN + "::" + token, JSON.toJSONString(user), TOKEN_EXPIRE);
        //将token写入cookie
        Cookie cookie = new Cookie(COOKIE_NAME_TOKEN, token);
        cookie.setMaxAge(TOKEN_EXPIRE);
        cookie.setPath("/");
        response.addCookie(cookie);
    }
}

UserMapper Dao层 和 User 实体 这里也不贴了

我相信你都学到了分布式 Session 这里,MyBatis 的基本使用应该不成问题吧

GitHub里也有完整代码

登录成功,目前密码是没有加密的,登录成功,data里有 token 字符串,前端可以将 token

存起来,比如 APP 端,没有 cookie 这种东西的话,可以存在 localStorage,然后请求的时候携带 token 到参数上即可。

目前我们后端是将 token 存在 cookie 里,所以前端非APP端,无需携带参数。

二、封装参数验证

UserArgumentResolver

代码语言:javascript
复制
package com.liuyanzhao.sens.config;
import com.liuyanzhao.sens.entity.User;
import com.liuyanzhao.sens.exception.GlobalException;
import com.liuyanzhao.sens.result.CodeMsg;
import com.liuyanzhao.sens.service.UserService;
import com.liuyanzhao.sens.service.impl.UserServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
 * 用户参数验证,验证是否有token
 */
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
    @Autowired
    private UserService userService;
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> clazz = parameter.getParameterType();
        return clazz == User.class;
    }
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
        String paramToken = request.getParameter(UserServiceImpl.COOKIE_NAME_TOKEN);
        String cookieToken = getCookieValue(request, UserServiceImpl.COOKIE_NAME_TOKEN);
        if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
            // return null;
            throw new GlobalException(CodeMsg.USER_NOT_LOGIN);
        }
        String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
        return userService.getByToken(response, token);
    }
    private String getCookieValue(HttpServletRequest request, String cookiName) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null || cookies.length <= 0) {
            // return null;
            throw new GlobalException(CodeMsg.TOKEN_INVALID);
        }
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals(cookiName)) {
                return cookie.getValue();
            }
        }
        return null;
    }
}

WebConfig

代码语言:javascript
复制
package com.liuyanzhao.sens.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/**
 * @author 言曌
 * @date 2019-06-26 22:37
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    UserArgumentResolver userArgumentResolver;
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        //所有方法执行前,都会验证参数,检查token是否存在
        argumentResolvers.add(userArgumentResolver);
    }
    /**
     * 解决跨域
     * @return
     */
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", buildConfig());
        return new CorsFilter(source);
    }
    private CorsConfiguration buildConfig() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.setAllowCredentials(true);
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        return corsConfiguration;
    }
}

如上配置,可以实现 SpringMVC 验证参数时,会执行上面我们重写的 resolveArgument 方法,该方法的目的是验证请求参数或 cookie 中是否有 token,如果有则根据 token 查询用户,然后返回(如果返回了 user 对象,会自动注入到 参数中),如下 UserController 中 current 方法示例,User user 里已有用户信息。

验证请求

UserController

代码语言:javascript
复制
package com.liuyanzhao.sens.controller;
import com.liuyanzhao.sens.entity.User;
import com.liuyanzhao.sens.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
 * 后台用户管理控制器
 * 异常不用捕获,用统一异常捕获处理
 *
 * @author liuyanzhao
 */
@Slf4j
@RestController
@RequestMapping(value = "/user")
public class UserController {
    /**
     * 当前登录用户
     *
     * @param user 用户信息,由封装注入
     * @return
     */
    @RequestMapping("/current")
    @ResponseBody
    public Result<User> current(User user) {
        return Result.success(user);
    }
}

这是登录后的,调用查询查询当前登录用户信息接口,即对应上面的方法

注意:cookies 不用自己客户端(请求方)管理,如果 浏览器或者 PostMan 对应该域名有 cookie 的话,会自动携带的,后端能直接获取的。

如果我这里,浏览器(或PostMan)清除 cookie,或者 token 过期,再次请求,就会返回用户未登录的状态信息

三、返回体和响应码封装

封装返回对象

代码语言:javascript
复制
package com.liuyanzhao.sens.result;
public class Result<T> {
    private int code;
    private String msg;
    private T data;
    /**
     * 成功时候的调用
     */
    public static <T> Result<T> success() {
        return new Result<T>(200, "成功");
    }
    /**
     * 成功时候的调用
     */
    public static <T> Result<T> success(T data) {
        return new Result<T>(200, "成功", data);
    }
    /**
     * 失败时候的调用
     */
    public static <T> Result<T> error(CodeMsg codeMsg) {
        return new Result<T>(codeMsg);
    }
    private Result(T data) {
        this.data = data;
    }
    private Result(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
    private Result(int code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }
    private Result(CodeMsg codeMsg) {
        if (codeMsg != null) {
            this.code = codeMsg.getCode();
            this.msg = codeMsg.getMsg();
        }
    }
    public int getCode() {
        return code;
    }
    public void setCode(int code) {
        this.code = code;
    }
    public String getMsg() {
        return msg;
    }
    public void setMsg(String msg) {
        this.msg = msg;
    }
    public T getData() {
        return data;
    }
    public void setData(T data) {
        this.data = data;
    }
}

状态码

代码语言:javascript
复制
package com.liuyanzhao.sens.result;
/**
 * 状态码,错误码
 * @author liuyanzhao
 */
public class CodeMsg {
    private int code;
    private String msg;
    //通用的错误码
    public static CodeMsg SUCCESS = new CodeMsg(200, "成功");
    public static CodeMsg SERVER_ERROR = new CodeMsg(500100, "服务端异常");
    public static CodeMsg BIND_ERROR = new CodeMsg(500101, "参数校验异常:%s");
    public static CodeMsg REQUEST_ILLEGAL = new CodeMsg(500102, "请求非法");
    public static CodeMsg ACCESS_LIMIT_REACHED = new CodeMsg(500103, "访问太频繁!");
    //登录模块 5002XX
    public static CodeMsg USER_NOT_LOGIN = new CodeMsg(500200, "用户未登录");
    public static CodeMsg TOKEN_INVALID = new CodeMsg(500201, "token无效");
    public static CodeMsg USERNAME_NOT_EXIST = new CodeMsg(500202, "用户名不存在");
    public static CodeMsg PASSWORD_ERROR = new CodeMsg(500203, "密码错误");
    private CodeMsg() {
    }
    private CodeMsg(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
    public int getCode() {
        return code;
    }
    public void setCode(int code) {
        this.code = code;
    }
    public String getMsg() {
        return msg;
    }
    public void setMsg(String msg) {
        this.msg = msg;
    }
    public CodeMsg fillArgs(Object... args) {
        int code = this.code;
        String message = String.format(this.msg, args);
        return new CodeMsg(code, message);
    }
    @Override
    public String toString() {
        return "CodeMsg ";
    }
}

四、统一异常处理

自定义统一异常

代码语言:javascript
复制
package com.liuyanzhao.sens.exception;
import com.liuyanzhao.sens.result.CodeMsg;
/**
 * 统一异常
 */
public class GlobalException extends RuntimeException{
    private static final long serialVersionUID = 1L;
    private CodeMsg cm;
    public GlobalException(CodeMsg cm) {
        super(cm.toString());
        this.cm = cm;
    }
    public CodeMsg getCm() {
        return cm;
    }
}

统一异常处理类

代码语言:javascript
复制
package com.liuyanzhao.sens.exception;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import com.liuyanzhao.sens.result.CodeMsg;
import com.liuyanzhao.sens.result.Result;
import org.springframework.validation.BindException;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
/**
 * 统一异常处理
 */
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
    @ExceptionHandler(value = Exception.class)
    public Result<String> exceptionHandler(HttpServletRequest request, Exception e) {
        e.printStackTrace();
        if (e instanceof GlobalException) {
            GlobalException ex = (GlobalException) e;
            return Result.error(ex.getCm());
        } else if (e instanceof BindException) {
            BindException ex = (BindException) e;
            List<ObjectError> errors = ex.getAllErrors();
            ObjectError error = errors.get(0);
            String msg = error.getDefaultMessage();
            return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));
        } else {
            return Result.error(CodeMsg.SERVER_ERROR);
        }
    }
}

五、Redis 封装类

代码语言:javascript
复制
package com.liuyanzhao.sens.utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
 * @author 言曌
 * @date 2018/12/16 下午6:57
 */
@Component
public class RedisUtil {
    @Autowired
    private StringRedisTemplate redisTemplate;
    // Key(键),简单的key-value操作
    /**
     * 实现命令:TTL key,以秒为单位,返回给定 key的剩余生存时间(TTL, time to live)。
     *
     * @param key
     * @return
     */
    public long ttl(String key) {
        return redisTemplate.getExpire(key);
    }
    /**
     * 实现命令:expire 设置过期时间,单位秒
     *
     * @param key
     * @return
     */
    public void expire(String key, long timeout) {
        redisTemplate.expire(key, timeout, TimeUnit.SECONDS);
    }
    /**
     * 实现命令:INCR key,增加key一次
     *
     * @param key
     * @return
     */
    public long incr(String key, long delta) {
        return redisTemplate.opsForValue().increment(key, delta);
    }
    /**
     * 实现命令:key,减少key一次
     *
     * @param key
     * @return
     */
    public long decr(String key, long delta) {
        if(delta<0){
//            throw new RuntimeException("递减因子必须大于0");
            del(key);
            return 0;
        }
        return redisTemplate.opsForValue().increment(key, -delta);
    }
    /**
     * 实现命令:KEYS pattern,查找所有符合给定模式 pattern的 key
     */
    public Set<String> keys(String pattern) {
        return redisTemplate.keys(pattern);
    }
    /**
     * 实现命令:DEL key,删除一个key
     *
     * @param key
     */
    public void del(String key) {
        redisTemplate.delete(key);
    }
    // String(字符串)
    /**
     * 实现命令:SET key value,设置一个key-value(将字符串值 value关联到 key)
     *
     * @param key
     * @param value
     */
    public void set(String key, String value) {
        redisTemplate.opsForValue().set(key, value);
    }
    /**
     * 实现命令:SET key value EX seconds,设置key-value和超时时间(秒)
     *
     * @param key
     * @param value
     * @param timeout (以秒为单位)
     */
    public void set(String key, String value, long timeout) {
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
    }
    /**
     * 实现命令:GET key,返回 key所关联的字符串值。
     *
     * @param key
     * @return value
     */
    public String get(String key) {
        return (String) redisTemplate.opsForValue().get(key);
    }
    // Hash(哈希表)
    /**
     * 实现命令:HSET key field value,将哈希表 key中的域 field的值设为 value
     *
     * @param key
     * @param field
     * @param value
     */
    public void hset(String key, String field, Object value) {
        redisTemplate.opsForHash().put(key, field, value);
    }
    /**
     * 实现命令:HGET key field,返回哈希表 key中给定域 field的值
     *
     * @param key
     * @param field
     * @return
     */
    public String hget(String key, String field) {
        return (String) redisTemplate.opsForHash().get(key, field);
    }
    /**
     * 实现命令:HDEL key field [field ...],删除哈希表 key 中的一个或多个指定域,不存在的域将被忽略。
     *
     * @param key
     * @param fields
     */
    public void hdel(String key, Object... fields) {
        redisTemplate.opsForHash().delete(key, fields);
    }
    /**
     * 实现命令:HGETALL key,返回哈希表 key中,所有的域和值。
     *
     * @param key
     * @return
     */
    public Map<Object, Object> hgetall(String key) {
        return redisTemplate.opsForHash().entries(key);
    }
    // List(列表)
    /**
     * 实现命令:LPUSH key value,将一个值 value插入到列表 key的表头
     *
     * @param key
     * @param value
     * @return 执行 LPUSH命令后,列表的长度。
     */
    public long lpush(String key, String value) {
        return redisTemplate.opsForList().leftPush(key, value);
    }
    /**
     * 实现命令:LPOP key,移除并返回列表 key的头元素。
     *
     * @param key
     * @return 列表key的头元素。
     */
    public String lpop(String key) {
        return (String) redisTemplate.opsForList().leftPop(key);
    }
    /**
     * 实现命令:RPUSH key value,将一个值 value插入到列表 key的表尾(最右边)。
     *
     * @param key
     * @param value
     * @return 执行 LPUSH命令后,列表的长度。
     */
    public long rpush(String key, String value) {
        return redisTemplate.opsForList().rightPush(key, value);
    }
}

代码GitHub地址:https://github.com/saysky/sensboot

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-12-31,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Java帮帮 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、基本登录
  • 二、封装参数验证
  • 三、返回体和响应码封装
  • 四、统一异常处理
  • 五、Redis 封装类
相关产品与服务
云数据库 Redis
腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档