头图

项目介绍
项目中现在有如下几个模块:

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;
转载来源:https://juejin.cn/post/7376915846109003817


运维社
12 声望4 粉丝