一、介绍

引用SpringSecurity官网的一句话:

Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications.

Spring 安全性是一个功能强大且高度可定制的身份验证和访问控制框架。它是保护基于 Spring 的应用程序的事实标准。

Spring Security is a framework that focuses on providing both authentication and authorization to Java applications. Like all Spring projects, the real power of Spring Security is found in how easily it can be extended to meet custom requirements

Spring 安全性是一个专注于为 Java 应用程序提供身份验证和授权的框架。像所有 Spring 项目一样,Spring 安全性的真正力量在于它可以轻松扩展以满足自定义需求。

SpringSecurity是Spring体系下的一个安全框架,常用于用户认证与角色授权相关;因为出自于Spirng家族,所以与Spring高度融合。使用的话个人感觉较Shiro更难一些,但是如果了解其中一个的话,对于学习另一个来说也是非常快的。

其中的原理的话这里就不详细说了,直接以SpringBoot集成SpringSecurity实现前后端分离下的用户认证与授权,这里是用SpringSecurity最新的配置,相较于以前的版本有一些变化,不过大差不差。。。

二、引入依赖

<!-- springboot-版本 -->
<parent>
<artifactId>spring-boot-starter-parent</artifactId>
<groupId>org.springframework.boot</groupId>
<version>2.6.9</version>
</parent>

<!-- 依赖坐标 -->
<!-- springboot-web -->
<dependency>
<artifactId>spring-boot-starter-web</artifactId>
<groupId>org.springframework.boot</groupId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>

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

三、代码编写

AuthenticationEntryPointImpl(认证异常处理)
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 这里ServletUtil.renderString就是想前端返回JSON数据
ServletUtil.renderString(response, JSON.toJSONString(R.fail("访问异常,请登录", 401)));
}
}

// TODO ServletUtil.renderString()
/**
* 将字符串渲染到客户端
*
* @param response 渲染对象
* @param string 待渲染的字符串
*/
public static void renderString(HttpServletResponse response, String string) {
PrintWriter writer = null;
try {
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
writer = response.getWriter();
writer.print(string);

} catch (IOException e) {
e.printStackTrace();
} finally {
writer.flush();
writer.close();
}
}
AccessDeniedHandlerImpl(授权异常处理)
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ServletUtil.renderString(response, JSON.toJSONString(R.fail("访问异常,无访问权限", 403)));
}
}
LoginUser(用于存储用户登录信息)

这个类的话实现SpringSecurity的UserDetails接口,SpringSecurity上下文访问用户登录信息依靠此类,此类高度自定义,可以根据自己需要的功能自定义()

// TODO 不可加lombok的@Data注解
public class LoginUser implements UserDetails {

// TODO 我这个例子只是演示,没有将用户,角色,权限分开,正常使用的话这里可以定义用户的登录信息,用户的权限集合等。

private Long userId;

private User user;

/**
* 登录时间
*/
private Long loginTime;

/**
* 过期时间
*/
private Long expireTime;

public LoginUser(User user) {
this.userId = user.getUserId();
this.user = user;
}

/**
* 权限集合
*
* @return
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// TODO 此处我将用户的权限放在了user信息中,其实可以通过构造器,分开传入权限列表
return Arrays.asList(new SimpleGrantedAuthority( user.getUserType().toString()));
}


@Override
public String getPassword() {
return user.getPassword();
}

@Override
public String getUsername() {
return user.getUsername();
}

/**
* 账户是否未过期
*
* @return
*/
@JSONField(serialize = false)
@Override
public boolean isAccountNonExpired() {
return true;
}

/**
* 账户是否为解锁,锁定的账户无法验证
*
* @return
*/
@JSONField(serialize = false)
@Override
public boolean isAccountNonLocked() {
return true;
}

/**
* 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
*
* @return
*/
@JSONField(serialize = false)
@Override
public boolean isCredentialsNonExpired() {
return true;
}

/**
* 是否可用
*
* @return
*/
@JSONField(serialize = false)
@Override
public boolean isEnabled() {
return true;
}


public Long getUserId() {
return userId;
}

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

public User getUser() {
return user;
}

public void setUser(User user) {
this.user = user;
}

public Long getLoginTime() {
return loginTime;
}

public void setLoginTime(Long loginTime) {
this.loginTime = loginTime;
}

public Long getExpireTime() {
return expireTime;
}

public void setExpireTime(Long expireTime) {
this.expireTime = expireTime;
}
}
AuthenticationContextHolder(定义存储Authentication的ThreadLocal)

