Skip to content

SpringSecurity认证鉴权

示例项目地址: https://git.code.tencent.com/xinzhang0618/oa2.git

参考文档:

springSecurity官方文档:

https://docs.spring.io/spring-security/site/docs/5.4.2/reference/html5/#servlet-hello-auto-configuration

springSecurity概念: https://www.jianshu.com/p/7b87ec108405

openssl生成RSA: https://blog.csdn.net/asd54090/article/details/103665966

基础回顾

SpringSecurity框架入门要先了解各种概念, 详情参考上面第二篇文档, 简单总结:


认证流程

  1. 请求经过xxxFilter
  2. filter抽取request封装成某一类型的token交于AuthenticationManager进行校验
  3. AuthenticationManager的ProviderManager实现, 根据token类型, 找到具体的xxxProvider进行校验, 核心伪代码如下
java
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
  
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Iterator var8 = this.getProviders().iterator();
        while(var8.hasNext()) {
            AuthenticationProvider provider = (AuthenticationProvider)var8.next();
            if (provider.supports(toTest)) {
                result = provider.authenticate(authentication);
                ...
            }
        }
    }
  1. 只要有一个provider验证成功则认证成功 17452243641141745224370075

相关概念

这里只介绍与本文相关的, 具体参见官方文档

  • 过滤器
    • AbstractAuthenticationProcessingFilter, 所有过滤器的基过滤器
    • UsernamePasswordAuthenticationFilter, 继承上一个, 处理表单登录, 可以看到这里定死了登录方式POST, 路径/login, 如果需要更改这些, 则自定义的Filter直接继承上一个
    • BasicAuthenticationFilter, 处理Basic认证, 本文实现中重写了这个作为token过滤器, 实际上token过滤也可以直接使用spring的OncePerRequestFilter等
  • 校验器, AuthenticationManager定义了校验的顶层接口, 其中ProviderManager作为主要实现, 其中维护了providers列表, 负责对token找到对应的provider进行校验, 比如上图的DaoAuthenticationProvider支持UsernamePasswordAuthenticationToken(及其子类型)的校验
    • 自定义校验器需要实现AuthenticationProvider接口
  • token, springSecurity在过滤器中会将用户信息封装成各个类型的token
    • 自定义的token需要实现AbstractAuthenticationToken接口
  • 其他
    • SecurityContextHolder, 持有SecurityContext上下文信息
    • Authentication, 鉴权对象, 包含principal当事人即当前用户, credentials凭证一般指密码, authorities用户权限

实现思路

做认证主要是登录, 访问刷新, token过期, 登出

  1. 登录, 校验用户名密码, 生成token存redis;
  2. 访问刷新, redis的token续期
  3. token过期, redis的token过期
  4. 登出, 清除redis的token

配置解析

整个配置跟业务关联非常低, 不同项目基本可以直接移植

依赖以及配置文件

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

这里前后端密码传递使用Openssl生成的秘钥对进行加解密的, 数据库存储的密码是md5加密的, 后端只需要存私钥, 公钥存前端

  1. 前端用户登陆时, 使用公钥对密码加密给到后端
  2. 后端拿私钥解密后, 再md5加密与数据库密码比对 1745224396811

私钥的读取

WebMvcConfig

这里有个坑, 特别注意读取的string不能有--xx--或换行符, 常见的FileUtils都会拼接换行符

还有就是这个Bean没有配置在WebSecurityConfig中, 是由于bean加载的顺序问题

java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Value("classpath:rsa_private_key.pem")
    private Resource privateKey;

    /**
     * 此处有坑, 注意读取的时候--XX--不能要, 且不能有换行符
     */
    @Bean
    public PrivateKey loginPrivateKey() throws IOException, InvalidKeySpecException, NoSuchAlgorithmException {
        try (InputStream inputStream = privateKey.getInputStream()) {
            final List<String> lines = IOUtils.readLines(inputStream, StandardCharsets.UTF_8.name());
            StringBuilder builder = new StringBuilder();
            for (String line : lines) {
                if (!line.startsWith("--")) {
                    builder.append(line);
                }
            }
            return SecurityUtils.getPrivateKey(builder.toString());
        }
    }
}

