基于Spring及Redis的Token鉴权

为什么用 Token

一般来说都是用 session 来存储登录信息的,但是移动端使用 session 不太方便,所以一般都用 token 。另外现在前后端分离,一般都用 token 来鉴权。用 token 也更加符合 RESTful 中无状态的定义。

交互流程

  1. 客户端通过登录请求提交用户名和密码,服务端验证通过后生成一个 Token 与该用户进行关联,并将 Token 返回给客户端。
  2. 客户端在接下来的请求中都会携带 Token,服务端通过解析 Token 检查登录状态。
  3. 当用户退出登录、其他终端登录同一账号(被顶号)、长时间未进行操作时 Token 会失效,这时用户需要重新登录。

程序示例

Token的生成算法

服务端生成的 Token 一般为随机的非重复字符串,根据应用对安全性的不同要求,会将其添加时间戳(通过时间判断 Token 是否被盗用)或 url 签名(通过请求地址判断 Token 是否被盗用)后加密进行传输。因为只是个 demo,所以这里简单写了

public class TokenUtil {

    private static final String SEPARATOR = "-";

    /**
     * Token格式:时间戳-userId-随机字符串
     */
    public static String createToken(long userId) {
        return new Date().getTime() + SEPARATOR + userId + SEPARATOR + RandomStringUtils.random(10, true, true);
    }

    /**
     * 解析Token,从中取得userId
     */
    public static Long getUserIdFromToken(String token) {
        if (StringUtils.isEmpty(token)) {
            return null;
        }
        String[] param = token.split(SEPARATOR);
        if (param.length != 3) {
            return null;
        }
        try {
            return NumberUtils.createLong(param[1]);
        } catch (NumberFormatException e) {
            return null;
        }
    }
}

Token的CRUD操作

Redis 是一个 Key-Value 结构的内存数据库,用它维护 userId 和 Token 的映射表会比传统数据库速度更快,这里使用 Spring-Data-Redis 封装的 RedisTokenManager 对 Token 进行基础操作:

首先定义一个 DAO 接口

package com.owen.favorite.repository;

import com.owen.favorite.domain.Token;

public interface TokenRepository {

    /**
     * 创建一个 token 并关联上指定用户
     */
    Token createToken(long userId);

    /**
     *  检查 token 是否有效
     */
    boolean checkToken(Token token);

    /**
     * 清除 token
     */
    void deleteToken (long userId);
}

然后是实现类

package com.owen.favorite.repository.impl;

import com.owen.favorite.constant.ApiConstant;
import com.owen.favorite.domain.Token;
import com.owen.favorite.repository.TokenDao;
import com.owen.favorite.util.TokenUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import java.util.concurrent.TimeUnit;

/**
 * 通过 Redis 管理 token 的实现类
 */
@Repository
public class RedisTokenDaoImpl implements TokenDao {

    private RedisTemplate<Long, String> redisTemplate;

    @Override
    public Token createToken(long userId) {
        String token = TokenUtil.createToken(userId);
        // 存储到 redis 并设置过期时间
        redisTemplate.boundValueOps(userId).set(token, ApiConstant.Token.EXPIRE_DAYS, TimeUnit.DAYS);
        return new Token(userId, token);
    }

    @Override
    public boolean checkToken(String tokenFromClient) {
        if (StringUtils.isEmpty(tokenFromClient)) {
            return false;
        }
        Long userId = TokenUtil.getUserIdFromToken(tokenFromClient);
        if (userId == null) {
            return false;
        }
        String tokenInRedis = redisTemplate.boundValueOps(userId).get();
        if (tokenFromClient.equals(tokenInRedis)) {
            // 如果验证成功,说明此用户进行了一次有效操作,延长 token 的过期时间
            redisTemplate.boundValueOps(userId).expire(ApiConstant.Token.EXPIRE_DAYS, TimeUnit.DAYS);
            return true;
        } else {
            return false;
        }
    }

    @Override
    public void deleteToken(long userId) {
        redisTemplate.delete(userId);
    }

    @Autowired
    public void setRedisTemplate(RedisTemplate<Long, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
}

登录与注册

登录与注册的 Controller

package com.owen.favorite.controller;

import com.owen.favorite.domain.APIResult;
import com.owen.favorite.domain.Token;
import com.owen.favorite.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    @Autowired
    private TokenService tokenService;

    @PostMapping("/login")
    public APIResult login(@RequestParam String username, @RequestParam String password) {
        User user = userService.findByUsername(username);
        if (user == null /* 未注册 */ || !user.getPassword().equals(password) /* 密码错误 */) {
            return APIResult.createNg("用户名或密码错误");
        }
        //生成一个token,保存用户登录状态
        Token token = tokenService.createToken(user.getId());
        return APIResult.createOk(token);
    }

    @PostMapping("/logout")
    public APIResult logout(@RequestParam String token) {
        Long userId = TokenUtil.getUserIdFromToken(token);
        if (userId == null) {
            return APIResult.createNg("退出失败");
        }
        tokenService.deleteToken(userId);
        return APIResult.createOKMessage("退出成功");
    }
}

token验证

客户端访问一些需要用户登录之后才能调用的接口,比如在数据库中插入一条记录,那么就需要判断 token 的合法性。而这样的接口又有很多,那么岂不是每一次都需要及你想那个判断,代码要重复写很多遍。这时候可以使用自定义注解和拦截器来实现。

首先定义一个注解

package com.owen.favorite.anno;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 在 Controller 的方法上使用此注解,该方法在映射时会检查用户是否登录,未登录返回 401 错误
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorization {
}

拦截器的实现

package com.owen.favorite.interceptor;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.owen.favorite.anno.Authorization;
import com.owen.favorite.constant.ApiConstant;
import com.owen.favorite.domain.APIResult;
import com.owen.favorite.service.TokenService;
import com.owen.favorite.util.TokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;

/**
 * 自定义拦截器,判断此次请求的用户是否已登录
 */
@Component
public class AuthorizationInterceptor extends HandlerInterceptorAdapter {

    @Autowired
    private TokenService tokenService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            // 如果不是映射到方法直接通过
            return true;
        }
        //从header中得到token
        String token = request.getHeader(ApiConstant.RequestParam.TOKEN);
        // 验证 token
        if (tokenService.checkToken(token)) {
            //如果token验证成功,将token对应的用户id存在request中,便于之后注入
            request.setAttribute(ApiConstant.RequestParam.USER_ID, TokenUtil.getUserIdFromToken(token));
            return true;
        } else {
            // 如果验证token失败,并且方法注明了Authorization,就告诉客户端token不对
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            if (handlerMethod.getMethodAnnotation(Authorization.class) != null) {
                response.setCharacterEncoding("UTF-8");
                response.setContentType("application/json;charset=utf-8");

                ObjectMapper objectMapper = new ObjectMapper();
                objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

                PrintWriter writer = response.getWriter();
                writer.write(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(APIResult.createNg("请登录")));
                writer.close();
                return false;
            } else {
                return true;
            }
        }
    }
}

一些细节

  • 登录请求一定要使用 HTTPS,否则无论 Token 做的安全性多好密码泄露了也是白搭
  • Token 的生成方式有很多种,例如比较热门的有 JWT(JSON Web Tokens)、OAuth 等。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容