Spring Security

Spring Security

简介

Spring Security 是一款安全框架。

基本使用

引入依赖包:

  • Developer Tools
    • Lombok
  • Web
    • Spring Web
  • Template Engines
    • Thymeleaf
  • Security
    • Spring Security
    • OAuth2 Resource Server
  • SQL
    • Spring Data JPA / MyBatis Framework
    • MySQL Driver / …

书写配置文件:

1
2
3
4
5
6
spring.datasource.url=${MYSQL_URI:jdbc:mysql://xxx.xxx.xxx:xxxx/xxx}
spring.datasource.username=${JDBC_USERNAME:xxx}
spring.datasource.password=${JDBC_PASSWORD:xxx}
spring.datasource.driver-class-name=${JDBC_DRIVER:com.mysql.cj.jdbc.Driver}
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
spring.jpa.hibernate.ddl-auto=update

新建用户模型 user/User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.proxy.HibernateProxy;

import java.util.Objects;

@Getter
@Setter
@ToString
@RequiredArgsConstructor
@Entity
@Table(name="T_USER")
public class User {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String roles;

public User(String username, String password, String roles) {
this.username = username;
this.password = password;
this.roles = roles;
}

@Override
public final boolean equals(Object o) {
if (this == o) return true;
if (o == null) return false;
Class<?> oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass();
Class<?> thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass();
if (thisEffectiveClass != oEffectiveClass) return false;
User user = (User) o;
return getId() != null && Objects.equals(getId(), user.getId());
}

@Override
public final int hashCode() {
return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode();
}

}

新建用户存储库 user/UserRepository.java

1
2
3
4
5
6
7
8
9
10
11
12
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {

Optional<User> findByUsername(String username);

}

新建用户初始化类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.lang.Nullable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Component
public class SetupUserLoader implements ApplicationListener<ContextRefreshedEvent> {

boolean alreadySetup = false;

private final UserRepository userRepository;

private final PasswordEncoder passwordEncoder;

public SetupUserLoader(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}

@Override
@Transactional
public void onApplicationEvent(@Nullable ContextRefreshedEvent event) {
if (alreadySetup)
return;
createUserIfNotFound("admin", "admin", "ROLE_ADMIN,ROLE_USER");
alreadySetup = true;
}

@Transactional
public void createUserIfNotFound(String username, String password, String role) {
Optional<User> optional = userRepository.findByUsername(username);
if (optional.isEmpty()) {
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setRoles(role);
userRepository.save(user);
}
}

}

新建登录请求类 auth/LoginRequest.java

1
2
3
4
5
6
7
8
9
10
import lombok.Data;

@Data
public class LoginRequest {

public String username;

public String password;

}

新建用户细节类 security/CustomUserDetail.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Arrays;
import java.util.Collection;

public class CustomUserDetail implements UserDetails {

private final User user;

public CustomUserDetail(User user) {
this.user = user;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.stream(user
.getRoles()
.split(","))
.map(SimpleGrantedAuthority::new)
.toList();
}

@Override
public String getPassword() {
return user.getPassword();
}

@Override
public String getUsername() {
return user.getUsername();
}

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

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

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

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

}

新建登录验证程序 security/CustomUserDetailsService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import jakarta.annotation.Resource;
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 java.util.Optional;

@Service
public class CustomUserDetailsService implements UserDetailsService {

@Resource
private UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<User> optional = userRepository.findByUsername(username);
if (optional.isEmpty()) {
throw new UsernameNotFoundException(username);
}
return new CustomUserDetail(optional.get());
}

}

Session

新建登出处理类 security/NoRedirectLogoutSuccessHandler.java :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

import java.io.IOException;

public class NoRedirectLogoutSuccessHandler implements LogoutSuccessHandler {

@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setStatus(200);
}

}

新建权限配置程序 security/SecurityConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.rsocket.EnableRSocketSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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.logout.HeaderWriterLogoutHandler;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter;

