Spring Boot Security (三)

Spring Boot Security (三)

之前的随笔(https://www.cnblogs.com/zolmk/p/14074227.html)简单的使用了Spring Boot Security,没有深入。

一、主要内容

这篇主要的应用场景为前后端分离,前端Vue,后端Spring Boot(WebFlux)
本文主要实现了以下几点:
1.使用JdbcUserDetailsManager或者InMemoryUserDetailsManager实现用户认证和用户账户修改
2.实现登入登出控制
3.实现资源权限管理
4.解决跨域(cors)问题
注意区分这两个单词:AuthenticationAuthorization(认证和授权),前者通过账号密码进行认证,后者通过token对请求进行授权。

二、实现步骤

2.1 添加依赖

在pom.xml中添加如下依赖项

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

2.2 Security 配置

这里需要在配置文件中开启 WebFluxSecurity、ReactiveMethodSecurity等、对登录登出的处理、vue跨域问题等。具体配置文件如下:
SecurityConfiguration.java

//your package;

import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.fy.shopexample.utils.ResponseCode;
import com.fy.shopexample.utils.ResponseUtil;
import com.fy.shopexample.utils.ServerHttpResponseUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.Collections;
import java.util.Objects;


@Slf4j
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfiguration {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, AuthenticationRepository repository,
                                                         UserDetailServiceImpl userDetailService) {


        // 设置用户信息Service和密码更新Service
        UserDetailsRepositoryReactiveAuthenticationManager authenticationManager =
                new UserDetailsRepositoryReactiveAuthenticationManager(userDetailService);
        // 给UserDetailServiceImpl设置ReactiveAuthenticationManager,来响应更新密码
        userDetailService.setAuthenticationManager(authenticationManager);

        authenticationManager.setUserDetailsPasswordService(userDetailService);
        authenticationManager.setPasswordEncoder(new PasswordEncoderImpl());

        /* 鉴权成功后的处理器 */
        final ServerAuthenticationSuccessHandler successHandler = (webFilterExchange, authentication) -> {
            String token = IdUtil.simpleUUID();
            repository.put(token ,authentication);
            return ServerHttpResponseUtil.print(webFilterExchange.getExchange().getResponse(), ResponseUtil.success(token));
        };

        /* 鉴权失败后的处理器 */
        final ServerAuthenticationFailureHandler failureHandler =
                (webFilterExchange, exception) ->
                        ServerHttpResponseUtil.print(webFilterExchange.getExchange().getResponse(),
                                ResponseUtil.of(ResponseCode.AUTHENTICATION_FAIL));

        /* 登出成功处理器 */
        final ServerLogoutSuccessHandler logoutHandler = (exchange, authentication) -> {
            String token = exchange.getExchange().getRequest().getHeaders().getFirst("token");
            if (StrUtil.isNotBlank(token)) {
                repository.delete(token);
            }
            return ServerHttpResponseUtil.print(exchange.getExchange().getResponse(), ResponseUtil.success());
        };

        /* 权限拒绝处理器 */
        final ServerAccessDeniedHandler deniedHandler = (exchange, denied) ->
                ServerHttpResponseUtil.print(exchange.getResponse(),
                        ResponseUtil.of(ResponseCode.AUTHENTICATION_FAIL));

        /* Security上下文仓库
        * 它的意义是根据token将当前用户的授权信息提取出来,供当前请求上下文使用。
        *  */
        final ServerSecurityContextRepository securityContextRepository = new ServerSecurityContextRepository() {
            @Override
            public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
                return Mono.empty();
            }
            @Override
            public Mono<SecurityContext> load(ServerWebExchange exchange) {
                String token = exchange.getRequest().getHeaders().getFirst("token");
                if (StrUtil.isNotBlank(token)) {
                    Authentication authentication = repository.get(token);
                    if (Objects.nonNull(authentication)) {
                        SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
                        return Mono.just(securityContext);
                    }
                }
                return Mono.empty();
            }
        };

        /* 解决跨域问题 */
        http.cors(corsSpec -> {
                    CorsConfiguration corsConfiguration = new CorsConfiguration();
                    corsConfiguration.setAllowedOriginPatterns(Collections.singletonList("*"));
                    corsConfiguration.addAllowedHeader(CorsConfiguration.ALL);
                    corsConfiguration.addAllowedMethod(CorsConfiguration.ALL);
                    corsConfiguration.setAllowCredentials(true);
                    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
                    source.registerCorsConfiguration("/**", corsConfiguration);
                    corsSpec.configurationSource(source);
                })
                .csrf()
                .disable()
                /* 登录和登出状态管理 */
                .formLogin(s -> {
                    s.loginPage("/login");
                    s.authenticationSuccessHandler(successHandler);
                    s.authenticationFailureHandler(failureHandler);
                })
                .logout(s -> {
                    s.logoutSuccessHandler(logoutHandler);
                })
                .passwordManagement()
                .and()
                .authorizeExchange(authorize -> {
                    authorize.pathMatchers("/signup", "/login", "/logout").permitAll()
                            .pathMatchers("/user/**").hasAnyRole("USER", "ADMIN", "DBA")
                            .pathMatchers("/admin/**").hasAnyRole("ADMIN", "DBA")
                            .pathMatchers("/db/**")
                            .hasRole("DBA")
                            .anyExchange().denyAll();
                })
                .exceptionHandling(s -> {
                    s.accessDeniedHandler(deniedHandler);
                })
                .authenticationManager(authenticationManager)
                .securityContextRepository(securityContextRepository);

        return http.build();
    }
}

