SpringBoot+SpringSecurity+JWT实现认证和授权

https://blog.csdn.net/u014553029/article/details/112759382

一、背景:

在 B/S 系统中,登录功基本都是依靠 Cookie 来实现的,用户登录成功之后主要需要客户端和服务端完成以下两项工作:

(1)服务端将登录状态记录到 Session 中,或者签发Token;

(2)客户端利用Cookie保存于服务端对应的 Session ID 或 Token。之后每次请求都会带上Cookie信息(包含Session ID或者Token),当服务端收到请求后,通过验证 Cookie 中的信息来判断用户是否登录 。

单点登录: 单点登录(Single Sign On, SSO)是指在同一帐号平台下的多个应用系统中,用户只需登录一次,即可访问所有相互信任的应用系统。单点登录的本质就是在多个应用系统中共享登录状态。 SSO是目前比较流行的企业业务整合的解决方案之一。

如果用户的登录状态是记录在 Session 中的,要实现共享登录状态,就要先共享 Session,比如可以将 Session 序列化到 Redis 中,让多个应用系统共享同一个 Redis,直接读取 Redis 来获取 Session。当然仅此是不够的,因为不同的应用系统有着不同的域名,尽管 Session 共享了,但是由于 Session ID 是往往保存在浏览器 Cookie 中的,因此存在作用域的限制,无法跨域名传递,也就是说当用户在 app1.com 中登录后,Session ID 仅在浏览器访问 app1.com 时才会自动在请求头中携带,而当浏览器访问 app2.com 时,Session ID 是不会被带过去的。实现单点登录的关键在于,如何让 Session ID(或 Token)在多个域中共享。

目前而言,主要有以下3种方式:

(1)父域 Cookie:例如:baike.baidu.com、wenku.baidu.com、zhida.baidu.com可以将认证的cookie放入baidu.com这个父级域名中,从而实现登录信息的共享。此种实现方式比较简单,但不支持跨主域名。

(2)认证中心:我们可以部署一个认证中心,认证中心就是一个专门负责处理登录请求的独立的 Web 服务。用户统一在认证中心进行登录,登录成功后,认证中心记录用户的登录状态,并将 Token 写入 Cookie。(注意这个 Cookie 是认证中心的,应用系统是访问不到的。)

应用系统检查当前请求有没有 Token,如果没有,说明用户在当前系统中尚未登录,那么就将页面跳转至认证中心。由于这个操作会将认证中心的 Cookie 自动带过去,因此,认证中心能够根据 Cookie 知道用户是否已经登录过了。如果认证中心发现用户尚未登录,则返回登录页面,等待用户登录,如果发现用户已经登录过了,就不会让用户再次登录了,而是会跳转回目标 URL ,并在跳转前生成一个 Token,拼接在目标 URL 的后面,回传给目标应用系统。

应用系统拿到 Token 之后,还需要向认证中心确认下 Token 的合法性,防止用户伪造。确认无误后,应用系统记录用户的登录状态,并将 Token 写入 Cookie,然后给本次访问放行。(注意这个 Cookie 是当前应用系统的,其他应用系统是访问不到的。)当用户再次访问当前应用系统时,就会自动带上这个 Token,应用系统验证 Token 发现用户已登录,于是就不会有认证中心什么事了。

目前被广泛使用的方案主要是Apereo CAS 。Apereo CAS 是一个企业级单点登录系统,其中 CAS 的意思是”Central Authentication Service“。它最初是耶鲁大学实验室的项目,后来转让给了 JASIG 组织,项目更名为 JASIG CAS,后来该组织并入了 Apereo 基金会,项目也随之更名为 Apereo CAS。

(3)LocalStorage 跨域:在前后端分离的情况下,完全可以不使用 Cookie,我们可以选择将 Session ID (或 Token )保存到浏览器的 LocalStorage 中,让前端在每次向后端发送请求时,主动将 LocalStorage 的数据传递给服务端。这些都是由前端来控制的,后端需要做的仅仅是在用户登录成功后,将 Session ID (或 Token )放在响应体中传递给前端。在这样的场景下,单点登录完全可以在前端实现。前端拿到 Session ID (或 Token )后,除了将它写入自己的 LocalStorage 中之外,还可以通过特殊手段将它写入多个其他域下的 LocalStorage 中。


二、JWT介绍

JWT概念说明

从分布式认证流程中,我们不难发现,这中间起最关键作用的就是token,token的安全与否,直接关系到系统的健壮性,这里我们选择使用JWT来实现token的生成和校验。JWT,全称JSON Web Token,官网地址https://jwt.io,是一款出色的分布式身份校验方案。可以生成token,也可以解析检验token。

