当前位置:首页 > 技术 > 正文内容

Spring Security入门指南

访客 技术 2026年9月16日 11

强烈推荐先观看视频教程

https://www.bilibili.com/video/BV1mm4y1X7Hc/

Spring Security基础入门

要快速上手Spring Security,只需在pom.xml文件中添加以下依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

然后启动Spring Boot应用即可,因为Spring Security已经内置了默认实现。

注意:默认用户名为user,密码会在控制台打印出来(如果控制台内容已清空,需要重新启动应用)

核心原理概述

首先需要理解,Spring Security的核心功能是认证与授权:

  • 认证:验证用户身份是否属于当前系统,通常是通过查询数据库确认用户是否存在
  • 授权:在认证通过后进行,用于规范用户可以执行的操作和禁止的操作(注意:这里指的是验证用户是否具有特定操作权限,而非授予权限)

登录验证流程图

初次登录时,系统会将传入的用户名密码与数据库中存储的数据进行比对。如果找到匹配的用户信息,系统会生成一个JWT(可理解为加密的用户标识)返回给用户。此后,用户的每次请求只需携带此JWT,无需重复输入用户名密码。

前文提到,Spring Security已有完整的默认实现,但我们通常需要自定义部分功能,例如:

  • 使用自己的登录页面替代默认首页
  • 从数据库而非内存验证用户凭据

因此,我们需要重写Spring Security的部分默认实现。关键问题是:需要重写哪些方法?让我们继续深入。

Spring Security本质上是一个过滤器链,简化结构如下:

众多过滤器中,以下三个特别重要:

UsernamePasswordAuthenticationFilter: 处理登录页面的用户名密码提交请求

ExceptionTranslationFilter: 处理过滤器链中抛出的AccessDeniedException和AuthenticationException异常

FilterSecurityInterceptor: 负责权限验证的过滤器,从SecurityContextHolder获取相关信息

现在我们重点关注UsernamePasswordAuthenticationFilter过滤器:

结合上述图表和Spring Security的不足之处,我们需要实现以下几个方面:

  1. 自定义登录接口(本质仍是使用Spring Security内置的authenticate方法)
  2. 自定义UserDetailsServiceImpl类(实现UserDetailsService接口),用于数据库查询
  3. 修改密码加密方式
  4. 添加自定义过滤器,在UsernamePasswordAuthenticationFilter之前处理JWT验证
  5. TODO

实践实现

基于MySQL数据库的用户查询实现

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.entity.LoginUser;
import com.example.entity.User;
import com.example.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getUserName, username);
        User user = userMapper.selectOne(queryWrapper);
        
        if (Objects.isNull(user)) {
            throw new RuntimeException("用户名或密码错误");
        }

        // TODO: 根据用户查询权限信息并添加到LoginUser中
        List<String> permissions = new ArrayList<>(List.of("test"));
        return new LoginUser(user, permissions);
    }
}

此代码中,loadUserByUsername方法是核心。我们通过创建LoginUser类继承UserDetails来实现自定义属性封装:

import com.alibaba.fastjson.annotation.JSONField;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {

    private User user;
    // 存储权限信息
    private List<String> permissions;

    public LoginUser(User user, List<String> permissions) {
        this.user = user;
        this.permissions = permissions;
    }

    // 存储SpringSecurity所需的权限信息集合
    @JSONField(serialize = false)
    private List<GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (authorities != null) {
            return authorities;
        }
        // 将permissions中的字符串权限信息转换为GrantedAuthority对象
        authorities = permissions.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUserName();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("user")
public class User {
    @TableId(type = IdType.AUTO)
    private Integer id;
    private String userName;
    private String password;
}

自定义密码验证方式

@Component
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // 注入AuthenticationManager
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    // 修改密码加密方式
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 禁用CSRF
        http.csrf().disable()
                // 不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                // 相关路径可匿名访问
                .antMatchers("/test/login", "/test/register").anonymous()
                // 其余路径需要认证
                .anyRequest().authenticated();
    }
}

因此,数据库中的密码需要使用BCrypt加密存储。(登录时的密码验证由Spring Security内部实现,我们只需指定加密方式)

关于BCryptPasswordEncoder的更多信息,可参考:https://www.cnblogs.com/ReturnOfTheKing/p/17271746.html

自定义用户登录页面(本质是重写UsernamePasswordAuthenticationFilter)

