项目介绍
项目中现在有如下几个模块:
- blog-auth:认证服务,目前只提供了基本的登录逻辑处理。
- blog-common:公共依赖模块,一些公共的依赖、工具类、配置等都封装在这里,其它的微服务都引入这个模块
- blog-gateway:API网关服务,是所有流量的入口。目前做了路径重写、登录校验功能。
- blog-web:web端api服务,还未做开发,设想是将web端的所有api接口,都写到这个服务中。
- blog-vue:博客系统的前端服务,使用Vue3开发,为了开发方便,我就将它和上面的模块放到了一个目录中,后续可能还会开发一个后台管理,也打算放在同一目录下。
技术栈方面:
- 后端:SpringCloud、SpringBoot、Mybatis-plus、SpringSecurity等。
- 前端:Vue3、Element Plus、Axios等。
- 中间件:目前还没有应用到中间件,后续会引入。例如使用redis去保存登录的token、rabbitmq在登录后发送站内信或邮件、ElasticSearch去做检索模块等等。
项目目录如下:
项目的架构大概就是这样,本文想分享的登录模块,主要涉及到了两个服务:blog-auth、blog-gateway。blog-auth中引入了SpringSecurity,实现了一个基本的登录流程逻辑。blog-gateway中针对每个请求,都去判断了是否需要登录、如果是,还要判断是否已登录,如果否,会返回JSON格式的提示信息。
下面就分享一下里面的细节:
blog-auth
这个模块主要用来实现一个基本的登录流程,因为自己学习过SpringSecurity,那这里就使用了它来做登录。SpringSecurity默认的登录流程是表单登录,但我们这里是前后端分离,需要使用JSON交互,所以就做了一些相关的配置,来让SpringSecurity返回JSON数据。
SpringSecurity的登录流程都是固定的,我们只需要修改几个地方:
- 对于登录参数的接收:原有登录流程中是接收的表单数据,我们这里需要改造为接收JSON格式的请求入参。
- 对于登录后的处理:原有流程中,无论是登录成功还是失败,都是进行重定向。这里要改造为返回JSON格式的数据,登录成功的场景下,还需要返回token。
- 提供一个配置类,配置一些拦截规则、以及上面提到的内容等等。
下面来一一看下
项目依赖
依赖很简单,就是引入了一个SpringSecurity,贴一下代码吧
xml
代码解读
复制代码
<dependencies>
<dependency>
<groupId>com.xb.blog</groupId>
<artifactId>blog-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--SpringSecurity-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
登录参数接收
SpringSecurity中,所有的功能都是由一个个的过滤器来完成的,所有的过滤器组合成一个过滤器链。登录参数接收相关的过滤器是UsernamePasswordAuthenticationFilter
,我们现在要做的是重写一个过滤器,并替换掉UsernamePasswordAuthenticationFilter。
在UsernamePasswordAuthenticationFilter中,作为一个过滤器组件,首先被调用的是doFilter方法,在这个方法中,会调用attemptAuthentication方法来完成登录参数获取与认证操作,我们要做的就是重写这个方法中的逻辑。
如下代码,定义一个AuthFilter,继承自UsernamePasswordAuthenticationFilter,并重写它的attemptAuthentication
方法,这个方法就是具体的登录参数的获取方法。
java
代码解读
复制代码
public class AuthFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_UTF8_VALUE)) {
Map<String, String> userInfo;
try {
userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
String username = userInfo.get(getUsernameParameter());
String password = userInfo.get(getPasswordParameter());
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
} catch (IOException e) {
e.printStackTrace();
}
}
return super.attemptAuthentication(request, response);
}
}
可以看到,先进行了判断,在SpringSecurity中,为了安全起见,登录请求被限制为Post请求。然后我们从请求体中获取了username和password,组合为了UsernamePasswordAuthenticationToken对象,然后调用了认证管理器的authenticate方法进行认证操作。
登录成功&失败处理器
登录成功与失败时,我们也需要返回JSON格式的数据。这里就提供两个处理器来做这件事。
SpringSecurity中的认证成功&失败处理器分别为 AuthenticationSuccessHandler、AuthenticationFailureHandler。这是两个接口,实现这两个接口,并重写对应方法就可以实现自定义回调逻辑。
登录成功处理器
java
代码解读
复制代码
@Component
public class AuthAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
//登录成功,生成token,并保存到响应头中
String token = AuthUtil.createToken(((AuthUser) authentication.getPrincipal()).getUsername());
response.setHeader("Token", token);
response.setHeader("Access-Control-Expose-Headers", "Token");
response.setContentType("application/json;charset=utf-8");
Result result = new Result();
result.setCode("0");
result.setMessage("登录成功");
response.getWriter().write(new ObjectMapper().writeValueAsString(result));
}
}
这个处理器中,不只是返回了JSON格式数据。还生成了token,将它放到了请求头中,方便前端从响应头中拿到token,在后面的请求中放到请求头中。
这个操作我是经过考虑的,本想将token保存到cookie中,但是在调研过程中发现,项目中可能为了安全,将Cookie设置为HttpOnly,这样前端就没法获取cookie中的值了。所以最后采取了放在响应头中的做法,前端会保存到localStorage中,然后每次请求时,都会从localStorage中获取token放到请求头中。
这个案例中使用了一个工具类AuthUtil,是我自己封装的,里面使用了HuTool工具包的JwtUtil生成了token。
java
代码解读
复制代码
public class AuthUtil {
private static String KEY = "KEY20240421";
/**
* 判断当前请求是否认证
*
* @param token
* @return
*/
public static Boolean isAuth(String token) {
return JWTUtil.verify(token, KEY.getBytes());
}
/**
* 根据username生成token
*
* @param username
* @return
*/
public static String createToken(String username) {
Map<String, Object> payload = new HashMap<>();
payload.put("username", username);
return JWTUtil.createToken(payload, KEY.getBytes());
}
/**
* 解析token 获取username
*
* @param token
* @return
*/
public static String getUsernameFromToken(String token) {
JWT jwt = JWTUtil.parseToken(token);
return jwt.getPayload("username").toString();
}
}
登录失败处理器
java
代码解读
复制代码
@Component
public class AuthAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
Result result = new Result();
result.setCode("1");
result.setMessage("登录失败:"+exception.getMessage());
response.getWriter().write(new ObjectMapper().writeValueAsString(result));
}
}
登陆失败回调就很简单,就是返回JSON格式的数据。前端拿到后会在页面上弹窗展示。
配置类
SpringSecurity高版本弃用了继承WebSecurityConfigurerAdapter的配置方法,标注了过时,但是还能用。因为对这种方式比较熟悉,我暂时就还是用这种配置方法,后面再修改吧
代码如下,主要就是将上面定义的组件进行了配置,然后配置了一些拦截规则等等,注释比较详细,就不再赘述。
java
代码解读
复制代码
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthAuthenticationSuccessHandler authAuthenticationSuccessHandler;
@Autowired
private AuthAuthenticationFailureHandler authAuthenticationFailureHandler;
/**
* 配置过滤器链
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login/**").permitAll()
.antMatchers("/getAuthUser/**").permitAll()
.anyRequest().authenticated();
http.csrf().disable();
}
/**
* 提供自定义登录过滤器,定义从post请求体中获取登录请求参数
*
* @return
*/
@Bean
public AuthFilter authFilter() throws Exception {
AuthFilter filter = new AuthFilter();
//认证管理器
filter.setAuthenticationManager(authenticationManagerBean());
//认证成功处理器
filter.setAuthenticationSuccessHandler(authAuthenticationSuccessHandler);
//认证失败处理器
filter.setAuthenticationFailureHandler(authAuthenticationFailureHandler);
return filter;
}
/**
* 提供认证管理器
*
* @return
* @throws Exception
*/
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
上述配置完毕之后,就可以访问/login
接口进行登录操作了,登录成功之后会返回JSON格式数据,并且token会被放到响应头中,前端中我将其保存到了localStorage中,然后每次请求时,都会从localStorage中获取token放到请求头中。
判断登录接口
目前还提供了一个判断当前请求是否已登录的接口,用于首页判断用户是否登录。
java
代码解读
复制代码
@RestController
public class AuthController {
@Autowired
private AuthUserDetailsService authUserDetailsService;
@GetMapping("getAuthUser")
public Result getAuthUser(HttpServletRequest request) {
String token = request.getHeader("Token");
if (StrUtil.isNotBlank(token)) {
Boolean isAuth = AuthUtil.isAuth(token);
if (isAuth) {
String username = AuthUtil.getUsernameFromToken(token);
UserDetails user = authUserDetailsService.loadUserByUsername(username);
AuthUserVo authUser = new AuthUserVo();
BeanUtils.copyProperties(user, authUser);
return Result.success(authUser);
}
}
return Result.success(null);
}
}
blog-gateway
网关作为一切流量的入口,我主要做了两件事
- 路径重写
- 登录拦截
路径重写
我现在的设计中,后端所有的接口都是/api
开头,调用不同的服务后面拼不同的后缀,例如/api/auth、/api/web等等。所以我要在网关中进行路径重写,将/api/auth/xxx 重写为/auth/xxx,这个在配置文件中配置即可。
下面的配置文件中,gateway:routes:
部分是配置了路径重写。然后还进行了一个自定义配置auth:excludePaths:
,这里是配置了所有不需要登录的路径。
application.yml
yml
代码解读
复制代码
# Tomcat
server:
port: 88
# 注册中心 配置中心
spring:
application:
name: blog-gateway
cloud:
nacos:
discovery:
# 注册中心地址
server-addr: 127.0.0.1:8848
# config:
# # 配置中心地址
# server-addr: 127.0.0.1:8848
gateway:
routes:
# web服务
- id: web_route
uri: lb://blog-web
predicates:
- Path=/api/web/**
filters:
- RewritePath=/api/(?<segment>.*),/$\{segment}
# 认证服务
- id: auth_route
uri: lb://blog-auth
predicates:
- Path=/api/auth/**
filters:
- RewritePath=/api/(?<segment>.*),/$\{segment}
config:
import: application.yml
auth:
# 不需要登录 即可访问的地址
excludePaths:
- /api/auth/**
登录拦截
上面的配置文件中,配置了不需要登录就可访问的地址,在GateWay中,登录拦截我采用了一个过滤器来完成。
代码中注释齐全,就不赘述了,关于返回状态码的硬编码问题,现在还没有处理,以后会进行处理的。
java
代码解读
复制代码
/**
* 认证过滤器:
* 将这个过滤器配置在 NettyRoutingFilter 之前,实现在路由转发之前进行登录校验工作
*/
@Component
public class AuthFilter implements GlobalFilter, Ordered {
@Autowired
private AuthProperties authProperties;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@SneakyThrows(IOException.class)
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
//判断是否需要登录
if (isLoginRequiredForPath(request.getPath().toString())) {
//判断用户是否已经登录
if (!isAuth(request)) {
Result result = new Result();
result.setCode("99");
result.setMessage("NO_LOGIN");
byte[] bytes = new ObjectMapper().writeValueAsBytes(result);
return response.writeWith(Mono.fromSupplier(() -> response.bufferFactory().wrap(bytes)));
}
}
//不需要登录&已登录,放行
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
/**
* 判断传入的地址是否需要登录
*
* @param path
* @return
*/
private boolean isLoginRequiredForPath(String path) {
for (String pattern : authProperties.getExcludePaths()) {
if (pathMatcher.match(pattern, path)) {
return false;
}
}
return true;
}
/**
* 判断该请求是否已经登录
*
* @param request
* @return
*/
private boolean isAuth(ServerHttpRequest request) {
//获取请求头中的token
List<String> headers = request.getHeaders().get("Token");
String token = "";
if (!CollUtil.isEmpty(headers)) {
token = headers.get(0);
}
if (StrUtil.isBlank(token)) {
return false;
}
//校验token
return AuthUtil.isAuth(token);
}
}
跨域配置
前后端开发中,还可能会出现跨域问题。在微服务项目中,可以在网关处统一配置跨域,提供一个配置类即可。
java
代码解读
复制代码
/**
* 跨域配置
*/
@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsWebFilter() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.addAllowedOriginPattern("*");
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return new CorsWebFilter(source);
}
}
前端代码
前端方面我是个小白,磕磕绊绊的实现了登录,贴一下请求拦截部分的代码吧
request.js
js
代码解读
复制代码
import axios from "axios";
import { localStorage } from "@/utils/storage";
// 创建axios实例
const service = axios.create({
baseURL: import.meta.env.VITE_APP_SERVICE_API,
timeout: 50000, // 请求超时时间:50s
headers: { "Content-Type": "application/json;charset=utf-8" },
});
// 请求拦截器
service.interceptors.request.use(
(config) => {
//如果localStorage中有token,取出放到请求头中
let token = localStorage.get("BLOG_TOKEN");
if (token) {
config.headers["Token"] = token;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response) => {
//如果响应头中有token,保存到localStorage中
const headers = response.headers;
let token = headers["token"];
if (token) {
localStorage.set("BLOG_TOKEN", token);
}
return response.data;
},
(error) => {
console.log("请求异常:", error);
}
);
// 导出实例
export default service;