mmall_v2.0 Redis + Cookie 实现单点登录

1,550 阅读7分钟

电商项目中,在单服务器时,用户登录时将用户信息设置到 session 中,获取用户信息从 session 中获取,退出时从 session 中删除即可。

但在搭建 Tomcat 集群后,就需要考虑 Session 共享问题,可通过单点登录解决方案实现,这里主要有两种方法,一种是通过 Redis + Cookie 自己实现,另一种是借助 Spring Session 框架解决。

Redis+Cookie 实现

单点登录的思路

用户登录:

  • 首先验证用户密码是否正确,并返回用户信息;
  • 使用 uuidsession.getId 生成唯一 id(token),设置到 cookie 中,将其写给客户端;
  • 将用户信息(user 对象)转换为 json 格式;
  • key=tokenvalue=(user 的 json 格式),写到 redis 中,并设置过期时间;

退出登录:

  • 用户请求时会携带 cookie,从 cookie 中获取到 token
  • 从请求中获取到 cookie,将其过期时间设置为 0,再写入到响应中,即删除了 token
  • 再从 redis 中删除 token

获取用户信息:

  • 从请求携带的 cookie 中获取到 token
  • 根据 tokenredis 中查询相应的 user 对象的 json 串;
  • json 串转换为 user 对象;

Redis 连接池及工具类

由于 tokenuser 对象都会存储在 redis 中,所以这里封装一个 redis 的连接池和工具类。

首先,封装一个 redis 连接池,每次直接从连接池中获取 jedis 实例即可。

public class RedisPool {

    private static JedisPool jedisPool;

    private static String redisIP = PropertiesUtil.getProperty("redis.ip", "192.168.23.130");
    private static Integer redisPort = Integer.parseInt(PropertiesUtil.getProperty("redis.port", "6379"));
    // 最大连接数
    private static Integer maxTotal = Integer.parseInt(PropertiesUtil.getProperty("redis.max.total", "20"));
    // 最大的 idle 状态的 jedis 实例个数
    private static Integer maxIdle = Integer.parseInt(PropertiesUtil.getProperty("redis.max.idle", "10"));
    // 最小的 idle 状态的 jedis 实例个数
    private static Integer minIdle = Integer.parseInt(PropertiesUtil.getProperty("redis.min.idle", "2"));
    // 在 borrow 一个 jedis 实例时,是否要进行验证操作
    private static Boolean testOnBorrow = Boolean.parseBoolean(PropertiesUtil.getProperty("redis.test.borrow", "true"));
    // 在 return 一个 jedis 实例时,是否要进行验证操作
    private static Boolean testOnReturn = Boolean.parseBoolean(PropertiesUtil.getProperty("redis.test.return", "true"));

    static {
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(maxTotal);
        config.setMaxIdle(maxIdle);
        config.setMinIdle(minIdle);
        config.setTestOnBorrow(testOnBorrow);
        config.setTestOnReturn(testOnReturn);
        jedisPool = new JedisPool(config, redisIP, redisPort, 1000*2);
    }

    public static Jedis getJedis() {
        return jedisPool.getResource();
    }
    public static void returnJedis(Jedis jedis) {
        jedis.close();
    }
}

然后,再将其封装成一个工具类,基本操作就是从 redis 连接池中获取 jedis 实例,进行 set/get/expire 等操作,然后将其放回到 redis 连接池中。

@Slf4j
public class RedisPoolUtil {

    // exTime 以秒为单位
    public static Long expire(String key, int exTime) {
        Jedis jedis = null;
        Long result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.expire(key, exTime);
        } catch (Exception e) {
            log.error("expire key:{}, error", key, e);
        }
        RedisPool.returnJedis(jedis);
        return result;
    }

    public static Long del(String key) {
        Jedis jedis = null;
        Long result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.del(key);
        } catch (Exception e) {
            log.error("del key:{}, error", key, e);
        }
        RedisPool.returnJedis(jedis);
        return result;
    }

    public static String get(String key) {
        Jedis jedis = null;
        String result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.get(key);
        } catch (Exception e) {
            log.error("get key:{}, error", key, e);
        }
        RedisPool.returnJedis(jedis);
        return result;
    }

    public static String set(String key, String value) {
        Jedis jedis = null;
        String result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.set(key, value);
        } catch (Exception e) {
            log.error("set key:{}, value:{}, error", key, value, e);
        }
        RedisPool.returnJedis(jedis);
        return result;
    }

    // exTime 以秒为单位
    public static String setEx(String key, String value, int exTime) {
        Jedis jedis = null;
        String result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.setex(key, exTime, value);
        } catch (Exception e) {
            log.error("setex key:{}, value:{}, error", key, value, e);
        }
        RedisPool.returnJedis(jedis);
        return result;
    }
}