import static jakarta.servlet.DispatcherType.ERROR;
import static jakarta.servlet.DispatcherType.FORWARD;
import static org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter.Directive.ALL;


@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@EnableRSocketSecurity
public class SecurityConfig {

@Resource
private CustomUserDetailsService customUserDetailsService;

private static final String ADMIN_ROLE_NAME = "ROLE_ADMIN";

private static final String[] AUTH_WHITELIST = {
// -- Swagger UI v3 (OpenAPI)
"/v3/api-docs/**",
"/swagger-ui/**",
// other
"/api/auth/login"
};

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.dispatcherTypeMatchers(FORWARD, ERROR).permitAll()
.requestMatchers(AUTH_WHITELIST).permitAll()
.requestMatchers("/api/admin/**").hasAuthority(ADMIN_ROLE_NAME)
.anyRequest().authenticated()
)
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(Customizer.withDefaults())
.cors(AbstractHttpConfigurer::disable)
.formLogin(Customizer.withDefaults())
.logout((logout) -> logout
.logoutUrl("/api/auth/logout")
.addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(ALL)))
.deleteCookies("JSESSIONID")
.logoutSuccessHandler(new NoRedirectLogoutSuccessHandler())
);
return http.build();
}

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

@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(customUserDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(daoAuthenticationProvider);
}

}

新建用户验证服务 auth/AuthService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.stereotype.Service;

@Service
public class AuthService {

@Resource
private AuthenticationManager authenticationManager;

private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();
SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler();

public void login(HttpServletRequest request,
HttpServletResponse response,
LoginRequest body
) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(body.getUsername(), body.getPassword());
Authentication authentication = authenticationManager.authenticate(token);
SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
SecurityContext context = securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication);
securityContextHolderStrategy.setContext(context);
securityContextRepository.saveContext(context, request, response);
}

public String getUsername() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof User user) {
return user.getUsername();
} else if (principal instanceof UserDetails userDetails) {
return userDetails.getUsername();
} else {
throw new ReportBadException(ErrorEnum.PARAM_EXCEPTION);
}
}

}

新建登录控制器 auth/AuthController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

@Resource
private AuthService authService;

@PostMapping("/login")
public void login(@RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) {
authService.login(request,response,loginRequest);
}

@GetMapping("/user")
public String getUser() {
return authService.getUsername();
}

}

使用方式

在 IDEA 中则可以使用如下方式进行请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
### Login
POST http://localhost:8080/api/auth/login
Content-Type: application/json

{
"username": "admin",
"password": "admin"
}

### Get User
GET http://localhost:8080/api/auth/user
Content-Type: application/json

### Logout
GET http://localhost:8080/api/auth/logout
Content-Type: application/json

获取用户相关信息的基本方式

1
2
3
4
5
6
7
8
@RestController
public class HelloController {

@GetMapping("/hello")
public String hello() {
return "当前登录用户:" + SecurityContextHolder.getContext().getAuthentication().getName();
}
}

JWT

新增如下配置:

1
2
jwt.public.key=classpath:pub.key
jwt.private.key=classpath:pri.key

新建权限配置程序 security/SecurityConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
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.data.repository.query.SecurityEvaluationContextExtension;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.web.SecurityFilterChain;

import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Value("${jwt.public.key}")
RSAPublicKey publicKey;

@Value("${jwt.private.key}")
RSAPrivateKey privateKey;

private static final String ADMIN_ROLE_NAME = "ROLE_ADMIN";

private static final String USER_ROLE_NAME = "ROLE_USER";