JWT生成的token由三部分组成:

该对象为一个很长的字符串,字符之间通过"."分隔符分为三个子串。 每一个子串表示了一个功能块,总共有以下三个部分:JWT 头、有效载荷和签名。

头部:JWT 头部分是一个描述 JWT 元数据的 JSON 对象,主要设置一些规范信息,签名部分的编码格式就在头部中声明。

载荷:token中存放有效信息的部分,是 JWT 的主体内容部分,是一个 JSON 对象,包含需要传递的数据。比如用户名,用户角色,过期时间等,但是不要放密码,会泄露!

签名:将头部与载荷分别采用base64编码后,用“.”相连,再加入盐,最后使用头部声明的编码类型进行编码,就得到了签名。通过指定的算法生成哈希,以确保数据不会被篡改。 首先,需要指定一个密码(secret)。该密码仅仅为保存在服务器中,并且不能向用户公 开。然后,使用标头中指定的签名算法(默认情况下为 HMAC SHA256)根据以下公式生成签名。 HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(claims), secret) 在计算出签名哈希后,JWT 头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用"."分隔,就构成整个 JWT 对象。

JWT生成token的安全性分析

从JWT生成的token组成上来看,要想避免token被伪造,主要就得看签名部分了,而签名部分又有三部分组成,其中头部和载荷的base64编码,几乎是透明的,毫无安全性可言,那么最终守护token安全的重担就落在了加入的盐上面了!试想:如果生成token所用的盐与解析token时加入的盐是一样的。岂不是类似于中国人民银行把人民币防伪技术公开了?大家可以用这个盐来解析token,就能用来伪造token。这时,我们就需要对盐采用非对称加密的方式进行加密,以达到生成token与校验token方所用的盐不一致的安全效果!

非对称加密RSA介绍

基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端私钥加密,持有私钥或公钥才可以解密公钥加密,持有私钥才可解密 优点:安全,难以破解 缺点:算法比较耗时,为了安全,可以接受 历史:三位数学家Rivest、Shamir 和 Adleman 设计了一种算法,可以实现非对称加密。这种算法用他们三个人的名字缩写:RSA。

三、具体代码实现

集中式认证流程:

用户认证: 使用UsernamePasswordAuthenticationFilter过滤器中attemptAuthentication方法实现认证功能,该过滤器父类中successfulAuthentication方法实现认证成功后的操作。

身份校验: 使用BasicAuthenticationFilter过滤器中doFilterInternal方法验证是否登录,以决定能否进入后续过滤器。

分布式认证流程:

用户认证:由于分布式项目,多数是前后端分离的架构设计,我们要满足可以接受异步post的认证请求参数,需要修改UsernamePasswordAuthenticationFilter过滤器中attemptAuthentication方法,让其能够接收请求体。另外,默认successfulAuthentication方法在认证通过后,是把用户信息直接放入session就完事了,现在我们需要修改这个方法,在认证通过后生成token并返回给用户。

身份校验: 原来BasicAuthenticationFilter过滤器中doFilterInternal方法校验用户是否登录,就是看session中是否有用户信息,我们要修改为,验证用户携带的token是否合法,并解析出用户信息,交给SpringSecurity,以便于后续的授权功能可以正常使用。

3.1引入依赖

<!--spring security 安全框架-->

<dependency>

    <groupId>org.springframework.boot</groupId>

    <artifactId>spring-boot-starter-security</artifactId>

</dependency>

<!--JWT工具依赖-->

<dependency>

    <groupId>io.jsonwebtoken</groupId>

    <artifactId>jjwt</artifactId>

    <version>0.9.0</version>

</dependency>


3.2编写核心配置类

Spring Security 的核心配置就是继承 WebSecurityConfigurerAdapter 并注解@EnableWebSecurity 的配置。这个配置指明了用户名密码的处理方式、请求路径、登录、登出控制等和安全相关的配置。

package com.oyc.security.config;

import com.oyc.security.filter.TokenAuthenticationFilter;

import com.oyc.security.filter.TokenLoginFilter;

import com.oyc.security.handler.UnauthorizedEntryPoint;

import com.oyc.security.util.DefaultPasswordEncoder;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.context.annotation.Configuration;

import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;

import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;

import org.springframework.security.config.annotation.web.builders.HttpSecurity;

import org.springframework.security.config.annotation.web.builders.WebSecurity;

import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

import org.springframework.security.core.userdetails.UserDetailsService;

/**

* @ClassName: TokenWebSecurityConfig

* @Description: TokenWebSecurityConfig

* @Author oyc

* @Date 2021/1/18 10:57

* @Version 1.0

*/

@Configuration

