Spring Security基于token的极简示例

1 引言

Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架,但是用起来有点复杂,为了便于学习理解,下面将用最简洁的配置和示例,展示整个流程。

2 代码

创建一个spring-security-demo的项目,总共包含5个文件

2.1 pom.xml

引入spring-boot-starter-security

复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>spring-security-demo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <!--  为Spring Boot项目提供一系列默认的配置和依赖管理-->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
        <relativePath/>
    </parent>

    <dependencies>
        <!--  Spring Boot核心依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <!-- Spring Boot单元测试和集成测试的依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- Spring Boot构建Web应用程序的依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

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

    </dependencies>

</project>

2.2 application.properties

保持空白即可

2.3 org/example/Main.java

启动类

复制代码
package org.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Main {

    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }

}

2.4 org/example/controller/UserController.java

测试类,注意@PreAuthorize注解,用于标记每个接口的权限标识

复制代码
package org.example.controller;

import org.example.conf.SecurityConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.*;


@Controller
@RequestMapping("/user")
public class UserController {

    @Autowired
    private AuthenticationManager authenticationManager;

    private Map<String, Object> returnOk(String obj) {
        Map<String, Object> map = new HashMap<>();
        map.put("code", HttpStatus.OK.value());
        map.put("msg", "ok");
        map.put("data", obj);
        return map;
    }

    private Map<String, Object> returnError(String msg) {
        Map<String, Object> map = new HashMap<>();
        map.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value());
        map.put("msg", msg);
        return map;
    }


    @PreAuthorize("hasAuthority('user:hello')")
    @RequestMapping("/hello")
    @ResponseBody
    public Object hello() {
        System.out.println("hello");
        return returnOk("hello");
    }

    @PreAuthorize("hasAuthority('user:hello1')")
    @RequestMapping("/hello1")
    @ResponseBody
    public Object hello1() {
        System.out.println("hello1");
        return returnOk("hello1");
    }


    @PostMapping("/login")
    @ResponseBody
    public Object login(@RequestBody Map<String, String> param) {
        String username = param.get("username");
        String password = param.get("password");
        //调用Spring Security的loadUserByUsername方法获取用户信息
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
        if (authenticate == null) {
            return returnError("用户不存在");
        } else {
            UserDetails userDetails = (UserDetails) authenticate.getPrincipal();
            //验证密码
            BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
            if (!passwordEncoder.matches(password, userDetails.getPassword())) {
                return returnError("账号密码错误");
            }
            // 生成token
            String token = UUID.randomUUID().toString();
            // 存储token
            SecurityConfig.tokenMap.put(token, userDetails);
            return returnOk(token);
        }
    }

    @PostMapping("/logout")
    @ResponseBody
    public Object logout() {
        return returnOk("退出成功");
    }


}

2.5 org/example/conf/SecurityConfig.java

Spring Security配置,为了简化代码,便于查看,我将所有需要自定义的类,以内部类的方式放到里面,然后引入到filterChain方法中即可。

复制代码
package org.example.conf;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.*;

/**
 * spring security配置
 */
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
@Configuration
public class SecurityConfig {

    /**
     * 简单模拟token存储,可以用redis代替。
     */
    public static Map<String, UserDetails> tokenMap = new HashMap<>();

    /**
     * 自定义token过滤器
     */
    public static class MyOncePerRequestFilter extends OncePerRequestFilter {
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
            String token = request.getHeader("token");
            System.out.println("url=" + request.getRequestURI() + ",token=" + token);
            UserDetails userDetails = tokenMap.get(token);
            if (null != userDetails) {
                //如果token存在,则进行spring security权限验证
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
            chain.doFilter(request, response);
        }

    }


