示例项目地址: 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框架入门要先了解各种概念, 详情参考上面第二篇文档, 简单总结:
**
认证流程
- 请求经过xxxFilter
- filter抽取request封装成某一类型的token交于AuthenticationManager进行校验
AuthenticationManager的ProviderManager实现, 根据token类型, 找到具体的xxxProvider进行校验, 核心伪代码如下
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);...}}}
只要有一个provider验证成功则认证成功


相关概念
这里只介绍与本文相关的, 具体参见官方文档
过滤器
- 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过期, 登出
- 登录, 校验用户名密码, 生成token存redis;
- 访问刷新, redis的token续期
- token过期, redis的token过期
- 登出, 清除redis的token
配置解析
依赖以及配置文件
<!--springSecurity--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
这里前后端密码传递使用Openssl生成的秘钥对进行加解密的, 数据库存储的密码是md5加密的, 后端只需要存私钥, 公钥存前端
- 前端用户登陆时, 使用公钥对密码加密给到后端
- 后端拿私钥解密后, 再md5加密与数据库密码比对

私钥的读取
WebMvcConfig
这里有个坑, 特别注意读取的string不能有—xx—或换行符, 常见的FileUtils都会拼接换行符
还有就是这个Bean没有配置在WebSecurityConfig中, 是由于bean加载的顺序问题
@Configurationpublic class WebMvcConfig implements WebMvcConfigurer {@Value("classpath:rsa_private_key.pem")private Resource privateKey;/*** 此处有坑, 注意读取的时候--XX--不能要, 且不能有换行符*/@Beanpublic 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
- 注册filer以及provider
- 允许跨域
- 禁止csrf
- 禁止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 {
@Autowiredprivate UsernamePasswordProvider usernamePasswordProvider;@Autowiredprivate UserTokenProvider userTokenProvider;@Overrideprotected 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);}@Beanpublic 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);}@Overridepublic void configure(WebSecurity web) throws Exception {super.configure(web);}@Overridepublic void configure(AuthenticationManagerBuilder auth) {auth.authenticationProvider(usernamePasswordProvider);auth.authenticationProvider(userTokenProvider);}
}
<a name="BsVvq"></a>### 用户名密码认证这里我们使用的自定义的校验方案, 也可以使用SpringSecurity的默认实现, 要实现UserService接口等等巴拉巴拉<br />一套校验方案包括, 自定义token, 自定义filter, 自定义provider<br />userToken```javapackage 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;}@Overridepublic Object getCredentials() {return null;}@Overridepublic Object getPrincipal() {return token;}public String getToken() {return token;}}
UsernamePasswordFilter
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;}@Overridepublic 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);}@Overrideprotected 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)));}@Overrideprotected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,AuthenticationException failed) throws IOException, ServletException {response.sendError(HttpServletResponse.SC_UNAUTHORIZED, failed.getMessage());}}
UsernamePasswordProvider
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;@Componentpublic class UsernamePasswordProvider implements AuthenticationProvider {@Autowiredprivate TokenManager tokenManager;@Autowiredprivate UserService userService;@Autowiredprivate PrivateKey privateKey;@Overridepublic 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("用户名和密码格式错误");}}@Overridepublic boolean supports(Class<?> authentication) {return authentication.equals(TenantToken.class);}}
认证完成后返回给前端的实体
UserLoginInfo
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
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;@Componentpublic class TokenManager {@Autowiredprivate 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
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;}@Overridepublic Object getCredentials() {return password;}@Overridepublic Object getPrincipal() {return userName;}}
UserTokenFilter
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);}@Overrideprotected 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("非法请求"));}@Overrideprotected void onUnsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,AuthenticationException failed) throws IOException {response.sendError(HttpServletResponse.SC_UNAUTHORIZED);}}
UserTokenProvider
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;@Componentpublic class UserTokenProvider implements AuthenticationProvider {@Autowiredprivate TokenManager tokenManager;@Overridepublic 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;}@Overridepublic boolean supports(Class<?> aClass) {return aClass.equals(UserToken.class);}}
常量类
WebConstants
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";}
