示例项目地址: 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加载的顺序问题
@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
- 注册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 {
@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);
}
}
<a name="BsVvq"></a>
### 用户名密码认证
这里我们使用的自定义的校验方案, 也可以使用SpringSecurity的默认实现, 要实现UserService接口等等巴拉巴拉<br />一套校验方案包括, 自定义token, 自定义filter, 自定义provider<br />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
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
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
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;
@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
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
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
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
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";
}