其中ResponseCode和ResponseUtil是我自己对响应的封装,不重要,ServerHttpResponseUtil内容如下,主要就是将响应对象写入到ServerHttpResponse中。

import cn.hutool.json.JSONUtil;
import com.fy.shopexample.dto.Response;
import org.springframework.http.server.reactive.ServerHttpResponse;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;


public class ServerHttpResponseUtil {
    public static Mono<Void> print(ServerHttpResponse httpResponse, Response<?,?> response) {
        return httpResponse.writeWith(Mono.just(httpResponse.bufferFactory().wrap(JSONUtil.toJsonStr(response).getBytes(StandardCharsets.UTF_8))));
    }
}

Security配置部分就这些内容,主要是在ServerHttpSecurity类上进行操作,配置认证与鉴权的规则和一些处理事件。

2.3 用户认证流程

  • vue前端通过axios发起post请求到/login接口,认证管理器对账号密码做校验,如果验证正确,则调用ServerAuthenticationSuccessHandler中的方法,如果失败,调用ServerAuthenticationFailureHandler中的方法
  • 认证成功后,我们可以给用户生成一个token,服务器对token和用户的授权信息进行保存后,将其返回给前端。
  • 前端拿到该token后进行本地保存,之后的每次请求都在headers上附带token,服务器根据该token对用户进行授权。

服务器对token和用户授权信息保存的相关类如下:
AuthenticationRepository.java


import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Repository;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 用来储存登录令牌
**/

@Repository
public class AuthenticationRepository {
    private final static Map<String, Authentication> MAP = new ConcurrentHashMap<>();
    private final static Map<String, String> USERNAME_TOKEN = new ConcurrentHashMap<>();
    public void put(String key, Authentication authentication) {
        MAP.put(key, authentication);
        Object user = authentication.getPrincipal();
        String username;
        if (user instanceof String) {
            username = (String) user;
        } else if (user instanceof UserDetails) {
            username = ((UserDetails) user).getUsername();
        } else {
            throw new RuntimeException("用户不存在");
        }
        USERNAME_TOKEN.put(username, key);
    }

    public Authentication get(String key) {
        return MAP.get(key);
    }

    public String getToken(String username) {
        return USERNAME_TOKEN.get(username);
    }

    public void delete(String key) {
        MAP.remove(key);
    }

    public void deleteByUsername(String username) {
        MAP.remove(USERNAME_TOKEN.get(username));
    }

}

这里简单省事直接将其保存在了内存中,最好的办法是将其保存在redis等缓存中间件中。类中的方法很简单,不再赘述。

一般而言,后端服务器数据库中不允许保存用户的明文密码,只允许保存加密后的用户密码,因此就需要一个密码编码器。密码验证流程为:1.服务器收到前端传来的密码 2.服务器通过密码编码器对密码进行编码 3.验证数据库中保存的密码和编码后的密码是否匹配 4.处理比对结果。

密码编码器如下:
PasswordEncoderImpl.java

import cn.hutool.core.codec.Base64;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

import java.util.Objects;

/**
 **/
@Slf4j
@Component
public class PasswordEncoderImpl implements PasswordEncoder {

    @Override
    public String encode(CharSequence rawPassword) {
        return Base64.encode(rawPassword);
    }

    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return Objects.equals(encodedPassword, encode(rawPassword));
    }

    @Override
    public boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

UserDetailsService类,该类实现了ReactiveUserDetailsServiceReactiveUserDetailsPasswordService接口,提供密码修改和使用用户名加载用户的功能。内容如下:
UserDetailServiceImpl.java


import cn.hutool.core.codec.Base64;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;

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

import static cn.hutool.core.codec.Base64.encode;

@Service
@Slf4j
public class UserDetailServiceImpl implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService {

    // 这里暂时使用了该类来管理
    private final Map<String, UserDetails> map;
    private final PasswordEncoder passwordEncoder;
    private ReactiveAuthenticationManager authenticationManager;
    private AuthenticationRepository authenticationRepository;

