前一阵我把 Spring Security 的登录、角色、权限这条链路重新捋了一遍,写了一个叫 springsecurity-hello 的小项目。它不复杂:五张表、一个登录接口、一个 JWT 过滤器,再加几个用 @PreAuthorize 标注的测试接口。把匿名访问、登录、带 token 访问、权限不足这四种情况都跑通之后,过滤器链、UserDetails、AuthenticationManager 这些概念才算是连起来了。
这篇把项目的代码和验证过程完整贴出来,目标是从空目录开始,跑出一个同样能登录、能鉴权、能注销的项目。数据库用 MariaDB,token 的会话信息放在 Redis,整体结构和我实际在用的项目一致。
项目跑起来是什么样
先把最终要验证的接口列出来,后面每写一块代码,都是在为这张表服务。
| 接口 | 要求 | 预期结果 |
|---|---|---|
GET /test | 匿名可访问 | 200 |
POST /login | 用户名、密码 | 200,返回 token |
GET /test1 | 登录即可 | 没 token 返回 401 |
GET /test2 | admin 角色 | 普通用户返回 403 |
GET /test3 | admin 或 CEO 角色 | CEO 可以访问 |
GET /test4 | CTO 和 CEO 两个角色 | 只有 CEO 时返回 403 |
GET /test5 | user:add 权限 | 没有权限返回 403 |
GET /test6 | user:add 或 user:del 权限 | 有任意一个即可 |
GET /test7 | user: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。
评论
暂未收到