private static final String[] AUTH_WHITELIST = {
// -- Swagger UI v3 (OpenAPI)
"/v3/api-docs/**",
"/swagger-ui/**",
// other
"/api/v1/user/login"
};

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.dispatcherTypeMatchers(FORWARD, ERROR).permitAll()
.requestMatchers(AUTH_WHITELIST).permitAll()
.requestMatchers(AUTH_WHITELIST).permitAll()
.requestMatchers("/api/v1/admin/**").hasAuthority(ADMIN_ROLE_NAME)
.requestMatchers("/api/v1/admin").hasAuthority(ADMIN_ROLE_NAME)
.requestMatchers("/api/v1/notice").hasAnyAuthority(USER_ROLE_NAME, ADMIN_ROLE_NAME)
.anyRequest().authenticated()
)
.csrf((csrf) -> csrf.ignoringRequestMatchers("/token"))
.httpBasic(Customizer.withDefaults())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
)
)
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling((exceptions) -> exceptions.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
.accessDeniedHandler(new BearerTokenAccessDeniedHandler())
).cors(AbstractHttpConfigurer::disable);
return http.build();
}

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

@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(this.publicKey).build();
}

@Bean
JwtEncoder jwtEncoder() {
JWK jwk = new RSAKey.Builder(this.publicKey).privateKey(this.privateKey).build();
return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk)));
}

}

新建登录返回类 auth/LoginResponse.java

1
2
3
4
5
6
import lombok.Data;

@Data
public class LoginResponse {
private String token;
}

新建用户验证服务 auth/AuthService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Optional;
import java.util.stream.Collectors;

@Slf4j
@Service
public class UserService {

@Value("${jwt.expiry:36000}")
Long expiry;

@Resource
private JwtEncoder jwtEncoder;

@Resource
private UserDetailsService userDetailsService;

@Resource
private PasswordEncoder passwordEncoder;

@Resource
private UserRepository userRepository;

public LoginResponse login(LoginRequest loginRequest) {
LoginResponse loginResponse = new LoginResponse();
UserDetails userDetails = userDetailsService.loadUserByUsername(loginRequest.getUsername());
if (passwordEncoder.matches(loginRequest.getPassword(), userDetails.getPassword())) {
loginResponse.setToken(generateToken(userDetails));
} else {
throw new RuntimeException("Invalid password");
}
return loginResponse;
}

public User getUserByUsername(String username) {
Optional<User> optionalUser = userRepository.findByUsername(username);
if (optionalUser.isEmpty()){
throw new RuntimeException("Not Found");
}
return optionalUser.get();
}

private String generateToken(UserDetails userDetails) {
Instant now = Instant.now();
String scope = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(" "));
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("self")
.issuedAt(now)
.expiresAt(now.plusSeconds(expiry))
.subject(userDetails.getUsername())
.claim("scope", scope)
.build();
return this.jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}

}

新建登录控制器 auth/AuthController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import io.swagger.v3.oas.annotations.Operation;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequestMapping("/api/auth")
public class AuthController {

@Resource
private UserService userService;

@GetMapping
@Operation(summary = "get current user")
public ResponseEntity<User> user() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof Jwt) {
String username = ((Jwt) principal).getSubject();
return ResponseEntity.ok(userService.getUserByUsername(username));
} else {
throw new RuntimeException("Token error");
}
}

@PostMapping("/login")
@Operation(summary = "login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest) {
return ResponseEntity.ok(userService.login(loginRequest));
}

@GetMapping("/roles")
public ResponseEntity<List<String>> authorities() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
List<String> authorities = new ArrayList<>();
for (GrantedAuthority authority : authentication.getAuthorities()) {
authorities.add(authority.getAuthority());
}
return ResponseEntity.ok(authorities);
}

}

使用方式

1
2
3
4
5
6
curl --location --request POST 'localhost:8080/api/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"username":"admin",
"password":"admin"
}'
1
curl --request GET 'http://localhost:8080/api/auth' --header 'Authorization: Bearer <token>'

在 Get 参数或 Form 中携带 token