web访问控制

WebSecurityConfig

  1. 注册filer以及provider
  2. 允许跨域
  3. 禁止csrf
  4. 禁止SpringSecurity默认的session策略, 因为我们用的自定义的token, 不用它的
java
package top.xinzhang0618.oa.security;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
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 org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import top.xinzhang0618.oa.WebConstants;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UsernamePasswordProvider usernamePasswordProvider;
    @Autowired
    private UserTokenProvider userTokenProvider;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().cors().and()
                .authorizeRequests()
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                .antMatchers(HttpMethod.POST, WebConstants.LOGIN_URL, WebConstants.LOGOUT_URL).permitAll()
                .anyRequest().authenticated()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(new UsernamePasswordFilter(WebConstants.LOGIN_URL, authenticationManager()),
                        UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new UserTokenFilter(authenticationManager()),
                        UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", corsConfiguration);
        return new CorsFilter(source);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(usernamePasswordProvider);
        auth.authenticationProvider(userTokenProvider);
    }
}

用户名密码认证

这里我们使用的自定义的校验方案, 也可以使用SpringSecurity的默认实现, 要实现UserService接口等等巴拉巴拉

一套校验方案包括, 自定义token, 自定义filter, 自定义provider

userToken

java
package top.xinzhang0618.oa.security;

import org.springframework.security.authentication.AbstractAuthenticationToken;

public class UserToken extends AbstractAuthenticationToken {

  private String token;

  public UserToken(String token, boolean authenticated) {
    super(null);
    this.setAuthenticated(authenticated);
    this.token = token;
  }

  @Override
  public Object getCredentials() {
    return null;
  }

  @Override
  public Object getPrincipal() {
    return token;
  }

  public String getToken() {
    return token;
  }
}

UsernamePasswordFilter

java
package top.xinzhang0618.oa.security;

import com.alibaba.fastjson.JSON;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import top.xinzhang0618.oa.WebConstants;
import top.xinzhang0618.oa.config.response.RestResponse;
import top.xinzhang0618.oa.domain.User;
import top.xinzhang0618.oa.util.StringUtils;

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

public class UsernamePasswordFilter extends AbstractAuthenticationProcessingFilter {

    private final AuthenticationManager authenticationManager;

    public UsernamePasswordFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
        super(defaultFilterProcessesUrl);
        this.authenticationManager = authenticationManager;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {
        User user = JSON.parseObject(request.getInputStream(), User.class);
        if (user == null || StringUtils.isEmpty(user.getUserName()) || StringUtils.isEmpty(user.getPassword())) {
            throw new BadCredentialsException("用户名或密码为空!");
        }
        TenantToken authenticationToken = new TenantToken(user.getUserName(), user.getPassword(), user.getTenantId());
        return authenticationManager.authenticate(authenticationToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {
        UserLoginInfo loginInfo = (UserLoginInfo) authResult.getDetails();
        response.setContentType(WebConstants.CONTENT_TYPE);
        response.getWriter().write(JSON.toJSONString(RestResponse.success(loginInfo)));
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException failed) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, failed.getMessage());
    }
}

UsernamePasswordProvider

java
package top.xinzhang0618.oa.security;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import top.xinzhang0618.oa.BizContext;
import top.xinzhang0618.oa.domain.User;
import top.xinzhang0618.oa.service.UserService;
import top.xinzhang0618.oa.util.SecurityUtils;
import top.xinzhang0618.oa.util.StringUtils;

import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import java.io.UnsupportedEncodingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;

@Component
public class UsernamePasswordProvider implements AuthenticationProvider {

