个人项目开发(1):使用Spring Secruity实现用户登录

文章目录

前言

最近在学习实现别人的项目后想着把一些后端开发中常用的功能自己实现以下,于是便有了以下文章。以后我会尽量保持更新,有问题或可更改的地方也请大家指出帮助我更改!

最基础的用户登录

相信做过苍穹外卖的同学都对用户登录这个功能不陌生,简单来说用户登录(这里先以基于用户密码登录为例)的核心骨架就是从前端接受数据。在根据用户名去数据库或缓存中查询出对应密码,最后进行比对并返回结果。整个过程大致为:controller层接受请求 -> 调用service层的方法 -> 在impl中完成核心逻辑:即调用mapper层从数据库中查询出用户密码并比较. 但这个过程可能存在如下一些问题:

普通登录功能的隐患

密码明文存储 / 比对风险:若直接存储明文密码,数据库泄露会导致用户信息全暴露;若自己实现加密又比较麻烦。

缺少身份认证状态管理:登录成功后,如何证明 "后续请求是已登录用户发起的"?自己实现 Cookie/Session 管理,需处理 Session 过期、分布式 Session 共享(多服务器部署)等问题。

缺少权限控制 登录后,不同用户(如普通用户、管理员)能访问的接口不同,若每个接口都写 "判断用户角色" 的代码,会大量重复。

安全防护缺失 无法抵御 CSRF 攻击(跨站请求伪造)、登录频率限制(防止暴力破解)、会话固定攻击等常见安全问题。

代码重复 每个项目都要重新写 "密码加密""Session 管理""权限判断",无法复用。

登陆方式多样 现在大部分网站都有多种登录方式,针对不同方式重写代码过于麻烦

虽然大部分的功能在苍穹外卖中都有所解决,如使用MD5加密存储密码,基于JWT令牌保存登录信息。但上述问题我们可以用Spring提供的组件Spring Secruity更方便的解决:

完善的登录逻辑

下面我们通过一张图简单了解基于Spring Secruity如何完成上述功能:

具体组件功能解析及代码实现:

在基础的步骤中我们并未使用 Spring Security实现安全管理,因此我们只需自己查库得到用户信息,但现在使用 Spring Security如何得到用户信息?我们需要一个:UserDetailsService。

UserDetailsService

作用:告诉 Spring Security "从哪里获取用户信息"。

为什么需要它 :**Spring Security 不关心你的用户存在哪个数据库、表结构是什么,**它只需要一个 "统一的接口" 来获取用户信息 ------UserDetailsService就是这个接口,我们需要实现它来适配自己的数据库。

实现原理:

认证时,Spring Security 会调用loadUserByUsername(username)方法,传入前端的用户名;

我们在方法中通过UserRepository查库,获取用户的username、password(加密后)、status(是否启用)等信息;

将查库结果封装成 Spring Security 能识别的UserDetails对象(包含用户名、加密密码、权限列表、账户状态),返回给 Spring Security。

具体代码:

java 复制代码
@Service
@RequiredArgsConstructor
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. 查询用户
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));

        log.info("查询到用户:{}", user.getUsername());

        // 2. 检查用户状态
        if (user.getStatus() == 0) {
            throw new RuntimeException("用户已被禁用");
        }

        // 3. 转换为Spring Security需要的UserDetails对象
        org.springframework.security.core.userdetails.User user1 = new org.springframework.security.core.userdetails.User(user.getUsername(),
                user.getPassword(),user.getStatus() == 1,true,true,true,
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")));
        return user1;
    }

}

(@RequiredArgsConstructor 这个注解可以帮助省略@Autowired)

UserRepository是一个接口,继承JpaRepository(或 MyBatis 的Mapper),MyBatis 会通过 动态代理 自动生成接口的实现类;

当调用userRepository.findByUsername(username)时,MyBatis 会自动解析方法名,生成 SQL(select * from sys_user where username = ?),并执行查库操作,返回User实体。这里因为实现的是一个测试类项目,因此我没写Mapper层而是直接使用Jpa的方式访问数据库。

PasswordEncoder

这里我使用的是Spring Secruity推荐的BCrypt 算法 ,简单来说就是他会生成一个盐值对密码加密,每次比对时会自动将加密后的密码的盐值提取出来并和前端接受的密码做运算并比对。要使用也很简单,只需要在配置类中显示申明即可:

java 复制代码
@Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

AuthenticationManager

要解决我们上述提到的应对多种登录方式这一问题,我们就需要用到AuthenticationManager

作用:统一管理认证逻辑,决定 "用哪个认证方式验证用户"(如用户名密码认证、短信验证码认证、OAuth2 认证等)。

为什么需要它:项目后期需要支持多种登录方式(如 "密码登录"+"短信登录"),AuthenticationManager可以统一调度,无需修改原有逻辑(有点像工厂设计模式)。

实现原理

AuthenticationManager本身不做具体认证,而是委托给AuthenticationProvider(认证提供者);

