目录

该篇文章我们主要讲解下如何使用拦截器+自定义注解来实现登录鉴权校验的功能,并且实现调用接口自动注入用户。关于拦截器和自定义注解的基础语法下面也会稍带讲解,但不是本文重点,所以简单带过。

SpringBoot实战 - 拦截器+自定义注解实现接口的登录校验

需求描述

因为拿到一个很久之前做的项目,是使用 Session 记录用户信息的,各种接口是否需要授权访问的逻辑在 Intercepter 中写得非常复杂,所以想着重构一下。

我们需要一个Java注解,使用这个注解标记在 Controller 的类或某个方法上时,就代表着该类下所有方法或某个注解标记的方法需要登录后才可以进行访问。

实现思路

首先我们需要一个注解,名字定义为 @LoginAuth,然后需要使用拦截器,名字就定为 LoginAuthInterceptor,在调用 Controller 方法前该拦截器根据调用类的基本信息判定是否标记了登录鉴权注解,如果在所在方法或该方法所在类上有标记,则进行登录鉴权逻辑。

那么用户的登录身份信息放在哪里呢?我们约定用户的身份信息通过 http 请求头参数方式传递,其参数字段名字为 X-Token,当然在你的系统中也可以设计为放入 cookie+header 两种并行的方式。

登录鉴权注解

package cn.notemi.demo.annotations;

import java.lang.annotation.*;

/**
 * @desc 已登录权限验证注解
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LoginAuth {

}

说明

注解类上有三个注解:

@Target 用它来指明自定义注解的使用范围,ElementType.TYPE代表可以将该注解使用在类、接口或枚举上,ElementType.METHOD 代表可以应用在类的方法上。

@Retention 用它来指明该注解在.java变.class文件过程中会被保留到那个阶段。RetentionPolicy.RUNTIME 这种类型的注解将被JVM保留,所以他们能在运行时被JVM或其他使用反射机制的代码所读取和使用。

@Documented 注解表明这个注解应该被 javadoc工具记录。

更详细的说明可 参考这里

拦截器类

package cn.notemi.demo.intercepter;

import java.lang.reflect.Method;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import cn.notemi.demo.annotations.LoginAuth;
import cn.notemi.demo.enums.ResultCode;
import cn.notemi.demo.exceptions.BusinessException;
import cn.notemi.demo.helper.LoginTokenHelper;
import cn.notemi.demo.model.bo.LoginToken;
import cn.notemi.demo.model.bo.LoginUser;
import cn.notemi.demo.service.LoginTokenService;
import cn.notemi.demo.util.StringUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

/**
 * @desc 已登录权限验证拦截器 备注:通过{@link LoginAuth}配合使用
 */
@Component
public class LoginAuthInterceptor implements HandlerInterceptor {

    @Autowired
    private LoginTokenService loginTokenCacheService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            final HandlerMethod handlerMethod = (HandlerMethod) handler;
            final Class<?> clazz = handlerMethod.getBeanType();
            final Method method = handlerMethod.getMethod();

            // 判断LoginAuth注解是标记在所调用的方法上还是在其类上
            if (clazz.isAnnotationPresent(LoginAuth.class) || method.isAnnotationPresent(LoginAuth.class)) {
                // 登录鉴权的主要业务逻辑解释是当发现用户没有做登陆的时候,立即抛出一个自定义的业务异常BusinessException,如果登录则return true继续执行后续代码。
                // 直接获取登录用户(防止请求转发时,第二次查询)
                LoginUser loginUser = LoginTokenHelper.getLoginUserFromRequest();
                if (loginUser != null) {
                    return true;
                }

                //获取登录TOKEN ID
                String loginTokenId = LoginTokenHelper.getLoginTokenId();
                if (StringUtil.isEmpty(loginTokenId)) {
                    throw new BusinessException(ResultCode.USER_NOT_LOGGED_IN);
                }

                //获取登录TOKEN信息
                LoginToken loginToken = loginTokenCacheService.getById(loginTokenId);
                if (loginToken == null) {
                    throw new BusinessException(ResultCode.USER_NOT_LOGGED_IN);
                }

                //登录TOKEN信息放入请求对象,方便后续controller中获取
                LoginTokenHelper.addLoginTokenToRequest(loginToken);
                return true;
            }
        }

        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        // nothing to do
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // nothing to do
    }

}

说明

这里实现了拦截器 HandlerInterceptor 接口来定义我们自己的拦截器,鉴定是否登陆的逻辑写在 preHandle 方法中,也就是在进入 Controller 方法前做一些业务逻辑的处理。