@EnableWebSecurity

@EnableGlobalMethodSecurity(prePostEnabled = true)

public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {

    /**

    * 密码管理工具类

    */

    @Autowired

    private DefaultPasswordEncoder defaultPasswordEncoder;

    /**

    * 用户服务类

    */

    @Autowired

    private UserDetailsService userDetailsService;

    /**

    * 配置设置,设置退出的地址和token

    */

    @Override

    protected void configure(HttpSecurity http) throws Exception {

        http.exceptionHandling()

                //未授权处理

                .authenticationEntryPoint(new UnauthorizedEntryPoint())

                .and().authorizeRequests()

                .anyRequest().authenticated()

                .and().csrf().disable()

                .logout().logoutUrl("/logout")

                .and()

                //.addLogoutHandler(new TokenLogoutHandler(tokenManager))

                .addFilter(new TokenLoginFilter(authenticationManager()))

                .addFilter(new TokenAuthenticationFilter(authenticationManager())).httpBasic();

    }

    /**

    * 密码处理

    */

    @Override

    public void configure(AuthenticationManagerBuilder auth) throws Exception {

        auth.userDetailsService(userDetailsService).passwordEncoder(defaultPasswordEncoder);

    }

    /**

    * 配置哪些请求不拦截

    */

    @Override

    public void configure(WebSecurity web) throws Exception {

        web.ignoring().antMatchers("/index**", "/api/**", "/swagger-ui.html/**");

    }

}


3.3 创建认证授权相关的工具类

(1)DefaultPasswordEncoder:密码处理的方法

package com.oyc.security.util;

import lombok.extern.slf4j.Slf4j;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import org.springframework.stereotype.Component;

/**

* @ClassName: DefaultPasswordEncoder

* @Description: DefaultPasswordEncoder

* @Author oyc

* @Date 2021/1/18 10:58

* @Version 1.0

*/

@Component

@Slf4j

public class DefaultPasswordEncoder extends BCryptPasswordEncoder {

    @Override

    public String encode(CharSequence rawPassword) {

        return rawPassword.toString();

    }

    @Override

    public boolean matches(CharSequence rawPassword, String encodedPassword) {

        if (rawPassword == null) {

            throw new IllegalArgumentException("rawPassword cannot be null");

        }

        if (encodedPassword == null || encodedPassword.length() == 0) {

            log.error("Empty encoded password");

            throw new IllegalArgumentException("encodedPassword is null");

        }

        return encodedPassword.equals(rawPassword);

    }

}

(2)JwtTokenUtil:token 操作的工具类

package com.oyc.security.util;

import io.jsonwebtoken.Claims;

import io.jsonwebtoken.CompressionCodecs;

import io.jsonwebtoken.Jwts;

import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;

/**

* @ClassName: TokenManager

* @Description: TokenManager

* @Author oyc

* @Date 2021/1/18 10:58

* @Version 1.0

*/

public class JwtTokenUtil {

    private static long tokenExpiration = 24 * 60 * 60 * 1000;

    private static String tokenSignKey = "123456";

    private static String userRoleKey = "userRole";

    public String createToken(String userName) {

        String token = Jwts.builder().setSubject(userName)

                .setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))

                .signWith(SignatureAlgorithm.HS512, tokenSignKey).compressWith(CompressionCodecs.GZIP).compact();

        return token;

    }

    public static String createToken(String userName, String role) {

        String token = Jwts.builder().setSubject(userName)

                .claim(userRoleKey, role)

                .setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))

                .signWith(SignatureAlgorithm.HS512, tokenSignKey).compressWith(CompressionCodecs.GZIP).compact();

        return token;

    }

    public static String getUserNameFromToken(String token) {

        String userName = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody().getSubject();

        return userName;

    }

    public static String getUserRoleFromToken(String token) {

        Claims claims = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody();

        return claims.get(userRoleKey).toString();

    }

}

(3)ResponseUtil :接口响应工具类

package com.oyc.security.util;

import com.fasterxml.jackson.databind.ObjectMapper;

import org.springframework.http.HttpStatus;

import org.springframework.http.MediaType;

import javax.servlet.http.HttpServletResponse;

import java.io.IOException;

import java.io.PrintWriter;

/**

* @ClassName: ResponseUtil

* @Description: ResponseUtil

* @Author oyc

* @Date 2020/12/29 20:14

* @Version 1.0

*/

public class ResponseUtil {

    public static void out(HttpServletResponse response, Result result) {

        ObjectMapper mapper = new ObjectMapper();

        PrintWriter writer = null;

        response.setStatus(HttpStatus.OK.value());

        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);

