SpringBoot & SpringSecurity 上

发布时间 2023-07-25 08:29:32作者: rslai

之前项目都用的是 shiro 这次改用 SpringSecurity,特意记录一下。

一、添加 WebSecurityConfig

 代码如下,这个文件是 SpringSecurity 配置主入口

package com.bjy.qa.util.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.annotation.Resource;

/**
 * SpringSecurity 配置
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启方法级别的权限认证,后续不需要,要删除
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; // JWT 过滤器

    @Resource
    AuthenticationEntryPointImpl autoetaticaticcAutryPointImpl; // 认证 时的异常(当用户请求一个受保护的资源,又没登录时触发)
    @Resource
    AccessDeniedPointImpl accessDeniedPointImpl; // 用户访问无权限资源 时的异常(用户登录后,请求一个受保护的资源,又没权限时触发)

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable() // 关闭跨站请求防护
                .cors().and() // 配置 CORS支持
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 不通过 session 获取 SecurityContext
                .and().authorizeRequests().antMatchers("/user/user/login").anonymous() // 对于登录接口允许匿名访问
                .anyRequest().authenticated(); // 除上面外的所有请求全部需要鉴权认


        // 配置异常处理器
        http
                .exceptionHandling()
                .authenticationEntryPoint(autoetaticaticcAutryPointImpl) // 认证 时的异常(当用户请求一个受保护的资源,又没登录时触发)
                .accessDeniedHandler(accessDeniedPointImpl); // 用户访问无权限资源 时的异常(用户登录后,请求一个受保护的资源,又没权限时触发)


        // 添加 JWT 过滤器
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

二、LoginUser

这个文件实现了 SpringSecurity 的 UserDetails,有两个作用一个是后续会把此对象缓存到 redis 中,另一个作用的会存入 SpringSecurity 的 context 中。

package com.bjy.qa.util.security;

import com.alibaba.fastjson.annotation.JSONField;
import com.bjy.qa.entity.user.User;
import lombok.Data;
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
public class LoginUser implements UserDetails {
    private User user; // 用户对象

    private List<String> permissions; // 权限列表(数据库中保存的 list)

    @JSONField(serialize = false)
    private List<SimpleGrantedAuthority> authorities; // Spring Security 中用到的权限列表(SimpleGrantedAuthority 类型)

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

    /**
     * 返回当前用户所拥有的权限信息
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 如果已经转过,直接返回
        if (authorities != null) {
            return authorities;
        }

        // 把数据库中的权限列表(permissions)转为 Spring Security 中用到的权限列表(authorities)。String 转 SimpleGrantedAuthority
        authorities = permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
        return authorities;
    }

    /**
     * 获取密码
     * @return
     */
    @Override
    public String getPassword() {
        return "{noop}" + user.getCode();
    }

    /**
     * 获取账号名称
     * @return
     */
    @Override
    public String getUsername() {
        return user.getAccount();
    }

    /**
     * 账号是否过期
     * @return true:未过期;false:已过期
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 账号是否被锁定
     * @return true:未锁定;false:已锁定
     */
    @Override
    public boolean isAccountNonLocked() {
        return !user.isLocked();
    }

    /**
     * 密码是否过期
     * @return true:未过期;false:已过期
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 是否可用
     * @return treu:可用;false:不可用
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}

三、JwtAuthenticationTokenFilter 

jwt 过滤器,扩展了 SpringSecurity 的 OncePerRequestFilter,每次请求后会调用此过滤器,其根据 token 中的 userId 从 redis 中拿回 LoninUser 信息。

四、CustomUserDetailsService

用户详细信息,实现了 UserDetailsService 接口,登录时会调用此类查询用户数据。

package com.bjy.qa.util.security;

import com.bjy.qa.dao.user.UserDao;
import com.bjy.qa.entity.user.User;
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 javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * 用户详细信息
 */
@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Resource
    UserDao userDao;

    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        User user = userDao.selectOne(userName);

        if (user == null) {
             throw new RuntimeException("登录失败,用户名或密码错误!");
        }

        // todo: 从数据库中获取用户权限信息
        List<String> list = new ArrayList<>(Arrays.asList("ROLE_ADMIN", "ROLE_USER", "admin"));

        return new LoginUser(user, list);
    }
}