    /**
     * 自定义未授权访问处理逻辑
     */
    public static class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
            Map<String, Object> map = new HashMap<>();
            map.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value());
            map.put("msg", "无权限访问该资源");

            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(new ObjectMapper().writeValueAsString(map));
        }
    }


    /**
     * 定义退出处理逻辑
     */
    public static class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {
        @Override
        public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
            String token = request.getHeader("header");
            tokenMap.remove(token);

            Map<String, Object> map = new HashMap<>();
            map.put("code", HttpStatus.OK.value());
            map.put("msg", "退出成功");

            response.setStatus(HttpStatus.OK.value());
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(new ObjectMapper().writeValueAsString(map));
        }
    }

    /**
     * 定义身份认证处理逻辑
     */
    public static class UserDetailsServiceImpl implements UserDetailsService {
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            // 一般情况下,从数据库查询用户信息和权限信息,TODO
            // 为了方便测试,直接模拟用户信息和权限信息。
            Set<GrantedAuthority> authorities = new HashSet<>();
            authorities.add(new SimpleGrantedAuthority("user:hello"));
            authorities.add(new SimpleGrantedAuthority("user:hello2"));
            return getUserDetails(username, new BCryptPasswordEncoder().encode("123456"), authorities);
        }

        private UserDetails getUserDetails(String username, String password, Set<GrantedAuthority> authorities) {
            return new UserDetails() {
                @Override
                public Collection<? extends GrantedAuthority> getAuthorities() {
                    return authorities;
                }

                @Override
                public String getPassword() {
                    return password;
                }

                @Override
                public String getUsername() {
                    return username;
                }

                @Override
                public boolean isAccountNonExpired() {
                    return UserDetails.super.isAccountNonExpired();
                }

                @Override
                public boolean isAccountNonLocked() {
                    return UserDetails.super.isAccountNonLocked();
                }

                @Override
                public boolean isCredentialsNonExpired() {
                    return UserDetails.super.isCredentialsNonExpired();
                }

                @Override
                public boolean isEnabled() {
                    return UserDetails.super.isEnabled();
                }
            };
        }
    }


    @Bean
    protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                // 不使用session,禁用CSRF
                .csrf(csrf -> csrf.disable())
                // 禁用HTTP响应标头
                .headers((headersCustomizer) -> {
                    headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin());
                })
                // 定义未授权访问处理逻辑
                .exceptionHandling(exception -> exception.authenticationEntryPoint(new AuthenticationEntryPointImpl()))
                // 不使用session
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // 记允许匿名访问的url
                .authorizeHttpRequests((requests) -> {
                    requests.requestMatchers("/user/login", "/user/register").permitAll()
                            // 除上面外的所有请求全部需要鉴权认证
                            .anyRequest().authenticated();
                })
                // 定义退出处理逻辑
                .logout(logout -> logout.logoutUrl("/user/logout").logoutSuccessHandler(new LogoutSuccessHandlerImpl()))
                // 定义token过滤器
                .addFilterBefore(new MyOncePerRequestFilter(), UsernamePasswordAuthenticationFilter.class).build();
    }

    /**
     * 身份验证实现
     */
    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(new UserDetailsServiceImpl());
        daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
        return new ProviderManager(daoAuthenticationProvider);
    }


    /**
     * 跨域配置
     */
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        // 设置访问源地址
        config.addAllowedOriginPattern("*");
        // 设置访问源请求头
        config.addAllowedHeader("*");
        // 设置访问源请求方法
        config.addAllowedMethod("*");
        // 有效期 1800秒
        config.setMaxAge(1800L);
        // 添加映射路径,拦截一切请求
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        // 返回新的CorsFilter
        return new CorsFilter(source);
    }

}

3 测试

  1. 启动项目访问http://localhost:8080/user/hello,可以看到提示"无权限访问该资源",由于未登录,请求被正常拦截。
  2. post调用 http://localhost:8080/user/login进行登录操作,用户名随意,密码123456(在认证方法中自定义的)。然后可以看到返回了data即自定义的token值。

  3. 再次访问http://localhost:8080/user/hello,并提前将token值放到header中。可以看到成功返回,表示请求通过了权限验证。这与我们设置的权限标识刚好一致。

    4. 用该token继续访问http://localhost:8080/user/hello1,可以看到 "无权限访问该资源",由于我们没有赋予用户"user:hello1"的权限标识,请求被正常拦截。
相关推荐
一只叫煤球的猫7 小时前
写代码很6,面试秒变菜鸟?不卖课,面试官视角走心探讨
前端·后端·面试
bobz9657 小时前
tcp/ip 中的多路复用
后端
bobz9657 小时前
tls ingress 简单记录
后端
皮皮林5518 小时前
IDEA 源码阅读利器,你居然还不会?
java·intellij idea
你的人类朋友8 小时前
什么是OpenSSL
后端·安全·程序员
bobz9659 小时前
mcp 直接操作浏览器
后端
前端小张同学11 小时前
服务器部署 gitlab 占用空间太大怎么办,优化思路。
后端
databook11 小时前
Manim实现闪光轨迹特效
后端·python·动效
武子康12 小时前
大数据-98 Spark 从 DStream 到 Structured Streaming:Spark 实时计算的演进
大数据·后端·spark
该用户已不存在12 小时前
6个值得收藏的.NET ORM 框架
前端·后端·.net