        try {

            writer = response.getWriter();

            mapper.writeValue(writer, result);

            writer.flush();

        } catch (IOException e) {

            e.printStackTrace();

        } finally {

            if (writer != null) {

                writer.flush();

                writer.close();

            }

        }

    }

}


3.4创建认证和授权的 filter

TokenAuthenticationFilter:授权 filter

package com.oyc.security.filter;

import com.oyc.security.util.JwtTokenUtil;

import com.oyc.security.util.ResponseUtil;

import com.oyc.security.util.Result;

import org.springframework.security.authentication.AuthenticationManager;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;

import org.springframework.security.core.authority.SimpleGrantedAuthority;

import org.springframework.security.core.context.SecurityContextHolder;

import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import org.springframework.util.StringUtils;

import javax.servlet.FilterChain;

import javax.servlet.ServletException;

import javax.servlet.http.HttpServletRequest;

import javax.servlet.http.HttpServletResponse;

import java.io.IOException;

import java.util.ArrayList;

import java.util.Collection;

/**

* @ClassName: TokenAuthenticationFilter

* @Description: TokenAuthenticationFilter

* @Author oyc

* @Date 2021/1/18 10:59

* @Version 1.0

*/

public class TokenAuthenticationFilter extends BasicAuthenticationFilter {

    public TokenAuthenticationFilter(AuthenticationManager authManager) {

        super(authManager);

    }

    @Override

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        logger.info("=================" + request.getRequestURI());

        //不需要鉴权

        if (request.getRequestURI().indexOf("index") != -1) {

            chain.doFilter(request, response);

        }

        UsernamePasswordAuthenticationToken authentication = null;

        try {

            authentication = getAuthentication(request);

        } catch (Exception e) {

            ResponseUtil.out(response, Result.error(e.getMessage()));

        }

        if (authentication != null) {

            SecurityContextHolder.getContext().setAuthentication(authentication);

        } else {

            ResponseUtil.out(response, Result.error("鉴权失败"));

        }

        chain.doFilter(request, response);

    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {

        // 获取Token字符串,token 置于 header 里

        String token = request.getHeader("token");

        if (!StringUtils.hasText(token)) {

            token = request.getParameter("token");

        }

        if (token != null && !"".equals(token.trim())) {

            // 从Token中解密获取用户名

            String userName = JwtTokenUtil.getUserNameFromToken(token);

            if (userName != null) {

                // 从Token中解密获取用户角色

                String role = JwtTokenUtil.getUserRoleFromToken(token);

                // 将ROLE_XXX,ROLE_YYY格式的角色字符串转换为数组

                String[] roles = role.split(",");

                Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();

                for (String s : roles) {

                    authorities.add(new SimpleGrantedAuthority(s));

                }

                return new UsernamePasswordAuthenticationToken(userName, token, authorities);

            }

            return null;

        }

        return null;

    }

}


3.5 UnauthorizedEntryPoint:未授权统一处理handler

public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {

    @Override

    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        ResponseUtil.out(response, Result.error("未授权统一处理"));

    }

}


3.6 测试控制类

package com.oyc.security.controller;

import org.springframework.security.access.annotation.Secured;

import org.springframework.security.access.prepost.PostAuthorize;

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.RestController;

/**

* @ClassName: TestController

* @Description: TestController

* @Author oyc

* @Date 2021/1/18 11:06

* @Version 1.0

*/

@RestController

public class TestController {

    @GetMapping(value = {"", "welcome"})

    public String welcome() {

        return "Welcome!!!";

    }

    @GetMapping("index")

    public String index() {

        return "index!!!";

    }

    @GetMapping("admin")

    public String admin() {

        return "admin!!!";

    }

    @GetMapping("user")

    public String user() {

        return "user!!!";

    }

    @GetMapping("customer")

    public String customer() {

        return "customer!!!";

    }

    /**

    * 方法执行前鉴权

    *

    * @return

    */

    @GetMapping("roleAdmin")

    @Secured("ROLE_ADMIN")

    public String roleAdmin() {

        return "roleAdmin!!!";

    }

    /**

    * 方法执行前鉴权

    *

    * @return

    */

    @GetMapping("preAuthorize")

    @PostAuthorize("hasAnyRole('ROLE_ADMIN')")

    public String preAuthorize() {

        System.out.println("preAuthorize…………");

        return "preAuthorize!!!";

    }

    /**

    * 方法执行完再鉴权

    *

    * @return

    */

    @GetMapping("postAuthorize")

    @PostAuthorize("hasAnyRole('ROLE_USER')")

    public String postAuthorize() {

        System.out.println("postAuthorize…………");

        return "PostAuthorize!!!";

    }

}

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

推荐阅读更多精彩内容