Spring Security6 OAuth2 实现流程
一 通用的权限框架需求
1 响应数据格式需要统一
示例
{ "code": "success", "message": "description", "data": null }
ps:
code: 状态码,后台定义给前端用,比如“token.expired”,前端收到这个状态码后,会立刻去调用刷新token接口
message: 状态码含义描述,可以做国际化翻译。不同语言返回不同的翻译版本(这个用不到,就不演示了)
重点:正常情况 和 异常情况 返回的数据格式都必须一样
实战
我们通过一个案例来模拟成功与失败的返回
定义Controller,实现两个api来模拟成功和失败。然后定义一个返回结果类Result 来规范统一的返回结果
@RestController
@RequestMapping("/open-api")
public class TestDemoController {
@GetMapping("/business-1")
public Result getA() {
return new Result("状态码A", "成功", "null");
}
@GetMapping("/business-2")
public Result getB() {
throw new RuntimeException("模拟失败");
}
}
如果要失败也要返回一致的结果,就要定义全局的拦截器,来返回Result
@RestControllerAdvice
@Slf4j
public class WebGlobalExceptionHandler {
@ExceptionHandler(value = Exception.class)
public Result exceptionHandler(HttpServletResponse response, Exception e) {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
log.info("服务器异常", e);
return Result.fail("服务器异常");
}
}
2. 支持多种方式登录
比如:用户名+密码登录,手机号+短信登录,后续以用户名+密码登录作为演示
我们需要想,在没有框架的时候,我们是怎么实现登陆认证的。抛开权限框架自己要怎么实现这个流程。
2.1 无框架下认证
首先,我们得设置一个入口,post请求加上url
然后,我们需要封装一个实体类来读取从前端传过来的用户的信息
然后登陆验证
接着,如果验证成功,就生成token并返回
最后返回一致的认证成功响应
这也是框架登陆的基本路流程
// 不基于框架自己来实现登陆
@PostMapping("/loginByMyself")
public Result loginByMyself(@RequestBody UsernameAuthentication requestParam){
// 1.获取请求参数
String username = requestParam.getUsername();
String password = requestParam.getPassword();
//2.认证逻辑...校验账号密码...
System.out.println("校验:username:"+ username + " password:" + password);
//3,认证通过,返回jwt Token
HashMap<Object,Object> responseData = new HashMap<>();
responseData.put("token","jwtToken...");
responseData.put("refreshToken","refreshToken...");
//4 返回响应结果
return ResultBuilder
.aResult()
.data(responseData)
.code(Result.SUCCESS_CODE)
.msg("登录成功")
.build();
}
2.2 使用框架认证
如果你要请求 /user/login/username
路径进行用户名和密码登录,下面是请求的处理流程及 Spring Security 底层调用的类和过滤器。
具体的全部代码
CustomWebSecurityConfig
package org.demo.security.authentication.config;
import jakarta.servlet.Filter;
import java.util.List;
import org.demo.security.authentication.handler.exception.CustomAuthenticationExceptionHandler;
import org.demo.security.authentication.handler.exception.CustomAuthorizationExceptionHandler;
import org.demo.security.authentication.handler.exception.CustomSecurityExceptionHandler;
import org.demo.security.authentication.handler.login.LoginFailHandler;
import org.demo.security.authentication.handler.login.LoginSuccessHandler;
import org.demo.security.authentication.handler.login.gitee.GiteeAuthenticationFilter;
import org.demo.security.authentication.handler.login.gitee.GiteeAuthenticationProvider;
import org.demo.security.authentication.handler.login.sms.SmsAuthenticationFilter;
import org.demo.security.authentication.handler.login.sms.SmsAuthenticationProvider;
import org.demo.security.authentication.handler.login.username.UsernameAuthenticationFilter;
import org.demo.security.authentication.handler.login.username.UsernameAuthenticationProvider;
import org.demo.security.authentication.handler.resourceapi.openapi1.MyJwtAuthenticationFilter;
import org.demo.security.authentication.handler.resourceapi.openapi2.OpenApi2AuthenticationFilter;
import org.demo.security.authentication.handler.resourceapi.openapi3.OpenApi3AuthenticationFilter;
import org.demo.security.authentication.service.JwtService;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.ProviderManager;
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.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class CustomWebSecurityConfig {
private final ApplicationContext applicationContext;
public CustomWebSecurityConfig(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
private final AuthenticationEntryPoint authenticationExceptionHandler = new CustomAuthenticationExceptionHandler();
private final AccessDeniedHandler authorizationExceptionHandler = new CustomAuthorizationExceptionHandler();
private final Filter globalSpringSecurityExceptionHandler = new CustomSecurityExceptionHandler();
/** 禁用不必要的默认filter,处理异常响应内容 */
private void commonHttpSetting(HttpSecurity http) throws Exception {
// 禁用SpringSecurity默认filter。这些filter都是非前后端分离项目的产物,用不上.
// yml配置文件将日志设置DEBUG模式,就能看到加载了哪些filter
// logging:
// level:
// org.springframework.security: DEBUG
// 表单登录/登出、session管理、csrf防护等默认配置,如果不disable。会默认创建默认filter
http.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
// requestCache用于重定向,前后端分析项目无需重定向,requestCache也用不上
.requestCache(cache -> cache
.requestCache(new NullRequestCache())
)
// 无需给用户一个匿名身份
.anonymous(AbstractHttpConfigurer::disable);
// 处理 SpringSecurity 异常响应结果。响应数据的结构,改成业务统一的JSON结构。不要框架默认的响应结构
http.exceptionHandling(exceptionHandling ->
exceptionHandling
// 认证失败异常
.authenticationEntryPoint(authenticationExceptionHandler)
// 鉴权失败异常
.accessDeniedHandler(authorizationExceptionHandler)
);
// 其他未知异常. 尽量提前加载。
http.addFilterBefore(globalSpringSecurityExceptionHandler, SecurityContextHolderFilter.class);
}
/**
* 密码加密使用的编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/** 登录api */
@Bean
public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception {
commonHttpSetting(http);
String enterUrl = "/user/login/*";
// 使用securityMatcher限定当前配置作用的路径
http.securityMatcher(enterUrl)
.authorizeHttpRequests(authorize ->
authorize
.anyRequest()
.authenticated());
LoginSuccessHandler loginSuccessHandler = applicationContext.getBean(LoginSuccessHandler.class);
LoginFailHandler loginFailHandler = applicationContext.getBean(LoginFailHandler.class);
// 加一个登录方式。用户名、密码登录
UsernameAuthenticationFilter usernameLoginFilter = new UsernameAuthenticationFilter(
new AntPathRequestMatcher("/user/login/username", HttpMethod.POST.name()),
new ProviderManager(
List.of(applicationContext.getBean(UsernameAuthenticationProvider.class))),
loginSuccessHandler,
loginFailHandler);
http.addFilterBefore(usernameLoginFilter, UsernamePasswordAuthenticationFilter.class);
// 加一个登录方式。短信验证码 登录
SmsAuthenticationFilter smsLoginFilter = new SmsAuthenticationFilter(
new AntPathRequestMatcher("/user/login/sms", HttpMethod.POST.name()),
new ProviderManager(
List.of(applicationContext.getBean(SmsAuthenticationProvider.class))),
loginSuccessHandler,
loginFailHandler);
http.addFilterBefore(smsLoginFilter, UsernamePasswordAuthenticationFilter.class);
// 加一个登录方式。Gitee 登录
GiteeAuthenticationFilter giteeFilter = new GiteeAuthenticationFilter(
new AntPathRequestMatcher("/user/login/gitee", HttpMethod.POST.name()),
new ProviderManager(
List.of(applicationContext.getBean(GiteeAuthenticationProvider.class))),
loginSuccessHandler,
loginFailHandler);
http.addFilterBefore(giteeFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public SecurityFilterChain myApiFilterChain(HttpSecurity http) throws Exception {
// 使用securityMatcher限定当前配置作用的路径
http.securityMatcher("/open-api/business-1")
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated());
commonHttpSetting(http);
MyJwtAuthenticationFilter openApi1Filter = new MyJwtAuthenticationFilter(
applicationContext.getBean(JwtService.class));
// 加一个登录方式。用户名、密码登录
http.addFilterBefore(openApi1Filter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public SecurityFilterChain thirdApiFilterChain(HttpSecurity http) throws Exception {
// 不使用securityMatcher限定当前配置作用的路径。所有没有匹配上指定SecurityFilterChain的请求,都走这里鉴权
http.securityMatcher("/open-api/business-2")
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated());
commonHttpSetting(http);
OpenApi2AuthenticationFilter openApiFilter = new OpenApi2AuthenticationFilter();
// 加一个登录方式。用户名、密码登录
http.addFilterBefore(openApiFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/** 不鉴权的api */
@Bean
public SecurityFilterChain publicApiFilterChain(HttpSecurity http) throws Exception {
commonHttpSetting(http);
http
// 使用securityMatcher限定当前配置作用的路径
.securityMatcher("/open-api/business-3")
.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());
return http.build();
}
// /** 其余路径,走这个默认过滤链 */
// @Bean
// @Order(Integer.MAX_VALUE) // 这个过滤链最后加载
// public SecurityFilterChain defaultApiFilterChain(HttpSecurity http) throws Exception {
// commonHttpSetting(http);
// http // 不用securityMatcher表示缺省值,匹配不上其他过滤链时,都走这个过滤链
// .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated());
// http.addFilterBefore(new OpenApi3AuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// return http.build();
// }
}
UsernameAuthenticationFilter
package org.demo.security.authentication.handler.login.username;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import java.util.stream.Collectors;
import org.demo.security.common.web.util.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
/**
* 用户名密码登录
* AbstractAuthenticationProcessingFilter 的实现类要做的工作:
* 1. 从HttpServletRequest提取授权凭证。假设用户使用 用户名/密码 登录,就需要在这里提取username和password。
* 然后,把提取到的授权凭证封装到的Authentication对象,并且authentication.isAuthenticated()一定返回false
* 2. 将Authentication对象传给AuthenticationManager进行实际的授权操作
*/
public class UsernameAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final Logger logger = LoggerFactory.getLogger(UsernameAuthenticationFilter.class);
public UsernameAuthenticationFilter(AntPathRequestMatcher pathRequestMatcher,
AuthenticationManager authenticationManager,
AuthenticationSuccessHandler authenticationSuccessHandler,
AuthenticationFailureHandler authenticationFailureHandler) {
super(pathRequestMatcher);
setAuthenticationManager(authenticationManager);
setAuthenticationSuccessHandler(authenticationSuccessHandler);
setAuthenticationFailureHandler(authenticationFailureHandler);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
logger.debug("use UsernameAuthenticationFilter");
// 提取请求数据
String requestJsonData = request.getReader().lines()
.collect(Collectors.joining(System.lineSeparator()));
Map<String, Object> requestMapData = JSON.parseToMap(requestJsonData);
String username = requestMapData.get("username").toString();
String password = requestMapData.get("password").toString();
// 封装成Spring Security需要的对象
UsernameAuthentication authentication = new UsernameAuthentication();
authentication.setUsername(username);
authentication.setPassword(password);
authentication.setAuthenticated(false);
// 开始登录认证。SpringSecurity会利用 Authentication对象去寻找 AuthenticationProvider进行登录认证
return getAuthenticationManager().authenticate(authentication);
}
}
UsernameAuthenticationProvider
package org.demo.security.authentication.handler.login.username;
import org.demo.security.authentication.service.UserService;
import org.demo.security.common.web.model.User;
import org.demo.security.authentication.handler.login.UserLoginInfo;
import org.demo.security.common.web.util.JSON;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* 帐号密码登录认证
*/
@Component
public class UsernameAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
public UsernameAuthenticationProvider() {
super();
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 用户提交的用户名 + 密码:
String username = (String)authentication.getPrincipal();
String password = (String) authentication.getCredentials();
// 查数据库,匹配用户信息
User user = userService.getUserFromDB(username);
if (user == null
|| !passwordEncoder.matches(password, user.getPassword())) {
// 密码错误,直接抛异常。
// 根据SpringSecurity框架的代码逻辑,认证失败时,应该抛这个异常:org.springframework.security.core.AuthenticationException
// BadCredentialsException就是这个异常的子类
// 抛出异常后后,AuthenticationFailureHandler的实现类会处理这个异常。
throw new BadCredentialsException("${invalid.username.or.pwd:用户名或密码不正确}");
}
UsernameAuthentication token = new UsernameAuthentication();
token.setCurrentUser(JSON.convert(user, UserLoginInfo.class));
token.setAuthenticated(true); // 认证通过,这里一定要设成true
return token;
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.isAssignableFrom(UsernameAuthentication.class);
}
}
LoginSuccessHandler
package org.demo.security.authentication.handler.login;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.demo.security.authentication.service.JwtService;
import org.demo.security.common.web.model.Result;
import org.demo.security.common.web.exception.ExceptionTool;
import org.demo.security.common.web.util.JSON;
import org.demo.security.common.web.util.TimeTool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.AbstractAuthenticationTargetUrlRequestHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
/**
* 认证成功/登录成功 事件处理器
*/
@Component
public class LoginSuccessHandler extends
AbstractAuthenticationTargetUrlRequestHandler implements AuthenticationSuccessHandler {
@Autowired
private ApplicationEventPublisher applicationEventPublisher;
@Autowired
private JwtService jwtService;
public LoginSuccessHandler() {
this.setRedirectStrategy(new RedirectStrategy() {
@Override
public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url)
throws IOException {
// 更改重定向策略,前后端分离项目,后端使用RestFul风格,无需做重定向
// Do nothing, no redirects in REST
}
});
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
Object principal = authentication.getPrincipal();
if (principal == null || !(principal instanceof UserLoginInfo)) {
ExceptionTool.throwException(
"登陆认证成功后,authentication.getPrincipal()返回的Object对象必须是:UserLoginInfo!");
}
UserLoginInfo currentUser = (UserLoginInfo) principal;
currentUser.setSessionId(UUID.randomUUID().toString());
// 生成token和refreshToken
Map<String, Object> responseData = new LinkedHashMap<>();
responseData.put("token", generateToken(currentUser));
responseData.put("refreshToken", generateRefreshToken(currentUser));
// 一些特殊的登录参数。比如三方登录,需要额外返回一个字段是否需要跳转的绑定已有账号页面
Object details = authentication.getDetails();
if (details instanceof Map) {
Map detailsMap = (Map)details;
responseData.putAll(detailsMap);
}
// 虽然APPLICATION_JSON_UTF8_VALUE过时了,但也要用。因为Postman工具不声明utf-8编码就会出现乱码
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
writer.print(JSON.stringify(Result.data(responseData, "${login.success:登录成功!}")));
writer.flush();
writer.close();
}
public String generateToken(UserLoginInfo currentUser) {
long expiredTime = TimeTool.nowMilli() + TimeUnit.MINUTES.toMillis(10); // 10分钟后过期
currentUser.setExpiredTime(expiredTime);
return jwtService.createJwt(currentUser, expiredTime);
}
private String generateRefreshToken(UserLoginInfo loginInfo) {
return jwtService.createJwt(loginInfo, TimeTool.nowMilli() + TimeUnit.DAYS.toMillis(30));
}
}
请求流程
-
请求匹配到相应的
SecurityFilterChain
:- 你发起一个
POST
请求到/user/login/username
,这个请求会匹配到CustomWebSecurityConfig
中配置的loginFilterChain
,该过滤链专门处理/user/login/*
路径的请求。
- 你发起一个
-
SecurityFilterChain
过滤链中的过滤器:- 该路径会被
http.securityMatcher("/user/login/*")
配置的过滤链处理。 - Spring Security 会根据请求路径(
/user/login/username
)选择对应的过滤器,UsernameAuthenticationFilter
就是处理用户名和密码登录的过滤器。
- 该路径会被
对应代码:
/** 登录api */
@Bean
public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception {
commonHttpSetting(http);
String enterUrl = "/user/login/*";
// 使用securityMatcher限定当前配置作用的路径
http.securityMatcher(enterUrl)
.authorizeHttpRequests(authorize ->
authorize
.anyRequest()
.authenticated());
LoginSuccessHandler loginSuccessHandler = applicationContext.getBean(LoginSuccessHandler.class);
LoginFailHandler loginFailHandler = applicationContext.getBean(LoginFailHandler.class);
// 加一个登录方式。用户名、密码登录
UsernameAuthenticationFilter usernameLoginFilter = new UsernameAuthenticationFilter(
new AntPathRequestMatcher("/user/login/username", HttpMethod.POST.name()),
new ProviderManager(
List.of(applicationContext.getBean(UsernameAuthenticationProvider.class))),
loginSuccessHandler,
loginFailHandler);
http.addFilterBefore(usernameLoginFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
-
请求到达
UsernameAuthenticationFilter
:UsernameAuthenticationFilter
继承自AbstractAuthenticationProcessingFilter
,用于处理用户名和密码的登录认证。- 这个过滤器的匹配路径是
/user/login/username
,会匹配到你请求的路径。
-
调用
UsernameAuthenticationFilter
的attemptAuthentication
方法:- 请求进入
UsernameAuthenticationFilter
之后,会调用attemptAuthentication
方法。它会从HttpServletRequest
中提取登录数据(用户名和密码),通常是请求体中的 JSON 数据。
-
在
attemptAuthentication
方法中,从请求体中解析出用户名和密码,并将其封装成UsernameAuthentication
对象(这个对象继承自AbstractAuthenticationToken
)。 -
然后,调用
getAuthenticationManager().authenticate(authentication)
方法,传递封装好的认证信息去进行认证。
- 请求进入
-
请求交给
AuthenticationManager
:(重要)UsernameAuthenticationFilter
调用的AuthenticationManager
是 Spring Security 处理认证的核心组件,负责根据传入的UsernameAuthentication
对象找到合适的AuthenticationProvider
进行认证。
在此案例中,Spring Security 会交给
UsernameAuthenticationProvider
来处理认证。
对应代码:
/**
* 用户名密码登录
* AbstractAuthenticationProcessingFilter 的实现类要做的工作:
* 1. 从HttpServletRequest提取授权凭证。假设用户使用 用户名/密码 登录,就需要在这里提取username和password。
* 然后,把提取到的授权凭证封装到的Authentication对象,并且authentication.isAuthenticated()一定返回false
* 2. 将Authentication对象传给AuthenticationManager进行实际的授权操作
*/
public class UsernameAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final Logger logger = LoggerFactory.getLogger(UsernameAuthenticationFilter.class);
public UsernameAuthenticationFilter(AntPathRequestMatcher pathRequestMatcher,
AuthenticationManager authenticationManager,
AuthenticationSuccessHandler authenticationSuccessHandler,
AuthenticationFailureHandler authenticationFailureHandler) {
super(pathRequestMatcher);
setAuthenticationManager(authenticationManager);
setAuthenticationSuccessHandler(authenticationSuccessHandler);
setAuthenticationFailureHandler(authenticationFailureHandler);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
logger.debug("use UsernameAuthenticationFilter");
// 提取请求数据
String requestJsonData = request.getReader().lines()
.collect(Collectors.joining(System.lineSeparator()));
Map<String, Object> requestMapData = JSON.parseToMap(requestJsonData);
String username = requestMapData.get("username").toString();
String password = requestMapData.get("password").toString();
// 封装成Spring Security需要的对象
UsernameAuthentication authentication = new UsernameAuthentication();
authentication.setUsername(username);
authentication.setPassword(password);
authentication.setAuthenticated(false);
// 开始登录认证。SpringSecurity会利用 Authentication对象去寻找 AuthenticationProvider进行登录认证
return getAuthenticationManager().authenticate(authentication);
}
}
-
UsernameAuthenticationProvider
处理认证:UsernameAuthenticationProvider
是一个自定义的认证提供者,它会从数据库中查询用户信息,并比较用户输入的密码和数据库中存储的密码是否一致。
@Component public class UsernameAuthenticationProvider implements AuthenticationProvider { @Autowired private UserService userService; @Autowired private PasswordEncoder passwordEncoder; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username = (String) authentication.getPrincipal(); String password = (String) authentication.getCredentials(); // 从数据库获取用户信息 User user = userService.getUserFromDB(username); if (user == null || !passwordEncoder.matches(password, user.getPassword())) { // 如果用户名或密码错误,抛出BadCredentialsException throw new BadCredentialsException("${invalid.username.or.pwd:用户名或密码不正确}"); } // 认证成功,返回一个UsernameAuthentication对象 UsernameAuthentication token = new UsernameAuthentication(); token.setCurrentUser(JSON.convert(user, UserLoginInfo.class)); token.setAuthenticated(true); // 设置认证成功 return token; } @Override public boolean supports(Class<?> authentication) { return authentication.isAssignableFrom(UsernameAuthentication.class); } }
UsernameAuthenticationProvider
会根据username
查找用户。如果用户存在且密码匹配,则认证成功,返回一个包含认证信息的UsernameAuthentication
对象。如果验证失败,会抛出BadCredentialsException
,导致认证失败。
-
认证成功:
- 如果认证成功,
UsernameAuthentication
会标记为已认证(setAuthenticated(true)
),然后返回给UsernameAuthenticationFilter
。 - 此时,Spring Security 会调用
AuthenticationSuccessHandler
(在这个例子中是LoginSuccessHandler
)。
- 如果认证成功,
-
LoginSuccessHandler
处理认证成功:LoginSuccessHandler
会生成一个 JWT token 和 refresh token 返回给客户端,作为认证成功的标志。
@Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { // 获取当前用户的信息 UserLoginInfo currentUser = (UserLoginInfo) authentication.getPrincipal(); Map<String, Object> responseData = new LinkedHashMap<>(); responseData.put("token", generateToken(currentUser)); responseData.put("refreshToken", generateRefreshToken(currentUser)); // 设置返回类型为 JSON response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE); PrintWriter writer = response.getWriter(); writer.print(JSON.stringify(Result.data(responseData, "${login.success:登录成功!}"))); writer.flush(); writer.close(); }
- 在
onAuthenticationSuccess
方法中,生成 JWT 和 refresh token,并返回给客户端作为认证成功的响应。
-
认证失败:
- 如果认证失败(如用户名密码不正确),Spring Security 会调用
AuthenticationFailureHandler
(在这个例子中是LoginFailHandler
),并返回失败信息。
- 如果认证失败(如用户名密码不正确),Spring Security 会调用
Spring Security 内部调用的类和 Filter
-
请求路径匹配:
SecurityFilterChain
中的http.securityMatcher("/user/login/*")
会匹配到/user/login/username
路径,并开始处理。
-
UsernameAuthenticationFilter
:- 请求到达后,
UsernameAuthenticationFilter
会从请求中提取用户名和密码,并交给AuthenticationManager
进行认证。
- 请求到达后,
-
AuthenticationManager
:- 负责调用
UsernameAuthenticationProvider
进行实际的认证。
- 负责调用
-
UsernameAuthenticationProvider
:- 根据提供的用户名和密码,查找数据库中的用户并进行验证。如果认证通过,返回一个
UsernameAuthentication
对象。
- 根据提供的用户名和密码,查找数据库中的用户并进行验证。如果认证通过,返回一个
-
LoginSuccessHandler
:- 认证成功时,生成 JWT 和 refresh token,返回给客户端。
3 用户认证流程
结合上一篇的看尚硅谷的课程的笔记,我们可以得出一个比较完整的流程,一个可以我们自己来参与配置的流程。通过用户名和密码的流程
第一步 用户端发送Post请求,带有用户名和密码传入后端
第二步,SecurityFilterChain拦截我们的请求,Spring Security 会根据司机情况选择对应的过滤器,UsernameAuthenticationFilter 就是处理用户名和密码登录的过滤器
第三步,UsernameAuthenticationFilter 过滤器 会调用 attemptAuthentication 方法 ,提取正在登陆的用户的信息,创建并封装到 Authentication 对象中。
第四步,attemptAuthentication 方法 会调用 AuthenticationManager 配置类 来执行实际的认证过程,并传入封装好的 Authentication 对象 。AuthenticationManager 配置类 会委托给配置的 UserDetailsService 配置类 来加载用户的详细信息。AuthenticationManager 配置类 还会
负责调用 UsernameAuthenticationProvider 配置类 进行实际的认证,根据传入的Authentication 对象和 UserDetailsService 配置类 来加载的用户的详细信息。
第五步,如果认证成功,Authentication 会标记为已认证(setAuthenticated(true)
),然后返回给 UsernameAuthenticationFilter
。
此时,Spring Security 会调用 AuthenticationSuccessHandler 返回认证成功的信息。 AuthenticationSuccessHandler 这个类要自己来实现,最好生成一个 JWT token 和 refresh token 返回给客户端,作为认证成功的标志。
如果认证失败,Spring Security 会调用 AuthenticationFailureHandler,并返回失败信息。这个类也要自己来实现,返回统一格式的响应信息。
二 逐步使用Spring Security
1 前置知识 Filter的使用
我们要自定义一个filter,首先要自定义一个filter类,并且把它加载到Spring容器中
@Configuration
public class MyFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 在请求处理之前执行的操作
System.out.println("Request is being processed by MyFilter");
// 继续传递请求和响应
chain.doFilter(request, response);
// 在响应处理之后执行的操作
System.out.println("Response is being processed by MyFilter");
}
}
然后,我们要注册这个Filter,用一个配置文件注册。然后设置过滤路径
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<MyFilter> loggingFilter() {
FilterRegistrationBean<MyFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new MyFilter()); // 设置过滤器实例
registrationBean.addUrlPatterns("/loginByMyself"); // 设置过滤的 URL 模式
registrationBean.setOrder(1); // 设置过滤器的顺序,数字越小优先级越高
return registrationBean;
}
}
模拟登陆
@PostMapping("/loginByMyself")
public Result loginByMyself(@RequestBody User requestParam){
// 1.获取请求参数
String username = requestParam.getUsername();
String password = requestParam.getPassword();
//2.认证逻辑...校验账号密码...
System.out.println("校验:username:"+ username + " password:" + password);
//3,认证通过,返回jwt Token
HashMap<Object,Object> responseData = new HashMap<>();
responseData.put("token","jwtToken...");
responseData.put("refreshToken","refreshToken...");
//4 返回响应结果
return ResultBuilder
.aResult()
.data(responseData)
.code(Result.SUCCESS_CODE)
.msg("登录成功")
.build();
}
请求结果
2 正式使用框架
框架使用Filter的逻辑是一样的,都需要自定义一个Filter,然后把他加载到过滤链上。并且要认证登陆信息的话,需要将用户信息加载到上下文中。
2.1 引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
2.2 配置SecurityFilterChain
首先我们要在类上加上 @EnableWebSecurity 注解,然后配置SecurityFilterChain 。在这里我们先禁用一些不需要的链,然后拦截所有请求,最后添加我们自定义的过滤链进SecurityFilterChain中。
@Configuration
@EnableWebSecurity
public class CustomWebSecurityConfig {
@Bean
public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception {
// 禁用SpringSecurity默认filter。这些filter都是非前后端分离项目的产物,用不上.
// yml配置文件将日志设置DEBUG模式,就能看到加载了哪些filter
// logging:
// level:
// org.springframework.security: DEBUG
// 表单登录/登出、session管理、csrf防护等默认配置,如果不disable。会默认创建默认filter
http.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
// requestCache用于重定向,前后端分析项目无需重定向,requestCache也用不上
.requestCache(cache -> cache
.requestCache(new NullRequestCache())
)
// 无需给用户一个匿名身份
.anonymous(AbstractHttpConfigurer::disable);
// 任意请求都拦截
http
.authorizeHttpRequests(authorization ->
authorization
.anyRequest()
.authenticated());
// 添加自定义过滤链
http
.addFilterBefore
(new MyFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
2.3 配置自定义过滤链
实现 Filter 接口,设置登陆的用户信息,然后继续执行请求。
@Configuration
public class MyFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 在请求处理之前执行的操作
System.out.println("Request is being processed by MyFilter");
// 设置登陆的用户信息
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication =
new TestingAuthenticationToken("username", "password", "ROLE_USER");
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
// 继续传递请求和响应
chain.doFilter(request, response);
// 在响应处理之后执行的操作
System.out.println("Response is being processed by MyFilter");
}
}
2.4 模拟登陆
我们发送请求就是发送到这个方法上
@PostMapping("/loginByMyself")
public Result loginByMyself(@RequestBody User requestParam){
// 1.获取请求参数
String username = requestParam.getUsername();
String password = requestParam.getPassword();
//2.认证逻辑...校验账号密码...
System.out.println("校验:username:"+ username + " password:" + password);
//3,认证通过,返回jwt Token
HashMap<Object,Object> responseData = new HashMap<>();
responseData.put("token","jwtToken...");
responseData.put("refreshToken","refreshToken...");
//4 返回响应结果
return ResultBuilder
.aResult()
.data(responseData)
.code(Result.SUCCESS_CODE)
.msg("登录成功")
.build();
}
2.5 发送请求
成功返回预期结果
2.6 失败返回统一结果
我们自定义三种失败:鉴权失败,认证失败,和全局异常,并在SecurityFilterChain中配置
认证异常
/**
* 认证失败时,会执行这个方法。将失败原因告知客户端
*/
public class CustomAuthenticationExceptionHandler implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
PrintWriter writer = response.getWriter();
writer.print(JSON.stringify(Result.fail("${authentication.fail:认证失败}")));
writer.flush();
writer.close();
}
}
鉴权异常
/**
* 认证成功(Authentication), 但无权访问时。会执行这个方法
* 或者SpringSecurity框架捕捉到 AccessDeniedException时,会转出
*/
public class CustomAuthorizationExceptionHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value());
PrintWriter writer = response.getWriter();
writer.print(JSON.stringify(Result.fail("${low.power:无权访问}")));
writer.flush();
writer.close();
}
}
全局异常
/**
* 捕捉Spring security filter chain 中抛出的未知异常
*/
public class CustomSecurityExceptionHandler extends OncePerRequestFilter {
public static final Logger logger = LoggerFactory.getLogger(
CustomSecurityExceptionHandler.class);
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (BaseException e) {
// 自定义异常
Result result = ResultBuilder.aResult()
.msg(e.getMessage())
.code(e.getCode())
.build();
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(e.getHttpStatus().value());
PrintWriter writer = response.getWriter();
writer.write(JSON.stringify(result));
writer.flush();
writer.close();
} catch (AuthenticationException | AccessDeniedException e) {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value());
PrintWriter writer = response.getWriter();
writer.print(JSON.stringify(Result.fail(e.getMessage())));
writer.flush();
writer.close();
} catch (Exception e) {
logger.error(e.getMessage(), e);
// 未知异常
Result result = ResultBuilder.aResult()
.msg("System Error")
.code("system.error")
.build();
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
PrintWriter writer = response.getWriter();
writer.write(JSON.stringify(result));
writer.flush();
writer.close();
}
}
}
SecurityFilterChain中配置
@Configuration
@EnableWebSecurity
public class CustomWebSecurityConfig {
private final AuthenticationEntryPoint authenticationExceptionHandler = new CustomAuthenticationExceptionHandler();
private final AccessDeniedHandler authorizationExceptionHandler = new CustomAuthorizationExceptionHandler();
private final Filter globalSpringSecurityExceptionHandler = new CustomSecurityExceptionHandler();
@Bean
public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception {
// 禁用SpringSecurity默认filter。这些filter都是非前后端分离项目的产物,用不上.
// yml配置文件将日志设置DEBUG模式,就能看到加载了哪些filter
// logging:
// level:
// org.springframework.security: DEBUG
// 表单登录/登出、session管理、csrf防护等默认配置,如果不disable。会默认创建默认filter
http.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
// requestCache用于重定向,前后端分析项目无需重定向,requestCache也用不上
.requestCache(cache -> cache
.requestCache(new NullRequestCache())
)
// 无需给用户一个匿名身份
.anonymous(AbstractHttpConfigurer::disable);
// 加上下面一段段代码
// begin
// 处理 SpringSecurity 异常响应结果。响应数据的结构,改成业务统一的JSON结构。不要框架默认的响应结构
http.exceptionHandling(exceptionHandling ->
exceptionHandling
// 认证失败异常
.authenticationEntryPoint(authenticationExceptionHandler)
// 鉴权失败异常
.accessDeniedHandler(authorizationExceptionHandler)
);
// 其他未知异常. 尽量提前加载。
http.addFilterBefore(globalSpringSecurityExceptionHandler, SecurityContextHolderFilter.class);
// 任意请求都拦截
http
.authorizeHttpRequests(authorization ->
authorization
.anyRequest()
.authenticated());
//end
// 添加自定义过滤链
http
.addFilterBefore
(new MyFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
2.7 测试错误返回
返回统一数据格式
3 实现用户名+密码认证
官方的说明文档内的图,认证流程图如下:
我们的Controller,就是Servlet,我们需要将我们的Controller移到Filter中。
官方附的实现认证流程的文字讲解版,我们一步一步来完成
1 当用户提交他们的凭证时,AbstractAuthenticationProcessingFilter 会从 HttpServletRequest
中创建一个要认证的Authentication。创建的认证的类型取决于 AbstractAuthenticationProcessingFilter
的子类。例如,UsernamePasswordAuthenticationFilter从 HttpServletRequest
中提交的 username 和 password 创建一个 UsernamePasswordAuthenticationToken
。
我们要做:
实现一个自定义类 继承 AbstractAuthenticationProcessingFilter
实现一个自定义的类 继承 AbstractAuthenticationToken(这个类实现了Authentication)
代码:
UsernameAuthenticationFilter extends AbstractAuthenticationProcessingFilter
/**
* 用户名密码登录
* AbstractAuthenticationProcessingFilter 的实现类要做的工作:
* 1. 从HttpServletRequest提取授权凭证。假设用户使用 用户名/密码 登录,就需要在这里提取username和password。
* 然后,把提取到的授权凭证封装到的Authentication对象,并且authentication.isAuthenticated()一定返回false
* 2. 将Authentication对象传给AuthenticationManager进行实际的授权操作
*/
public class UsernameAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final Logger logger = LoggerFactory.getLogger(UsernameAuthenticationFilter.class);
public UsernameAuthenticationFilter(AntPathRequestMatcher pathRequestMatcher,
AuthenticationManager authenticationManager,
AuthenticationSuccessHandler authenticationSuccessHandler,
AuthenticationFailureHandler authenticationFailureHandler) {
super(pathRequestMatcher);
setAuthenticationManager(authenticationManager);
setAuthenticationSuccessHandler(authenticationSuccessHandler);
setAuthenticationFailureHandler(authenticationFailureHandler);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
logger.debug("use UsernameAuthenticationFilter");
// 提取请求数据
String requestJsonData = request.getReader().lines()
.collect(Collectors.joining(System.lineSeparator()));
Map<String, Object> requestMapData = JSON.parseToMap(requestJsonData);
String username = requestMapData.get("username").toString();
String password = requestMapData.get("password").toString();
// 封装成Spring Security需要的对象
UsernameAuthentication authentication = new UsernameAuthentication();
authentication.setUsername(username);
authentication.setPassword(password);
authentication.setAuthenticated(false);
// 开始登录认证。SpringSecurity会利用 Authentication对象去寻找 AuthenticationProvider进行登录认证
return getAuthenticationManager().authenticate(authentication);
}
}
UsernameAuthentication extends AbstractAuthenticationToken:
/**
* SpringSecurity传输登录认证的数据的载体,相当一个Dto
* 必须是 {@link Authentication} 实现类
* 这里选择extends{@link AbstractAuthenticationToken},而不是直接implements Authentication,
* 是为了少些写代码。因为{@link Authentication}定义了很多接口,我们用不上。
*/
public class UsernameAuthentication extends AbstractAuthenticationToken {
private String username; // 前端传过来
private String password; // 前端传过来
private UserLoginInfo currentUser; // 认证成功后,后台从数据库获取信息
public UsernameAuthentication() {
// 权限,用不上,直接null
super(null);
}
@Override
public Object getCredentials() {
// 根据SpringSecurity的设计,授权成后,Credential(比如,登录密码)信息需要被清空
return isAuthenticated() ? null : password;
}
@Override
public Object getPrincipal() {
// 根据SpringSecurity的设计,授权成功之前,getPrincipal返回的客户端传过来的数据。授权成功后,返回当前登陆用户的信息
return isAuthenticated() ? currentUser : username;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public UserLoginInfo getCurrentUser() {
return currentUser;
}
public void setCurrentUser(UserLoginInfo currentUser) {
this.currentUser = currentUser;
}
}
2 接下来,Authentication 被传入 AuthenticationManager,以进行认证。
我们要做:
实现一个类继承 AuthenticationProvider:
/**
* 帐号密码登录认证
*/
@Component
public class UsernameAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
public UsernameAuthenticationProvider() {
super();
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 用户提交的用户名 + 密码:
String username = (String)authentication.getPrincipal();
String password = (String) authentication.getCredentials();
// 查数据库,匹配用户信息
User user = userService.getUserFromDB(username);
if (user == null
|| !passwordEncoder.matches(password, user.getPassword())) {
// 密码错误,直接抛异常。
// 根据SpringSecurity框架的代码逻辑,认证失败时,应该抛这个异常:org.springframework.security.core.AuthenticationException
// BadCredentialsException就是这个异常的子类
// 抛出异常后后,AuthenticationFailureHandler的实现类会处理这个异常。
throw new BadCredentialsException("${invalid.username.or.pwd:用户名或密码不正确}");
}
UsernameAuthentication token = new UsernameAuthentication();
token.setCurrentUser(JSON.convert(user, UserLoginInfo.class));
token.setAuthenticated(true); // 认证通过,这里一定要设成true
return token;
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.isAssignableFrom(UsernameAuthentication.class);
}
}
3 如果认证失败,则为 Failure。
-
SecurityContextHolder 被清空。
-
RememberMeServices.loginFail
被调用。如果没有配置(remember me),这就是一个无用功。请参阅 rememberme 包。 -
AuthenticationFailureHandler
被调用。参见 AuthenticationFailureHandler 接口。
我们要做:
实现一个类继承 AuthenticationFailureHandler
/**
* AbstractAuthenticationProcessingFilter抛出AuthenticationException异常后,会跑到这里来
*/
@Component
public class LoginFailHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String errorMessage = exception.getMessage();
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
Result responseData = ResultBuilder.aResult()
.data(null)
.code("login.fail")
.msg(errorMessage)
.build();
writer.print(JSON.stringify(responseData));
writer.flush();
writer.close();
}
}
4 如果认证成功,则为 Success。
-
SessionAuthenticationStrategy
被通知有新的登录。参见 SessionAuthenticationStrategy 接口。 -
Authentication 是在 SecurityContextHolder 上设置的。后来,如果你需要保存
SecurityContext
以便在未来的请求中自动设置,必须显式调用SecurityContextRepository#saveContext
。参见 SecurityContextHolderFilter 类。 -
RememberMeServices.loginSuccess
被调用。如果没有配置 remember me,这就是一个无用功。请参阅 rememberme 包。 -
ApplicationEventPublisher
发布一个InteractiveAuthenticationSuccessEvent
事件。 -
AuthenticationSuccessHandler
被调用。参见 AuthenticationSuccessHandler 接口。
我们要做:
实现一个类继承 AuthenticationSuccessHandler
/**
* 认证成功/登录成功 事件处理器
*/
@Component
public class LoginSuccessHandler extends
AbstractAuthenticationTargetUrlRequestHandler implements AuthenticationSuccessHandler {
@Autowired
private ApplicationEventPublisher applicationEventPublisher;
@Autowired
private JwtService jwtService;
public LoginSuccessHandler() {
this.setRedirectStrategy(new RedirectStrategy() {
@Override
public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url)
throws IOException {
// 更改重定向策略,前后端分离项目,后端使用RestFul风格,无需做重定向
// Do nothing, no redirects in REST
}
});
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
Object principal = authentication.getPrincipal();
if (principal == null || !(principal instanceof UserLoginInfo)) {
ExceptionTool.throwException(
"登陆认证成功后,authentication.getPrincipal()返回的Object对象必须是:UserLoginInfo!");
}
UserLoginInfo currentUser = (UserLoginInfo) principal;
currentUser.setSessionId(UUID.randomUUID().toString());
// 生成token和refreshToken
Map<String, Object> responseData = new LinkedHashMap<>();
responseData.put("token", generateToken(currentUser));
responseData.put("refreshToken", generateRefreshToken(currentUser));
// 一些特殊的登录参数。比如三方登录,需要额外返回一个字段是否需要跳转的绑定已有账号页面
Object details = authentication.getDetails();
if (details instanceof Map) {
Map detailsMap = (Map)details;
responseData.putAll(detailsMap);
}
// 虽然APPLICATION_JSON_UTF8_VALUE过时了,但也要用。因为Postman工具不声明utf-8编码就会出现乱码
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
writer.print(JSON.stringify(Result.data(responseData, "${login.success:登录成功!}")));
writer.flush();
writer.close();
}
public String generateToken(UserLoginInfo currentUser) {
long expiredTime = TimeTool.nowMilli() + TimeUnit.MINUTES.toMillis(10); // 10分钟后过期
currentUser.setExpiredTime(expiredTime);
return jwtService.createJwt(currentUser, expiredTime);
}
private String generateRefreshToken(UserLoginInfo loginInfo) {
return jwtService.createJwt(loginInfo, TimeTool.nowMilli() + TimeUnit.DAYS.toMillis(30));
}
}
5 最后,修改CustomWebSecurityConfig ,将上面的内容添加到SecurityFilterChain 里面
@Configuration
@EnableWebSecurity
public class CustomWebSecurityConfig {
private final ApplicationContext applicationContext;
public CustomWebSecurityConfig(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
private final AuthenticationEntryPoint authenticationExceptionHandler = new CustomAuthenticationExceptionHandler();
private final AccessDeniedHandler authorizationExceptionHandler = new CustomAuthorizationExceptionHandler();
private final Filter globalSpringSecurityExceptionHandler = new CustomSecurityExceptionHandler();
/** 禁用不必要的默认filter,处理异常响应内容 */
private void commonHttpSetting(HttpSecurity http) throws Exception {
// 禁用SpringSecurity默认filter。这些filter都是非前后端分离项目的产物,用不上.
// yml配置文件将日志设置DEBUG模式,就能看到加载了哪些filter
// logging:
// level:
// org.springframework.security: DEBUG
// 表单登录/登出、session管理、csrf防护等默认配置,如果不disable。会默认创建默认filter
http.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
// requestCache用于重定向,前后端分析项目无需重定向,requestCache也用不上
.requestCache(cache -> cache
.requestCache(new NullRequestCache())
)
// 无需给用户一个匿名身份
.anonymous(AbstractHttpConfigurer::disable);
// 处理 SpringSecurity 异常响应结果。响应数据的结构,改成业务统一的JSON结构。不要框架默认的响应结构
http.exceptionHandling(exceptionHandling ->
exceptionHandling
// 认证失败异常
.authenticationEntryPoint(authenticationExceptionHandler)
// 鉴权失败异常
.accessDeniedHandler(authorizationExceptionHandler)
);
// 其他未知异常. 尽量提前加载。
http.addFilterBefore(globalSpringSecurityExceptionHandler, SecurityContextHolderFilter.class);
}
/**
* 密码加密使用的编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/** 登录api */
@Bean
public SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception {
commonHttpSetting(http);
String enterUrl = "/user/login/*";
// 使用securityMatcher限定当前配置作用的路径
http.securityMatcher(enterUrl)
.authorizeHttpRequests(authorize ->
authorize
.anyRequest()
.authenticated());
LoginSuccessHandler loginSuccessHandler = applicationContext.getBean(LoginSuccessHandler.class);
LoginFailHandler loginFailHandler = applicationContext.getBean(LoginFailHandler.class);
// 加一个登录方式。用户名、密码登录
UsernameAuthenticationFilter usernameLoginFilter = new UsernameAuthenticationFilter(
new AntPathRequestMatcher("/user/login/username", HttpMethod.POST.name()),
new ProviderManager(
List.of(applicationContext.getBean(UsernameAuthenticationProvider.class))),
loginSuccessHandler,
loginFailHandler);
http.addFilterBefore(usernameLoginFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
6 请求测试
成功返回
4 支持多种资源API鉴权
比如,自家用户请求API时鉴权。对外提供开放API使用的是md5签名验签鉴权,或其他自定义鉴权
本质就是,添加多条过滤链,去过滤不同的url,然后去实现不同的逻辑。
比如说,我们去增加通过jwt鉴权。
逻辑:用户登陆之后会返回jwt,然后如果要访问一个地址,要携带这个jwt才能请求成功。
比如我们要在访问一个url之前去进行jwt鉴权,那么我们其实就只需要在用户登陆这条链的基础上再定义多一条过滤链,来实现jwt的验证,把它加进SecurityFilterChain里面。然后去实现Authentication来存放jwt。
4.1 jwt鉴权
自定义Filter
主要逻辑就是验证jwt
public class MyJwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(MyJwtAuthenticationFilter.class);
private JwtService jwtService;
public MyJwtAuthenticationFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
logger.debug("Use OpenApi1AuthenticationFilter");
String jwtToken = request.getHeader("Authorization");
if (StringUtils.isEmpty(jwtToken)) {
ExceptionTool.throwException("JWT token is missing!", "miss.token");
}
if (jwtToken.startsWith("Bearer ")) {
jwtToken = jwtToken.substring(7);
}
try {
UserLoginInfo userLoginInfo = jwtService.verifyJwt(jwtToken, UserLoginInfo.class);
MyJwtAuthentication authentication = new MyJwtAuthentication();
authentication.setJwtToken(jwtToken);
authentication.setAuthenticated(true); // 设置true,认证通过。
authentication.setCurrentUser(userLoginInfo);
// 认证通过后,一定要设置到SecurityContextHolder里面去。
SecurityContextHolder.getContext().setAuthentication(authentication);
}catch (ExpiredJwtException e) {
// 转换异常,指定code,让前端知道时token过期,去调刷新token接口
ExceptionTool.throwException("jwt过期", HttpStatus.UNAUTHORIZED, "token.expired");
} catch (Exception e) {
ExceptionTool.throwException("jwt无效", HttpStatus.UNAUTHORIZED, "token.invalid");
}
// 放行
filterChain.doFilter(request, response);
}
}
实现Authentication来存放jwt
public class MyJwtAuthentication extends AbstractAuthenticationToken {
private String jwtToken; // 前端传过来
private UserLoginInfo currentUser; // 认证成功后,后台从数据库获取信息
public MyJwtAuthentication() {
// 权限,用不上,直接null
super(null);
}
@Override
public Object getCredentials() {
// 根据SpringSecurity的设计,授权成后,Credential(比如,登录密码)信息需要被清空
return isAuthenticated() ? null : jwtToken;
}
@Override
public Object getPrincipal() {
// 根据SpringSecurity的设计,授权成功之前,getPrincipal返回的客户端传过来的数据。授权成功后,返回当前登陆用户的信息
return isAuthenticated() ? currentUser : jwtToken;
}
public String getJwtToken() {
return jwtToken;
}
public void setJwtToken(String jwtToken) {
this.jwtToken = jwtToken;
}
public UserLoginInfo getCurrentUser() {
return currentUser;
}
public void setCurrentUser(UserLoginInfo currentUser) {
this.currentUser = currentUser;
}
}
配置SecurityFilterChain,拦截指定url,添加自定义Filter
@Bean
public SecurityFilterChain myApiFilterChain(HttpSecurity http) throws Exception {
// 使用securityMatcher限定当前配置作用的路径
http.securityMatcher("/open-api/business-1")
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated());
commonHttpSetting(http);
MyJwtAuthenticationFilter openApi1Filter = new MyJwtAuthenticationFilter(
applicationContext.getBean(JwtService.class));
// 加一个登录方式。用户名、密码登录
http.addFilterBefore(openApi1Filter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
拦截的api,里面的逻辑自己随便实现一点。
@PostMapping("/open-api/business-1")
public Result business1(){
// 1.获取请求参数
String username = "username";
String password = "password";
//2.认证逻辑...校验账号密码...
System.out.println("校验:username:"+ username + " password:" + password);
//3,认证通过,返回jwt Token
HashMap<Object,Object> responseData = new HashMap<>();
responseData.put("token","jwtToken...");
responseData.put("refreshToken","refreshToken...");
//4 返回响应结果
return ResultBuilder
.aResult()
.data(responseData)
.code(Result.SUCCESS_CODE)
.msg("登录成功")
.build();
}
测试
先实现登陆,拿到jwt
然后携带jwt访问,返回成功
结语
本质上就是实现不同的Filter,然后不同的Filter去实现不同的鉴权逻辑
三 OAuth2
基本流程
A: 鉴权请求,网页会询问用户是否同意鉴权。
B: 用户同意,返回同意请求给浏览器
C: 浏览器会将用户的信息,封装成一个Authentication对象传给授权服务器
D: 然后授权服务器对其鉴权,返回一个认证的信物,最常用的是token
E: 用户每次访问我们的资源服务器,也就是鉴权框架保护起来的资源,都要携带授权服务器授予的信物,供资源服务器检验
F: 如果正确,返回资源
示例演示Gitee
用请求url的方式来演示第三方登陆Gitee
1 创建第三方应用
进入官网,在个人图标的下拉链表中找到账号设置
进入后在左侧菜单栏找到第三方应用
点击创建应用,自己创建一个应用
回调地址是任务授权成功之后所到达的页面地址,我们可以提前准备一个页面来回调,定义一个静态页面来回调
点击完成,会返回id和密钥,自己保存好,后面有用
之后,我们下拉到页面最下方,点进openapi
点开OAuth2,就能看到认证的流程
他比OAuth2多一个步骤,他在获取token前多了一个步骤,就是获取授权码Code C步骤。之后我们根据他给的流程来实现一遍他的第三方认证
2 OAuth2 获取 AccessToken 认证步骤 ---- 授权码模式
2.1 应用通过 浏览器 或 Webview 将用户引导到码云三方认证页面上( GET请求 )
这个时候就要使用到我们上面保存的id和密钥
https://gitee.com/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code
2.2 点击同意授权之后,就会返回Code ,授权码,我们需要保存,用于下面请求token
2.3 应用服务器 或 Webview 使用 access_token API 向 码云认证服务器发送post请求传入 用户授权码 以及 回调地址( POST请求 )注:请求过程建议将 client_secret 放在 Body 中传值,以保证数据安全。
https://gitee.com/oauth/token?grant_type=authorization_code&code={code}&client_id={client_id}&redirect_uri={redirect_uri}&client_secret={client_secret}
最后会返回 token 和 refresh_token 记得保存下来
2.4 当 access_token 过期后(有效期为一天),你可以通过以下 refresh_token 方式重新获取 access_token( POST请求 )
https://gitee.com/oauth/token?grant_type=refresh_token&refresh_token={refresh_token}
注意:如果获取 access_token 返回 403,可能是没有设置User-Agent的原因。
详见:获取Token时服务端响应状态403是什么情况
2.5 获取用户信息
路径和需要的参数
请求,返回了用户的信息
至此,一次完整的第三方鉴权完成
3 第三方鉴权代码实现
使用AI生成代码,生成前端的代码,提示词:
我正在测试oauth2三方授权登录,需要你帮我实现前端页面代码,尽量美观一点,要有商业风格:
1.首选创建一个index.html代码,在页面正中间创建一个按钮:Gitee授权登录
2.点击按钮时,先GET请求http://localhost:8080/public/login/gitee/config接口,这个会返回json数据:{"data": {"clientId":"clientId...","redirectUri":"redirectUri..."}}。 得到json数据后替换替换这个url(登录 - Gitee.com{client_id}&redirect_uri={redirect_uri}&response_type=code)请求参数中的动态参数{client_id}和{redirect_uri},得到一个三方授权页面的完整url,最后重定向到这个url。用户汇在这个页面中进行确认授权
3.创建一个新的html页面gitee-callback.html
4.当用户授权完毕,gitee服务器会重定向到一个这个新的页面(http://localhost:8080/gitee-callback.html),同时url上面会带上请求参数code。当重定向请求到达后,需要自动提取url上面的code,同时发post请求到后台服务器(http://localhost:8080/user/login/gitee),请求body数据{code:"..."},响应数据{"message": "登录信息...", "data":{"nickname": "用户昵称"}},得到响应数据后,提取中message字段中的数据,重定向到一个新的页面home-page.html,同时在home-page.html中展示message + 'hello, ' + nickname信息(home-page.html也请帮我实现)
代码实现流程图:
3.1 去定义Authentication
我们第一次请求鉴权服务器,传回来的是code,我们去接收他
public class GiteeAuthentication extends AbstractAuthenticationToken {
private String code;
private UserLoginInfo currentUser;
public GiteeAuthentication() {
super(null); // 权限,用不上,直接null
}
@Override
public Object getCredentials() {
return isAuthenticated() ? null : code;
}
@Override
public Object getPrincipal() {
return isAuthenticated() ? currentUser : null;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public UserLoginInfo getCurrentUser() {
return currentUser;
}
public void setCurrentUser(UserLoginInfo currentUser) {
this.currentUser = currentUser;
}
}
3.2 定义过滤链Filter
提取code,给AuthenticationManager验证
public class GiteeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final Logger logger = LoggerFactory.getLogger(GiteeAuthenticationFilter.class);
public GiteeAuthenticationFilter(AntPathRequestMatcher pathRequestMatcher,
AuthenticationManager authenticationManager,
AuthenticationSuccessHandler authenticationSuccessHandler,
AuthenticationFailureHandler authenticationFailureHandler) {
super(pathRequestMatcher);
setAuthenticationManager(authenticationManager);
setAuthenticationSuccessHandler(authenticationSuccessHandler);
setAuthenticationFailureHandler(authenticationFailureHandler);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
logger.debug("use GiteeAuthenticationFilter");
// 提取请求数据
String requestJsonData = request.getReader().lines()
.collect(Collectors.joining(System.lineSeparator()));
Map<String, Object> requestMapData = JSON.parseToMap(requestJsonData);
String code = requestMapData.get("code").toString();
GiteeAuthentication authentication = new GiteeAuthentication();
authentication.setCode(code);
authentication.setAuthenticated(false); // 提取参数阶段,authenticated一定是false
return this.getAuthenticationManager().authenticate(authentication);
}
}
3.3 编写AuthenticationProvider
里面根据传来的code获取token,然后查询数据库获取用户openid,如无异常鉴权成功
@Component
public class GiteeAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserService userService;
@Autowired
private GiteeApiClient giteeApiClient;
public static final String PLATFORM = "gitee";
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String code = authentication.getCredentials().toString();
try {
String token = giteeApiClient.getTokenByCode(code);
if (token == null) {
// 乱传code过来。用户根本没授权!
ExceptionTool.throwException("${gitee.get.open.id.fail:Gitee授权失败!}");
}
Map<String, Object> thirdUser = giteeApiClient.getThirdUserInfo(token);
if (thirdUser == null) {
// 未知异常。获取不到用户openId,也就无法继续登录了
ExceptionTool.throwException("${gitee.get.open.id.fail:Gitee授权失败!}");
}
String openId = thirdUser.get("openId").toString();
// 通过第三方的账号唯一id,去匹配数据库中已有的账号信息
User user = userService.getUserByOpenId(openId, PLATFORM);
boolean notBindAccount = user == null; // gitee账号没有绑定我们系统的用户
if (notBindAccount) {
// 没找到账号信息,那就是第一次使用gitee登录,可能需要创建一个新用户
user = new User();
userService.createUserWithOpenId(user, openId, PLATFORM);
}
GiteeAuthentication successAuth = new GiteeAuthentication();
successAuth.setCurrentUser(JSON.convert(user, UserLoginInfo.class));
successAuth.setAuthenticated(true); // 认证通过,一定要设成true
HashMap<String, Object> loginDetail = new HashMap<>();
// 第一次使用三方账号登录,需要告知前端,让前端跳转到初始化账号页面(可能需要)
loginDetail.put("needInitUserInfo", notBindAccount);
loginDetail.put("nickname", thirdUser.get("nickname").toString()); // sayHello
successAuth.setDetails(loginDetail);
return successAuth;
} catch (BaseException e) {
// 转换已知异常,将异常内容返回给前端
throw new BadCredentialsException(e.getMessage());
} catch (Exception e) {
// 未知异常
throw new BadCredentialsException("Gitee Authentication Failed");
}
}
@Override
public boolean supports(Class<?> authentication) {
return GiteeAuthentication.class.isAssignableFrom(authentication);
}
}
3.4 认证成功Handler
/**
* 认证成功/登录成功 事件处理器
*/
@Component
public class LoginSuccessHandler extends
AbstractAuthenticationTargetUrlRequestHandler implements AuthenticationSuccessHandler {
@Autowired
private ApplicationEventPublisher applicationEventPublisher;
@Autowired
private JwtService jwtService;
public LoginSuccessHandler() {
this.setRedirectStrategy(new RedirectStrategy() {
@Override
public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url)
throws IOException {
// 更改重定向策略,前后端分离项目,后端使用RestFul风格,无需做重定向
// Do nothing, no redirects in REST
}
});
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
Object principal = authentication.getPrincipal();
if (principal == null || !(principal instanceof UserLoginInfo)) {
ExceptionTool.throwException(
"登陆认证成功后,authentication.getPrincipal()返回的Object对象必须是:UserLoginInfo!");
}
UserLoginInfo currentUser = (UserLoginInfo) principal;
currentUser.setSessionId(UUID.randomUUID().toString());
// 生成token和refreshToken
Map<String, Object> responseData = new LinkedHashMap<>();
responseData.put("token", generateToken(currentUser));
responseData.put("refreshToken", generateRefreshToken(currentUser));
// 一些特殊的登录参数。比如三方登录,需要额外返回一个字段是否需要跳转的绑定已有账号页面
Object details = authentication.getDetails();
if (details instanceof Map) {
Map detailsMap = (Map)details;
responseData.putAll(detailsMap);
}
// 虽然APPLICATION_JSON_UTF8_VALUE过时了,但也要用。因为Postman工具不声明utf-8编码就会出现乱码
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
writer.print(JSON.stringify(Result.data(responseData, "${login.success:登录成功!}")));
writer.flush();
writer.close();
}
public String generateToken(UserLoginInfo currentUser) {
long expiredTime = TimeTool.nowMilli() + TimeUnit.MINUTES.toMillis(10); // 10分钟后过期
currentUser.setExpiredTime(expiredTime);
return jwtService.createJwt(currentUser, expiredTime);
}
private String generateRefreshToken(UserLoginInfo loginInfo) {
return jwtService.createJwt(loginInfo, TimeTool.nowMilli() + TimeUnit.DAYS.toMillis(30));
}
}
3.5 配置SecurityFilterChain
@Bean
public SecurityFilterChain thirdApiFilterChain(HttpSecurity http) throws Exception {
// 登录方式。Gitee 登录
LoginSuccessHandler loginSuccessHandler = applicationContext.getBean(LoginSuccessHandler.class);
LoginFailHandler loginFailHandler = applicationContext.getBean(LoginFailHandler.class);
GiteeAuthenticationFilter giteeFilter = new GiteeAuthenticationFilter(
new AntPathRequestMatcher("/user/login/gitee", HttpMethod.POST.name()),
new ProviderManager(
List.of(applicationContext.getBean(GiteeAuthenticationProvider.class))),
loginSuccessHandler,
loginFailHandler);
http.addFilterBefore(giteeFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}