前言
幾年前寫(xiě)過(guò)一篇 Spring Security 相關(guān)博文:Spring Boot - 集成 Spring Security,基于 5.0 版本。而當(dāng)前 Spring Security 最新穩(wěn)定版本為 Spring Security 6.2.0,相較于 5.0 版本,6.0 版本的 Spring Security 引入了很多破壞性更新,比如對(duì)一些類進(jìn)行了移除,方法重命名,采用DSL配置,廢棄了一些方法...,因此,那篇博文中的很多配置已不能生效了。
Token下發(fā)
當(dāng)前 Spring Security 最新穩(wěn)定版本為 Spring Security 6.2.0,相較于 5.0 版本,6.0 版本的 Spring Security 引入了很多破壞性更新,比如對(duì)一些類進(jìn)行了移除,方法重命名,采用DSL配置,廢棄了一些方法...,因此,本文中的很多配置已不能生效了。
這里采用最新 Spring Secuirty 6+,對(duì) Token下發(fā) 給出最新示例配置。
前文介紹過(guò),Spring Security 默認(rèn)登錄接口為/login,默認(rèn)是由UsernamePasswordAuthenticationFilter進(jìn)行表單登錄認(rèn)證,我們前面也是通過(guò)自定義UsernamePasswordAuthenticationFilter實(shí)現(xiàn) JSON登錄認(rèn)證。不過(guò),此處我們進(jìn)行簡(jiǎn)化,不再用 Spring Security 默認(rèn)的登錄接口和邏輯,而是通過(guò)自定義注冊(cè)和登錄接口(/signup & /signin)實(shí)現(xiàn)登錄認(rèn)證,然后通過(guò)自定義一個(gè) JSON Web Token 過(guò)濾器(JwtTokenAuthenticationFilter),進(jìn)行 Token 驗(yàn)證,實(shí)現(xiàn)用戶認(rèn)證。具體步驟如下:
-
前期配置:在正式進(jìn)行 Sprng Security 配置前,先將前期環(huán)境配置一下,包含下面幾方面:
-
測(cè)試接口:添加測(cè)試接口,模擬真實(shí)業(yè)務(wù)接口:
@RestController @RequestMapping public class TestApi { // 測(cè)試 @GetMapping("/test") public String index() { return "hello world!"; } // 獲取當(dāng)前用戶信息 @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(); } } -
用戶數(shù)據(jù)庫(kù):這里我們使用真實(shí)的數(shù)據(jù)庫(kù),用戶表如下所示:
-- 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`) ); -
數(shù)據(jù)庫(kù)相關(guān)配置:
- 導(dǎo)入相關(guān)依賴:
<!-- 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> - 設(shè)置相關(guān)配置:
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
- 導(dǎo)入相關(guān)依賴:
-
用戶表操作:采用 MyBatis
-
實(shí)體類:這里實(shí)體類
User實(shí)現(xiàn)了 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 轉(zhuǎn)成 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,本質(zhì)是一個(gè)唯一的標(biāo)識(shí),此處可使用其他唯一性字段進(jìn)行代替 @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); } -
用戶表服務(wù)類:
// 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); } }
-
至此,前期準(zhǔn)備工作已完成,可以開(kāi)始配置 Spring Security 相關(guān)內(nèi)容。
-
-
引入相關(guān)依賴:
<!-- 導(dǎo)入 Spring Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- 導(dǎo)入 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工具類:配置過(guò)程如下:
-
配置 JWT 相關(guān)信息:
# application.yml jwt: token: # 密鑰 secret-key: "this_is_private_secret_key" # 過(guò)期時(shí)間 7 天 # jshell> TimeUnit.DAYS.toMillis(7); # $1 ==> 604800000 expiration: 604800000 # token 前綴 token-prefix: Bearer -
抽取一個(gè) 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); // 用戶權(quán)限 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,包含用戶主體和其權(quán)限 public String generateToken(String username, Object authorities) { String token = Jwts.builder() // 用戶名 .setSubject(username) // payload .claim(KEY_USER_AUTHORITIES, authorities) // 發(fā)行時(shí)間 .setIssuedAt(new Date()) // 過(guò)期時(shí)間 .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 位,因此將私鑰進(jìn)行 sha256,只是單純?yōu)榱松?256 個(gè)字節(jié) 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; } // 循環(huán)字符串添加到 256 個(gè)字節(jié) 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 個(gè)字節(jié) 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; } }
-
-
用戶登錄認(rèn)證配置:Spring Security 對(duì)于用戶登錄有一套完整的認(rèn)證流程,比如我們常見(jiàn)的表單登錄認(rèn)證,它是由
UsernamePasswordAuthenticationFilter負(fù)責(zé)處理的,整個(gè)認(rèn)證流程如下圖所示:spring-security-authentication-process注:圖片來(lái)源于互聯(lián)網(wǎng),侵刪
簡(jiǎn)單來(lái)說(shuō),認(rèn)證過(guò)程是通過(guò)
AuthenticationManager#authenticate開(kāi)啟認(rèn)證,然后經(jīng)由AuthenticationProvider#authenticate,然后從與其綁定的UserDetailsService#loadUserByUsername獲取到真實(shí)的用戶信息UserDetails,如此,AuthenticationProvider就可以比對(duì)前端傳遞過(guò)來(lái)的用戶密碼與數(shù)據(jù)庫(kù)中該用戶密碼是否匹配,匹配則驗(yàn)證成功,最后會(huì)構(gòu)建一個(gè)新的Authentication對(duì)象,保存用戶相關(guān)信息,并放置到SecurityContextHolder的上下中,供后續(xù)組件獲取該用戶信息。因此,對(duì)于用戶認(rèn)證流程,我們這里需要配置以上相關(guān)組件,如下所示:
@Configuration @EnableWebSecurity @AllArgsConstructor public class SecurityConfiguration { private final IUserService userService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 手動(dòng)關(guān)聯(lián)我們?cè)O(shè)置的 AuthenticationManager(可選,默認(rèn)注冊(cè)已關(guān)聯(lián)) http.authenticationManager(this.authenticationManager()); return http.build(); } // 密碼加密器 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 獲取用戶及其相關(guān)詳細(xì)信息 @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; } }; } // 負(fù)責(zé)具體用戶認(rèn)證流程 @Bean public AuthenticationProvider authenticationProvider() { // 創(chuàng)建一個(gè)用戶認(rèn)證提供者 DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); // 將該 Provider 關(guān)聯(lián)到我們?cè)O(shè)置的 UserDetailsService authProvider.setUserDetailsService(this.userDetailsService()); // 關(guān)聯(lián)到我們?cè)O(shè)置的加密算法 authProvider.setPasswordEncoder(this.passwordEncoder()); return authProvider; } // 認(rèn)證管理者 @Bean public AuthenticationManager authenticationManager() { // 關(guān)聯(lián)到我們?cè)O(shè)置的 AuthenticationProvider return new ProviderManager(this.authenticationProvider()); } } -
自定義注冊(cè)和登錄接口:
-
Controller
@RestController @RequestMapping("/auth") public class AuthApi { @Autowired private IAuthService authService; // 用戶注冊(cè)接口 @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"); } // 構(gòu)造一個(gè) Authentication 對(duì)象,設(shè)置 principal & credentials // principal 意為主要的,對(duì)應(yīng)數(shù)據(jù)庫(kù)中唯一字段(unique key),此處設(shè)置為 username, // 若進(jìn)行更改,則相應(yīng)的 userDetails#getUsername() 和 UserDetailsService#loadUserByUsername(String username) // 都要設(shè)置為對(duì)同一字段進(jìn)行操作 // credentials 就是指代密碼 Authentication authentication = new UsernamePasswordAuthenticationToken(username, password); // 可自定義填充其余信息,方便后續(xù)獲取用戶時(shí),能獲取這些自定義信息 // ((UsernamePasswordAuthenticationToken)authentication).setDetails(null); // AuthenticationManager 進(jìn)行認(rèn)證,認(rèn)證失敗拋異常 // 認(rèn)證通過(guò),成功返回一個(gè)新的 Authentication 對(duì)象,其內(nèi)包含有用戶所有信息(包含上面自定義信息 setDetails),只是將密碼去除 Authentication successDetailedAuth = this.authManager.authenticate(authentication); // 認(rèn)證通過(guò) // 獲取用戶詳細(xì)信息 Collection<? extends GrantedAuthority> authorities = successDetailedAuth.getAuthorities(); // 下發(fā) jwt token jwtToken = this.jwtTokenService.generateToken(username, authorities); } catch (AuthenticationException e) { e.printStackTrace(); } return jwtToken; } }
至此,登錄和注冊(cè)功能就完成了。每次登錄時(shí),成功后會(huì)在響應(yīng)頭中攜帶上一串 Jwt Token,后續(xù)請(qǐng)求都需要攜帶該 token,后端對(duì)該 token 驗(yàn)證成功,才允許其訪問(wèn)相應(yīng)資源。
-
-
設(shè)置 Jwt Token 驗(yàn)證過(guò)濾器:
@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); // 認(rèn)證通過(guò),從 token 中提取出 username String username = (String) userDetailsMap.get(JwtTokenService.KEY_USER_NAME); // 獲取用戶詳細(xì)信息 UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); // 構(gòu)建一個(gè) Authentication 認(rèn)證對(duì)象,填入用戶相關(guān)信息 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( username, // principal 用戶名 null, // credentials 密碼敏感數(shù)據(jù),直接置為空即可 userDetails.getAuthorities()); // 附加詳細(xì)信息,比如請(qǐng)求體,有些認(rèn)證方式需要除了用戶名密碼外更多的信息 // 后續(xù)可通過(guò) Authentication#getDetails() 獲取自定義的額外信息 authentication.setDetails(request); // 認(rèn)證成功,直接設(shè)置到 SecurityContextHolder 中,供后續(xù) Filters 使用 // 該操作會(huì)將 Authentication 存放到 ThreadLocal 中,這樣當(dāng)前請(qǐng)求在后續(xù)操作中就能獲取到該 Authentication SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { e.printStackTrace(); } filterChain.doFilter(request, response); } }當(dāng) Jwt token 解析成功時(shí),則表示驗(yàn)證通過(guò),最后會(huì)將當(dāng)前用戶相關(guān)信息包裹在一個(gè)
Authentication對(duì)象中,并將該對(duì)象放置在全局SecurityContextHolder中,方便后續(xù)組件獲取當(dāng)前用戶信息。 -
配置一個(gè) SecurityFilterChain:配置一個(gè)
SecurityFilterChain,關(guān)聯(lián)我們自定義的JwtTokenAuthenticationFilter,讓其生效;同時(shí)配置其他相關(guān)信息:@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) // 請(qǐng)求認(rèn)證授權(quán) .authorizeHttpRequests(requests -> { // 放行 POST 請(qǐng)求接口 /auth/signin,/auth/signup requests.requestMatchers(HttpMethod.POST, "/auth/signin", "/auth/signup").permitAll() // 放行 /test/** 接口所有請(qǐng)求 .requestMatchers("/test/**").permitAll() // 其余請(qǐng)求,一律需要進(jìn)行認(rèn)證 .anyRequest().authenticated(); }); return http.build(); } // ... }
以上,我們便完成了 Spring Security 6+ 版本對(duì) Token下發(fā) 功能的一個(gè)配置。
我們可以模擬一個(gè)用戶注冊(cè),登錄,訪問(wèn)完整邏輯,測(cè)試如下:
# 注冊(cè)用戶: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
# 可以看到,登錄成功后會(huì)返回一個(gè) Jwt token
< Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImF1dGhvcml0aWVzIjpbeyJhdXRob3JpdHkiOiJjcmVhdGUifSx7ImF1dGhvcml0eSI6InJlYWQifSx7ImF1dGhvcml0eSI6InVwZGF0ZSJ9LHsiYXV0aG9yaXR5IjoiZGVsZXRlIn0seyJhdXRob3JpdHkiOiJST0xFX0FETUlOIn1dLCJpYXQiOjE3MDI2MzQ3MTEsImV4cCI6MTcwMzIzOTUxMX0.RpyqdBlmbfWsLR1M6mb7SH9RPpFgJJODiZ1mIvx8T5Y
login successfully!%
# 訪問(wèn)資源
$ 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
