SpringBoot 2.x 开发案例之前后端分离鉴权

image

前言

阅读本文需要一定的前后端开发基础,前后端分离已成为互联网项目开发的业界标准使用方式,通过Nginx代理+Tomcat的方式有效的进行解耦,并且前后端分离会为以后的大型分布式架构、弹性计算架构、微服务架构、多端化服务(多种客户端,例如:浏览器,小程序,安卓,IOS等等)打下坚实的基础。这个步骤是系统架构从猿进化成人的必经之路。

其核心思想是前端页面通过AJAX调用后端的API接口并使用JSON数据进行交互。

原始模式

开发者通常使用Servlet、Jsp、Velocity、Freemaker、Thymeleaf以及各种框架模板标签的方式实现前端效果展示。通病就是,后端开发者从后端撸到前端,前端只负责切切页面,修修图,更有甚者,一些团队都没有所谓的前端。

分离模式

在传统架构模式中,前后端代码存放于同一个代码库中,甚至是同一工程目录下。页面中还夹杂着后端代码。前后端分离以后,前后端分成了两个不同的代码库,通常使用 Vue、React、Angular、Layui等一系列前端框架实现。

权限校验

回到文章的主题,这里我们使用目前最流行的跨域认证解决方案JSON Web Token(缩写 JWT

pom.xml引入:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

工具类,签发JWT,可以存储简单的用户基础信息,比如用户ID、用户名等等,只要能识别用户信息即可,重要的角色权限不建议存储:

/**
 * JWT加密和解密的工具类
 */
public class JwtUtils {
    /**
     * 加密字符串 禁泄漏
     */
    public static final String SECRET = "e3f4e0ffc5e04432a63730a65f0792b0";
    public static final int JWT_ERROR_CODE_NULL = 4000; // Token不存在
    public static final int JWT_ERROR_CODE_EXPIRE = 4001; // Token过期
    public static final int JWT_ERROR_CODE_FAIL = 4002; // 验证不通过

    /**
     * 签发JWT
     * @param id
     * @param subject
     * @param ttlMillis
     * @return  String
     */
    public static String createJWT(String id, String subject, long ttlMillis) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        SecretKey secretKey = generalKey();
        JwtBuilder builder = Jwts.builder()
                .setId(id)
                .setSubject(subject)   // 主题
                .setIssuer("爪哇笔记")     // 签发者
                .setIssuedAt(now)      // 签发时间
                .signWith(signatureAlgorithm, secretKey); // 签名算法以及密匙
        if (ttlMillis >= 0) {
            long expMillis = nowMillis + ttlMillis;
            Date expDate = new Date(expMillis);
            builder.setExpiration(expDate); // 过期时间
        }
        return builder.compact();
    }
    /**
     * 验证JWT
     * @param jwtStr
     * @return  CheckResult
     */
    public static CheckResult validateJWT(String jwtStr) {
        CheckResult checkResult = new CheckResult();
        Claims claims;
        try {
            claims = parseJWT(jwtStr);
            checkResult.setSuccess(true);
            checkResult.setClaims(claims);
        } catch (ExpiredJwtException e) {
            checkResult.setErrCode(JWT_ERROR_CODE_EXPIRE);
            checkResult.setSuccess(false);
        } catch (SignatureException e) {
            checkResult.setErrCode(JWT_ERROR_CODE_FAIL);
            checkResult.setSuccess(false);
        } catch (Exception e) {
            checkResult.setErrCode(JWT_ERROR_CODE_FAIL);
            checkResult.setSuccess(false);
        }
        return checkResult;
    }

    /**
     * 密钥
     * @return
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.decode(SECRET);
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }
    
    /**
     * 解析JWT字符串
     * @param jwt
     * @return
     * @throws Exception  Claims
     */
    public static Claims parseJWT(String jwt) {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(jwt)
            .getBody();
    }
}

验证实体信息:

/**
 * 验证信息
 */
public class CheckResult {
    private int errCode;

    private boolean success;

    private Claims claims;

    public int getErrCode() {
        return errCode;
    }

    public void setErrCode(int errCode) {
        this.errCode = errCode;
    }

    public boolean isSuccess() {
        return success;
    }

    public void setSuccess(boolean success) {
        this.success = success;
    }

    public Claims getClaims() {
        return claims;
    }

    public void setClaims(Claims claims) {
        this.claims = claims;
    }
}

拦截访问配置,跨域访问设置以及请求拦截过滤:

/**
 * 拦截访问配置
 */
@Configuration
public class SafeConfig implements WebMvcConfigurer {

    @Bean
    public SysInterceptor myInterceptor(){
        return new SysInterceptor();
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE","OPTIONS")
                .allowCredentials(false).maxAge(3600);
    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        String[] patterns = new String[] { "/user/login","/*.html"};
        registry.addInterceptor(myInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns(patterns);
    }
}

拦截器统一权限校验:

/**
 * 认证拦截器
 */
public class SysInterceptor  implements HandlerInterceptor {
    
