springboot整合springsecurity从Hello World到源码解析(五):springsecurity+jwt整合restful服务

cover


上一章我们详细介绍了springsecurity的基础结构,并且在第三章我们已经知道了springsecurity中的
基础配置,但是那是基于all in one模式的,前端页面和后端集成在一起,然而事实上现在越来越多的开发模式是前后端分离,后端提供的restful接口,那我们本章就来学习下
springsecurity+jwt整合restful服务。

springboot整合springsecurity系列文章:
springboot整合springsecurity从Hello World到源码解析(一):hello world程序入门
springboot整合springsecurity从Hello World到源码解析(二):springsecurity配置加载解析
springboot整合springsecurity从Hello World到源码解析(三):基础配置详解
springboot整合springsecurity从Hello World到源码解析(四):springsecurity基础架构解析
springboot整合springsecurity从Hello World到源码解析(五):springsecurity+jwt整合restful服务

jwt

首先我们先了解下什么是jwt,jwt全称是json web tokens,它是基于RFC 7519开放标准用于双方安全展示信息的一种方式。通俗说就是是用于服务端和客户端相互交换信息的一种凭证。
这个凭证我们就叫token,在传统的认证模式中,我们普遍的做法是这样的:
用户登录-》服务端生成session-》写入sessionId到cookie-》浏览器携带该session访问服务端-》服务器基于该sessionId查找信息-》认证通过(找到了)
这种做法存在如下问题:

  1. 服务端需一定资源保存session信息,用户多时资源消耗较大
  2. 扩展性不好,当我们的服务端需要集群时,因session保存在服务端,此时无法定位session,造成登录失效(传统解决办法:iphash,session写入redis等)
  3. 跨域问题,当我们访问A网站时,此时不想再登录就能够访问关联网站B。(传统解决办法:写入持久层,A,B同时访问)
    虽然上面面对的问题我们普遍都有解决办法,但是显然都不怎么 ”友好“,所以这个时候就有一种干脆的解决办法了,服务端不再保存session,这样就轻松解决了上面所有问题。
    服务端只颁发token,那么现在的流程变成了这样:
    用户登录-》服务端颁发token-》客户端保存token(放入cooken或者header)-》携带token访问服务端-》服务端验证token(通过)-》调用api-》获取信息
    那么既然是服务端颁发的token,那肯定要保证该token的安全(只有该服务端颁发的token才认),唯一(不能伪造),而jwt则是用来生成这个安全的token的。 jwt的组成如下:
  • header(头),保存算法,类型
  • payload(负载),用户的信息,如id,用户名等等
  • signature(签名),将生成的token编码(加密)
    他们之间用 "."号隔开,例如:xxxxx.yyyyy.zzzzz


    token

整合rest服务

我们知道,在restful服务中,服务端不再直接生成页面了,而是只返回数据(json),客户端渲染,而我们前面的例子已经知道了springsecurity默认也是直接生成整个页面的,所以这里的关键
就成了我们需要自己定义返回数据形式了,主要是错误处理,那我们接下来就开始实践。

实践

pom

新建一个springboot项目,pom文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-security-demos</artifactId>
        <groupId>cn.jsbintask</groupId>
        <version>1.0.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>security-jwt-restful</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

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

    </dependencies>
</project>

数据源

定义数据源,application.yml文件以及sql脚本如下:

server:
  port: 8080

spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    username: root
    password: jason
    url:  jdbc:mysql://localhost:3306/springsecurity_demos?useSSL=false

  jpa:
    show-sql: true
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true

logging:
  level:
    org.springframework.*: debug

sql脚本如下,本例我们了简单,密码使用明文:

CREATE DATABASE springsecurity_demos;
USE springsecurity_demos;


SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role`  (
                       `id` int(11) NOT NULL AUTO_INCREMENT,
                       `role_name` varchar(16) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
                       `description` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
                       PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of role
-- ----------------------------
INSERT INTO `role` VALUES (1, 'user', 'this is a user role.');
INSERT INTO `role` VALUES (2, 'admin', 'this is a admin role.');

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
                       `id` int(11) NOT NULL AUTO_INCREMENT,
                       `username` varchar(16) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
                       `password` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
                       `age` int(11) NULL DEFAULT NULL,
                       `address` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
                       `role_id` int(11) NOT NULL,
                       PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, 'jsbintask', '123456', 22, 'China, Wuhan', 1);

SET FOREIGN_KEY_CHECKS = 1;

接着定义实体类domain,repository等,参考之前的博客,值得注意的是,此处我们的AuthUser稍有不同:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AuthUser implements UserDetails {
    private String username;
    private String password;
    private List<Role> roles;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.isEmpty() ? Collections.EMPTY_LIST :
                // ROLE_ 是springsecurity对于角色的默认前缀,如果不加,验证会失败
                (roles.parallelStream().map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRoleName())).collect(Collectors.toList()));
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

配置securityConfig

我们和前面章节一样,定义一个SecurityConfig类,继承WebSecurityConfigurerAdapter

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

接下来就是开始详细配置了,前面我们学习基础配置的时候已经知道,配置的重点是自定义
UserDetailsService,并且我们通过源码的方式知道了是UsernamePasswordAuthenticationFilter帮我们认证了用户,并且生成了页面,转发等等。
但是!在本例中,因为我们是整合restful服务,返回的都是json数据,所以我们不再需要这些。而既然要返回json数据,那我们先定义好一个通用的数据类,ResultVO:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResultVO<T> {
    // code请自己定义,例如  10成功,20需要登陆  30无权限等等
    private Integer code;
    private String msg;
    private T data;
}

而既然我们的服务端是生成jwt,那现在登录的接口就成了生成jwt了,我们首先编写JwtUtil工具类:

public class JwtUtil {
    private static final String secret = "jsbintask@gmail.com";

    public static String generateToken(String username, List<Role> roles) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", roles.parallelStream().map(Role::getRoleName).collect(Collectors.joining(",")));

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(username)
                //创建时间
                .setIssuedAt(new Date())
                //过期时间,我们设置为 五分钟
                .setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))
                //签名,通过密钥保证安全性
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public static AuthUser parseToken(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
        String username = claims.getSubject();
        String roles = (String) claims.get("roles");

        //因为生成的时候没有放入密码,所以不需要密码
        return new AuthUser(username, null, Arrays.stream(roles.split(",")).map(name -> {
            Role role = new Role();
            role.setRoleName(name);
            return role;
        }).collect(Collectors.toList()));
    }
}

编写生成token的controller,UserController:

@RestController
public class UserController {
    @Resource
    private UserRepository userRepository;
    @Resource
    private RoleRepository roleRepository;

    @GetMapping("/token")
    public ResultVO login(String username, String password) {
        User user = userRepository.findByUsername(username);

        if (user == null || !user.getPassword().equals(password)) {
            ResultVO<Object> result = new ResultVO<>();
            result.setCode(10);
            result.setMsg("用户名或密码错误");
            return result;
        }

        ResultVO<Object> success = new ResultVO<>();
        //用户名密码正确,生成token给客户端
        success.setCode(0);
        List<Role> roles = Collections.singletonList(roleRepository.findById(user.getId()).get());
        success.setData(JwtUtil.generateToken(username, roles));

        return success;
    }
}

我们在定义一个如果异常处理,用于处理请求没有携带token以及一个禁止访问处理器,用于返回没有权限的用户并且全部作为bean
TokenExceptionHandler:

@Component
public class TokenExceptionHandler implements AuthenticationEntryPoint {
    
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // 直接返回 json错误
        ResultVO<Object> result = new ResultVO<>();
        //20,标识没有token
        result.setCode(20);
        result.setMsg("请求无效,没有有效token");

        ObjectMapper objectMapper = new ObjectMapper();
        
        response.getWriter().write(objectMapper.writeValueAsString(result));
    }
}

AccessDeniedHandler:

@Component
public class AccessDeniedHandler implements org.springframework.security.web.access.AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 返回我们的自定义json
        ObjectMapper objectMapper = new ObjectMapper();
        ResultVO<Object> result = new ResultVO<>();
        //50,标识有token,但是该用户没有权限
        result.setCode(50);
        result.setMsg("请求无效,没有有效token");
        response.getWriter().write(objectMapper.writeValueAsString(result));
    }
}

除此之外,前面我们已经通过debug(第四章)得知,在UsernamePasswordAuthenticationFilter中,springsecurity加入了SecurityContext,既然我们现在不用了,那我们要自己定义拦截器并且加入
securityContext以便springsecurity作权限处理,所以我们自定义拦截器:

@Component
public class JwtTokenFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("token");

        //获取token,并且解析token,如果解析成功,则放入 SecurityContext
        if (token != null) {
            try {
                AuthUser authUser = JwtUtil.parseToken(token);
                //todo: 如果此处不放心解析出来的 authuser,可以再从数据库查一次,验证用户身份:

                //解析成功
                if (SecurityContextHolder.getContext().getAuthentication() == null) {
                    //我们依然使用原来filter中的token对象
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());

                    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
                }
            } catch (Exception e) {
                logger.info("解析失败,可能是伪造的或者该token已经失效了(我们设置失效5分钟)。");
            }
        }

        filterChain.doFilter(request, response);
    }
}

前面已经说了,我们不再需要UserDetailsService和UsernamePasswordAuthenticationFilter,当然密码加密器也不再需要,那现在我们的websecurity如下:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Resource
    private TokenExceptionHandler tokenExceptionHandler;
    @Resource
    private AccessDeniedHandler accessDeniedHandler;
    @Resource
    private JwtTokenFilter jwtTokenFilter;

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // 因为我们的token是无状态的,不需要跨站保护
                .csrf().disable()
                // 添加异常处理,以及访问禁止(无权限)处理
                .exceptionHandling().authenticationEntryPoint(tokenExceptionHandler).accessDeniedHandler(accessDeniedHandler).and()

                // 我们不再需要session了
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()

                //定义拦截页面,所有api全部需要认证
                .authorizeRequests()

                .anyRequest().authenticated();

        //最后,我们定义 filter,用来替换原来的UsernamePasswordAuthenticationFilter
        httpSecurity.addFilterAt(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring()
                // 让我们获取 token的api不走springsecurity的过滤器,大道开放
                .antMatchers(HttpMethod.GET, "/token");
    }
}

值得注意的是,我们把 /token(登录)这个api完全开放了,因为它根本不需要走过滤器链,接下来我们再写一个Controller用于测试,叫PermissionController:

@RestController
@RequestMapping
public class PermissionController {

    @GetMapping("/permission")
    public ResultVO loginTest(@AuthenticationPrincipal AuthUser authUser) {
        ResultVO<String> resultVO = new ResultVO<>();
        resultVO.setCode(0);

        resultVO.setData("你成功访问了该api,这代表你已经登录,你是: " + authUser);
        return resultVO;
    }

    @GetMapping("/permission")
    @PreAuthorize("hasRole('user')")
    public ResultVO loginTest() {
        ResultVO<String> resultVO = new ResultVO<>();
        resultVO.setCode(0);

        resultVO.setData("你成功访问了需要有 user 角色的api。");
        return resultVO;
    }
}

该controller一共两个api,一个为登录可访问,一个为登陆后还需要有 user角色的api。

测试

接下来就是见证奇迹的时候了,首先我们直接访问 /8080,我们猜想应该是错误code=20,因为所有api都需要token,结果如下:

/8080

果然和我们想的一样(乱码原因是我们手动new的jackson对象,没有设置编码),同时我们debug JwtTokenFilter,果然在过滤器链中找到了我们的filter,并且替换了原来的UsernamePasswordAuthenticationFilter
不见了:
/filter

接下来我们登录,访问 http://localhost:8080/token?username=jsbintask&password=12345,用户名
密码根据自己数据库调整,结果如下:用户名密码错误:
/token?

然后我们输入正确的用户名密码,如下:
/token?username=jsbintask&password=123456

果然,我们得到了code=0,代表成功了,并且拿到了token,接下来我们用该token去访问我们的PermissionController:
访问不需要身份的 /normal:
/normal

成功!并且看到了我们的信息。
接下来访问 /role,需要user身份,我们创建一个没有该身份的用户:

INSERT INTO `springsecurity_demos`.`user`(`id`, `address`, `age`, `password`, `role_id`, `username`) VALUES (2, 'Wuhan, China', 22, '123456', 22, 'jsbintask2');

同样登陆后,访问:


/denied

果然,我们得到了我们自定义的json数据 coe=50,接下来我们切换有该身份的用户:


/role

果然成功了!,这样,我们的目的就全部达到了。 restful服务也完全整合好了

总结

本章,我们首先介绍了jwt是什么,以及和传统session的不同,然后我们通过一个完整的例子展示了前面章节所学以及在实际中该如何保护我们的 api。并且成功的达到了我们的目的。
本章源码:https://github.com/jsbintask22/spring-security-demos.git
到此,我们的spingsecurity系列的文章就写完了,同系列文章地址:https://jsbintask.cn/tags/springsecurity/
本文原创地址:https://jsbintask.cn/2019/01/21/springsecurity-restful/#more,未经允许,禁止转载。

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

推荐阅读更多精彩内容