    @Autowired
    private TokenManager tokenManager;
    @Autowired
    private UserService userService;
    @Autowired
    private PrivateKey privateKey;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        TenantToken tenantToken = (TenantToken) authentication;
        if (StringUtils.isEmpty(tenantToken.getUserName())) {
            throw new UsernameNotFoundException("用户名不能为空");
        }
        try {
            // mybatisPlus的租户处理器会拼接tenant_id条件
            BizContext.setTenantId(tenantToken.getTenantId());
            String password = SecurityUtils.decryptRSA(tenantToken.getPassword(), privateKey);
            User user = userService.get(new LambdaQueryWrapper<User>().eq(User::getUserName,
                    tenantToken.getUserName()));
            if (user == null) {
                throw new UsernameNotFoundException("未找到用户:" + tenantToken.getUserName());
            }

            if (!SecurityUtils.md5Hex(password).equals(user.getPassword())) {
                throw new BadCredentialsException("用户名或密码错误");
            }
            if (!user.isEnable()) {
                throw new BadCredentialsException("用户已禁用");
            }

            // 设置上下文
            BizContext.setUserId(user.getUserId());
            BizContext.setUserName(user.getUserName());

            UserLoginInfo loginInfo = new UserLoginInfo(user);
            tokenManager.generate(loginInfo);
            UsernamePasswordAuthenticationToken result =
                    new UsernamePasswordAuthenticationToken(authentication.getPrincipal(),
                            authentication.getCredentials());
            result.setDetails(loginInfo);
            return result;
        } catch (NoSuchAlgorithmException | IllegalBlockSizeException | InvalidKeyException
                | UnsupportedEncodingException | BadPaddingException | NoSuchPaddingException e) {
            throw new BadCredentialsException("用户名和密码格式错误");
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(TenantToken.class);
    }
}

认证完成后返回给前端的实体

UserLoginInfo

java
package top.xinzhang0618.oa.security;


import top.xinzhang0618.oa.domain.User;

public class UserLoginInfo {

    private String token;
    private Long userId;
    private String nickname;
    private String userName;
    private String headUrl;
    private Long tenantId;

    public UserLoginInfo() {
    }

    public UserLoginInfo(User user) {
        this.userId = user.getUserId();
        this.nickname = user.getNickname();
        this.userName = user.getUserName();
        this.headUrl = user.getHeadUrl();
        this.tenantId = user.getTenantId();
    }

    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }

    public String getNickname() {
        return nickname;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public String getHeadUrl() {
        return headUrl;
    }

    public void setHeadUrl(String headUrl) {
        this.headUrl = headUrl;
    }

    public Long getTenantId() {
        return tenantId;
    }

    public void setTenantId(Long tenantId) {
        this.tenantId = tenantId;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }
}

token管理器, 这里直接用uuid生成的token, 用jwt意义不大

TokenManager

java
package top.xinzhang0618.oa.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import top.xinzhang0618.oa.WebConstants;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Component
public class TokenManager {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public UserLoginInfo getUser(String token) {
        return (UserLoginInfo) redisTemplate.opsForValue().get(buildKey(token));
    }

    /**
     * 生成token
     *
     * @param loginInfo
     * @return
     */
    public void generate(UserLoginInfo loginInfo) {
        String token = UUID.randomUUID().toString();
        loginInfo.setToken(token);
        redisTemplate.opsForValue().set(buildKey(token), loginInfo, 30L, TimeUnit.MINUTES);
    }

    /**
     * 移除token
     *
     * @param token
     */
    public void remove(String token) {
        redisTemplate.delete(token);
    }

    /**
     * token刷新
     *
     * @param token
     */
    public void refresh(String token) {
        redisTemplate.expire(buildKey(token), 30, TimeUnit.MINUTES);
    }

    private String buildKey(String token) {
        return WebConstants.TOKEN_PREFIX + token;
    }
}

token认证