JsonUtil 工具类

user 对象存储在 redis 中,需要转换为 json 格式,从 redis 中获取 user 对象,又需要转换为 user 对象。这里封装一个 json 的工具类。

JsonUtil 工具类主要使用 ObjectMapper 类。

  • bean 类转换为 String 类型,使用 writerValueAsString 方法。
  • String 类型转换为 bean 类,使用 readValue 方法。
@Slf4j
public class JsonUtil {

    private static ObjectMapper objectMapper = new ObjectMapper();

    static {
        // 序列化时将所有字段列入
        objectMapper.setSerializationInclusion(JsonSerialize.Inclusion.ALWAYS);
        // 取消默认将 DATES 转换为 TIMESTAMPS
        objectMapper.configure(SerializationConfig.Feature.WRITE_DATES_AS_TIMESTAMPS, false);
        // 忽略空 bean 转 json 的错误
        objectMapper.configure(SerializationConfig.Feature.FAIL_ON_EMPTY_BEANS, false);
        // 所有日期样式统一
        objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
        // 忽略 在 json 字符串中存在,在 java 对象中不存在对应属性的情况
        objectMapper.configure(DeserializationConfig.Feature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }

    public static <T> String obj2Str(T obj) {
        if (obj == null) { return null; }
        try {
            return obj instanceof String ? (String) obj : objectMapper.writeValueAsString(obj);
        } catch (Exception e) {
            log.warn("Parse Object to String error", e);
            return null;
        }
    }

    public static <T> String obj2StrPretty(T obj) {
        if (obj == null) { return null; }
        try {
            return obj instanceof String ? (String) obj :
                    objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj);
        } catch (Exception e) {
            log.warn("Parse Object to String error", e);
            return null;
        }
    }

    public static <T> T str2Obj(String str, Class<T> clazz) {
        if (StringUtils.isEmpty(str) || clazz == null) {
            return null;
        }
        try {
            return clazz.equals(String.class) ? (T)str : objectMapper.readValue(str, clazz);
        } catch (Exception e) {
            log.warn("Parse String to Object error", e);
            return null;
        }
    }

    public static <T> T str2Obj(String str, TypeReference<T> typeReference) {
        if (StringUtils.isEmpty(str) || typeReference == null) {
            return null;
        }
        try {
            return typeReference.getType().equals(String.class) ? (T)str : objectMapper.readValue(str, typeReference);
        } catch (Exception e) {
            log.warn("Parse String to Object error", e);
            return null;
        }
    }

    public static <T> T str2Obj(String str, Class<?> collectionClass, Class<?> elementClass) {
        JavaType javaType = objectMapper.getTypeFactory().constructParametricType(collectionClass, elementClass);
        try {
            return objectMapper.readValue(str, javaType);
        } catch (Exception e) {
            log.warn("Parse String to Object error", e);
            return null;
        }
    }
}

CookieUtil 工具类

登录时需要将 token 设置到 cookie 中返回给客户端,退出时需要从 request 中携带的 cookie 中读取 token,设置过期时间后,又将其设置到 cookie 中返回给客户端,获取用户信息时,获取用户信息时,需要从 request 中携带的 cookie 中读取 token,在 redis 中查询后获得 user 对象。这里呢,也封装一个 cookie 的工具类。

CookieUtil 中:

  • readLoginToken 方法主要从 request 读取 Cookie
  • writeLoginToken 方法主要设置 Cookie 对象加到 response 中;
  • delLoginToken 方法主要从 request 中读取 Cookie,将其 maxAge 设置为 0,再添加到 response 中;
@Slf4j
public class CookieUtil {

    private static final String COOKIE_DOMAIN = ".happymmall.com";
    private static final String COOKIE_NAME = "mmall_login_token";

    public static String readLoginToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                log.info("read cookieName:{}, cookieValue:{}", cookie.getName(), cookie.getValue());
                if (StringUtils.equals(COOKIE_NAME, cookie.getName())) {
                    log.info("return cookieName:{}, cookieValue:{}", cookie.getName(), cookie.getValue());
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    public static void writeLoginToken(HttpServletResponse response, String token) {
        Cookie cookie  = new Cookie(COOKIE_NAME, token);
        cookie.setDomain(COOKIE_DOMAIN);
        cookie.setPath("/");
        // 防止脚本攻击
        cookie.setHttpOnly(true);
        // 单位是秒,如果是 -1,代表永久;
        // 如果 MaxAge 不设置,cookie 不会写入硬盘,而是在内存,只在当前页面有效
        cookie.setMaxAge(60 * 60 * 24 * 365);
        log.info("write cookieName:{}, cookieValue:{}", cookie.getName(), cookie.getValue());
        response.addCookie(cookie);
    }

    public static void delLoginToken(HttpServletRequest request, HttpServletResponse response) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (StringUtils.equals(COOKIE_NAME, cookie.getName())) {
                    cookie.setDomain(COOKIE_DOMAIN);
                    cookie.setPath("/");
                    // maxAge 设置为 0,表示将其删除
                    cookie.setMaxAge(0);
                    log.info("del cookieName:{}, cookieValue:{}", cookie.getName(), cookie.getValue());
                    response.addCookie(cookie);
                    return;
                }
            }
        }
    }

}