if (clazz.isAnnotationPresent(LoginAuth.class) || method.isAnnotationPresent(LoginAuth.class))

上述代码是在判断 LoginAuth 注解是标记在所调用的方法上还是在其类上
登录鉴权的主要业务逻辑解释是当发现用户没有做登陆的时候,立即抛出一个自定义的业务异常 BusinessException,如果登录则 return true 继续执行后续代码。

LoginUser loginUser = LoginTokenHelper.getLoginUserFromRequest();
if (loginUser != null) {
    return true;
}

说明下为什么会有上面的这个逻辑,是为了防止在 spring mvc 中程序进行了请求的转发和重定向后,request 参数信息仍然会被传递下一个方法中所以没有必要再次去走一遍登录校验逻辑。

这里我们使用了自定义异常来结束程序,由最外层的全局异常捕捉类捕获处理后给用户端返回,我们会在后续中讲解下开发中如何定义异常、如何更好的使用异常、全局异常类该如何处理。

用户登录辅助类

package cn.notemi.demo.helper;

import cn.notemi.demo.annotations.LoginAuth;
import cn.notemi.demo.model.bo.LoginToken;
import cn.notemi.demo.model.bo.LoginUser;
import cn.notemi.demo.util.CookieUtil;
import cn.notemi.demo.util.RequestContextUtil;
import cn.notemi.demo.util.StringUtil;
import org.apache.commons.codec.digest.DigestUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;

/**
 * @desc 登录TOKEN辅助类
 */
public class LoginTokenHelper {

    private final static String SECRET_KEY = "Ld4Dl5f9OoYTezPK";

    private final static String LOGIN_TOKEN_COOKIE_NAME = "X-Token";

    private final static String LOGIN_TOKEN_KEY = "LOGIN-TOKEN";

    /**
     * 根据登录的相关信息生成TOKEN ID
     */
    public static String generateId(String loginAccount, String accountType, String ip, String platform, Date loginTime, long ttl) {
        StringBuilder noEncodeLoginTokenId = new StringBuilder(loginAccount)
                .append(accountType)
                .append(ip)
                .append(platform)
                .append(loginTime)
                .append(ttl);

        return DigestUtils.sha256Hex(SECRET_KEY + DigestUtils.md5Hex(noEncodeLoginTokenId.toString()) + DigestUtils.md5Hex(SECRET_KEY));
    }

    /**
     * 添加登录TOKEN的ID信息到COOKIE中
     */
    public static void addLoginTokenIdToCookie(String loginTokenId, Integer expiredTimeSec) {
        HttpServletResponse response = RequestContextUtil.getResponse();
        CookieUtil.addCookie(response, LOGIN_TOKEN_COOKIE_NAME, loginTokenId, expiredTimeSec == null ? -1 : expiredTimeSec, true);
    }

    /**
     * 清理登录账号信息从COOKIE中
     */
    public static void delLoginTokenIdFromCookie() {
        HttpServletRequest request = RequestContextUtil.getRequest();
        HttpServletResponse response = RequestContextUtil.getResponse();

        CookieUtil.delCookie(request, response, LOGIN_TOKEN_COOKIE_NAME);
    }

    /**
     * 获取登录的TOKEN的ID(取头信息或Cookie中)
     */
    public static String getLoginTokenId() {
        HttpServletRequest request = RequestContextUtil.getRequest();
        String token = request.getHeader(LOGIN_TOKEN_COOKIE_NAME);
        if (StringUtil.isEmpty(token)) {
            token = CookieUtil.getCookieValue(request, LOGIN_TOKEN_COOKIE_NAME, true);
        }
        return token;
    }

    /**
     * 将登录TOKEN信息放入请求对象
     */
    public static void addLoginTokenToRequest(LoginToken loginToken) {
        RequestContextUtil.getRequest().setAttribute(LOGIN_TOKEN_KEY, loginToken);
    }

    /**
     * 获取登录用户信息从请求对象 备注:使用该方法时需要在对应controller类或方法上加{@link LoginAuth}}注解
     */
    public static LoginUser getLoginUserFromRequest() {
        LoginToken loginToken = getLoginTokenFromRequest();
        if (loginToken == null) {
            return null;
        }

        return loginToken.getLoginUser();
    }

    /**
     * 获取登录TOKEN信息从请求对象 备注:使用该方法时需要在对应controller类或方法上加{@link LoginAuth}}注解
     */
    public static LoginToken getLoginTokenFromRequest() {
        Object loginTokenO = RequestContextUtil.getRequest().getAttribute(LOGIN_TOKEN_KEY);
        if (loginTokenO == null) {
            return null;
        }
        return (LoginToken) loginTokenO;
    }
}