我们配置的DaoAuthenticationProvider就是 "用户名密码认证" 的提供者,它会关联UserDetailsService(查用户)和PasswordEncoder(比密码);

登录时,AuthenticationManager接收UsernamePasswordAuthenticationToken(封装了用户名密码的认证请求),找到对应的AuthenticationProvider执行认证。

接下来看对应的实际代码:

java 复制代码
// SecurityConfig中配置,从Spring容器获取默认实现
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
    return authConfig.getAuthenticationManager();
}

// 配置认证提供者,关联UserDetailsService和PasswordEncoder
@Bean
public DaoAuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
    authProvider.setUserDetailsService(userDetailsService); // 查用户
    authProvider.setPasswordEncoder(passwordEncoder()); // 比密码
    return authProvider;
}

SecurityFilterChain

为了实现权限控制我们需要SecurityFilterChain 这样一个过滤器链:

作用:拦截所有 HTTP 请求,实现 "哪些接口需要登录""哪些接口所有人可访问""哪些接口只有特定角色能访问" 的控制。

为什么需要它:基础逻辑中,若每个接口都写 "判断是否登录""判断角色" 的代码,会大量重复;SecurityFilterChain通过配置实现全局权限控制。

实现原理:

SecurityFilterChain本质是一个 "过滤器链 ",包含多个过滤器 (如UsernamePasswordAuthenticationFilter拦截登录请求、JwtAuthenticationFilter验证 JWT、FilterSecurityInterceptor判断权限);

通过authorizeHttpRequests配置权限规则,Spring Security 会自动在请求到达 Controller 前,检查用户是否登录、是否有对应权限。

具体代码如下:

