package cn.tedu.tea.admin.server.core.config;
import cn.tedu.tea.admin.server.common.web.JsonResult;
import cn.tedu.tea.admin.server.common.web.ServiceCode;
import cn.tedu.tea.admin.server.core.filter.JwtAuthorizationFilter;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
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.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.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* Spring Security的配置类
*
* @author java@tedu.cn
* @version 1.0
*/
@Slf4j
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启基于方法的安全检查
// @EnableWebSecurity(debug = true) // 开启调试模式,在控制台将显示很多日志,在生产环境中不宜开启
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;
public SecurityConfiguration() {
log.debug("创建配置类对象:SecurityConfiguration");
}
@Bean
public PasswordEncoder passwordEncoder() {
// return NoOpPasswordEncoder.getInstance();
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 配置Security框架不使用Session
// SessionCreationPolicy.NEVER:从不主动创建Session,但是,Session存在的话,会自动使用
// SessionCreationPolicy.STATELESS:无状态,无论是否存在Session,都不使用
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 将自定义的解析JWT的过滤器添加到Security框架的过滤器链中
// 必须添加在检查SecurityContext的Authentication之前,具体位置并不严格要求
http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
// 允许跨域访问,本质上是启用了Security框架自带的CorsFilter
// 如果不启用CorsFilter,也可以改为对所有OPTIONS请求直接许可,一样可以解决复杂请求预检的跨域问题
// 注意:即使此处许可以复杂请求的预检,Spring MVC配置类中的启用跨域的配置仍是必须的
http.cors();
// 处理“无认证信息却访问需要认证的资源时”的响应
http.exceptionHandling().authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
log.warn("{}", e);
response.setContentType("application/json; charset=utf-8");
String message = "操作失败,您当前未登录!";
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERROR_UNAUTHORIZED, message);
PrintWriter writer = response.getWriter();
writer.println(JSON.toJSONString(jsonResult));
writer.close();
}
});
// 白名单
String[] urls = {
"/favicon.ico",
"/doc.html",
"/**/*.css",
"/**/*.js",
"/swagger-resources",
"/v2/api-docs",
"/resources/**", // 静态资源文件夹,通常是上传的文件,请与配置文件中的"tea-store.upload.base-dir-name"一致
"/account/users/login" // 用户登录
};
// 禁用“防止伪造的跨域攻击的防御机制”
http.csrf().disable();
// 配置请求授权
// 如果某个请求被多次配置,按照“第一匹配原则”处理
// 应该将精确的配置写在前面,将较模糊的配置写在后面
http.authorizeRequests()
// .mvcMatchers(HttpMethod.OPTIONS, "/**") // 匹配所有OPTIONS类型的请求
// .permitAll() // 许可
.mvcMatchers(urls) // 匹配某些请求
.permitAll() // 许可,即不需要通过认证就可以访问
.anyRequest() // 任何请求,从执行效果来看,也可以视为:除了以上配置过的以外的其它请求
.authenticated(); // 需要通过认证才可以访问
// 是否调用以下方法,将决定是否启用默认的登录页面
// 当未通过认证时,如果有登录页,则自动跳转到登录,如果没有登录页,则直接响应403
// http.formLogin();
// super.configure(http); // 不要调用父类的同名方法,许多默认的效果都是父类方法配置的
}
}
登录认证流程:
1.controller接收到请求,请求的处理,获取request,通过request.getRemoteAddr();获取用户IP地址,通过request.getHeader(HEADER_USER_AGENT);枚举类:HEADER_USER_AGENT="User-Agent",获取客户端浏览器
@PostMapping("/login")
@ApiOperation("用户登录")
@ApiOperationSupport(order = 10)
public JsonResult login(@Validated UserLoginInfoParam userLoginInfoParam,
@ApiIgnore HttpServletRequest request) {
log.debug("开始处理【用户登录】的请求,参数:{}", userLoginInfoParam);
String remoteAddr = request.getRemoteAddr();
String userAgent = request.getHeader(HEADER_USER_AGENT);
UserLoginResultVO userLoginResultVO = userService.login(userLoginInfoParam, remoteAddr, userAgent);
return JsonResult.ok(userLoginResultVO);
}
2.userService处理登录认证
public UserLoginResultVO login(UserLoginInfoParam userLoginInfoParam,
String remoteAddr, String userAgent) {
log.debug("开始处理【用户登录】的业务,参数:{}", userLoginInfoParam);
Authentication authentication = new UsernamePasswordAuthenticationToken(
userLoginInfoParam.getUsername(), userLoginInfoParam.getPassword());
log.debug("准备调用AuthenticationManager的认证方法,判断此用户名、密码是否可以成功登录……");
Authentication authenticateResult
= authenticationManager.authenticate(authentication);
log.debug("验证用户登录成功,返回的认证结果:{}", authenticateResult);
Object principal = authenticateResult.getPrincipal();
log.debug("从认证结果中获取当事人:{}", principal);
CustomUserDetails userDetails = (CustomUserDetails) principal;
Long id = userDetails.getId();
log.debug("从认证结果中的当事人中获取ID:{}", id);
String username = userDetails.getUsername();
log.debug("从认证结果中的当事人中获取用户名:{}", username);
String avatar = userDetails.getAvatar();
log.debug("从认证结果中的当事人中获取头像:{}", avatar);
Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
log.debug("从认证结果中的当事人中获取权限列表:{}", authorities);
String authoritiesJsonString = JSON.toJSONString(authorities);
log.debug("将权限列表对象转换为JSON格式的字符串:{}", authoritiesJsonString);
Date date = new Date(System.currentTimeMillis() + 1L * 60 * 1000 * durationInMinute);
// ↑ 注意加L,避免int溢出为负数
Map<String, Object> claims = new HashMap<>();
claims.put("id", id);
claims.put("username", username);
// 生成JWT时,不再存入权限列表
// claims.put("authoritiesJsonString", authoritiesJsonString);
String jwt = Jwts.builder()
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
.setClaims(claims)
.setExpiration(date)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
// 生成JWT之后,需要将权限列表存入到Redis中
UserLoginInfoPO userLoginInfoPO = new UserLoginInfoPO();
userLoginInfoPO.setUserAgent(userAgent);
userLoginInfoPO.setIp(remoteAddr);
userLoginInfoPO.setAuthoritiesJsonString(authoritiesJsonString);
userCacheRepository.saveLoginInfo(jwt,userLoginInfoPO);
// 将用户状态存入到Redis中
userCacheRepository.saveEnableByUserId(id, 1);
UserLoginResultVO userLoginResultVO = new UserLoginResultVO()
.setId(id)
.setUsername(username)
.setAvatar(avatar)
.setToken(jwt);
return userLoginResultVO;
// 改为使用JWT后,不必在登录成功后就将认证信息存入到SecurityContext中
// log.debug("准备将认证信息结果存入到SecurityContext中……");
// SecurityContext securityContext = SecurityContextHolder.getContext();
// securityContext.setAuthentication(authenticateResult);
// log.debug("已经将认证信息存入到SecurityContext中,登录业务处理完成!");
}
2.1 authenticationManager.authenticate(authentication):Security框架将会自动调UserDetailsService
我这边自定义了一个UserDetailsServiceImpl,将由框架自动调用,下面是具体代码
/**
* Spring Security处理认证时使用到的获取用户登录详情的实现类
*
* @author java@tedu.cn
* @version 1.0
*/
@Slf4j
@Component
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private IUserRepository userRepository;
public UserDetailsServiceImpl() {
log.debug("创建Spring Security的UserDetailsService接口对象:UserDetailsServiceImpl");
}
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security框架自动调用了UserDetailsService对象,将根据用户名获取用户详情,参数:{}", s);
UserLoginInfoVO loginInfo = userRepository.getLoginInfoByUsername(s);
log.debug("根据用户名【{}】从数据库中查询用户详情,查询结果:{}", s, loginInfo);
if (loginInfo == null) {
return null;
}
List<GrantedAuthority> authorities = new ArrayList<>();
List<String> permissions = loginInfo.getPermissions();
for (String permission : permissions) {
GrantedAuthority authority = new SimpleGrantedAuthority(permission);
authorities.add(authority);
}
CustomUserDetails userDetails = new CustomUserDetails(
loginInfo.getId(), loginInfo.getUsername(), loginInfo.getPassword(),
loginInfo.getAvatar(), loginInfo.getEnable() == 1, authorities);
//UserDetails userDetails = User.builder()
// .username(loginInfo.getUsername())
// .password(loginInfo.getPassword()) // 密文
// .disabled(loginInfo.getEnable() == 0) // 账号是否被禁用
// .accountLocked(false) // 账号是否被锁定,当前项目中无此概念,则所有账号的此属性都是false
// .accountExpired(false) // 账号是否过期,当前项目中无此概念,则所有账号的此属性都是false
// .credentialsExpired(false) // 凭证是否过期,当前项目中无此概念,则所有账号的此属性都是false
// .authorities(authorities)
// .build();
log.debug("即将向Spring Security框架返回UserDetails类型的结果:{}", userDetails);
log.debug("接下来,将由Spring Security框架自动验证用户状态、密码等,以判断是否可以成功登录!");
return userDetails;
}
}
2.2 该接口实现了UserDetailsService,该接口是Security框架加载用户特定数据的核心接口,重写了接口中loadUserByUsername方法,
2.3 GrantedAuthority表示授予身份认证对象的权限,将从数据库查到的权限放入其中.
2.4 CustomUserDetails 继承自Security的User(User源码的注释:Models core user information retrieved by a UserDetailsService.
Developers may use this class directly, subclass it, or write their own UserDetails implementation from scratch.)
2.5 返回结果给authenticationManager
3 如果认证成果 将继续执行userService的代码,
3.1获取当事人(也就是UserDetailsServiceImpl 返回的userDetails)
3.2从principal中获取相关数据,
3.3配置一个时间,时间中的durationInMinute 为配置文件中提前配置好的以分钟为单位的时间,方便后期维护
3.4使用JJWT工具框架生成jwt(Json Web Token) JWT中只放id和username即可,因为jwt数据并不是安全的,其中的数据在jwt官网中是可以被解析的,虽然说签名不一致不会导致jwt被盗用,但总归数据还是不能够透露的,签名在配置文件中自定义,以便后期维护.
3.5.考虑到权限列表会占据到很多空间,所以将其存入Redis,以减少数据传输产生的流量,将jwt作为key值,(由dao层userCacheRepository进行处理,注意要在前面加上前缀以分文件夹显示key,否则会导致key不方便在Redis中查找,例如:user:jwt:":"作为默认的分隔符,可以分层显示key)
3.6 封装userLoginInfoPO,作为vlue值
4. 封装登录结果,返回登录结果
注:.密码加密使用BCyrpt算法,与md5算法相比,主要的特点是相同的原文,密文却是不同的,md5采用类似与UUID的结构,相同的原文加密出来的密文是相同的,如果是6位数数字密码普通计算机仅需不到1秒即可暴力求解.而BCrypt字符采用大小写字母数字.和/ 密文复杂度高.不易破解.第二个特点是特别慢,默认的构造方法strength值默认为10,2的10次方,意为采用1024次哈希运算,可以在构造方法上更改这个数值让运算速度更慢.从而加大解密的时间,