🎶 文章简介:木字楠后台管理系统开发(4):SpringSecurity引入并编写登陆接口
💡 创作目的:为了带大家完整的体验木字楠后台管理系统模版的开发流程
☀️ 今日天气:冬天来啦!
📝 每日一言:用柔软的面点,对抗这个坚硬的世界吧
🧣1、SpringSecurity的认证流程
在使用SpringSecurity之前我们需要了解:
- 什么是SpringSecurity?
- SpringSecurity的基本工作原理是什么?
🎒 1-1、什么是SpringSecurity
- springsecurity是一个功能强大且高度可定制的身份验证和访问控制框架。
- springsecurity是一个专注于为Java应用程序提供身份验证和授权的框架。
- 与所有Spring项目一样,Spring安全性的真正威力在于它可以很容易地扩展以满足定制需求。
Spring Security可以在 Controller层、 Service层、Mapper层等以加注解的方式来保护应用程序的安全。 Spring Security提供了细粒度的权限控制,可以精细到每一个API接口、每一个业务的方法,或者每一个操作数据库的Mapper层的方法。 Spring Security提供的是应用程序层的安全解决方案,一个系统的安全还需要考虑传输层和系统层的安全,例如采用Htps协议、服务器部署防火墙等。
🎈1-2、SpringSecurity的基本工作原理
Spring Security对Web资源的保护是靠
过滤器链(FilterChain)
实现的。
当我们发送网络请求至后端服务时,该网络请求会经过一系列的过滤器的过滤,直至通过所有过滤器才可以访问到服务器API。
而我们也可以对这一些列过滤器进行重写,按照我们自己的逻辑来进行过滤,由于SpringSecurity已经帮我们定制的大部分的过滤器,我们仅需要修改少部分过滤器即可完成权限管理。
💒 2、引入SpringSecurity依赖
本项目中的权限管理使用的是
SpringSecurity + Jwt
来进行实现权限控制。
<!--============== 项目版本号规定 ===============-->
<properties>
<!--============== 工具依赖 ==================-->
<userAgent.version>1.21</userAgent.version>
</properties>
<dependencies>
<!--============== SpringBoot相关依赖 ===============-->
<!-- security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--================== 工具依赖 =======================-->
<!-- Token生成与解析-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jwt.version}</version>
</dependency>
</dependencies>
引入依赖成功之后,我们重新启动项目。
我们会发现控制台中多了一串字符串
而当我们访问接口的时候会发现界面变为了一个登陆界面,而非我们的接口返回值。
这是SpringSecurity的登陆拦截,当我们匿名访问接口的时候就会被拦截提示需要登陆,SpringSecurity默认的登陆Username
为user
,而Password
则是控制台内打印的字符串
。
我们登陆之后发现接口可以正常访问
但是我们项目中肯定不会使用SpringSecurity的登陆方式,所以我们需要对SpringSecurity进行配置,来以api接口的方式进行登录。
🍠 3、SpringSecurity配置
我们在config包内新建一个配置类,继承
WebSecurityConfigurerAdapter
重写其中的三个config
方法
- @Slf4j用于日志记录
- @RequiredArgsConstructor 用于构造器注入bean(本项目中
不使用@Autowired
注入bean)- WebSecurityConfigurerAdapter SpringSecurity安全配置类 (Spring Security 5.7.0-M2已经弃用)
@Slf4j
@Configuration
@RequiredArgsConstructor
public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
super.configure(auth);
}
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
}
}
🎁 3-1、网络请求拦截配置
我们对
protected void configure(HttpSecurity http)
进行中的内容进行配置,此方法主要是对于网络请求进行拦截过滤处理。
🍟 3-1-1、自定义 登录/注销 操作
springsecurity默认的登录界面并不是我们所需要的,我们需要的是一个登录接口。
这里我们配置/user/login
为登录接口的接口,/user/logout
为注销登录的接口(虽然我们可以修改SpringSecurity的登录方式,修改登录接口。但是由于SpringSecurity底层对登录接口进行了实现,所所以我们只需要去重写其底层实现即可完成自定义登录)
🌭 3-1-1-1、登录成功处理器
我们查看登录成功处理器发现需要一个
AuthenticationSuccessHandler
类型的接口,我们可以对接口进行实现,自定义一个登录成功处理器。
@Component
public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(ResponseResult.success(HttpCodeEnum.USER_LOGIN_SUCCESS)));
}
}
🍔 3-1-1-2、登录失败处理器
我们查看登录失败处理器发现需要一个
AuthenticationFailureHandler
类型的接口,我们可以对接口进行实现,自定义一个登录失败处理器。但是引起登录失败的原因有很多,所以这里我们需要根据异常进行返回提示
@Slf4j
@Component
public class AuthenticationFailureHandlerImpl implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
log.info("登录失败 =>" + exception.getMessage());
response.getWriter().write(JSON.toJSONString(ResponseResult.fail(exception.getMessage())));
}
}
🌄 3-1-1-3、注销成功处理器
我们查看注销成功处理器发现需要一个
LogoutSuccessHandler
类型的接口,我们可以对接口进行实现,自定义一个注销成功处理器,与登录成功的处理器功能相同。
@Component
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(ResponseResult.success(HttpCodeEnum.USER_LOGOUT_SUCCESS)));
}
}
在配置类中进行引入
@Slf4j
@Configuration
@RequiredArgsConstructor
public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter {
private final AuthenticationSuccessHandlerImpl authenticationSuccessHandler;
private final AuthenticationFailureHandlerImpl authenticationFailureHandler;
private final LogoutSuccessHandlerImpl logoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
//region 自定义 登录/注销 处理器
http.formLogin()
.loginProcessingUrl("/user/login")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.logout()
.logoutUrl("/user/logout")
.logoutSuccessHandler(logoutSuccessHandler);
//endregion
}
}
🌅 3-1-2、请求拦截
本项目中默认会
拦截所有请求
,但是仍有少部分请求时允许匿名访问
的,所以这里我们通过使用自定义注解标识
的方法来放行允许匿名访问的接口。
- 匿名访问实现原理:我们在项目启动时获取标记有 @AnonymousAccess 注解的所有方法的请求路径添加入集合中,最终进行统一放行即可。
新建一个自定义注解,被本注解修饰的方法将会被允许匿名访问。(该注解仅仅时一个标记作用)
获取被
@AnonymousAccess
注解标记的RequestMapping
的参数,并且统计为一个Set集合。
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AnonymousAccess {
}
@Slf4j
@Configuration
@RequiredArgsConstructor
public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter {
private final AuthenticationSuccessHandlerImpl authenticationSuccessHandler;
private final AuthenticationFailureHandlerImpl authenticationFailureHandler;
private final LogoutSuccessHandlerImpl logoutSuccessHandler;
private final ApplicationContext applicationContext;
@Override
protected void configure(HttpSecurity http) throws Exception {
//region 自定义 登录/注销 处理器
http.formLogin()
.loginProcessingUrl("/user/login")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.logout()
.logoutUrl("/user/logout")
.logoutSuccessHandler(logoutSuccessHandler);
//endregion
//region 请求拦截
http.authorizeRequests()
.antMatchers(listAnonymous().toArray(new String[0])).permitAll()
.anyRequest().authenticated();
//endregion
}
/**
* 查找可以匿名访问的接口
*
* @return 匿名访问接口集合
*/
private Set<String> listAnonymous() {
Map<RequestMappingInfo, HandlerMethod> handlerMethods = applicationContext.getBean(RequestMappingHandlerMapping.class).getHandlerMethods();
Set<String> anonymousUrls = new HashSet<>();
anonymousUrls.add("/user/login");
for (Map.Entry<RequestMappingInfo, HandlerMethod> infoEntry : handlerMethods.entrySet()) {
HandlerMethod handlerMethod = infoEntry.getValue();
AnonymousAccess anonymousAccess = handlerMethod.getMethodAnnotation(AnonymousAccess.class);
if (anonymousAccess != null) {
assert infoEntry.getKey().getPatternsCondition() != null;
anonymousUrls.addAll(infoEntry.getKey().getPatternsCondition().getPatterns());
}
}
log.info("可以匿名访问的url:{}", anonymousUrls);
return anonymousUrls;
}
}
❤️ 3-1-3、未登录/未授权处理
上面我们对于网络请求
是否允许匿名访问
进行了处理,接下来我们对未登录/未授权进行处理。
未登录 <=> 匿名 未授权 <=> 无权限
🧡 3-1-3-1、未登录处理器
我们查看未登录处理器发现需要一个
AuthenticationSuccessHandler
类型的接口,我们可以对接口进行实现,自定义一个未登录处理器。(注意:未登录处理器 与 登录失败处理器 不冲突
)
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(ResponseResult.fail(HttpCodeEnum.USER_NOT_LOGIN)));
}
}
🎟️ 3-1-3-2、未授权处理器
我们查看未授权处理器发现需要一个
AuthenticationSuccessHandler
类型的接口,我们可以对接口进行实现,自定义一个未授权处理器。
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(ResponseResult.fail(HttpCodeEnum.PERMISSION_NOT_DEFINED)));
}
}
🎢 3-2、静态资源拦截配置
我们对
protected void configure(WebSecurity web)
进行中的内容进行配置,此方法主要是对于静态资源拦截过滤处理。我们只需要放行需要放行的静态资源即可。
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().mvcMatchers(
"/",
"/js/**",
"/css/**",
"/img/**",
"/fonts/**",
"/index.html",
"/favicon.ico",
"/doc.html",
"/swagger-ui.html",
"/webjars/**",
"/swagger-resources/**",
"/v3/**",
"/store/**"
);
}
🎪 3-3、登录底层以及密码加密方式配置
我们对
protected void configure(AuthenticationManagerBuilder auth)
进行中的内容进行配置,此方法主要是对于登录实现类的配置以及密码加密方式的配置。
🎨 3-3-1、登录逻辑重写
SpringSecurity默认的登录逻辑是由接口
userDetailsService
的实现类执行的,这里我们需要对具体的登录进行重写,所以我们新建一个具体的实现类。(先进行配置,不做具体实现
)
🎊 3-3-2、密码加密方式
我们这里选用无法被反向破解的密码加密方式
BCrypt
加密方式。我们直接将加密方式使用@Bean进行注入。
🎏 4、获取用户信息重写
我们需要使用到UserAgent依赖来对请求进行解析,获取请求来源的ip地址、ip来源等信息…
loadUserByUsername()
方法的实际作用是根据用户名 来获取用户信息
如果获取到则返回用户实体类,若不存在则直接抛出异常。若我们在loadUserByUsername()方法中获取到实体类则会对方法进行层层封装,最终的密码校验在
AbstractUserDetailsAuthenticationProvider
中执行。若校验成功,则会去执行登录成功处理器
中的内容。若校验失败,则会去执行登录失败处理器
中的内容。
<!--============== 项目版本号规定 ===============-->
<properties>
<!--============== 工具依赖 ==================-->
<userAgent.version>1.21</userAgent.version>
</properties>
<dependencies>
<!-- 解析客户端操作系统、浏览器等 -->
<dependency>
<groupId>eu.bitwalker</groupId>
<artifactId>UserAgentUtils</artifactId>
<version>${userAgent.version}</version>
</dependency>
</dependencies>
具体的登录逻辑重写如下:
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final HttpServletRequest request;
private final UserAuthService userAuthService;
private final UserInfoService userInfoService;
private final RoleService roleService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserAuth userAuth = getUserAuthInfo(username);
UserInfo userInfo = getUserBasicInfo(userAuth.getUserInfoId());
return convertToUser(request, userAuth, userInfo);
}
/**
* 用户数据转换
*
* @param request request请求
* @param userAuth 用户权限信息
* @param userInfo 用户基础信息
* @return {@link User} 封装后的用户信息
*/
private User convertToUser(HttpServletRequest request, UserAuth userAuth, UserInfo userInfo) {
//region 获取请求用户ip地址相关信息
String ipAddress = UserAgentUtil.getIpAddress(request);
String ipSource = UserAgentUtil.getIpSource(ipAddress);
UserAgent userAgent = UserAgentUtil.getUserAgent(request);
String browser = userAgent.getBrowser().getName();
String os = userAgent.getOperatingSystem().getName();
//endregion
//region 查询用户角色信息并查询对应权限列表
Role role = roleService.getOne(new LambdaQueryWrapper<Role>().eq(Role::getId, userAuth.getUserRoleId()));
Optional.ofNullable(role).orElseThrow(() -> new BaseException(HttpCodeEnum.USER_IDENTITY_LOAD_FAIL));
Set<String> permissionList = roleService.listRolePermission(role);
//endregion
return new User() {{
setId(userAuth.getId());
setUsername(userAuth.getUsername());
setPassword(userAuth.getPassword());
setNickname(userInfo.getNickname());
setRole(role);
setLoginType(userAuth.getLoginType());
setAvatar(userInfo.getAvatar());
setGender(userInfo.getGender());
setPersonIntro(userInfo.getPersonIntro());
setIpAddress(ipAddress);
setIpSource(ipSource);
setGmtCreate(userInfo.getGmtCreate());
setGmtUpdate(userInfo.getGmtUpdate());
setLastLoginTime(userInfo.getLastLoginTime());
setEmailLogin(userAuth.getEmailLogin());
setIsDisabled(userAuth.getIsDisabled());
setBrowser(browser);
setOs(os);
setPermissionList(permissionList);
}};
}
/**
* 根据用户名查询用户权限信息
*
* @param username 用户名
* @return {@link UserAuth} 用户权限信息
*/
private UserAuth getUserAuthInfo(String username) {
// 用户名非空校验
if (StringUtils.isEmpty(username)) {
throw new BaseException(HttpCodeEnum.USERNAME_OR_PASSWORD_ERROR);
}
//region 用户权限信息查询并校验
UserAuth userAuth = userAuthService.getOne(new LambdaQueryWrapper<UserAuth>()
.eq(UserAuth::getUsername, username)
.eq(UserAuth::getIsDeleted, 0));
Optional.ofNullable(userAuth).orElseThrow(() -> new BaseException(HttpCodeEnum.USERNAME_OR_PASSWORD_ERROR));
if (userAuth.getIsDisabled()) {
throw new BaseException(HttpCodeEnum.ACCOUNT_IS_DISABLED);
}
//endregion
return userAuth;
}
/**
* 根据用户信息Id查询用户权限信息
*
* @param userInfoId 用户信息Id
* @return {@link UserInfo} 用户信息
*/
private UserInfo getUserBasicInfo(Long userInfoId) {
//region 用户基础信息查询并校验
UserInfo userInfo = userInfoService.getOne(new LambdaQueryWrapper<UserInfo>()
.eq(UserInfo::getId, userInfoId));
Optional.ofNullable(userInfo).orElseThrow(() -> new BaseException(HttpCodeEnum.USER_INFO_LOAD_FAIL));
//endregion
return userInfo;
}
}
RoleServiceImpl
@Override
public Set<String> listRolePermission(Role role) {
Set<String> permissionList = new HashSet<>();
//region 通过校验则为超级管理员
if ("super_admin".equals(role.getRoleLabel())) {
permissionList.add("**:**:**");
return permissionList;
}
//endregion
return roleMapper.listPermissionByRoleId(role.getId());
}
RoleMapper.xml
<select id="listPermissionByRoleId" resultType="java.lang.String">
select m.perm
from role
left join role_menu rm on role.id = rm.role_id
left join menu m on rm.menu_id = m.id
where role_id = #{roleId}
</select>