拦截器配置类(以spring boot为例)

package cn.notemi.demo.config;

import cn.notemi.demo.intercepter.HeaderParamsCheckInterceptor;
import cn.notemi.demo.intercepter.LoginAuthInterceptor;
import cn.notemi.demo.intercepter.ResponseResultInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@Configuration
public class InterceptorConfig extends WebMvcConfigurerAdapter {

    @Bean
    public LoginAuthInterceptor loginAuthInterceptor() {
        return new LoginAuthInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //登录拦截
        registry.addInterceptor(loginAuthInterceptor());
    }

}

说明

上述配置后拦截器才会正真的在程序中生效,如果你的程序中有多个拦截器,并且你希望他们按照你的想法顺序调用,可以调整多个 registry.addInterceptor 方法的代码顺序来控制拦截器的调用顺序。

测试

数据库结构及数据

CREATE TABLE `login_credential` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `account` varchar(100) NOT NULL DEFAULT '',
  `pwd` varchar(100) NOT NULL DEFAULT '',
  `random_salt` varchar(100) NOT NULL DEFAULT '',
  `user_id` varchar(100) NOT NULL DEFAULT '',
  `type` varchar(100) NOT NULL DEFAULT '',
  `create_time` datetime NOT NULL,
  `update_time` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;