一样的三件, token, filter, provider

TenantToken

java
package top.xinzhang0618.oa.security;

import org.springframework.security.authentication.AbstractAuthenticationToken;

public class TenantToken extends AbstractAuthenticationToken {

  private String userName;
  private String password;
  private Long tenantId;

  public TenantToken(String userName, String password,
                     Long tenantId) {
    super(null);
    this.userName = userName;
    this.password = password;
    this.tenantId = tenantId;
  }


  public String getUserName() {
    return userName;
  }

  public String getPassword() {
    return password;
  }


  public Long getTenantId() {
    return tenantId;
  }

  @Override
  public Object getCredentials() {
    return password;
  }

  @Override
  public Object getPrincipal() {
    return userName;
  }
}

UserTokenFilter

java
package top.xinzhang0618.oa.security;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import top.xinzhang0618.oa.WebConstants;
import top.xinzhang0618.oa.util.StringUtils;

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

public class UserTokenFilter extends BasicAuthenticationFilter {

  public UserTokenFilter(
      AuthenticationManager authenticationManager) {
    super(authenticationManager);
  }


  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws IOException, ServletException {
    String token = request.getHeader(WebConstants.TOKEN_HEADER);
    if (!StringUtils.isEmpty(token)) {
      UserToken userToken = new UserToken(token, false);
      Authentication authenticate = getAuthenticationManager().authenticate(userToken);
      if (authenticate.isAuthenticated()) {
        SecurityContextHolder.getContext().setAuthentication(authenticate);
        chain.doFilter(request, response);
        return;
      }
    }
    this.onUnsuccessfulAuthentication(request, response, new BadCredentialsException("非法请求"));
  }

  @Override
  protected void onUnsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException failed) throws IOException {
    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
  }
}

UserTokenProvider

java
package top.xinzhang0618.oa.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
import top.xinzhang0618.oa.BizContext;

@Component
public class UserTokenProvider implements AuthenticationProvider {

    @Autowired
    private TokenManager tokenManager;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UserToken userToken = (UserToken) authentication;
        String token = userToken.getToken();
        UserLoginInfo user = tokenManager.getUser(token);
        if (user == null) {
            return authentication;
        }
        tokenManager.refresh(token);
        BizContext.setUserId(user.getUserId());
        BizContext.setUserName(user.getUserName());
        BizContext.setTenantId(user.getTenantId());
        userToken.setAuthenticated(true);
        return authentication;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return aClass.equals(UserToken.class);
    }
}

常量类

WebConstants

java
package top.xinzhang0618.oa;

/**
 * @author buer
 * @version 2018-01-17 14:53
 */
public final class WebConstants {

  public static final String TOKEN_HEADER = "Authorization";
  public static final String TOKEN_PREFIX = "LOGIN_TOKEN:";
  public static final String CORS_REQUEST_METHOD = "OPTIONS";
  public static final String LOGIN_URL = "/login";
  public static final String LOGOUT_URL = "/logout";
  public static final String CONTENT_TYPE = "application/json;charset=utf-8";
  public static final String HEAD_CORS_ALLOW_ORIGIN = "Access-Control-Allow-Origin";
  public static final String HEAD_CORS_ALLOW_METHODS = "Access-Control-Allow-Methods";
  public static final String HEAD_CORS_ALLOW_HEADERS = "Access-Control-Allow-Headers";
  public static final String HEAD_CORS_MAX_AGE = "Access-Control-Max-Age";
  public static final String HEAD_CORS_ALLOW_ORIGIN_VALUE = "*";
  public static final String HEAD_CORS_ALLOW_METHODS_VALUE = "*";
  public static final String HEAD_CORS_ALLOW_HEADERS_VALUE = "Origin, X-Requested-With, Content-Type, Accept, " +
          "Authorization, X-OA-ORGAN";
  public static final String HEAD_CORS_MAX_AGE_VALUE = "3600";

}