认证
username和password认证
读取 用户名和密码
springsecurity可以用表单 Basic Digest等机制来从HttpServletRequest读取用户名和密码
表单
简单说就是当用户访问私有资源时,如果未登录会抛出AccessDeniedException,然后跳转到LoginController与相应的静态资源
严格说就过程就是
当一个用户访问未被授权的资源/private会发出一个未经认证的请求
接着springsecurity的AuthorizationFilter会抛出一个AccessDeniedException来表明未经认证的请求被拒绝了
然后ExceptionTranslationFilter由于上游抛错了,所以会Start Authentication,并发送一个重定向到配置的AutheticationEntryPoint的登录页面
浏览器请求进入被重定向的登录页面
当username和password被提交后,UsernamePasswordAuthenticationFilter会对其进行认证
为了读懂这个图,我跳转去了Servlet 认证架构把这些名词给看懂了
SecurityContextHolder:
是SpringSecurity储存认证用户细节的地方,它包含了SecurityContext
简单说,SecurityContextHolder基于ThreadLocal的全局访问点,其包含了SecurtiyContext,而SecurityContext作为容器保存Authentication
而最重要的我认为是Authentication,它表示主体(用户)及其相关信息
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities(); // 权限/角色
Object getCredentials(); // 凭据(如密码)
Object getDetails(); // 额外细节(如 IP 地址)
Object getPrincipal(); // 用户标识(用户名或 UserDetails)
boolean isAuthenticated(); // 是否已认证
void setAuthenticated(boolean isAuthenticated); // 设置认证状态
String getName(); // 用户名
}
图的流程:
1.当用户提交他们的用户名和密码时,UsernamePasswordAuthenticationFilter会通过HttpServletRequest中拿到用户的用户名和密码,创建一个UsernamePasswordAuthenticationToken,同时也是Authentication的一种
2.接下来Token会被传入AuthenticationManager里面,以进行认证,而其认证细节取决于用户信息存储方式。这里的存储方式最常见的就是mysql,然后mysql的操作又有很多框架,如Mybatis,R2dbc等,当然还有内存存储等,后面会讲的
3.如果认证失败,就会Failure那么
- SecurityContextHolder 被清空。
RememberMeServices.loginFail
被调用。如果没有配置remember me,这就是一个无用功。AuthenticationFailureHandler
被调用。
4.如果成功了,那么SessionAuthenticationStrategy会被通知有新的登录
然后Authentication会被放在SecurtiyContextHolder里面,调用RememberMeService接口,ApplicationEventPublisher发布事件,AuthenticatonSuccessHandler被调用
看得懂吗,我是没怎么看懂哈哈哈
默认情况下,SpringSecurity都是基于表单登录的
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.formLogin(withDefaults());
// ...
}
如何自定义一个登录表单呢
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.formLogin(form -> form
.loginPage("/login")
.permitAll()
);
// ...
}
如果指定了登录的页面如/login,那么在java的Thymeleaf( 在 HTML 页面中嵌入 Java 数据和逻辑的“桥梁”,比如把登录用户信息、表单、消息等数据填入 HTML 页面)
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log In</title>
</head>
<body>
<h1>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>
<input type="text" name="username" placeholder="Username"/>
</div>
<div>
<input type="password" name="password" placeholder="Password"/>
</div>
<input type="submit" value="Log in" />
</form>
</body>
</html>
关于默认的HTML表单,有几个关键点。
表单应该以 post 方法请求 /login。
该表单需要包含 CSRF Token,Thymeleaf 会 自动包含。
该表单应在一个名为 username 的参数中指定用户名。
表单应该在一个名为 password 的参数中指定密码。
如果发现名为 error 的HTTP参数,表明用户未能提供一个有效的用户名或密码。
如果发现名为 logout 的HTTP参数,表明用户已经成功注销
当然,我们的SpringMVC可以用一个控制器去映射到我们的登录模板,如
@Controller
class LoginController {
@GetMapping("/login")
String login() {
return "login";
}
}
当然在实际开发中都是自定义登录接口才能满足前后端分离的开发需求
表单当作了解就好其实,现在流行基于前后端分离的开发模式,所以一般都是自定义一个登录接口,再自定义UserDetailsService来从Mysql获取用户数据
下边给出一个实际开发的框架
前端 POST /login(JSON账号密码)
↓
后端 Controller 接口接收,封装 UsernamePasswordAuthenticationToken
↓
调用 AuthenticationManager 进行认证
↓
触发自定义 UserDetailsService,从 MySQL 查询用户
↓
认证成功 → 生成 JWT → 返回给前端
前端存储 JWT(localStorage / cookie)
↓
之后所有请求在 Header 加上 Authorization: Bearer xxx
↓
后端使用 JWT 过滤器解析认证并授权
后端完整实现
@Entity
@Table(name = "sys_user")
@Data
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String role; // 例:ROLE_USER
}
@Data
public class LoginRequest {
private String username;
private String password;
}
@Data
@AllArgsConstructor
public class LoginResponse {
private String token;
}
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByUsername(String username);
}
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) {
UserEntity user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
return User.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole()) // 自动加前缀 ROLE_
.build();
}
}
package com.example.security.config;
import com.example.security.filter.JwtAuthFilter;
import com.example.security.service.MyUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.*;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity // 启用 Spring Security 的 Web 安全支持
@RequiredArgsConstructor
public class SecurityConfig {
private final MyUserDetailsService myUserDetailsService;
private final JwtAuthFilter jwtAuthFilter;
/**
* Spring Security 主过滤器链配置
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable() // 关闭 CSRF:前后端分离中不需要表单防护
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register").permitAll() // 登录和注册接口无需认证
.anyRequest().authenticated() // 其他接口必须认证
)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 使用无状态会话
.and()
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); // 添加 JWT 认证过滤器
return http.build();
}
/**
* 认证管理器(处理登录时用户认证逻辑)
*/
@Bean
public AuthenticationManager authManager(HttpSecurity http, PasswordEncoder encoder) throws Exception {
return http.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(myUserDetailsService) // 设置自定义 UserDetailsService
.passwordEncoder(encoder) // 设置密码加密方式
.and()
.build();
}
/**
* 密码加密器:使用 BCrypt 算法
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@RestController
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());
Authentication authentication = authenticationManager.authenticate(authToken);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String jwt = jwtUtil.generateToken(userDetails);
return ResponseEntity.ok(new LoginResponse(jwt));
}
}
实际开发一般自定义登录接口,并且在配置中取消跳转页面
@Component
public class JwtUtil {
private final String SECRET = "secret123456";
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.claim("roles", userDetails.getAuthorities())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 3600))
.signWith(SignatureAlgorithm.HS512, SECRET)
.compact();
}
public String extractUsername(String token) {
return Jwts.parser().setSigningKey(SECRET)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public boolean validateToken(String token, UserDetails userDetails) {
return extractUsername(token).equals(userDetails.getUsername());
}
}
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private MyUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
String username = jwtUtil.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
//Authorities表示用户拥有的权限或角色
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
}
filterChain.doFilter(request, response);
}
}
以上就是一个基本框架,其中UserDetailsService是 用户认证信息来源
Security是安全规则配置
在上述配置中,用户通过登录接口或者注册端口,如果登录错误就会抛出错误返回401
接着,在Security配置中配置了一些要求,在UserDetails里面配置了从数据库抽取数据的方式自定义认证方式
其中 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 是指会话管理策略是无状态会话,也就是服务器不携带登录状态信息如Session,每次请求都得携带如token等认证信息
CSRF
跨站请求伪造 Cross Set Request Forgery
CSRF 攻击的本质是:攻击者诱导已登录用户,在不知情的情况下,向受信任网站发起恶意请求,从而执行非本意的操作
举个例子:
- 你登录了银行网站,拥有了合法的 Cookie。
- 未登出前,浏览另一个恶意网站。
- 这个网站偷偷发起一个请求
<img src="https://bank.com/transfer?amount=10000&to=attacker" />
此时钱就没了
当然,在前后端分离的情况下是不用开的
因为:
- 不使用 Cookie 自动认证,改为手动在 Header 里传 token
- 请求头中必须有
Authorization: Bearer <token>
- 这类请求 不会自动被浏览器带上凭证,不满足 CSRF 的攻击条件
以上,简单总结一下
UserDetails就是用来进行用户信息认证的核心逻辑和方式
Security配置是用来配置SpringSecurity的核心配置类
当使用login或者register时,会在controller里面生成一个UsernamePasswordToken然后用authenticationManger来验证用户信息,也就是认证,认证的方式取决于UserDetails,加密方式也是。
登录后会返回token给浏览器,浏览器之后会带着它来访问,这个时候先经过jwtFilter解析出信息放在SecurityContextHolder里面,然后再进入UsernamePasswordFilter生成Token,再用Authenticaition认证,然后再进行下一步操作
各大名词的解释
SecurityContextHolder,SecurityContext,Authenticaiton
SecurityContext包含SecurityContext,SecurityContext包含Authenticaiton,并且Holder简单说是Context的线程获取器,简单说就是一个ThreadLocal,Context是安全信息凭证,Authentication就是安全信息本身的具体内容,如username和password和角色权限
GrantedAuthorities是被授予的权限,可以从Authentication中获取,也就是getAuthorities
AuthenticationManger简单说就是进行用户信息验证的东西,验证后返回Authentication到SecurityContextHolder里面
类似于表单登录的流程
用户请求 (带Token) -> 进入 Filter Chain -> 到达 Token Filter -> 检查/验证 Token -> [Token无效 -> 拒绝请求] / [Token有效 -> 解析用户/权限 -> 创建 Authentication 对象如 UsernamePasswordAuthenticationToken -> 设置到 SecurityContextHolder] -> 请求传递给下一个 Filter -> (授权检查等) -> 到达业务代码 -> 业务处理 -> 返回响应。
创建Authentication是在AuthenticationManger认证后补充生成的Authentication,同时UsernamePasswordAuthenticationToken是一个Authentication的实现
AuthenticationProvider
可以根据凭证Authenticaiton的类型具体实现认证方式
是实现认证的具体逻辑的接口,有俩个核心接口:
- Authentication authenticate(Authentication authentication):执行认证逻辑,返回已认证的 Authentication 或抛出异常。
- boolean supports(Class<?> authentication):判断是否支持某种 Authentication 类型(如 UsernamePasswordAuthenticationToken)。
ProviderManger
- ProviderManager 是 AuthenticationManager 的默认实现,管理多个 AuthenticationProvider。
ProviderManager
自己不亲自动手做验证,它的主要工作是管理一个列表的AuthenticationProvider
。当有认证请求来时,它负责把请求分发给合适的“验证专家”(AuthenticationProvider
) 去处理- 工作方式:
- ProviderManager 遍历 AuthenticationProvider 列表。
- 调用 supports() 检查是否支持当前 Authentication 类型。
- 如果支持,调用 authenticate() 进行认证。
AuthenticationEntryPoint
向浏览器发送一个要求 提供凭证如UsernamePasswordToken的请求
实际的前后端分离项目在自定义接口进行认证,不会用到AbstractAuthenticationProcessingFilter
那么这就是基于表单进一步学习前后端分离项目的认证过程
简单说前后端分离的认证过程就是:在login时调用先生成一个Token(Authentication)然后用AuthenticationManger进行认证,认证成功生成token返回给浏览器客户端
之后客户端带着token访问其他接口,会先被JWTFilter解析,然后放入SecurityContext里面的Autentication,执行下一步的AuthenticationManger(通常是ProviderManger)中合适的AuthenticationProvider的认证,认证成功才会到达具体接口
额外的,在开发时,register时,也要用BCrypt的加密方式对密码加密存储
如
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final BCryptPasswordEncoder passwordEncoder; // 通过依赖注入获取 BCryptPasswordEncoder Bean
public UserService(BCryptPasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
public void registerUser(String username, String rawPassword) {
// 1. 对明文密码进行加密
String encodedPassword = passwordEncoder.encode(rawPassword);
// 2. 将用户名和加密后的密码保存到数据库
// saveUserToDatabase(username, encodedPassword);
System.out.println("用户 " + username + " 的加密密码是: " + encodedPassword);
// 实际开发中,这里会调用你的 Repository 或 DAO 将 encodedPassword 存入数据库
}
// ... 其他用户相关方法
}
// 在你的配置类中声明 BCryptPasswordEncoder Bean
// @Configuration
// public class SecurityConfig {
// @Bean
// public BCryptPasswordEncoder passwordEncoder() {
// return new BCryptPasswordEncoder();
// }
// }