java 复制代码
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable()) // 前后端分离禁用CSRF(CSRF依赖Cookie)
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无状态(不用Session)
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/login", "/api/test/**").permitAll() // 登录接口、测试接口所有人可访问
            .requestMatchers("/swagger-ui/**").permitAll() // Swagger文档所有人可访问
            .anyRequest().authenticated() // 其他接口必须登录
        );
    // 把JWT过滤器加到Spring Security的过滤器链中
    http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

这里提到的JWT过滤器下面就会提到:

JWT 组件

JWT相信大家都不陌生了,它可以很好的解决登录状态共享这一问题(Session当然也可以,但是在分布式系统中由于不同服务器存储的不同可能会存在问题)

作用:替代传统 Session,解决 "分布式系统登录状态共享" 问题(Session 存在于单个服务器,多服务器部署时,用户换服务器会重新登录)。

为什么需要它:JWT 是一个 "自包含" 的令牌,里面包含了用户信息(如用户名)和过期时间,前端每次请求都带令牌,后端验证令牌即可确认身份,无需存储 Session。

实现原理:

登录成功生成 JWT:JwtTokenProvider用jwt.secret(密钥)对 "用户名 + 过期时间" 签名,生成 JWT 令牌(如eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...),返回给前端;

后续请求验证 JWT:JwtAuthenticationFilter拦截所有请求,从Authorization头中提取 JWT,用密钥验证令牌签名(防止篡改)和过期时间,验证通过后,将用户信息存入SecurityContext(Spring Security 的上下文,后续接口可直接获取用户信息)

代码也很简单了,由于JWT令牌的格式都是固定的,因此这段的逻辑也很简单:

java 复制代码
// JwtTokenProvider生成JWT
public String generateToken(Authentication authentication) {
    UserDetails userDetails = (UserDetails) authentication.getPrincipal();
    Date expiryDate = new Date(System.currentTimeMillis() + jwtExpirationMs);
    return Jwts.builder()
            .setSubject(userDetails.getUsername()) // 存用户名
            .setExpiration(expiryDate) // 存过期时间
            .signWith(SignatureAlgorithm.HS512, jwtSecret) // 用密钥签名
            .compact();
}

// JwtAuthenticationFilter验证JWT
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) {
    String jwt = tokenProvider.getJwtFromRequest(request); // 从请求头取JWT
    if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { // 验证JWT
        String username = tokenProvider.getUsernameFromJWT(jwt); // 从JWT取用户名
        UserDetails userDetails = userDetailsService.loadUserByUsername(username); // 查用户
        // 将用户信息存入SecurityContext,后续接口可通过SecurityContext获取用户
        Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
    chain.doFilter(request, response); // 继续执行后续过滤器
}

具体实现类

最后我们结合具体实现类代码逻辑看下整个过程:

java 复制代码
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
    private final AuthenticationManager authenticationManager;
    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public LoginResponse login(LoginRequest loginRequest) {
        // 1. 进行身份认证
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getUsername(),
                        loginRequest.getPassword()
                )
        );


        // 2. 将认证信息存入上下文
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 3. 生成JWT令牌
        String token = jwtTokenProvider.generateToken(authentication);
        String refreshToken = jwtTokenProvider.generateRefreshToken(authentication);

        // 4. 查询用户信息
        User user = userRepository.findByUsername(loginRequest.getUsername())
                .orElseThrow(() -> new RuntimeException("用户不存在"));

        // 5. 构建并返回登录结果
        return LoginResponse.builder()
                .token(token)
                .refreshToken(refreshToken)
                .expiresIn(jwtTokenProvider.getExpirationInSeconds())
                .username(user.getUsername())
                .build();
    }
}

我们不难发现好像除了

java 复制代码
Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getUsername(),
                        loginRequest.getPassword()
                )
        );

这段代码我们并未看到很多和Spring Secruity相关的代码,那具体他是怎么串联起之前的组件帮我们完成密码加密、权限认证这些功能的呢?

事实上,当我们调用authenticationManager.authenticate(...)时,看似简单的一行代码,实际上触发了 Spring Security 的完整认证流程:

java 复制代码
// 传入的是"未认证的Token"(仅包含用户名和明文密码)
new UsernamePasswordAuthenticationToken(username, password)

步骤 2:Spring Security 自动调用UserDetailsService查库

AuthenticationManager会委托DaoAuthenticationProvider(之前配置的认证提供者)执行认证,而DaoAuthenticationProvider会:

调用我们实现的UserDetailsServiceImpl,通过userRepository.findByUsername(username)查询数据库,获取用户信息(包含加密后的密码);

拿到UserDetails对象(包含username、password(加密后)、authorities等)。

步骤 3:自动使用PasswordEncoder比对密码

DaoAuthenticationProvider会自动调用BCryptPasswordEncoder(你在SecurityConfig中配置的加密器):

用passwordEncoder.matches(明文密码, 数据库加密密码)进行比对;

这个过程完全由 Spring Security 内部完成,业务代码中看不到任何加密 / 比对逻辑。

三、Spring Security 如何完成 "请求过滤与权限控制"?

过滤功能(如拦截未登录请求、验证 JWT)看似与AuthServiceImpl无关,实际上是通过过滤器链在 "请求到达 Controller 之前" 就完成了处理。

1. 过滤器链的 "前置拦截"

当前端发送登录请求(/api/auth/login)时,请求会先经过 Spring Security 的过滤器链(SecurityFilterChain):

UsernamePasswordAuthenticationFilter:判断是否是登录请求(默认处理/login,但我们的接口是/api/auth/login,所以这里不拦截,直接放行到 Controller);

其他过滤器:如CsrfFilter(我们已禁用)、CorsFilter等,按配置规则处理请求。

注意:登录接口本身是 "公开接口"(permitAll()),所以过滤器链会放行,让请求到达AuthController和AuthServiceImpl。

2. 非登录请求的过滤逻辑(如查询用户信息接口)

对于需要认证的接口(如/api/user/info),过滤器链会在请求到达 Controller 前进行拦截:

JwtAuthenticationFilter(你配置的自定义过滤器):从请求头提取 JWT,验证有效性,若有效则将用户信息存入SecurityContext;

FilterSecurityInterceptor(Spring Security 内置过滤器):检查SecurityContext中是否有认证信息,若没有则返回 401(未登录);同时检查用户权限是否符合接口要求(如是否有ROLE_ADMIN)。

这些过滤逻辑完全独立于业务代码,但会影响请求是否能到达AuthServiceImpl或其他 Controller。

四、为什么业务代码中看不到 Spring Security 的具体实现?

这正是 Spring Security 的设计理念 ------"面向接口编程" 和 "依赖注入":

接口隔离:Spring Security 定义了AuthenticationManager、UserDetailsService、PasswordEncoder等接口,我们只需实现这些接口的 "业务部分"(如查库逻辑),而复杂的安全逻辑由框架实现;

自动装配:通过@Bean配置(如SecurityConfig中的passwordEncoder()、authenticationProvider()),Spring 会自动将这些组件组装成完整的安全链条,业务代码只需注入AuthenticationManager等组件即可使用;

总结

不难发现,整个过程使用相比自己实现整洁了不少,大部分功能都被交给Spring Secruity实现了。完整代码在https://github.com/elsa-xiatian/enterprise-features可见,谢谢大家

相关推荐
陈文锦丫4 小时前
MQ的学习
java·开发语言
乌暮4 小时前
JavaEE初阶---线程安全问题
java·java-ee
爱笑的眼睛114 小时前
GraphQL:从数据查询到应用架构的范式演进
java·人工智能·python·ai
Seven975 小时前
剑指offer-52、正则表达式匹配
java
代码or搬砖5 小时前
RBAC(权限认证)小例子
java·数据库·spring boot
青蛙大侠公主5 小时前
Thread及其相关类
java·开发语言
Coder_Boy_5 小时前
DDD从0到企业级:迭代式学习 (共17章)之 四
java·人工智能·驱动开发·学习
2301_768350235 小时前
MySQL为什么选择InnoDB作为存储引擎
java·数据库·mysql
派大鑫wink5 小时前
【Java 学习日记】开篇:以日记为舟,渡 Java 进阶之海
java·笔记·程序人生·学习方法