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

之前介绍过不少关于登录功能的代码,本文介绍一下关于分布式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 中

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 接口这里就不贴了

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

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

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

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 的话,会自动携带的,后端能直接获取的。

想要学习Java开发的同学,可以参考成都Java培训班提供的学习大纲;

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

三、返回体和响应码封装

封装返回对象

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;

}

}

状态码

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 ";

}

} 四、统一异常处理

自定义统一异常

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;

}

}

统一异常处理类

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 封装类 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

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值