import com.alibaba.fastjson.JSONObject;
import com.example.entity.User;
import com.example.service.IndexService;
import com.example.util.HttpUtil;
import com.example.util.ResultData;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;

@Slf4j
@RestController
@RequestMapping("/index")
public class IndexController {

    @Autowired
    private IndexService indexService;

    @PostMapping("/login")
    public ResultData login(User user){
        return indexService.login(user);
    }
    
    @GetMapping("/logout")
    public ResultData logout(){
        return indexService.logout();
    }
}
import com.example.entity.User;
import com.example.util.ResultData;

public interface IndexService {
    ResultData login(User user);
    ResultData logout();
}
import com.example.entity.LoginUser;
import com.example.entity.User;
import com.example.service.IndexService;
import com.example.util.JwtUtil;
import com.example.util.RedisCache;
import com.example.util.ResultData;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Objects;

@Service
public class IndexServiceImpl implements IndexService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;

    @Override
    public ResultData login(User user) {
        // 用户名密码校验
        UsernamePasswordAuthenticationToken authenticationToken = 
            new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);

        if (Objects.isNull(authenticate)) {
            return ResultData.fail("用户名和密码错误");
        }

        // 使用userId生成token
        LoginUser loginUser = (LoginUser)authenticate.getPrincipal();
        String userId = loginUser.getUser().getId().toString();
        String jwt = JwtUtil.createJWT(userId);

        // 存储用户信息到Redis
        redisCache.setCacheObject("login:" + userId, loginUser);
        redisCache.expire("login:" + userId, 60);

        // 把token响应给前端
        HashMap<String, String> tokenMap = new HashMap<>();
        tokenMap.put("token", jwt);

        return ResultData.success(tokenMap);
    }

    @Override
    public ResultData logout() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        Integer userId = loginUser.getUser().getId();
        redisCache.deleteObject("login:" + userId);
        return new ResultData(200, "退出成功", null);
    }
}

如前所述,我们本质上是使用AuthenticationManager的authenticate方法进行用户认证,因此需要在SecurityConfig中配置将AuthenticationManager注入容器:

需要强调的是,过滤器链是层层调用的,每个过滤器执行完成后会继续调用下一个,形成环路。

前面提到,UsernamePasswordAuthenticationFilter负责权限验证。如何完成这一验证?

SecurityContextHolder的作用:保存系统当前的安全上下文信息,包括当前登录用户的信息。项目中通常通过此方法获取当前登录用户。

用户首次登录成功后,服务器会将JWT返回给用户。当用户携带JWT进行后续请求时(即JWT过滤器认证时),Spring Security框架会将当前用户信息存入SecurityContextHolder中。

需要注意的是,每个请求对应的SecurityContextHolder是不同的(基于ThreadLocal,一次请求由一个线程处理,因此每次请求都会独立存储)。

核心理解:所有过滤器都会先检查SecurityContextHolder中是否有值,若有则直接放行。

这一理解基本正确:

  • Spring Security判断是否认证的依据是SecurityContextHolder中的Authentication是否已设置且为已认证状态(isAuthenticated() == true)
  • JWT过滤器通过后,其他认证过滤器会被跳过(因为已认证),但授权等其他安全过滤器仍会执行。整个机制依赖于SecurityContextHolder中的认证状态

JWT过滤器实现

import com.example.entity.LoginUser;
import com.example.util.JwtUtil;
import com.example.util.RedisCache;
import io.jsonwebtoken.Claims;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private RedisCache redisCache;

    @SneakyThrows
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 
                                  FilterChain filterChain) throws ServletException, IOException {
        // 获取请求头中的token
        String token = request.getHeader("token");

        if (!StringUtils.hasText(token)) {
            // 没有token则放行,由Spring Security的其他过滤器拦截
            filterChain.doFilter(request, response);
            return;
        }
        
        // 解析token
        Claims claims = JwtUtil.parseJWT(token);
        String userId = claims.getSubject();

        String redisKey = "login:" + userId;
        LoginUser loginUser = redisCache.getCacheObject(redisKey);
        if (Objects.isNull(loginUser)) {
            throw new RuntimeException("用户未登录");
        }
        
        // 存入SecurityContextHolder
        // 注意三参数构造函数会设置已授权标志位
        UsernamePasswordAuthenticationToken authenticationToken = 
            new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        
        // 设置用户信息,方便后续读取
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}