为什么要使用ThreadLocal,因为以下UserDetailsServiceImpl中的loadUserByUsername我们不能主动调用,要通过SpringSecurity来调用,其中的入参username就是从Authentication中获取。对于ThreadLocal来说是一个容器,在多线程下保存每一个线程的副本,因为对于登录来说,如果同一时间多个人登录,可能照成获取到username产生混乱,ThreadLocal的多线程安全则避开了这一情况。如果说有更好的方法,也可自定义。

public class AuthenticationContextHolder {
private static final ThreadLocal<Authentication> contextHolder = new ThreadLocal<>();

public static Authentication getContext() {
return contextHolder.get();
}

public static void setContext(Authentication context) {
contextHolder.set(context);
}

public static void clearContext() {
contextHolder.remove();
}
}
UserDetailsServiceImpl(此类用于查询用户的信息,登录时供SpringSecurity调用)
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

@Resource
private UserMapper userMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 一般在此处定义
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, username));

if (ObjUtil.isNull(user)) {
throw new UserException("账号不存在");
} else if (user.getStatus() == 0) {
throw new UserException("账号被锁定");
}
// 比较密码
Authentication authentication = AuthenticationContextHolder.getContext();
String password = authentication.getCredentials().toString();
boolean pwdPass = SecurityUtil.matchesPassword(password, user.getPassword());
if (!pwdPass) {
throw new UserException("密码错误");
}
authentication.remove();
// TODO 不能设置为空
// user.setPassword(null);
return new LoginUser(user);
}
}

// 当中提到的:SecurityUtil.matchesPassword(),判断加密密码与原密码进行对比
AesPasswordEncoder与SecurityUtil,密码加密类与安全工具类

// TODO 加密工具类
public class AesPasswordEncoder implements PasswordEncoder {

// TODO 不要改变这里,加密的salt
public static final String KEY = "wangqingzezzzzzz";

/**
* 加密
*
* @param rawPassword 待加密密码
* @return
*/
@Override
public String encode(CharSequence rawPassword) {
try {
byte[] raw = KEY.getBytes("utf-8");
SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES");
//"算法/模式/补码方式"
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, skeySpec);
byte[] encrypted = cipher.doFinal(rawPassword.toString().getBytes("utf-8"));
// base64转码
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

/**
* 比对
*
* @param rawPassword 未加密密码
* @param encodedPassword 数据库加密的密码
* @return
*/
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.toString().equals(decode(encodedPassword));
}

/**
* 解密
*
* @param rawPassword 待解密密码
* @return
*/
public String decode(String rawPassword) {
try {
byte[] raw = KEY.getBytes("utf-8");
SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, skeySpec);
byte[] decode = Base64.getDecoder().decode(rawPassword);
byte[] original = cipher.doFinal(decode);
String originalString = new String(original, "utf-8");
return originalString;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}


// TODO 安全工具类
public class SecurityUtil {

public static Long getUserId() {
Long userId = getLoginUser().getUserId();
return userId;
}

public static LoginUser getLoginUser() {
try {
return (LoginUser) getAuthentication().getPrincipal();
} catch (Exception e) {
throw new UserException("获取用户信息异常");
}
}

/**
* 获取Authentication
*/
public static Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}

public static String encryptPassword(String password) {
AesPasswordEncoder passwordEncoder = new AesPasswordEncoder();
return passwordEncoder.encode(password);
}

/**
* 判断密码是否相同
*
* @param rawPassword 真实密码
* @param encodedPassword 加密后字符
* @return 结果
*/
public static boolean matchesPassword(String rawPassword, String encodedPassword) {
AesPasswordEncoder passwordEncoder = new AesPasswordEncoder();
return passwordEncoder.matches(rawPassword, encodedPassword);
}


public static String decodePassword(String rawPassword) {
AesPasswordEncoder passwordEncoder = new AesPasswordEncoder();
return passwordEncoder.decode(rawPassword);
}

}
JwtAuthenticationTokenFilter(登录认证过滤)
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