具体业务

登录时验证密码后:

CookieUtil.writeLoginToken(response, session.getId());
RedisShardedPoolUtil.setEx(session.getId(), JsonUtil.obj2Str(serverResponse.getData()), Const.RedisCacheExtime.REDIS_SESSION_EXTIME);

退出登录时:

String loginToken = CookieUtil.readLoginToken(request);
CookieUtil.delLoginToken(request, response);
RedisShardedPoolUtil.del(loginToken);

获取用户信息时:

String loginToken = CookieUtil.readLoginToken(request);
if (StringUtils.isEmpty(loginToken)) {
    return ServerResponse.createByErrorMessage("用户未登录,无法获取当前用户信息");
}
String userJsonStr = RedisShardedPoolUtil.get(loginToken);
User user = JsonUtil.str2Obj(userJsonStr, User.class);

SessionExpireFilter 过滤器

另外,在用户登录后,每次操作后,都需要重置 Session 的有效期。可以使用过滤器来实现。

public class SessionExpireFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException { }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String loginToken = CookieUtil.readLoginToken(httpServletRequest);
        if (StringUtils.isNotEmpty(loginToken)) {
            String userJsonStr = RedisShardedPoolUtil.get(loginToken);
            User user = JsonUtil.str2Obj(userJsonStr, User.class);
            if (user != null) {
                RedisShardedPoolUtil.expire(loginToken, Const.RedisCacheExtime.REDIS_SESSION_EXTIME);
            }
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() { }
}

还需要在 web.xml 文件中进行配置:

<filter>
    <filter-name>sessionExpireFilter</filter-name>
    <filter-class>com.mmall.controller.common.SessionExpireFilter</filter-class>
</filter>
<filter-mapping>
    <filter-name>sessionExpireFilter</filter-name>
    <url-pattern>*.do</url-pattern>
</filter-mapping>

此方式的缺陷

  • redis + cookie 方式实现的单点登录对代码侵入性比较大;
  • 客户端必须启用 cookie,而有些浏览器不支持 cookie
  • Cookie 设置 domain 时必须统一,服务器也必须统一域名方式;

Spring Session 实现

Spring SessionSpring 的项目之一,它提供了创建和管理 Server HTTPSession 的方案。并提供了集群 Session 功能,默认采用外置的 Redis 来存储 Session 数据,以此来解决 Session 共享的问题。

Spring Session 可以无侵入式地解决 Session 共享问题,但是不能进行分片。

Spring Session 项目集成

1、引入 Spring Session pom

<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
  <version>1.2.2.RELEASE</version>
</dependency>

2、配置 DelegatingFilterProxy

<filter>
    <filter-name>springSessionRepositoryFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
    <filter-name>springSessionRepositoryFilter</filter-name>
    <url-pattern>*.do</url-pattern>
</filter-mapping>

3、配置 RedisHttpSessionConfiguration

<bean id="redisHttpSessionConfiguration" class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
    <property name="maxInactiveIntervalInSeconds" value="1800" />
</bean>

4、配置 JedisPoolConfig

<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
    <property name="maxTotal" value="20" />
</bean>

5、配置 JedisSessionFactory

<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory" >
    <property name="hostName" value="192.168.23.130" />
    <property name="port" value="6379" />
    <property name="database" value="0" />
    <property name="poolConfig" ref="jedisPoolConfig" />
</bean>

6、配置 DefaultCookieSerializer

<bean id="defaultCookieSerializer" class="org.springframework.session.web.http.DefaultCookieSerializer">
    <property name="cookieName" value="SESSION_NAME" />
    <property name="domainName" value=".happymmall.com" />
    <property name="useHttpOnlyCookie" value="true" />
    <property name="cookiePath" value="/" />
    <property name="cookieMaxAge" value="31536000" />
</bean>

业务代码

用户登录时:

session.setAttribute(Const.CURRENT_USER, response.getData());

退出登录时:

session.removeAttribute(Const.CURRENT_USER);

获得用户信息时:

User user = (User) session.getAttribute(Const.CURRENT_USER);