首先需要参照如下样例修改配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception {
DefaultBearerTokenResolver resolver = new DefaultBearerTokenResolver();
resolver.setAllowUriQueryParameter(true);
resolver.setAllowFormEncodedBodyParameter(true);
http
.authorizeHttpRequests((authorize) -> authorize
.dispatcherTypeMatchers(FORWARD, ERROR).permitAll()
.requestMatchers(antMatcher("/api/auth/login")).permitAll()
.anyRequest().authenticated()
)
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(Customizer.withDefaults())
.oauth2ResourceServer(oauth2 -> oauth2
.bearerTokenResolver(resolver)
.jwt(jwt -> jwt
.decoder(jwtDecoder())
)
)
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling((exceptions) -> exceptions
.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()));
return http.build();
}
}

然后即可在请求中添加 access_token 参数即可,GET 请求样例如下:

1
curl --request GET 'http://localhost:8080/api/user?access_token=<token>'

注:无需添加 Bearer 字段

OpenAPI 相关配置

可以加入如下配置,可以在 OpenAPI 中自动携带 JWT Token。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenAPIConf {

@Bean
public OpenAPI customizeOpenAPI() {
String securitySchemeName = "bearerAuth";
return new OpenAPI()
.addSecurityItem(new SecurityRequirement()
.addList(securitySchemeName))
.components(new Components()
.addSecuritySchemes(securitySchemeName, new SecurityScheme()
.name(securitySchemeName)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.description(
"Provide the JWT token. JWT token can be obtained from the Login API. For testing, use the credentials <strong>john/password</strong>")
.bearerFormat("JWT")));
}
}

与 Spring Data 集成

可以在依赖中添加 org.springframework.security:spring-security-data 来与 SpringData 集成,通过下面的查询直接返回用户所拥有的内容:

1
2
3
4
5
@Repository
public interface MessageRepository extends PagingAndSortingRepository<Message,Long> {
@Query("select m from Message m where m.to.id = ?#{ principal?.id }")
Page<Message> findInbox(Pageable pageable);
}

自定义登录页

编写如下 resources/templates/login.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html lang="zh" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log In</title>
</head>
<body>
<h1>Here is Custom Login page. Please Log In: </h1>
<div th:if="${param.error}">
Invalid username and password.</div>
<div th:if="${param.logout}">
You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<label>
<input type="text" name="username" placeholder="Username"/>
</label>
</div>
<div>
<label>
<input type="password" name="password" placeholder="Password"/>
</label>
</div>
<input type="submit" value="Log in" />
</form>
</body>
</html>

编写 security/SecurityConfig.java 配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
.formLogin(form -> form.loginPage("/login").permitAll());
return http.build();
}
}

编写 auth/LoginController.java

1
2
3
4
5
6
7
8
9
10
11
12
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {

@GetMapping("/login")
public String login() {
return "login";
}

}

注:此处如果单个页面样式上需要使用 Tailwind CSS 可以参照参考资料样例。

单元测试

在测试中可以使用如下方式指定测试用户:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class UserControllerTest {

@Autowired
private MockMvc mockMvc;

@Test
@WithMockUser(username = "user", roles = "USER")
public void testGetUser() throws Exception {
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.username").value("user"));
}
}

方法鉴权

除了在接口层面上做安全之外,还可以在方法层面上进行补充和完善,确保数据安全。

开启下面的注解后即可使用方法鉴权:

1
2
3
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@EnableMethodSecurity

在通过 Spring 调用相应需要鉴权的方法时就会触发安全检查,抛出相应异常。

参考资料

Spring Security 例程

baeldung 教程

RSA 密钥生成

spring-boot-3-jwt-security

spring-boot-tailwind

Spring Tips: Spring Security method security with special guest Rob Winch

method-security 样例项目

Let’s Explore Spring Security 6.4 (SpringOne 2024)

Bootiful Spring Boot 3.4: Spring Security

spring-security-64

Testing with CSRF Protection


Spring Security
https://wangqian0306.github.io/2021/spring-security/
作者
WangQian
发布于
2021年10月27日
许可协议