// TODO 此处是Jwt工具类
@Resource
private JwtUtil jwtUtil;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 获取登录用户信息,以自己的方式实现
LoginUser loginUser = jwtUtil.getLoginUser(request);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (ObjUtil.isNotNull(loginUser) && ObjUtil.isNull(authentication)) {
// 代表登录未授权
// TODO 这里注意,这里是检查Redis中登录信息的过期时间,如果快过期了,对其进行续期处理
jwtUtil.verifyToken(loginUser);
// 至于这里的入参,可以选择自己系统的实现方式进行调整
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(request, response);
}
}


// TODO 附上jwtUtil.verifyToken()是如何进行登录信息续期的。
/**
* 当里过期时间还有10min时,刷新令牌
*
* @param loginUser
*/
public void verifyToken(LoginUser loginUser) {
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= 10 * MINUTE) {
refreshToken(loginUser);
}
}

/**
* 刷新令牌有效期
*
* @param loginUser 登录信息
*/
public void refreshToken(LoginUser loginUser) {
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expire * MINUTE);
// 根据uuid将loginUser缓存
String userKey = RedisKey.LOGIN_USER_KEY + (loginUser.getUserId());
// 其中expire是你的登录信息缓存过期时间
redisUtil.set(userKey, loginUser, expire);
}
LogoutService(退出登录处理器,不需要我们编写退出登录的Service,使用SpringSecurity自带的)

// TODO 此Servic不需要我们自己调用

@Service
public class LogoutService implements LogoutSuccessHandler {

@Resource
private JwtUtil jwtUtil;
@Resource
private RedisUtil redisUtil;

@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
LoginUser loginUser = jwtUtil.getLoginUser(request);
if (ObjUtil.isNotNull(loginUser)) {
redisUtil.del(RedisKey.LOGIN_USER_KEY + loginUser.getUserId());
}
ServletUtil.renderString(response, JSON.toJSONString(R.ok("退出登录成功")));
}
}
SpringSecurityConfig(SpringSecurity的核心配置)
// TODO 如果需要开启注解授权,需要替换下注解,可以搜索查询下,这里有时间更新
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig {


@Resource
private AuthenticationEntryPointImpl authenticationEntryPoint;
@Resource
private AccessDeniedHandlerImpl accessDeniedHandler;
@Resource
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Resource
private UserDetailsService userDetailsService;
@Resource
private LogoutService logoutService;

/**
* 获取AuthenticationManager(认证管理器),登录时认证使用
*
* @param authenticationConfiguration
* @return
* @throws Exception
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// 异常处理
.exceptionHandling(handle -> handle
// 认证异常(没登陆)
.authenticationEntryPoint(authenticationEntryPoint)
// 访问异常(没权限)
.accessDeniedHandler(accessDeniedHandler)
)
// 基于token,不需要csrf
.csrf().disable()
// 基于token,不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 设置权限
.authorizeRequests(auth -> auth
// 请求放开
// swagger
.antMatchers("/favicon.ico", "/doc.html", "/webjars/**", "/swagger-resources/**", "/v3/api-docs/**").permitAll()
// 支付
.antMatchers("/user/book/syncNotify","/user/book/asyncNotify").permitAll()
// 方形接口(具体匹配接口放上面,防止匹配顺序问题)
.antMatchers("/user/login/**", "/manage/login/login").permitAll()
// TODO hasRole会给权限字符串加上 ROLE_ 前缀,hasAuthority不会
// TODO 如果需要实现更多权限,可以在此基础上拓展,以后再来更新
// 普通用户接口(拥有0权限字符串的用户可以访问)
.antMatchers("/user/**").hasAuthority("0")
// 管理员接口(拥有1权限字符串的用户可以访问)
.antMatchers("/manage/**").hasAuthority("1")
// 其它全部需要验证
.anyRequest().authenticated()
)
// 通配/user/logout与/manage/logout
.logout().logoutUrl("/*/logout").logoutSuccessHandler(logoutService).and()
// 访问前jwt认证(在UsernamePasswordAuthenticationFilter前加入jwt过滤器)
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
// 身份认证
.userDetailsService(userDetailsService)
.build();
}

/**
* 密码明文加密方式配置
*
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new AesPasswordEncoder();
}

/**
* 跨域支持
*
* @return
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// 所有请求都支持跨域
source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
return source;
}

}

四、关于其中一些细节部分,以及相关的注解配置权限,以后再来更新。。。。。。