前言
几年前写过一篇 Spring Security 相关博文:Spring Boot - 集成 Spring Security,基于 5.0 版本。而当前 Spring Security 最新稳定版本为 Spring Security 6.2.0,相较于 5.0 版本,6.0 版本的 Spring Security 引入了很多破坏性更新,比如对一些类进行了移除,方法重命名,采用DSL配置,废弃了一些方法...,因此,那篇博文中的很多配置已不能生效了。
Token下发
当前 Spring Security 最新稳定版本为 Spring Security 6.2.0,相较于 5.0 版本,6.0 版本的 Spring Security 引入了很多破坏性更新,比如对一些类进行了移除,方法重命名,采用DSL配置,废弃了一些方法...,因此,本文中的很多配置已不能生效了。
这里采用最新 Spring Secuirty 6+,对 Token下发 给出最新示例配置。
前文介绍过,Spring Security 默认登录接口为/login
,默认是由UsernamePasswordAuthenticationFilter
进行表单登录认证,我们前面也是通过自定义UsernamePasswordAuthenticationFilter
实现 JSON登录认证。不过,此处我们进行简化,不再用 Spring Security 默认的登录接口和逻辑,而是通过自定义注册和登录接口(/signup
& /signin
)实现登录认证,然后通过自定义一个 JSON Web Token 过滤器(JwtTokenAuthenticationFilter
),进行 Token 验证,实现用户认证。具体步骤如下:
-
前期配置:在正式进行 Sprng Security 配置前,先将前期环境配置一下,包含下面几方面:
-
测试接口:添加测试接口,模拟真实业务接口:
@RestController @RequestMapping public class TestApi { // 测试 @GetMapping("/test") public String index() { return "hello world!"; } // 获取当前用户信息 @GetMapping("/user") public String whoami() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); String name = auth.getName(); Object principal = auth.getPrincipal(); String password = (String) auth.getCredentials(); Collection<? extends GrantedAuthority> authorities = auth.getAuthorities(); HttpServletRequest request = (HttpServletRequest) auth.getDetails(); StringBuilder builder = new StringBuilder(); builder.append(String.format("name: %s\n", name)); builder.append(String.format("principal: %s\n", principal)); builder.append(String.format("password: %s\n", password)); builder.append(String.format("authorities: %s\n", authorities.stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(","))) ); builder.append(String.format("getDetails: %s\n", request)); return builder.toString(); } }
-
用户数据库:这里我们使用真实的数据库,用户表如下所示:
-- create tbale tb_user create table `tb_user` ( `id` bigint unique not null auto_increment, `name` varchar(30) unique not null comment 'user name', `password` varchar(100) not null comment 'user password', `role` enum('admin','normal','anonymous') default 'anonymous' comment 'user role', `authority` set('create','read','update','delete') comment 'user authorities', primary key(`id`) );
-
数据库相关配置:
- 导入相关依赖:
<!-- pom.xml --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency>
- 设置相关配置:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/whyn username: root password: 123456 mybatis: mapper-locations: classpath:mapper/**/*.xml
- 导入相关依赖:
-
用户表操作:采用 MyBatis
-
实体类:这里实体类
User
实现了 Spring Security 的UserDetails
,表示用户信息:@Data @AllArgsConstructor @NoArgsConstructor @Builder public class User implements UserDetails { private Long id; private String name; private String password; private String role; private String authority; @Override public Collection<? extends GrantedAuthority> getAuthorities() { String[] authorities = this.authority.split(","); String rolePrefix = "ROLE_"; String roleAuthority = this.role; // 将 role 转成 ROLE_XXX if (null != roleAuthority && !roleAuthority.startsWith(rolePrefix)) { roleAuthority = (rolePrefix + roleAuthority).toUpperCase(); } return Stream.concat( Arrays.stream(authorities), Stream.of(roleAuthority) ).filter(Predicate.not(String::isBlank)) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); } // UserDetailsService#loadUserByUsername(username) // 其中的 username 就是 getUsername,本质是一个唯一的标识,此处可使用其他唯一性字段进行代替 @Override public String getUsername() { return this.name; } @Override public String getPassword() { return this.password; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
-
用户表操作类:
@Mapper public interface IUserDao { @Insert("insert into tb_user values( #{ id }, #{ name }, #{ password }, #{ role }, #{ authority })") int insert(User user); @Select("select * from tb_user where id = #{id}") User selectOneByPrimaryKey(Long id); @Select("select * from tb_user where name=#{ name }") User selectOneByName(String name); }
-
用户表服务类:
// IUserService.java public interface IUserService { User findUser(String name); } // UserServiceImpl.java @Service @AllArgsConstructor public class UserServiceImpl implements IUserService { private final IUserDao userDao; @Override public User findUser(String name) { return this.userDao.selectOneByName(name); } }
-
至此,前期准备工作已完成,可以开始配置 Spring Security 相关内容。
-
-
引入相关依赖:
<!-- 导入 Spring Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- 导入 jjwt --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.3</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.3</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred --> <version>0.12.3</version> <scope>runtime</scope> </dependency>
-
JWT工具类:配置过程如下:
-
配置 JWT 相关信息:
# application.yml jwt: token: # 密钥 secret-key: "this_is_private_secret_key" # 过期时间 7 天 # jshell> TimeUnit.DAYS.toMillis(7); # $1 ==> 604800000 expiration: 604800000 # token 前缀 token-prefix: Bearer
-
抽取一个 JWT 工具类:
@Service public class JwtTokenService { public static final String KEY_USER_NAME = "username"; public static final String KEY_USER_AUTHORITIES = "authorities"; @Value("${jwt.token.secret-key}") private String secretKey; @Value("${jwt.token.expiration}") private long expiration; @Value("${jwt.token.token-prefix}") private String tokenPrefix; public String getTokenPrefix() { return this.tokenPrefix; } // 解析 token public Map<String, Object> parseToken(String token) { Map<String, Object> userDetails = new HashMap<>(); try { token = validateToken(token); Jws<Claims> claimsJws = Jwts.parser() .setSigningKey(this.getSecretKey()).build().parseClaimsJws(token); // 用户名 String username = claimsJws.getBody().getSubject(); userDetails.put(KEY_USER_NAME, username); // 用户权限 List<Map<String, String>> authorities = (List<Map<String, String>>) claimsJws.getBody().get(KEY_USER_AUTHORITIES); if (null != authorities) { Collection<? extends GrantedAuthority> userAuthorities = authorities.stream() .map(item -> new SimpleGrantedAuthority(item.get("authority"))) .collect(Collectors.toSet()); userDetails.put(KEY_USER_AUTHORITIES, userAuthorities); } return userDetails; } catch (JwtException e) { throw new IllegalStateException(String.format("invalid token: %s", token)); } } // 生成token public String generateToken(String username) { String token = Jwts.builder() .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + this.expiration)) .signWith(this.getSecretKey()) .compact(); return generateTokenWithPrefix(token); } // 生成 token,包含用户主体和其权限 public String generateToken(String username, Object authorities) { String token = Jwts.builder() // 用户名 .setSubject(username) // payload .claim(KEY_USER_AUTHORITIES, authorities) // 发行时间 .setIssuedAt(new Date()) // 过期时间 .setExpiration(new Date(System.currentTimeMillis() + this.expiration)) // 私钥 .signWith(getSecretKey()) .compact(); return generateTokenWithPrefix(token); } // token 添加前缀 Bearer private String generateTokenWithPrefix(final String token) { return String.format("%s %s", this.tokenPrefix, token); } // 生成签名私钥 private Key getSecretKey() { return Keys.hmacShaKeyFor(generateSecretKey()); } // 加密要求至少 256 位,因此将私钥进行 sha256,只是单纯为了生存 256 个字节 private byte[] generateSecretKey() { byte[] hashKey = null; String secretKey = this.secretKey; try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); hashKey = digest.digest(secretKey.getBytes(StandardCharsets.UTF_8)); } catch (NoSuchAlgorithmException e) { hashKey = this.fillBytes(secretKey); } return hashKey; } // 循环字符串添加到 256 个字节 private byte[] fillBytes(String str) { if (str == null) { throw new IllegalArgumentException("secret key must not be null!"); } byte[] bytes256 = new byte[256]; int length = str.length(); for (int i = 0; i < 256; ++i) { // 忽视精度缺失,只是为了添加到 256 个字节 bytes256[i] = (byte) str.charAt(i % length); } return bytes256; } // 去除 token 前缀:Bearer private String validateToken(String token) { String rawToken = token; String tokenPrefix = this.tokenPrefix; if (rawToken.startsWith(tokenPrefix)) { rawToken = rawToken.substring(tokenPrefix.length()).trim(); } return rawToken; } }
-
-
用户登录认证配置:Spring Security 对于用户登录有一套完整的认证流程,比如我们常见的表单登录认证,它是由
UsernamePasswordAuthenticationFilter
负责处理的,整个认证流程如下图所示:注:图片来源于互联网,侵删
简单来说,认证过程是通过
AuthenticationManager#authenticate
开启认证,然后经由AuthenticationProvider#authenticate
,然后从与其绑定的UserDetailsService#loadUserByUsername
获取到真实的用户信息UserDetails
,如此,AuthenticationProvider
就可以比对前端传递过来的用户密码与数据库中该用户密码是否匹配,匹配则验证成功,最后会构建一个新的Authentication
对象,保存用户相关信息,并放置到SecurityContextHolder
的上下中,供后续组件获取该用户信息。因此,对于用户认证流程,我们这里需要配置以上相关组件,如下所示:
@Configuration @EnableWebSecurity @AllArgsConstructor public class SecurityConfiguration { private final IUserService userService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 手动关联我们设置的 AuthenticationManager(可选,默认注册已关联) http.authenticationManager(this.authenticationManager()); return http.build(); } // 密码加密器 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 获取用户及其相关详细信息 @Bean public UserDetailsService userDetailsService() { return new UserDetailsService() { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userService.findUser(username); if (null == user) { throw new UsernameNotFoundException(String.format("[username: %s] not found!", username)); } return user; } }; } // 负责具体用户认证流程 @Bean public AuthenticationProvider authenticationProvider() { // 创建一个用户认证提供者 DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); // 将该 Provider 关联到我们设置的 UserDetailsService authProvider.setUserDetailsService(this.userDetailsService()); // 关联到我们设置的加密算法 authProvider.setPasswordEncoder(this.passwordEncoder()); return authProvider; } // 认证管理者 @Bean public AuthenticationManager authenticationManager() { // 关联到我们设置的 AuthenticationProvider return new ProviderManager(this.authenticationProvider()); } }
-
自定义注册和登录接口:
-
Controller
@RestController @RequestMapping("/auth") public class AuthApi { @Autowired private IAuthService authService; // 用户注册接口 @PostMapping("/signup") public boolean signUp(@RequestBody User user) { return this.authService.signUp(user); } // 用户登录接口 @PostMapping("/signin") public void signIn(@RequestBody User user, HttpServletResponse response) { String jwtToken = this.authService.signIn(user); Optional.ofNullable(jwtToken) .ifPresentOrElse(token -> { this.success(response, token); }, () -> { this.failed(response); }); } private void success(HttpServletResponse response, String jwtToken) { response.addHeader(HttpHeaders.AUTHORIZATION, jwtToken); response.setCharacterEncoding("utf-8"); response.setContentType("application/json"); try (PrintWriter writer = response.getWriter()) { writer.print("login successfully!"); } catch (IOException e) { throw new RuntimeException(e); } } private void failed(HttpServletResponse response) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); try (PrintWriter writer = response.getWriter()) { writer.print("login failed! username or password incorrect"); } catch (IOException e) { throw new RuntimeException(e); } } }
-
Service:
// IAuthService.java public interface IAuthService { boolean signUp(User user); String signIn(User user); } // AuthServiceImpl.java @Service @AllArgsConstructor public class AuthServiceImpl implements IAuthService { private final IUserDao userDao; private final JwtTokenService jwtTokenService; private final AuthenticationManager authManager; private final PasswordEncoder passwordEncoder; @Override public boolean signUp(User user) { String encodePassword = this.passwordEncoder.encode(user.getPassword()); user.setPassword(encodePassword); return this.userDao.insert(user) > 0; } @Override public String signIn(User user) { String jwtToken = null; try { String username = user.getUsername(); String password = user.getPassword(); if (!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) { throw new AuthenticationServiceException("username & password must not be null"); } // 构造一个 Authentication 对象,设置 principal & credentials // principal 意为主要的,对应数据库中唯一字段(unique key),此处设置为 username, // 若进行更改,则相应的 userDetails#getUsername() 和 UserDetailsService#loadUserByUsername(String username) // 都要设置为对同一字段进行操作 // credentials 就是指代密码 Authentication authentication = new UsernamePasswordAuthenticationToken(username, password); // 可自定义填充其余信息,方便后续获取用户时,能获取这些自定义信息 // ((UsernamePasswordAuthenticationToken)authentication).setDetails(null); // AuthenticationManager 进行认证,认证失败抛异常 // 认证通过,成功返回一个新的 Authentication 对象,其内包含有用户所有信息(包含上面自定义信息 setDetails),只是将密码去除 Authentication successDetailedAuth = this.authManager.authenticate(authentication); // 认证通过 // 获取用户详细信息 Collection<? extends GrantedAuthority> authorities = successDetailedAuth.getAuthorities(); // 下发 jwt token jwtToken = this.jwtTokenService.generateToken(username, authorities); } catch (AuthenticationException e) { e.printStackTrace(); } return jwtToken; } }
至此,登录和注册功能就完成了。每次登录时,成功后会在响应头中携带上一串 Jwt Token,后续请求都需要携带该 token,后端对该 token 验证成功,才允许其访问相应资源。
-
-
设置 Jwt Token 验证过滤器:
@AllArgsConstructor public class JwtTokenAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenService jwtTokenService; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String jwtToken = request.getHeader(HttpHeaders.AUTHORIZATION); if (null != jwtToken && jwtToken.startsWith(this.jwtTokenService.getTokenPrefix())) { // 解析 jwt token,失败抛异常 Map<String, Object> userDetailsMap = this.jwtTokenService.parseToken(jwtToken); // 认证通过,从 token 中提取出 username String username = (String) userDetailsMap.get(JwtTokenService.KEY_USER_NAME); // 获取用户详细信息 UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); // 构建一个 Authentication 认证对象,填入用户相关信息 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( username, // principal 用户名 null, // credentials 密码敏感数据,直接置为空即可 userDetails.getAuthorities()); // 附加详细信息,比如请求体,有些认证方式需要除了用户名密码外更多的信息 // 后续可通过 Authentication#getDetails() 获取自定义的额外信息 authentication.setDetails(request); // 认证成功,直接设置到 SecurityContextHolder 中,供后续 Filters 使用 // 该操作会将 Authentication 存放到 ThreadLocal 中,这样当前请求在后续操作中就能获取到该 Authentication SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { e.printStackTrace(); } filterChain.doFilter(request, response); } }
当 Jwt token 解析成功时,则表示验证通过,最后会将当前用户相关信息包裹在一个
Authentication
对象中,并将该对象放置在全局SecurityContextHolder
中,方便后续组件获取当前用户信息。 -
配置一个 SecurityFilterChain:配置一个
SecurityFilterChain
,关联我们自定义的JwtTokenAuthenticationFilter
,让其生效;同时配置其他相关信息:@Configuration @EnableWebSecurity @AllArgsConstructor public class SecurityConfiguration { private final IUserService userService; private final JwtTokenService jwtTokenService; // @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) .sessionManagement(sessionManager -> sessionManager.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authenticationManager(this.authenticationManager()) // 配置 JwtTokenAuthenticationFilter .addFilterBefore(new JwtTokenAuthenticationFilter( this.jwtTokenService, this.userDetailsService()), UsernamePasswordAuthenticationFilter.class) // 请求认证授权 .authorizeHttpRequests(requests -> { // 放行 POST 请求接口 /auth/signin,/auth/signup requests.requestMatchers(HttpMethod.POST, "/auth/signin", "/auth/signup").permitAll() // 放行 /test/** 接口所有请求 .requestMatchers("/test/**").permitAll() // 其余请求,一律需要进行认证 .anyRequest().authenticated(); }); return http.build(); } // ... }
以上,我们便完成了 Spring Security 6+ 版本对 Token下发 功能的一个配置。
我们可以模拟一个用户注册,登录,访问完整逻辑,测试如下:
# 注册用户:admin
$ curl -X POST 'localhost:8080/auth/signup' --header 'Content-Type: application/json; charset=utf-8' --data '{"name":"admin", "password":"admin_password", "role": "admin", "authority": "create,read,update,delete"}'
true%
# 登录用户:admin
$ curl -X POST 'localhost:8080/auth/signin' --header 'Content-Type: application/json; charset=utf-8' --data '{"name":"admin", "password":"admin_password"}' -v
# 可以看到,登录成功后会返回一个 Jwt token
< Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImF1dGhvcml0aWVzIjpbeyJhdXRob3JpdHkiOiJjcmVhdGUifSx7ImF1dGhvcml0eSI6InJlYWQifSx7ImF1dGhvcml0eSI6InVwZGF0ZSJ9LHsiYXV0aG9yaXR5IjoiZGVsZXRlIn0seyJhdXRob3JpdHkiOiJST0xFX0FETUlOIn1dLCJpYXQiOjE3MDI2MzQ3MTEsImV4cCI6MTcwMzIzOTUxMX0.RpyqdBlmbfWsLR1M6mb7SH9RPpFgJJODiZ1mIvx8T5Y
login successfully!%
# 访问资源
$ curl -X GET 'localhost:8080/user' --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImF1dGhvcml0aWVzIjpbeyJhdXRob3JpdHkiOiJjcmVhdGUifSx7ImF1dGhvcml0eSI6InJlYWQifSx7ImF1dGhvcml0eSI6InVwZGF0ZSJ9LHsiYXV0aG9yaXR5IjoiZGVsZXRlIn0seyJhdXRob3JpdHkiOiJST0xFX0FETUlOIn1dLCJpYXQiOjE3MDI2MzQ3MTEsImV4cCI6MTcwMzIzOTUxMX0.RpyqdBlmbfWsLR1M6mb7SH9RPpFgJJODiZ1mIvx8T5Y'
name: admin
principal: admin
password: null
authorities: create,read,update,delete,ROLE_ADMIN
getDetails: org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@60e25235
完整源码可查看:spring-security-demo