    private static final Logger logger = LoggerFactory.getLogger(SysInterceptor.class);
    
    @Autowired
    private SysUserService sysUserService;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
            Object handler){
        if (handler instanceof HandlerMethod){
            String authHeader = request.getHeader("token");
            if (StringUtils.isEmpty(authHeader)) {
                  logger.info("验证失败");
                  print(response,Result.error(JwtUtils.JWT_ERROR_CODE_NULL,"签名验证不存在,请重新登录"));
                  return false;
            }else{
                CheckResult checkResult = JwtUtils.validateJWT(authHeader);
                if (checkResult.isSuccess()) {
                    /**
                     * 权限验证
                     */
                    String userId = checkResult.getClaims().getId();
                    HandlerMethod handlerMethod = (HandlerMethod) handler;
                    Annotation roleAnnotation= handlerMethod.getMethod().getAnnotation(RequiresRoles.class);
                    if(roleAnnotation!=null){
                        String[] role = handlerMethod.getMethod().getAnnotation(RequiresRoles.class).value();
                        Logical logical = handlerMethod.getMethod().getAnnotation(RequiresRoles.class).logical();
                        List<String> list = sysUserService.getRoleSignByUserId(Integer.parseInt(userId));
                        int count = 0;
                        for(int i=0;i<role.length;i++){
                            if(list.contains(role[i])){
                                count++;
                                if(logical==Logical.OR){
                                    continue;
                                }
                            }
                        }
                        if(logical==Logical.OR){
                            if(count==0){
                                print(response,Result.error("无权限操作"));
                                return false;
                            }
                        }else{
                            if(count!=role.length){
                                print(response,Result.error("无权限操作"));
                                return false;
                            }
                        }
                    }
                    return true;
                } else {
                    switch (checkResult.getErrCode()) {
                        case SystemConstant.JWT_ERROR_CODE_FAIL:
                            logger.info("签名验证不通过");
                            print(response,Result.error(checkResult.getErrCode(),"签名验证不通过,请重新登录"));
                            break;
                        case SystemConstant.JWT_ERROR_CODE_EXPIRE:
                            logger.info("签名过期");
                            print(response,Result.error(checkResult.getErrCode(),"签名过期,请重新登录"));
                            break;
                        default:
                            break;
                    }
                    return false;
                }
            }
        }else{
            return true;
        }
    }
    /**
     * 打印输出
     * @param response
     * @param message  void
     */
    public void print(HttpServletResponse response,Object message){
        try {
            response.setStatus(HttpStatus.OK.value());
            response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
            response.setHeader("Cache-Control", "no-cache, must-revalidate");
            response.setHeader("Access-Control-Allow-Origin", "*");
            PrintWriter writer = response.getWriter();
            writer.write(JSONObject.toJSONString(message));
            writer.flush();
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
     }
}

配置角色注解,可以直接把安全框架Shiro的拷贝过来,如果有需要,菜单权限也可以配置上:

/**
 * 权限注解
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresRoles {

    /**
     * A single String role name or multiple comma-delimitted role names required in order for the method
     * invocation to be allowed.
     */
    String[] value();

    /**
     * The logical operation for the permission check in case multiple roles are specified. AND is the default
     * @since 1.1.0
     */
    Logical logical() default Logical.OR;
}

模拟演示代码:

@RestController
@RequestMapping("/user")
public class UserController {
    /**
     * 列表
     * @return
     */
    @RequestMapping("/list")
    @RequiresRoles(value="admin")
    public Result list() {
        return Result.ok("十万亿个用户");
    }

    /**
     * 登录
     * @return
     */
    @RequestMapping("/login")
    public Result login() {
        /**
         * 模拟登录过程并返回token
         */
        String token = JwtUtils.createJWT("101","爪哇笔记",1000*60*60);
        return Result.ok(token);
    }
}

前端请求模拟,发送请求之前在Header中附带token信息,更多代码见源码案例:

function login(){
   $.ajax({
        url : "/user/login",
        type : "post",
        dataType : "json",
        success : function(data) {
            if(data.code==0){
               $.cookie('token', data.msg);
            }
        },
        error : function(XMLHttpRequest, textStatus, errorThrown) {

        }
    });
}
function user(){
   $.ajax({
        url : "/user/list",
        type : "post",
        dataType : "json",
        success : function(data) {
            alert(data.msg)
        },
        beforeSend: function(request) {
            request.setRequestHeader("token", $.cookie('token'));
        },
        error : function(XMLHttpRequest, textStatus, errorThrown) {

        }
    });
}
</script>

安全说明

JWT本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT的有效期建议设置的相对短一些。对于一些比较重要的权限,使用时应该再次对用户进行数据库认证。为了减少盗用,JWT强烈建议使用 HTTPS 协议传输。

由于服务器不保存用户状态,因此无法在使用过程中注销某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。

源码案例

https://gitee.com/52itstyle/safe-jwt

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