SpringBoot Security
关于 SpringBoot Security
安全框架,以及在项目中如何使用(未完成)
Spring Security
[toc]
什么是 Spring Security
一个安全框架
主要负责实现两大功能:认证与授权
事实上就是一系列具有特定功能的过滤器组合成为的一个过滤链(Filler Chain)
过滤器链解析
ChannelProcessingFilter
:确保请求通过正确的通道(HTTP 或 HTTPS)传输。WebAsyncManagerIntegrationFilter
:集成 Spring Security 与 Spring Web 的异步请求处理。SecurityContextPersistenceFilter
:负责从HttpSession
中加载SecurityContext
,并在请求处理完成后将其存储回HttpSession
。HeaderWriterFilter
:用于添加安全相关的 HTTP 头,例如X-Content-Type-Options
、X-Frame-Options
、X-XSS-Protection
等。CsrfFilter
:防止跨站请求伪造(CSRF)攻击。LogoutFilter
:处理用户注销请求。UsernamePasswordAuthenticationFilter
负责处理我们在登录界面填写了用户名和密码后的登陆请求。负责认证工作
处理基于用户名和密码的身份验证请求。
DefaultLoginPageGeneratingFilter
:生成默认的登录页面(如果未提供自定义登录页面)。DefaultLogoutPageGeneratingFilter
:生成默认的注销页面(如果未提供自定义注销页面)。BasicAuthenticationFilter
:处理 HTTP Basic 认证请求。RequestCacheAwareFilter
:确保用户在登录后重定向到他们最初请求的 URL。SecurityContextHolderAwareRequestFilter
:将SecurityContext
中的信息添加到HttpServletRequest
中。AnonymousAuthenticationFilter
:为未认证的用户提供匿名身份。SessionManagementFilter
:管理用户会话,包括并发会话控制和会话固定攻击防护。ExceptionTranslationFilter
处理认证和授权过程中抛出的异常,并将用户重定向到适当的错误页面。
处理
AccessDeniedException
AuthenticationException
FilterSecurityInterceptor
负责权限校验的过滤器,负责授权
执行访问控制决策,确定当前用户是否有权访问请求的资源。
核心类 (接口)
Authentication
Authentication
类是一个核心接口,表示用户的认证信息,有如下的方法
- **
getAuthorities()
**:返回用户的权限(角色)。 - **
getCredentials()
**:返回用户的凭证(如密码)。 - **
getDetails()
**:返回与认证请求相关的附加信息。 - **
getPrincipal()
**:返回用户的主体信息(如用户名)。 - **
isAuthenticated()
**:返回用户是否已认证。 - **
setAuthenticated(boolean isAuthenticated)
**:设置用户的认证状态。
SecurityContextHolder
SecurityContextHolder
是 Spring Security 中的一个核心类,用于存储和获取当前应用程序的安全上下文(SecurityContext
包含了当前用户的认证信息 Authentication
对象)
主要作用为:
- 存储认证信息:
SecurityContextHolder
存储当前用户的SecurityContext
,其中包含了用户的认证信息和权限。 - 获取认证信息:可以通过
SecurityContextHolder
获取当前用户的SecurityContext
,从而获取用户的认证信息和权限。 - 线程安全:
SecurityContextHolder
提供了多种策略来确保在多线程环境中安全地存储和访问SecurityContext
。
项目配置
1 | <dependency> |
添加项目配置后重新运行项目,会产生一个 security password
如果此时直接访问这个应用会出现 401 错误
基于表单的登陆和登出
Security 提供了两个界面用于登陆和登出,分别位于 /login 和 /logout 路径下
只有通过这个进行验证后才能访问服务的其他端点
禁用 Spring Security
1 |
Security + JWT 实现用户权限控制流程
先进行 Spring Security 的相关配置
Spring Security 相关配置
核心是很多 Bean
的装载以及构建一个核心的 securityFilterChain
1 | // SecurityConfig.java |
Register 用户注册
实现密码加密存储,数据库中的密码不应该存储明文
1 | // UserServiceImpl.java |
Login 用户登录
实现 userdetailService
1 |
|
生成 token security 上下文
1 | // UserServiceImpl.class |
Other Request 其他请求
自定义的实现拦截器
实现一个拦截器用于拦截其他请求并且进行验证
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// JwtAuthenticationFilter.class
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private TokenUtil tokenUtil;
CustomUserDetailsService userDetailsService;
// 用于定义不需要拦截的请求路径
private static final Set<String> EXCLUDED_PATHS = Set.of("/api/user/register", "/api/user/login", "/authenticate");
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class); //定义logger
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 获取路径 判断是否需要拦截 不需要拦截的直接放行
String path = request.getRequestURI();
if (EXCLUDED_PATHS.contains(path)) {
filterChain.doFilter(request, response);
return;
}
/**
从请求头的 Authorization 中获得用户 Token
通过 tokenUtil 工具类中的方法获得用户的 user_name(实际上是用户手机号)
判断获得的 user_name(phone) 是否存在于 security 上下文中(用户是否登录)
如果存在用户的信息就再包装一个 authentication 存入 security 上下文中
这样在该会话的后续处理过程中用户可以通过 security 上下文来获得用户信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
**/
logger.info("JwtAuthenticationFilter");
String token = request.getHeader("Authorization");
logger.info("token: " + token);
String username = tokenUtil.parseTokenWithoutKey(token).getUsername();
logger.info("username: " + username);
// If the token is valid, set the authentication in the context
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
// session 中存储用户信息
request.getSession().setAttribute("currentUser", tokenUtil.getUser(token));
}
// 拦截器完成用户身份验证请求继续传递进行后续处理
try {
filterChain.doFilter(request, response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}使用 spring security 已经配置好的
在 config 中进行额外的配置
1
Token
这里的 Token
分为使用密钥对(KeyPair)加密和不使用密钥加密的两种实现
使用密钥加密更加安全,可以避免对 Token 的篡改,需要修改 JwtDecoder
JwtEncoder
两个 Bean
token 生成
1
2
3
4
5
6
7
8// 不对 token 进行加密
public String generateTokenWithoutKey(UserDetails userDetails) {
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
return JWT.create()
.withAudience(userDetails.getUsername())
.withExpiresAt(date)
.sign(Algorithm.HMAC256(SECRET_KEY)); // Use SECRET_KEY here
}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// 进行加密
/**
* 此方法用于创建一个JWT(JSON Web Token)。
*
* @param authentication Spring Security的Authentication对象,包含了用户的认证信息。
* @return String 返回创建的JWT令牌。
*
* 方法的工作流如下:
* 1. 获取当前的时间。
* 2. 创建一个JwtClaimsSet对象,这个对象包含了JWT的一些声明(claims)。声明是一些关于用户的信息,例如用户的名字、令牌的发行者、令牌的发行时间、令牌的过期时间等。
* - 设置令牌的发行者为"self"。
* - 设置令牌的发行时间为当前时间。
* - 设置令牌的过期时间为1小时后。
* - 设置令牌的主题为当前用户的名字。这通常是用户的唯一标识,例如用户名或用户ID。
* - 添加一个自定义的声明"scope"。这个声明的值是通过createScope(authentication)方法获取的。这个方法的具体实现没有在这段代码中给出,但通常它会返回一个表示用户权限的字符串或字符串列表。
* 3. 使用JwtEncoder将上面创建的JwtClaimsSet对象编码为一个JWT字符串,然后返回这个字符串。这个JWT字符串就是我们通常说的JWT令牌,它可以被发送给用户,然后在用户的后续请求中作为身份验证的凭证。
*/
public String create(Authentication authentication) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("self")
.issuedAt(now)
.expiresAt(now.plusSeconds(60 * 60)) // 1 hour
.subject(authentication.getName())
.claim("scope", createScope(authentication))
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}token 解码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// 不对 token 进行加密
public UserDetails parseTokenWithoutKey(String token) {
logger.info("parseTokenWithoutKey");
// Decode the token
DecodedJWT jwt = JWT.decode(token);
// Get the username from the "audience" claim
String phone = jwt.getAudience().get(0);
logger.info("phone: " + phone);
// Get the expiration date
Date expiresAt = jwt.getExpiresAt();
UserDetails userDetails = userDetailsService.loadUserByUsername(phone);
// Check if the token is expired
if (expiresAt.before(new Date())) {
throw new RuntimeException("Token is expired");
}
return userDetails;
}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// token 加密
/**
* 此方法用于解析由create方法生成的JWT令牌。
*
* @param token 要解析的JWT令牌。
* @return Authentication 返回一个Authentication对象,该对象包含了从令牌中解析出的用户信息。
*
* 方法的工作流程如下:
* 1. 使用JwtDecoder将JWT令牌解码为一个Jwt对象。
* 2. 从Jwt对象中获取claims(声明)。
* 3. 从claims中获取用户名(subject)和权限(scope)。
* 4. 使用用户名从用户服务中获取UserDetails对象。
* 5. 创建一个UsernamePasswordAuthenticationToken对象,该对象包含了用户的详细信息和权限。
* 6. 返回创建的UsernamePasswordAuthenticationToken对象。
*/
public Authentication parse(String token) {
Jwt jwt = jwtDecoder.decode(token);
Map<String, Object> claims = jwt.getClaims();
String username = (String) claims.get("sub");
List<String> scopes = (List<String>) claims.get("scope");
Collection<GrantedAuthority> authorities = scopes.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
}token 使用密钥对(keyPair)进行签名加密的原理和理由
- 在 JWT(JSON Web Tokens)的使用中,密钥对(Key Pair)的使用是为了保证令牌的安全性。
- 密钥对由两部分组成:公钥和私钥。私钥用于生成(签名)令牌,公钥用于验证令牌。
- 当你创建一个 JWT 时,你会使用私钥对其进行签名。这个签名可以确保令牌在传输过程中没有被篡改。只有知道私钥的人才能生成这个签名,所以如果令牌的接收者可以验证这个签名,他们就可以确信令牌是由持有私钥的人生成的,而且在传输过程中没有被修改。
- 公钥用于验证令牌的签名。公钥可以安全地公开,因为即使有人知道公钥,他们也不能用它来生成有效的签名。只有持有私钥的人才能生成可以用公钥验证的签名。
- 因此,当你创建一个 JWT 并将其发送给客户端时,你也应该提供用于验证令牌的公钥。这样,客户端就可以使用公钥来验证他们收到的令牌是否有效,以及是否在传输过程中被篡改。
- 总的来说,使用密钥对(公钥和私钥)是为了保证 JWT 的安全性。私钥保证了令牌的生成过程是安全的,公钥则让令牌的接收者可以验证令牌的有效性
密钥对配置
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
public class JwtConfig {
// 1. Create Key Pair 这里的 Key Pair 可以持久化 在一定时间内保持一致
public KeyPair keyPair() {
try {
var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
// 2. Create RSA Key object using the key pair
public RSAKey rsaKey(KeyPair keyPair) {
return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
.privateKey(keyPair.getPrivate())
.keyID(UUID.randomUUID().toString())
.build();
}
// 3. Create JWK Source (JSON Web Key Source)
// 3.1 Create JWK Set with the RSA Key
// 3.2 Create JWK Source with the JWK Set
public JWKSource<SecurityContext> jwkSource(RSAKey rsaKey) {
var jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
// 4. Use RSA Public Key to encode
public JwtDecoder jwtDecoder(RSAKey rsaKey) throws JOSEException {
return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build();
}
// 5. Use JWK Source to decode
public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
return new NimbusJwtEncoder(jwkSource);
}
}
使用 Redis 进行优化
用户登录后存储用户信息,再次请求时不访问数据库 直接访问 redis
1 | // CustomerUserDetailService.java |
第三方登录
CSRF
验证码验证
可能会遇到的问题
无法自动装配。找不到 ‘HttpSecurity’ 类型的 Bean
1
2
3//启动类添加注解
java: java.lang.NoSuchFieldError
1
2
3
4
5Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid'
// 升级lombok版本到 1.18.30
// 参考 https://stackoverflow.com/questions/77297895/how-to-fix-nosuchfielderror-com-sun-tools-javac-tree-jctreeCannot invoke “org.apache.commons.logging.Log.isDebugEnabled ()” because “this.logger” is null 问题解决
参考链接 StackoverFlow
出现的原因,使用 AOP + logger 时将一些不正确的类也包括了进去,导致报错。具体来说是因为 GenericFilterBean 是 Spring 提供的一个基础类,用于创建过滤器。它在初始化时会设置 logger 对象。如果在 logger 对象被设置之前就调用了需要使用 logger 的方法,就会出现这个问题。 不一定是直接使用这个类,可能是继承了这个类或者其子类
1
@Around("execution(* com.wcx.blog.BlogBackend..*(..))
修改后
1
2@Around("execution(* com.wcx.blog.BlogBackend..*(..)) && !execution(* com.wcx.blog.BlogBackend.config.JwtAuthenticationTokenFilter.*(..))")