用脚步丈量人生宽度

Spring Security 入门:用一个小项目串起登录、JWT 和权限控制

前一阵我把 Spring Security 的登录、角色、权限这条链路重新捋了一遍,写了一个叫 springsecurity-hello 的小项目。它不复杂:五张表、一个登录接口、一个 JWT 过滤器,再加几个用 @PreAuthorize 标注的测试接口。把匿名访问、登录、带 token 访问、权限不足这四种情况都跑通之后,过滤器链、UserDetails、AuthenticationManager 这些概念才算是连起来了。

这篇把项目的代码和验证过程完整贴出来,目标是从空目录开始,跑出一个同样能登录、能鉴权、能注销的项目。数据库用 MariaDB,token 的会话信息放在 Redis,整体结构和我实际在用的项目一致。

项目跑起来是什么样

先把最终要验证的接口列出来,后面每写一块代码,都是在为这张表服务。

接口要求预期结果
GET /test匿名可访问200
POST /login用户名、密码200,返回 token
GET /test1登录即可没 token 返回 401
GET /test2admin 角色普通用户返回 403
GET /test3admin 或 CEO 角色CEO 可以访问
GET /test4CTO 和 CEO 两个角色只有 CEO 时返回 403
GET /test5user:add 权限没有权限返回 403
GET /test6user:add 或 user:del 权限有任意一个即可
GET /test7user:add 和 user:del 两个权限两个都有才放行
POST /logout带上 token删除 Redis 里的会话

种子数据里放两个用户:13800000000 同时有 admin、CEO、CTO 三个角色和两个权限;13900000000 只有 CEO 角色,没有任何权限。这样同一组接口就能同时看到 200 和 403。

目录结构是这样:

springsecurity-hello
├── pom.xml
├── docker-compose.yml
├── init.sql
└── src/main
    ├── java/com/youth/springsecurityhello
    │   ├── SpringsecurityHelloApplication.java
    │   ├── beans/Result.java
    │   ├── controller/LoginController.java
    │   ├── controller/HelloController.java
    │   ├── entity/User.java
    │   ├── entity/Role.java
    │   ├── entity/Permission.java
    │   ├── entity/UserRole.java
    │   ├── entity/RolePermission.java
    │   ├── mapper/...
    │   ├── security/...
    │   ├── utils/JwtUtils.java
    │   └── utils/RedisClient.java
    └── resources
        ├── application.yml
        └── xml/*.xml

依赖

项目SpringBoot依赖版本用的是 Spring Boot 2.7.6。pom.xml 里主要是 Web、Security、MyBatis、MariaDB 驱动、Redis、JWT 和 Hutool:

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.6</version>
        <relativePath/>
    </parent>

    <groupId>com.youth</groupId>
    <artifactId>springsecurity-hello</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>springsecurity-hello</name>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <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.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>org.mariadb.jdbc</groupId>
            <artifactId>mariadb-java-client</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>4.4.0</version>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.32</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

数据库和 Redis

我本地是用 Docker Compose 起的 MariaDB 和 Redis,init.sql 挂到 MariaDB 的初始化目录,容器第一次启动就会把表和数据建好:

services:
  mariadb:
    image: mariadb:10.11
    environment:
      MARIADB_ROOT_PASSWORD: root
      MARIADB_DATABASE: security_demo
      MARIADB_USER: security_demo
      MARIADB_PASSWORD: security_demo
    ports:
      - "3306:3306"
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro

  redis:
    image: redis:7
    ports:
      - "6379:6379"

我在 docker-compose.yml 和 init.sql 的同级目录执行 docker compose up -d,两个容器分别监听 3306 和 6379;端口被占用时,改 ports 和 application.yml 里的地址。之前起过同名的容器,init.sql 不会重复执行,想重置就 docker compose down -v 再起。

init.sql 里是 RBAC 的五张表:用户、角色、权限,以及两张关联表。

CREATE DATABASE IF NOT EXISTS security_demo
  DEFAULT CHARACTER SET utf8mb4
  COLLATE utf8mb4_general_ci;
USE security_demo;

DROP TABLE IF EXISTS `user_role`;
DROP TABLE IF EXISTS `role_permission`;
DROP TABLE IF EXISTS `permission`;
DROP TABLE IF EXISTS `role`;
DROP TABLE IF EXISTS `user`;

CREATE TABLE `user` (
  `user_id` BIGINT NOT NULL AUTO_INCREMENT,
  `phone` VARCHAR(20) NOT NULL,
  `password` VARCHAR(100) NOT NULL,
  `username` VARCHAR(50) NOT NULL,
  PRIMARY KEY (`user_id`),
  UNIQUE KEY `uk_user_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `role` (
  `role_id` BIGINT NOT NULL AUTO_INCREMENT,
  `role_name` VARCHAR(50) NOT NULL,
  PRIMARY KEY (`role_id`),
  UNIQUE KEY `uk_role_name` (`role_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `permission` (
  `permission_id` BIGINT NOT NULL AUTO_INCREMENT,
  `permission_name` VARCHAR(50) NOT NULL,
  PRIMARY KEY (`permission_id`),
  UNIQUE KEY `uk_permission_name` (`permission_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `user_role` (
  `user_id` BIGINT NOT NULL,
  `role_id` BIGINT NOT NULL,
  PRIMARY KEY (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `role_permission` (
  `role_id` BIGINT NOT NULL,
  `permission_id` BIGINT NOT NULL,
  PRIMARY KEY (`role_id`, `permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `user` (`user_id`, `phone`, `password`, `username`) VALUES
(1, '13800000000', '$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG', '小北'),
(2, '13900000000', '$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG', '小南');

INSERT INTO `role` (`role_id`, `role_name`) VALUES
(1, 'admin'), (2, 'CEO'), (3, 'CTO');

INSERT INTO `permission` (`permission_id`, `permission_name`) VALUES
(1, 'user:add'), (2, 'user:del');

INSERT INTO `user_role` (`user_id`, `role_id`) VALUES
(1, 1), (1, 2), (1, 3),
(2, 2);

INSERT INTO `role_permission` (`role_id`, `permission_id`) VALUES
(1, 1), (1, 2);

种子数据里的密码统一是 password,哈希来自 Spring Security 文档里的 BCrypt 示例。想换成别的密码时,用 BCryptPasswordEncoder.encode() 生成一个 60 位的哈希替换进去。

application.yml 里只放本地容器的值,真实的数据库和 Redis 账号走环境变量:

server:
  port: 8080

spring:
  datasource:
    url: jdbc:mariadb://localhost:3306/security_demo?characterEncoding=utf-8
    username: security_demo
    password: security_demo
    driver-class-name: org.mariadb.jdbc.Driver
  redis:
    host: localhost
    port: 6379
    database: 0

mybatis:
  mapper-locations: classpath:xml/*.xml
  type-aliases-package: com.youth.springsecurityhello.entity
  configuration:
    map-underscore-to-camel-case: true

jwt:
  secret: ${JWT_SECRET:dev-only-change-me-32-bytes-minimum}

spring.redis.* 是 Boot 2.7 的写法,换到 Boot 3 会变成 spring.data.redis.*。jwt.secret 里的默认值只用于本地跑通,部署时用 JWT_SECRET 覆盖。

实体和 Mapper

五个实体都是 Lombok 的普通 POJO,字段名和表列通过 map-underscore-to-camel-case 对上:

// ==== User.java ====
package com.youth.springsecurityhello.entity;

import lombok.Data;

@Data
public class User {
    private long userId;
    private String phone;
    private String password;
    private String username;
}

// ==== Role.java ====
package com.youth.springsecurityhello.entity;

import lombok.Data;

@Data
public class Role {
    private long roleId;
    private String roleName;
}

// ==== Permission.java ====
package com.youth.springsecurityhello.entity;

import lombok.Data;

@Data
public class Permission {
    private long permissionId;
    private String permissionName;
}

// ==== UserRole.java ====
package com.youth.springsecurityhello.entity;

import lombok.Data;

@Data
public class UserRole {
    private long userId;
    private long roleId;
}

// ==== RolePermission.java ====
package com.youth.springsecurityhello.entity;

import lombok.Data;

@Data
public class RolePermission {
    private long roleId;
    private long permissionId;
}

Mapper 接口只做最简单的查询:

// ==== UserMapper.java ====
package com.youth.springsecurityhello.mapper;

import com.youth.springsecurityhello.entity.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper {
    User getUserByPhone(String phone);
}

// ==== UserRoleMapper.java ====
package com.youth.springsecurityhello.mapper;

import com.youth.springsecurityhello.entity.UserRole;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface UserRoleMapper {
    List<UserRole> selectByUserId(Long userId);
}

// ==== RoleMapper.java ====
package com.youth.springsecurityhello.mapper;

import com.youth.springsecurityhello.entity.Role;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface RoleMapper {
    List<Role> selectByIds(List<Long> ids);
}

// ==== RolePermissionMapper.java ====
package com.youth.springsecurityhello.mapper;

import com.youth.springsecurityhello.entity.RolePermission;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface RolePermissionMapper {
    List<RolePermission> selectByRoleId(List<Long> roleIds);
}

// ==== PermissionMapper.java ====
package com.youth.springsecurityhello.mapper;

import com.youth.springsecurityhello.entity.Permission;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface PermissionMapper {
    List<Permission> selectByIds(List<Long> ids);
}

XML 放在 src/main/resources/xml 下,resultType 用的是 type-aliases-package 注册的简单别名:

<!-- ==== UserMapper.xml ==== -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youth.springsecurityhello.mapper.UserMapper">
    <select id="getUserByPhone" resultType="User">
        select * from `user` where phone = #{phone}
    </select>
</mapper>

<!-- ==== UserRoleMapper.xml ==== -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youth.springsecurityhello.mapper.UserRoleMapper">
    <select id="selectByUserId" resultType="UserRole">
        select * from user_role where user_id = #{userId}
    </select>
</mapper>

<!-- ==== RoleMapper.xml ==== -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youth.springsecurityhello.mapper.RoleMapper">
    <select id="selectByIds" resultType="Role">
        select * from `role` where role_id in
        <foreach collection="list" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </select>
</mapper>

<!-- ==== RolePermissionMapper.xml ==== -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youth.springsecurityhello.mapper.RolePermissionMapper">
    <select id="selectByRoleId" resultType="RolePermission">
        select * from role_permission where role_id in
        <foreach collection="list" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </select>
</mapper>

<!-- ==== PermissionMapper.xml ==== -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youth.springsecurityhello.mapper.PermissionMapper">
    <select id="selectByIds" resultType="Permission">
        select * from `permission` where permission_id in
        <foreach collection="list" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </select>
</mapper>

这里登录名用的是手机号,不是 username 字段。原项目就是这么设计的,所以 UserDetailsService 拿到的是表单里的 username,实际去查 phone。

把数据库用户交给 Spring Security

Spring Security 只认 UserDetailsService。我把它实现成从五张表里把用户、角色、权限查出来,包成一个 LoginUserDetails:

package com.youth.springsecurityhello.security;

import com.youth.springsecurityhello.entity.User;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

@Data
@NoArgsConstructor
public class LoginUserDetails implements UserDetails {

    private User user;
    private List<String> roleNames;
    private List<String> permissionNames;

    public LoginUserDetails(User user, List<String> roleNames, List<String> permissionNames) {
        this.user = user;
        this.roleNames = roleNames;
        this.permissionNames = permissionNames;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        if (!CollectionUtils.isEmpty(roleNames)) {
            for (String roleName : roleNames) {
                authorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
            }
        }
        if (!CollectionUtils.isEmpty(permissionNames)) {
            for (String permissionName : permissionNames) {
                authorities.add(new SimpleGrantedAuthority(permissionName));
            }
        }
        return authorities;
    }

    @Override
    public String getPassword() {
        return user == null ? "" : user.getPassword();
    }

    @Override
    public String getUsername() {
        return user == null ? "" : user.getPhone();
    }

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

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

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

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

这里有两个约定:角色名统一加 ROLE_ 前缀,权限名原样保留。所以数据库里角色是 admin,@PreAuthorize("hasRole('admin')") 才能对上;权限写 user:add,就用 hasAuthority('user:add')。

UserDetailsServiceImpl 负责把五张表串起来:

package com.youth.springsecurityhello.security;

import com.youth.springsecurityhello.entity.Permission;
import com.youth.springsecurityhello.entity.Role;
import com.youth.springsecurityhello.entity.RolePermission;
import com.youth.springsecurityhello.entity.User;
import com.youth.springsecurityhello.entity.UserRole;
import com.youth.springsecurityhello.mapper.PermissionMapper;
import com.youth.springsecurityhello.mapper.RoleMapper;
import com.youth.springsecurityhello.mapper.RolePermissionMapper;
import com.youth.springsecurityhello.mapper.UserMapper;
import com.youth.springsecurityhello.mapper.UserRoleMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Service
@Transactional(readOnly = true)
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private UserMapper userMapper;

    @Resource
    private UserRoleMapper userRoleMapper;

    @Resource
    private RoleMapper roleMapper;

    @Resource
    private RolePermissionMapper rolePermissionMapper;

    @Resource
    private PermissionMapper permissionMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.getUserByPhone(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        List<String> roleNames = new ArrayList<>();
        List<String> permissionNames = new ArrayList<>();

        List<UserRole> userRoles = userRoleMapper.selectByUserId(user.getUserId());
        if (!CollectionUtils.isEmpty(userRoles)) {
            List<Long> roleIds = userRoles.stream()
                    .map(UserRole::getRoleId)
                    .distinct()
                    .collect(Collectors.toList());

            if (!roleIds.isEmpty()) {
                List<Role> roles = roleMapper.selectByIds(roleIds);
                if (!CollectionUtils.isEmpty(roles)) {
                    roleNames.addAll(roles.stream()
                            .map(Role::getRoleName)
                            .collect(Collectors.toList()));
                }

                List<RolePermission> rolePermissions = rolePermissionMapper.selectByRoleId(roleIds);
                if (!CollectionUtils.isEmpty(rolePermissions)) {
                    List<Long> permissionIds = rolePermissions.stream()
                            .map(RolePermission::getPermissionId)
                            .distinct()
                            .collect(Collectors.toList());
                    List<Permission> permissions = permissionMapper.selectByIds(permissionIds);
                    if (!CollectionUtils.isEmpty(permissions)) {
                        permissionNames.addAll(permissions.stream()
                                .map(Permission::getPermissionName)
                                .collect(Collectors.toList()));
                    }
                }
            }
        }

        return new LoginUserDetails(user, roleNames, permissionNames);
    }
}

密码校验不用自己写。AuthenticationManager 会拿 UserDetailsService 返回的密码哈希和登录密码做 BCrypt 比对,前提是容器里有一个 PasswordEncoder。

过滤器链

SecurityConfig 把匿名、登录、鉴权、异常处理和注销串到一起:

package com.youth.springsecurityhello.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.annotation.Resource;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Resource
    private LoginUnAuthenticationEntryPointHandler authenticationEntryPoint;

    @Resource
    private LoginUnAccessDeniedHandler accessDeniedHandler;

    @Resource
    private LogoutStatusSucessHandler logoutSuccessHandler;

    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/login", "/test").permitAll()
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)
                .and()
                .logout()
                .logoutSuccessHandler(logoutSuccessHandler);

        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }
}

几个点:

  • SessionCreationPolicy.STATELESS 表示不创建 Session,身份完全靠每次请求带的 token。
  • csrf().disable() 是前后端分离 + token 放请求头时的常见配置;如果还用 Cookie 传身份,CSRF 不能随手关。
  • addFilterBefore 把自定义 JWT 过滤器放在用户名密码过滤器之前,这样请求进到授权判断时,SecurityContext 里已经有用户了。
  • @EnableMethodSecurity 是方法级权限的开关,没有它 @PreAuthorize 不生效。

原项目里 401 和 403 的处理器只写了 JSON,HTTP 状态还是 200。我在这里把状态码补上了,前端按状态码判断更直接:

package com.youth.springsecurityhello.security;

import cn.hutool.json.JSONUtil;
import com.youth.springsecurityhello.beans.Result;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

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

@Component
public class LoginUnAuthenticationEntryPointHandler implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONUtil.toJsonStr(
                Result.error("用户未认证或登录已过期,请重新登录后再访问")));
    }
}
package com.youth.springsecurityhello.security;

import cn.hutool.json.JSONUtil;
import com.youth.springsecurityhello.beans.Result;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

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

@Component
public class LoginUnAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONUtil.toJsonStr(Result.error("访问无权限")));
    }
}

登录和 token

登录接口做三件事:调 AuthenticationManager 认证、生成 token、把用户信息写进 Redis。

package com.youth.springsecurityhello.controller;

import cn.hutool.json.JSONUtil;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.youth.springsecurityhello.beans.Result;
import com.youth.springsecurityhello.security.LoginUserDetails;
import com.youth.springsecurityhello.utils.JwtUtils;
import com.youth.springsecurityhello.utils.RedisClient;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;

@RestController
public class LoginController {

    private static final long TOKEN_TTL_MILLIS = 7L * 24 * 60 * 60 * 1000;

    @Resource
    private AuthenticationManager authenticationManager;

    @Resource
    private JwtUtils jwtUtils;

    @Resource
    private RedisClient redisClient;

    @PostMapping("/login")
    public ResponseEntity<Result> login(
            @RequestParam String username,
            @RequestParam String password,
            @RequestHeader(value = "token", required = false) String oldToken) {

        if (StringUtils.hasText(oldToken)) {
            try {
                String subject = jwtUtils.getSubject(oldToken);
                if (username.equals(subject)) {
                    redisClient.del("login:token:" + oldToken);
                }
            } catch (JWTVerificationException ignored) {
            }
        }

        try {
            Authentication authentication = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(username, password));
            LoginUserDetails principal = (LoginUserDetails) authentication.getPrincipal();
            String token = jwtUtils.sign(principal.getUsername(), TOKEN_TTL_MILLIS);
            redisClient.set("login:token:" + token, JSONUtil.toJsonStr(principal), TOKEN_TTL_MILLIS);

            Map<String, Object> data = new HashMap<>();
            data.put("token", token);
            return ResponseEntity.ok(Result.ok(data));
        } catch (AuthenticationException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Result.error("用户名或密码错误"));
        }
    }
}

JwtUtils 负责签发和校验。密钥从配置里读,不在代码里写死:

package com.youth.springsecurityhello.utils;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
public class JwtUtils {

    private final Algorithm algorithm;
    private final JWTVerifier verifier;

    public JwtUtils(@Value("${jwt.secret}") String secret) {
        this.algorithm = Algorithm.HMAC256(secret);
        this.verifier = JWT.require(algorithm).build();
    }

    public String sign(String subject, long expiresInMillis) {
        return JWT.create()
                .withSubject(subject)
                .withExpiresAt(new Date(System.currentTimeMillis() + expiresInMillis))
                .sign(algorithm);
    }

    public boolean verify(String token) {
        verifier.verify(token);
        return true;
    }

    public String getSubject(String token) {
        return verifier.verify(token).getSubject();
    }
}

Redis 这一层只做简单的字符串读写:

package com.youth.springsecurityhello.utils;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@Component
public class RedisClient {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public void set(String key, String value, long timeMillis) {
        stringRedisTemplate.opsForValue().set(key, value, timeMillis, TimeUnit.MILLISECONDS);
    }

    public String get(String key) {
        return stringRedisTemplate.opsForValue().get(key);
    }

    public void del(String key) {
        stringRedisTemplate.delete(key);
    }
}

这里把整个 LoginUserDetails 序列化进了 Redis,里面带着密码哈希。demo 里为了少写一个会话对象就这么做了;真实项目里我更愿意只存用户 id、角色和权限。

每次请求怎么恢复身份

登录之后,客户端每次请求都带 token 头。过滤器先验证签名和过期时间,再拿 token 去 Redis 查会话:

package com.youth.springsecurityhello.security;

import cn.hutool.json.JSONUtil;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.youth.springsecurityhello.utils.JwtUtils;
import com.youth.springsecurityhello.utils.RedisClient;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private JwtUtils jwtUtils;

    @Resource
    private RedisClient redisClient;

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

        if (StringUtils.hasText(token)) {
            try {
                jwtUtils.verify(token);
                String json = redisClient.get("login:token:" + token);

                if (StringUtils.hasText(json)) {
                    LoginUserDetails userDetails = JSONUtil.toBean(json, LoginUserDetails.class);
                    if (userDetails != null) {
                        UsernamePasswordAuthenticationToken authentication =
                                new UsernamePasswordAuthenticationToken(
                                        userDetails, null, userDetails.getAuthorities());
                        authentication.setDetails(
                                new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            } catch (JWTVerificationException e) {
                SecurityContextHolder.clearContext();
            }
        }

        filterChain.doFilter(request, response);
    }
}

这里没有 token 也放行,交给后面的授权规则去判断。/test 是 permitAll(),所以没有 token 也能过;/test1 要求认证,没有 token 就会被 401 处理器接住。

注销处理器只做一件事:把 Redis 里对应的 key 删掉。

package com.youth.springsecurityhello.security;

import cn.hutool.json.JSONUtil;
import com.youth.springsecurityhello.beans.Result;
import com.youth.springsecurityhello.utils.RedisClient;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class LogoutStatusSucessHandler implements LogoutSuccessHandler {

    @Resource
    private RedisClient redisClient;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
                                Authentication authentication) throws IOException {
        String token = request.getHeader("token");
        if (StringUtils.hasText(token)) {
            redisClient.del("login:token:" + token);
        }

        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONUtil.toJsonStr(Result.ok("注销成功")));
    }
}

方法级权限

最后是验证用的控制器。@PreAuthorize 里的表达式直接对应 LoginUserDetails 里的 authority:

package com.youth.springsecurityhello.controller;

import com.youth.springsecurityhello.beans.Result;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/test")
    public Result test() {
        return Result.ok("test");
    }

    @GetMapping("/test1")
    public Result test1() {
        return Result.ok("test1");
    }

    @PreAuthorize("hasRole('admin')")
    @GetMapping("/test2")
    public Result test2() {
        return Result.ok("test2");
    }

    @PreAuthorize("hasAnyRole('admin', 'CEO')")
    @GetMapping("/test3")
    public Result test3() {
        return Result.ok("test3");
    }

    @PreAuthorize("hasRole('CTO') and hasRole('CEO')")
    @GetMapping("/test4")
    public Result test4() {
        return Result.ok("test4");
    }

    @PreAuthorize("hasAuthority('user:add')")
    @GetMapping("/test5")
    public Result test5() {
        return Result.ok("test5");
    }

    @PreAuthorize("hasAnyAuthority('user:add', 'user:del')")
    @GetMapping("/test6")
    public Result test6() {
        return Result.ok("test6");
    }

    @PreAuthorize("hasAuthority('user:add') and hasAuthority('user:del')")
    @GetMapping("/test7")
    public Result test7() {
        return Result.ok("test7");
    }
}

返回体统一用 Result:

package com.youth.springsecurityhello.beans;

import lombok.Data;

@Data
public class Result {

    private int code = 200;
    private String msg = "OK";
    private Object data;

    public Result() {
    }

    public Result(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public Result(Object data) {
        this.data = data;
    }

    public static Result ok() {
        return new Result();
    }

    public static Result ok(Object data) {
        return new Result(data);
    }

    public static Result ok(String msg) {
        return new Result(200, msg);
    }

    public static Result error(String msg) {
        return new Result(-1, msg);
    }
}

主类就是 Spring Boot 生成的那个:

package com.youth.springsecurityhello;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringsecurityHelloApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringsecurityHelloApplication.class, args);
    }
}

跑起来验证

容器起来之后,用 mvn spring-boot:run 启动应用。然后按下面几组请求验证。

先看匿名接口和未登录访问:

curl -i http://localhost:8080/test

/test 在配置里放行了,返回 200;接着访问 /test1:

curl -i http://localhost:8080/test1

没有 token,返回 401,响应体是 用户未认证或登录已过期,请重新登录后再访问。

用第一个用户登录:

curl -s -X POST http://localhost:8080/login \
  -d "username=13800000000&password=password"

返回体里会带 token:

{
  "code": 200,
  "msg": "OK",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiJ9..."
  }
}

把 token 存到变量里,后面的请求都带上它。没装 jq 的话,从上面的返回里手动复制也可以:

TOKEN=$(curl -s -X POST http://localhost:8080/login \
  -d "username=13800000000&password=password" | jq -r '.data.token')

这个用户三个角色都有,/test1 到 /test7 应该全部返回 200:

curl -i http://localhost:8080/test2 -H "token: $TOKEN"
curl -i http://localhost:8080/test4 -H "token: $TOKEN"
curl -i http://localhost:8080/test7 -H "token: $TOKEN"

再用第二个用户登录,它只有 CEO 角色,没有任何权限:

TOKEN2=$(curl -s -X POST http://localhost:8080/login \
  -d "username=13900000000&password=password" | jq -r '.data.token')

curl -i http://localhost:8080/test2 -H "token: $TOKEN2"   # 403,没有 admin
curl -i http://localhost:8080/test3 -H "token: $TOKEN2"   # 200,有 CEO
curl -i http://localhost:8080/test4 -H "token: $TOKEN2"   # 403,缺 CTO
curl -i http://localhost:8080/test5 -H "token: $TOKEN2"   # 403,没有 user:add

最后验证注销和 token 篡改:

curl -i -X POST http://localhost:8080/logout -H "token: $TOKEN"
curl -i http://localhost:8080/test1 -H "token: $TOKEN"     # 401,Redis 里的 key 已删除

curl -i http://localhost:8080/test1 -H "token: ${TOKEN}x"  # 401,签名校验失败

到这里,匿名、登录、角色、权限、注销这几条路径都走到了。

特别说明

角色名加了 ROLE_ 前缀,权限名没有。 症状是数据库里明明有 admin 角色,接口还是 403。原因是 hasRole('admin') 查的是 ROLE_admin,而 hasAuthority('admin') 查的才是 admin;LoginUserDetails 给角色统一加了前缀,所以 hasRole 能对上。判断的时候把 authentication.getAuthorities() 打出来,看一眼前缀就清楚了。

过滤器没进链,或者顺序不对。 症状是请求明明带了 token,接口还是 401。常见原因是自定义过滤器没有用 addFilterBefore 注册,或者注册在授权判断之后;也有可能是 Redis 的 key 前缀拼错了。判断方法是在过滤器入口打一行日志,确认每个请求都进来了,再对照 login:token: 加 token 拼出来的 key 在 Redis 里是否存在。这个项目里过滤器放在 UsernamePasswordAuthenticationFilter 之前。

JWT.decode 不等于 JWT.verify。 症状是改掉 token 最后一位,接口仍然能访问。原因是 decode 只做 Base64 解码,不验签;只要 Redis 里还有这个 token 对应的 key,过滤器就会放行。判断方法是把 token 改一个字符再请求,如果还能过,就说明校验只做了解码。这个项目的过滤器先 verify 再查 Redis,两种情况都会拦住。

STATELESS 下注销不能只靠清 Session。 症状是注销之后旧 token 还能用。原因是服务端没有 Session 可清,JWT 本身在过期前一直有效;注销必须删掉 Redis 里的会话 key,或者维护一份失效列表。判断方法是注销后直接查 login:token:<token> 是否还存在。这个项目的注销处理器就是删这个 key。

评论

暂未收到

写下评论

支持 Markdown 语法