五、AuthenticationEntryPointImpl

认证 时的异常(当用户请求一个受保护的资源,又没登录时触发),实现了 AuthenticationEntryPoint 接口。当认证(登录)失败回调此方法。

package com.bjy.qa.util.security;

import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.parser.ParserConfig;
import com.bjy.qa.entity.Response;
import com.bjy.qa.enumtype.ErrorCode;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

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

/**
 * 认证 时的异常(当用户请求一个受保护的资源,又没登录时触发)
 */
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json");
        httpServletResponse.getWriter().println(JSONObject.toJSONString(Response.fail(ErrorCode.FORBIDDEN, null))); // 401
        httpServletResponse.getWriter().flush();
    }
}

六、AccessDeniedPointImpl

用户访问无权限资源 时的异常(用户登录后,请求一个受保护的资源,又没权限时触发),实现了 AccessDeniedHandler。当没有权限时回调此方法。

package com.bjy.qa.util.security;

import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.parser.ParserConfig;
import com.bjy.qa.entity.Response;
import com.bjy.qa.enumtype.ErrorCode;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

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

/**
 * 用户访问无权限资源 时的异常(用户登录后,请求一个受保护的资源,又没权限时触发)
 */
@Component
public class AccessDeniedPointImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json");
        httpServletResponse.getWriter().println(JSONObject.toJSONString(Response.fail(ErrorCode.UNAUTHORIZED, null))); // 403
        httpServletResponse.getWriter().flush();
    }
}

七、修改登录(login)接口

    @RequestMapping(value = "/login", method = {RequestMethod.POST})
    @ResponseBody
    @Validated(User.UserLoingGroup.class)
    @ApiOperation(value = "用户登录", notes = "用户登录接口")
    public Response<UserResponse> login(@RequestBody @Valid User user) {
        logger.info("{}", user);

        // 使用 UsernamePasswordAuthenticationToken 认证
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getAccount(), user.getCode());
        Authentication authentication = authenticationManager.authenticate(authenticationToken); // 认证

        // 如果认证没通过,给出错误提示
        if (authentication == null) {
            throw new RuntimeException("登录失败,用户名或密码错误!");
        }

        // 如果认证通过,生成 token 返回
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        String userId = loginUser.getUser().getId().toString();

         //Response response = Response.success(iUserService.login(user));
        Response response = Response.success("token=" + userId);
        logger.info("{}", response);
        return response;
    }

注意 login 接口地址,要跟刚才 WebSecurityConfig 中放开的 anonymous 地址相同

八、随便找一个接口添加访问角色

@PreAuthorize("hasAnyAuthority('admin')") 添加后就要有 admin 角色才能访问

    @PreAuthorize("hasAnyAuthority('admin')")
    @RequestMapping(value = "/logout", method = {RequestMethod.GET, RequestMethod.POST})
    @ResponseBody
    @ApiOperation(value = "用户退出", notes = "用户退出")
    public String logout() throws Exception {
        // 获取SecurtiryContextHolder 中获取用户id
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) usernamePasswordAuthenticationToken.getPrincipal();

        Long userId = loginUser.getUser().getId();
        // redisCache.deleteObject("login:" + userId);

        // TODO: 2022/6/4 临时写的没有完成资源释放
        return "{\"code\":0,\"data\":{}}";
    }

注意加的这个角色一定要在 JwtAuthenticationTokenFilter 中存在

九、测试

下图是登录接口测试结果

下图是传入一个错误的 token ,token 解码错误的图

 修改成正确的 token 后请求正常

将 logout 接口改一个没有的角色,再请求提示没权限

以上就是 SpringSecurity 的标准用法,每个接口需要提前定义哪些角色可以访问,这样就不能动态增加角色了。SpringBoot & SpringSecurity 下 - 动态角色和权限验证 中会讲解如何动态增加角色和权限验证。

 

参考文档:

https://www.bilibili.com/video/BV1mm4y1X7Hc?p=31