    public UserDetailServiceImpl(PasswordEncoder passwordEncoder, AuthenticationRepository authenticationRepository) {
        this.map = new HashMap<>();
        this.passwordEncoder = passwordEncoder;
        this.authenticationRepository = authenticationRepository;
        // 先初始化一个用户
        // 这里由于前端传入的就是经过Base64编码后的密码,然后在PasswordEncoder又进行来编码,所以这里要进行两次编码
        UserDetails userDetails = User.withUsername("admin").password(encode(encode("1234"))).roles("DBA").build();
        this.map.put(userDetails.getUsername(), userDetails);
    }

    @Override
    public Mono<UserDetails> updatePassword(UserDetails user, String newPassword) {
        String encodePassword = passwordEncoder.encode(newPassword);
        UserDetails userDetails = User.withUserDetails(user).password(encodePassword).build();
        this.map.put(user.getUsername(), userDetails);
        Mono<UserDetails> res = Mono.just(userDetails);
        if (this.authenticationManager != null) {
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(user.getUsername(), newPassword);
            Mono<Authentication> mono = this.authenticationManager.authenticate(token);
            res = mono.map(authentication -> (UserDetails)authentication.getPrincipal());
        }
        // 最后修改鉴权存储器
        return res.doOnSuccess(u -> {
            this.authenticationRepository.deleteByUsername(u.getUsername());
        });
    }

    @Override
    public Mono<UserDetails> findByUsername(String username) {
        return Mono.just(map.get(username));
    }

    public void setAuthenticationManager(ReactiveAuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }
}

2.4 用户授权

用户授权过程如下:
1.前端发送请求,并在headers中附带token,通过在SecurityConfiguration配置文件中设置ServerSecurityContextRepository
2.当请求来临时,通过抽取headers中的token,然后根据token从AuthenticationRepository中获取相应的Authentication
3.然后生成上下文SecurityContextImpl securityContext = new SecurityContextImpl(authentication),从而对当前请求进行授权。

2.5 修改密码

简单起见,我直接卸载了Controller层,具体代码如下:

    // 这个在UserDetailsService中有实现
    @Autowired
    private ReactiveUserDetailsPasswordService passwordService;
    @Autowired
    private PasswordEncoder passwordEncoder;
    @PostMapping(path = "/change-password")
    public Mono<Response<?,?>> changePassword(@RequestBody Map<String,String> map, Authentication authentication) {
        String newPassword = map.get("newPassword");
        if (StrUtil.isBlank(newPassword)) {
            return Mono.just(ResponseUtil.fail());
        }
        // 获取用户信息
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
       
        //将用户权限设置到当前的上下文中(不知道为什么直接通过SecurityContextHolder.getContext().getAuthentication()为空)
        SecurityContextHolder.getContext().setAuthentication(authentication);

        return passwordService.updatePassword(userDetails, newPassword).map(u -> {
            if (StrUtil.equals(passwordEncoder.encode(newPassword), u.getPassword())) {
                return ResponseUtil.success();
            }
            return ResponseUtil.fail();
        });
    }

2.6 方法级权限控制

这部分主要用注解来进行控制,示例代码如下:

import com.fy.shopexample.dto.Response;
import org.springframework.security.access.prepost.PreAuthorize;
import reactor.core.publisher.Mono;

import java.util.List;

public interface UserService {
    @PreAuthorize("hasAnyRole('DBA')")
    Mono<Response<?,?>> listUsers(int pageNumber, int pageSize);
}

这里使用了PreAuthorize注解,它的参数在IDEA中有自动完成提示,大概有以下几种:

  • hasAnyRole(roleList):该方法需要具有相应的角色才能调用(只需满足其中一个)
  • hasRole(role):同上
  • hasPermission():当前用户需要具有对应权限
  • hasAuthority(authorityList):当前用户需要获得对应授权(只需满足其中一个)
  • hasAnyAuthority(authority):同上

三、问题汇总

3.1 SecurityContextHolder.getContext().getAuthentication()获取不到当前用户

在Controller层,可以通过下面的方式获得:

@PostMapping(path = "/change-password")
public Mono<Response<?,?>> changePassword(@RequestBody Map<String,String> map, 
Authentication authentication) {
}

3.2 使用数据库实现UserDetails的最快方式

Spring Boot Security提供了JdbcUserDetailsManager类,该类实现了UserDetailsManagerUserDetailsService接口,可以很方便的存取和修改用户。相应的表结构,懒得去找,如果要用,在JdbcUserDetailsManager类中能得到。

posted @ 2023-04-22 16:52  zolmk  阅读(131)  评论(0编辑  收藏  举报