CREATE TABLE `user` (
  `id` varchar(100) NOT NULL COMMENT '主键ID',
  `nickname` varchar(100) DEFAULT NULL,
  `gender` varchar(100) DEFAULT NULL,
  `avatar` varchar(100) DEFAULT NULL,
  `type` varchar(100) DEFAULT NULL,
  `status` varchar(100) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `login_credential` (`id`, `account`, `pwd`, `random_salt`, `user_id`, `type`, `create_time`, `update_time`)
VALUES
    (1, 'superadmin', '646603b21556844bbacae1e9bbc27d6abf2a5ae8af0880c776dd69fa80fd1cf0', '1abcf2c3a6e44a38836c33e17b6a1d6c', '1', 'CUSTOM', '2020-03-01 23:36:48', '2020-03-01 23:36:48');

INSERT INTO `user` (`id`, `nickname`, `gender`, `avatar`, `type`, `status`, `create_time`, `update_time`)
VALUES
    ('1', 'nickname1', 'MALE', 'bbbb', 'NORMAL', 'ENABLED', '2020-03-01 23:36:48', '2020-03-01 23:36:48'),
    ('2', 'nickname2', 'MALE', 'bbbb', 'ADMIN', 'ENABLED', '2020-03-01 23:36:48', '2020-03-01 23:36:48'),
    ('3', 'nickname3', 'MALE', 'bbbb', 'ADMIN', 'ENABLED', '2020-03-01 23:36:48', '2020-03-01 23:36:48');

实体及持久层

略,GitHub自行查看

Service

/**
 * @desc 登录服务
 */
public interface LoginService {

    LoginVO login(LoginQO loginQO);

    void logout();
}
package cn.notemi.demo.service.impl;

import cn.notemi.demo.constant.HeaderConstants;
import cn.notemi.demo.enums.CacheKeyEnum;
import cn.notemi.demo.enums.ResultCode;
import cn.notemi.demo.exceptions.BusinessException;
import cn.notemi.demo.helper.LoginTokenHelper;
import cn.notemi.demo.helper.PasswordHelper;
import cn.notemi.demo.model.bo.LoginToken;
import cn.notemi.demo.model.bo.LoginUser;
import cn.notemi.demo.model.po.LoginCredential;
import cn.notemi.demo.model.po.User;
import cn.notemi.demo.model.qo.LoginQO;
import cn.notemi.demo.model.vo.LoginCredentialVO;
import cn.notemi.demo.model.vo.LoginVO;
import cn.notemi.demo.repository.LoginCredentialRepository;
import cn.notemi.demo.repository.UserRepository;
import cn.notemi.demo.service.LoginService;
import cn.notemi.demo.service.LoginTokenService;
import cn.notemi.demo.util.BeanUtil;
import cn.notemi.demo.util.IpUtil;
import cn.notemi.demo.util.RequestContextUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.List;

/**
 * @desc 登录服务实现
 */
@Slf4j
@Service
public class LoginServiceImpl implements LoginService {

    @Autowired
    private LoginTokenService loginTokenService;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private LoginCredentialRepository loginCredentialRepository;

    @Override
    public LoginVO login(LoginQO loginQO) {

        List<LoginCredential> loginCredentialList = loginCredentialRepository.findAllByAccountAndTypeIn(loginQO.getAccount(), loginQO.getType());
        if (loginCredentialList.size() == 0) {
            log.info("login account is nonexistent, account:{}", loginQO.getAccount());
            throw new BusinessException(ResultCode.USER_LOGIN_ERROR);
        }

        //验证密码是否正确
        LoginCredential firstLoginCredential = loginCredentialList.get(0);
        if (!firstLoginCredential.getPwd().equals(PasswordHelper.encodeBySalt(loginQO.getPwd(), firstLoginCredential.getRandomSalt()))) {
            log.info("login account' password is error");
            throw new BusinessException(ResultCode.USER_LOGIN_ERROR);
        }

        User user = userRepository.findById(firstLoginCredential.getUserId()).orElse(null);
        if (user == null) {
            log.info("login user is null");
            throw new BusinessException(ResultCode.USER_LOGIN_ERROR);
        }

        LoginToken loginToken = this.saveLoginToken(user, firstLoginCredential);

        LoginUser loginUser = new LoginUser();
        BeanUtil.copyProperties(user, loginUser);

        LoginCredentialVO loginCredential = new LoginCredentialVO();
        BeanUtil.copyProperties(firstLoginCredential, loginCredential);

        return LoginVO.builder()
                .token(loginToken.getId())
                .loginTime(loginToken.getCreateTime())
                .ip(loginToken.getIp())
                .platform(loginToken.getPlatform())
                .ttl(loginToken.getTtl())
                .user(loginUser)
                .loginCredential(loginCredential)
                .build();
    }

    private LoginToken saveLoginToken(User user, LoginCredential loginCredential) {
        Date currentDate = new Date();
        LoginUser loginUser = new LoginUser();
        BeanUtil.copyProperties(user, loginUser);

        HttpServletRequest request = RequestContextUtil.getRequest();

        LoginToken loginToken = LoginToken.builder()
                .createTime(currentDate)
                .ip(IpUtil.getRealIp(request))
                .platform(request.getHeader(HeaderConstants.CALL_SOURCE))
                .ttl(CacheKeyEnum.VALUE_LOGIN_TOKENS.sec().longValue())
                .loginCredential(loginCredential)
                .loginUser(loginUser)
                .build();

        loginToken = loginTokenService.add(loginToken);
        LoginTokenHelper.addLoginTokenIdToCookie(loginToken.getId(), CacheKeyEnum.VALUE_LOGIN_TOKENS.sec());
        return loginToken;
    }

    @Override
    public void logout() {
        LoginToken loginToken = LoginTokenHelper.getLoginTokenFromRequest();
        if (loginToken == null) {
            throw new BusinessException(ResultCode.USER_NOT_LOGGED_IN);
        }

        loginTokenService.deleteById(loginToken.getId());
        LoginTokenHelper.delLoginTokenIdFromCookie();
    }
}

Controller

package cn.notemi.demo.controller;

import cn.notemi.demo.annotations.LoginAuth;
import cn.notemi.demo.annotations.ResponseResult;
import cn.notemi.demo.model.po.User;
import cn.notemi.demo.model.qo.LoginQO;
import cn.notemi.demo.model.vo.LoginVO;
import cn.notemi.demo.repository.UserRepository;
import cn.notemi.demo.service.LoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;

/**
 * Title:AuthController
 **/
@RestController
@RequestMapping("/api/auth")
@ResponseResult
public class AuthController {

    @Autowired
    private LoginService loginService;
    @Autowired
    private UserRepository userRepository;

    @PostMapping("/login")
    public LoginVO login(@Valid @RequestBody LoginQO loginQO) {
        return loginService.login(loginQO);
    }

    @PostMapping("/logout")
    @LoginAuth
    public void logout() {
        loginService.logout();
    }

    @GetMapping("/test")
    @LoginAuth
    public List<User> findAllUser() {
        return userRepository.findAll();
    }
}

直接访问 /test 接口会返回未登录:

SpringBoot实战 - 拦截器+自定义注解实现接口的登录校验

需要先登录再调 /test 接口就能成功返回了:

SpringBoot实战 - 拦截器+自定义注解实现接口的登录校验

至此,针对接口的登录校验就做好了,是不是很方便呢?

本例文章中的代码是不全的,只放了主要思路的代码,感兴趣的可以直接前往 Github 查看。