此处需要特别注意UsernamePasswordAuthenticationToken的构造函数参数数量;三参数构造函数会内置一个已授权的标志位。

记得将此过滤器添加到Spring Security框架中:

// SecurityConfig配置类
httpSecurity.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

最后,还需注意以下Spring Security配置:

@Component
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // 注入AuthenticationManager
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    // 修改密码加密方式
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 禁用CSRF
        http.csrf().disable()
                // 不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                // 相关路径可匿名访问
                .antMatchers("/test/login", "/test/register").anonymous()
                // 其余路径需要认证
                .anyRequest().authenticated();
    }
}

启用注解方式权限校验

  1. 在SecurityConfig类上添加@EnableGlobalMethodSecurity注解

  1. 在目标方法上添加所需校验的权限注解

Redis自定义序列化方式

注意:不要使用阿里巴巴提供的FastJson序列化方法,否则反序列化会出错

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import com.alibaba.fastjson.parser.ParserConfig;
import java.nio.charset.Charset;

public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {
    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    static {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }

    public FastJsonRedisSerializer(Class<T> clazz) {
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException {
        if (t == null) {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException {
        if (bytes == null || bytes.length <= 0) {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);
        return JSON.parseObject(str, clazz);
    }

    protected JavaType getJavaType(Class<?> clazz) {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Component;

@Component
public class RedisConfig {
    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        
        // 使用自定义序列化方式
        FastJsonRedisSerializer<Object> fastJsonRedisSerializer = 
            new FastJsonRedisSerializer<>(Object.class);

        // key的序列化采用StringRedisSerializer
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        
        // value值的序列化采用fastJsonRedisSerializer
        template.setValueSerializer(fastJsonRedisSerializer);
        template.setHashValueSerializer(fastJsonRedisSerializer);

        return template;
    }
}

基于角色的访问控制(RBAC)模型

本质:直接为每个用户分配权限,当用户和权限数量增多时操作会变得繁琐。因此提出为用户分配角色,为角色分配权限的解决方案:

JWT相关概念

JWT本质是对用户信息进行加密,类似于通行证或信物,全称为JSON Web Token。它通过数字签名方式,以JSON对象为载体进行数据传输。

JWT由三部分组成:

  • Header:头部信息
  • Payload:载荷(存放需要加密的信息)
  • Signature:签名

代码实现

添加依赖:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

生成JWT

public String createJwt(String userName, String passWd) {
    Map<String, Object> claims = new HashMap<>();
    claims.put("userName", userName);
    claims.put("passWd", passWd);

    return Jwts.builder()
            // 设置claims
            .setClaims(claims)
            // 设置签名算法和密钥
            .signWith(SignatureAlgorithm.HS256, signature)
            // 设置过期时间
            .setExpiration(new Date(System.currentTimeMillis() + expire))
            .compact();
}

解析JWT

public Claims parserJwt(String token) {
    return Jwts.parser()
            .setSigningKey(signature)
            .parseClaimsJws(token)
            .getBody();
}

完整工具类示例

package com.example.utils;

import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/**
 * JWT工具类
 */
@Component
public class JwtUtil {
    @Value("${jwt.secret}")
    private String signature;

    @Value("${jwt.expire}")
    private Long expire;

    /**
     * 生成JWT
     * @param userName 用户名
     * @param passWd 密码
     * @return JWT字符串
     */
    public String createJwt(String userName, String passWd) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userName", userName);
        claims.put("passWd", passWd);

        return Jwts.builder()
                // 设置claims
                .setClaims(claims)
                // 设置签名算法和密钥
                .signWith(SignatureAlgorithm.HS256, signature)
                // 设置过期时间
                .setExpiration(new Date(System.currentTimeMillis() + expire))
                .compact();
    }

    /**
     * 解析JWT
     * @param token JWT字符串
     * @return Claims对象
     */
    public Claims parserJwt(String token) {
        return Jwts.parser()
                .setSigningKey(signature)
                .parseClaimsJws(token)
                .getBody();
    }
}

参考资源

[1] Spring Boot Security默认用户名和密码 [2] https://blog.csdn.net/qq_45847507/article/details/126681110 [3] 配置类相关函数介绍 [4] UsernamePasswordAuthenticationToken函数介绍

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。