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
7
8
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
jwt.public.key=classpath:pub.key
jwt.private.key=classpath:pri.key
spring.jpa.hibernate.ddl-auto=update

新建用户/权限相关模型:

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
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import org.hibernate.Hibernate;

import java.util.Collection;
import java.util.Objects;

@Getter
@Setter
@RequiredArgsConstructor
@NamedEntityGraph(
name = "user.roles",
attributeNodes = @NamedAttributeNode("roles")
)
@Entity
@Table(name = "T_USER")
public class User {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(unique=true)
private String username;

private String email;

private String phone;

@JsonIgnore
private String password;

@Transient
private String rawPass;

private Boolean enabled;

@JsonIgnore
private Boolean tokenExpired;

@JsonIgnore
@ManyToMany
@JoinTable(
name = "T_USER_ROLE_REL",
joinColumns = @JoinColumn(
name = "USER_ID", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(
name = "ROLE_ID", referencedColumnName = "id"))
private Collection<Role> roles;

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
User that = (User) o;
return id != null && Objects.equals(id, that.id);
}

@Override
public int hashCode() {
return getClass().hashCode();
}

}
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 jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import com.fasterxml.jackson.annotation.JsonIgnore;

import java.util.Collection;

@Slf4j
@Setter
@Getter
@Entity
@Table(name = "T_ROLE")
public class Role {

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

private String name;

@JsonIgnore
@ManyToMany(mappedBy = "roles")
private Collection<User> users;

}

新建用户/权限相关存储库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.springframework.data.jpa.repository.EntityGraph;
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> {

@EntityGraph(value = "user.roles", type = EntityGraph.EntityGraphType.FETCH)
Optional<User> findByUsername(String username);

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

@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {

Role findByName(String name);

}

新建权限配置程序:

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)));
}

}

新建登录验证程序:

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 jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
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;

@Slf4j
@Service
public class CustomUserDetailService 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 CustomUserPrincipal(optional.get());
}

}

新建登录请求和返回类:

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

@Data
public class LoginRequest {
private String username;
private String password;
}
1
2
3
4
5
6
import lombok.Data;

@Data
public class LoginResponse {
private String token;
}

新建用户验证服务:

1
2
3
4
5
6
7
public interface UserService {

LoginResponse login(LoginRequest loginRequest);

User getUserByUsername(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
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
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 UserServiceImpl implements UserService {

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

@Resource
private JwtEncoder jwtEncoder;

@Resource
private UserDetailsService userDetailsService;

@Resource
private PasswordEncoder passwordEncoder;

@Resource
private UserRepository userRepository;

@Override
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;
}

@Override
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();
}

}
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
import lombok.Getter;
import lombok.Setter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

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

@Getter
@Setter
public class CustomUserPrincipal implements UserDetails {

private CustomUser user;

public CustomUserPrincipal(CustomUser user) {
this.user = user;
}

@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 user.getEnabled();
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : user.getRoles()) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}

}

新建登录控制器:

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/v1/user")
public class UserController {

@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
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
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.List;
import java.util.Optional;

@Component
public class SetupDataLoader implements ApplicationListener<ContextRefreshedEvent> {

boolean alreadySetup = false;

private final UserRepository userRepository;

private final RoleRepository roleRepository;

private final PasswordEncoder passwordEncoder;

public SetupDataLoader(UserRepository userRepository, RoleRepository roleRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.passwordEncoder = passwordEncoder;
}

@Override
@Transactional
public void onApplicationEvent(@Nullable ContextRefreshedEvent event) {
if (alreadySetup)
return;
Role adminRole = createRoleIfNotFound("ROLE_ADMIN");
createUserIfNotFound(adminRole);
alreadySetup = true;
}

@Transactional
public Role createRoleIfNotFound(String name) {
Role role = roleRepository.findByName(name);
if (role == null) {
role = new Role();
role.setName(name);
roleRepository.save(role);
}
return role;
}

@Transactional
public void createUserIfNotFound(Role adminRole) {
Optional<User> optional = userRepository.findByUsername("admin");
if (optional.isEmpty()) {
User user = new User();
user.setUsername("admin");
user.setPassword(passwordEncoder.encode("admin"));
user.setRoles(List.of(adminRole));
user.setEnabled(true);
userRepository.save(user);
}
}
}

使用方式

1
2
3
4
5
6
curl --location --request POST 'localhost:8080/api/v1/user/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"username":"admin",
"password":"admin"
}'
1
curl --request GET 'http://localhost:8080/api/v1/user' --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/v1/user/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/v1/user?access_token=<token>'

注:无需添加 Bearer 字段

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

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

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

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>

编写 SecurityConfig 配置文件:

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();
}
}

编写 LoginController

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";
}

}

单元测试

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

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"));
}
}

参考资料

Spring Security 例程

baeldung 教程

RSA 密钥生成

spring-boot-3-jwt-security


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