使用前后端分离的登录
Spring Security框架自带了登录页面和退出登录的页面,不是前后端分离的,则不可与自行开发的前端项目进行交互,如果需要改为前后端分离的模式,需要:
-
不再启用登录表单
-
使用控制器接收客户端提交的登录请求
- 需要自定义DTO类封装客户端提交的用户名、密码
-
使用Service组件实现登录的验证
-
在
IAdminService中添加抽象方法,在AdminServiceImpl中实现 -
具体的登录验证,仍可由Spring Security框架来完成,仅需调用
AuthenticationManager对象的authenticate()方法即可,则AuthenticationManager会自动基于用户名调用UserDetailsService接口对象的loadUserByUsername()方法,并得到返回的UserDetails对象,然后自动判断密码是否正确、账号状态是否有效等- 可通过Spring Security的配置类中添加
@Bean方法来配置AuthenticationManager
- 可通过Spring Security的配置类中添加
-
在SecurityConfiguration类中添加方法配置AuthenticationManager:
// 【注意】配置AuthenticationManager对象时
// 不要使用authenticationManager()方法,如果使用此方法,在测试时可能导致死循环,从而内存溢出
// 必须使用authenticationManagerBean()方法
// @Bean
// @Override
// protected AuthenticationManager authenticationManager() throws Exception {
// return super.authenticationManager();
// }
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
在项目的根包下创建pojo.dto.AdminLoginInfoDTO类:
@Data
public class AdminLoginInfoDTO implements Serializable {
/**
* 用户名
*/
private String username;
/**
* 密码(原文)
*/
private String password;
}
然后,在IAdminService中添加抽象方法:
/**
* 管理员登录
* @param adminLoginInfoDTO 封装了用户名、密码等相关信息的对象
*/
void login(AdminLoginInfoDTO adminLoginInfoDTO);
并在AdminServiceImpl中实现以上方法:
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void login(AdminLoginInfoDTO adminLoginInfoDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginInfoDTO);
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginInfoDTO.getUsername(), adminLoginInfoDTO.getPassword());
authenticationManager.authenticate(authentication);
log.debug("认证通过!(如果未通过,过程中将抛出异常,你不会看到此条日志!)");
}
在AdminController中添加处理登录请求的方法:
// http://localhost:9081/admins/login
@PostMapping("/login")
@ApiOperation("管理员登录")
@ApiOperationSupport(order = 10)
public JsonResult<Void> login(AdminLoginInfoDTO adminLoginInfoDTO) {
log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginInfoDTO);
adminService.login(adminLoginInfoDTO);
return JsonResult.ok();
}
在测试使用之前,还应该将以上登录的URL添加到“白名单”中,例如:
String[] urls = {
"/doc.html",
"/**/*.css",
"/**/*.js",
"/swagger-resources",
"/v2/api-docs",
"/admins/login" // 管理员登录的URL
};
测试访问时,如果用户名不存在,Spring Security框架将抛出异常:
org.springframework.security.authentication.InternalAuthenticationServiceException: UserDetailsService returned null, which is an interface contract violation
如果密码错误,则是:
org.springframework.security.authentication.BadCredentialsException: 用户名或密码错误
如果账号被禁用,则是:
org.springframework.security.authentication.DisabledException: 用户已失效
接下来,还应该在全局异常处理器中添加对以上3种异常的处理。
对于用户名错误、密码错误,在反馈到客户端的信息中,通常并不会明确的区分开来,而是直接提示“用户名或密码错误”的字样即可!
首先,在ServiceCode中添加对应的业务状态码:
/**
* 错误:未通过认证,或未找到认证信息
*/
ERROR_UNAUTHORIZED(40100),
/**
* 错误:未通过认证,因为账号被禁用
*/
ERROR_UNAUTHORIZED_DISABLED(40101),
关于用户名错误和密码错误时的异常,其继承结构是:
AuthenticationException
-- BadCredentialsException【密码错误】
-- AuthenticationServiceException
-- -- InternalAuthenticationServiceException【用户名错误】
并处理异常:
@ExceptionHandler({
InternalAuthenticationServiceException.class,
BadCredentialsException.class
})
public JsonResult<Void> handleAuthenticationException(AuthenticationException e) {
log.warn("程序运行过程中出现AuthenticationException,将统一处理!");
log.warn("异常类型:{}", e.getClass().getName());
log.warn("异常信息:{}", e.getMessage());
String message = "登录失败,用户名或密码错误!";
return JsonResult.fail(ServiceCode.ERROR_UNAUTHORIZED, message);
}
@ExceptionHandler
public JsonResult<Void> handleDisabledException(DisabledException e) {
log.warn("程序运行过程中出现DisabledException,将统一处理!");
log.warn("异常信息:{}", e.getMessage());
String message = "登录失败,账号已经被禁用!";
return JsonResult.fail(ServiceCode.ERROR_UNAUTHORIZED_DISABLED, message);
}
关于通过认证的标准
在Spring Security框架中,为每个客户端分配了一个SecurityContext,会根据在SecurityContext中是否存在认证信息来判断是否已经通过认证,即:
- 如果在
SecurityContext中存在认证信息,则视为“已通过认证” - 如果在
SecurityContext中没有认证信息,则视为“未通过认证”
同时,SecurityContext默认是基于Session机制的,所以,也符合Session的相关特征,例如默认的有效期。
在项目中,可以通过SecurityContextHolder的静态方法getContext()方法来获取当前的SecurityContext对象,也可以通过SecurityContextHolder的静态方法clearContext()方法来清空SecurityContext中的信息。
所以,在AdminServiceImpl中处理登录时,当验证通过后,应该及时获取认证信息,并保存到SecurityContext中:
@Override
public void login(AdminLoginInfoDTO adminLoginInfoDTO) {
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginInfoDTO.getUsername(), adminLoginInfoDTO.getPassword());
// 注意:需要获取验证登录后的返回结果
Authentication authenticateResult
= authenticationManager.authenticate(authentication);
// 将返回的认证信息保存到SecurityContext中
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authenticateResult);
}
关于未通过认证的拒绝访问
当未通过认证时,访问那些需要授权的资源(必须登录后才可以发起的请求),默认响应403错误!
需要在Spring Security的配置类中的void configurer(HttpSecurity http)方法进行处理:
// 处理未通过认证时导致的拒绝访问
http.exceptionHandling().authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setContentType("application/json; charset=utf-8");
PrintWriter writer = response.getWriter();
writer.println("{\n" +
" "state": 40100,\n" +
" "message": "您当前未登录,请登录!"\n" +
"}");
writer.close();
}
});
关于authenticate()的认证结果
当调用AuthenticationManager的authenticate()方法执行认证,且认证通过时,返回的认证结果例如:
2023-03-07 11:43:52.695 DEBUG 22308 --- [nio-9081-exec-2] c.t.c.p.service.impl.AdminServiceImpl : 认证结果:
UsernamePasswordAuthenticationToken [
Principal=org.springframework.security.core.userdetails.User [
Username=root,
Password=[PROTECTED],
Enabled=true,
AccountNonExpired=true,
credentialsNonExpired=true,
AccountNonLocked=true,
Granted Authorities=[这是一个临时使用的山寨权限]
],
Credentials=[PROTECTED],
Authenticated=true,
Details=null,
Granted Authorities=[这是一个临时使用的山寨权限]
]
可以发现,当登录验证通过后,返回的认证信息(Authentication)中的当事人(Principal)就是UserDetailsService接口对象的loadUserByUsername()返回的结果!
识别当事人
当通过登录验证后,在SecurityContext中就已经存入了认证信息(Authentication),在认证信息中还包含了当事人(Principal),后续,可以在任何需要识别当事人的场景中,获取此当事人信息!
在控制器中处理请求的方法的参数列表上,可以注入当事人类型的参数,并且需要在此参数上添加@AuthenticationPrincipal注解:
@GetMapping("")
// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 注解
// 当事人类型 ↓↓↓↓↓↓↓↓↓↓↓
public JsonResult<List<AdminListItemVO>> list(@AuthenticationPrincipal UserDetails userDetails) {
log.debug("开始处理【查询管理员列表】的请求,参数:无");
log.debug("当事人信息:{}", userDetails);
log.debug("当事人信息中的用户名:{}", userDetails.getUsername());
List<AdminListItemVO> list = adminService.list();
return JsonResult.ok(list);
}
注意:当添加以上参数后,API文档框架会误以为此参数是需要由客户端提交的请求参数,在API文档的调试页面中将显示相对应的输入框(可能需要刷新),要求输入相关的参数,实际此参数是由Spring Security从SecurityContext中取出认证信息中的当事人来注入的,并不应该由客户端提交,所以,应该在此参数上添加@ApiIgnore注解,表示API文档应该忽略此参数:
@GetMapping("")
// ↓↓↓↓↓↓↓↓↓↓ 注解
public JsonResult<List<AdminListItemVO>> list(@ApiIgnore @AuthenticationPrincipal UserDetails userDetails) {
log.debug("开始处理【查询管理员列表】的请求,参数:无");
log.debug("当事人信息:{}", userDetails);
log.debug("当事人信息中的用户名:{}", userDetails.getUsername());
List<AdminListItemVO> list = adminService.list();
return JsonResult.ok(list);
}
由于Spring Security框架要求loadUserByUsername()返回UserDetails类型的对象,且框架提供的实现类User中并不完全包含开发实践时所需要的属性,例如ID等,则使用Spring Security已有的类型并不能满足编程需求!
可以自定义类,实现UserDetails接口,或者,继承自User类,然后,在自定义类中声明所需的各属性等,后续,在loadUserByUsername()中返回自定义类的对象,则验证登录通过时返回的认证信息中的当事人也是此对象,存入到SecurityContext中的认证信息也是同一个认证信息,所以在控制器的方法中注入的当事人也是此对象!
在项目的根包下创建security.AdminDetails类,继承自User类:
@Getter
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class AdminDetails extends User {
private Long id;
public AdminDetails(Long id, String username, String password, boolean enabled,
Collection<? extends GrantedAuthority> authorities) {
super(username, password, enabled,
true, true, true, authorities);
this.id = id;
}
}
然后,在UserDetailsService中,在loadUserByUsername()方法中返回以上自定义类的对象:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
if (loginInfo == null) {
return null;
}
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("这是第一个临时使用的山寨权限"));
authorities.add(new SimpleGrantedAuthority("这是第二个临时使用的山寨权限"));
AdminDetails adminDetails = new AdminDetails(
loginInfo.getId(),
loginInfo.getUsername(),
loginInfo.getPassword(),
loginInfo.getEnable() == 1,
authorities);
return adminDetails;
}
后续,在控制器类中处理请求的方法的参数列表中,就可以注入自定义类型的当事人:
@GetMapping("")
public JsonResult<List<AdminListItemVO>> list(
// ↓↓↓↓↓↓↓↓↓↓↓↓ 自定义类型的当事人
@ApiIgnore @AuthenticationPrincipal AdminDetails adminDetails) {
log.debug("开始处理【查询管理员列表】的请求,参数:无");
log.debug("当事人信息:{}", adminDetails);
log.debug("当事人信息中的ID:{}", adminDetails.getId()); // 获取扩展的ID属性的值
log.debug("当事人信息中的用户名:{}", adminDetails.getUsername());
List<AdminListItemVO> list = adminService.list();
return JsonResult.ok(list);
}
授权访问
首先,需要调整原有的“根据用户名查询管理员的登录信息”的查询功能,将管理员对应的权限列表查询出来,需要执行的SQL语句大致是:
-- 管理员表 <===> 管理员与角色的关联表 <===> 角色表 <===> 角色与权限的关联表 <===> 权限表
SELECT
ams_admin.id,
ams_admin.username,
ams_admin.password,
ams_admin.enable,
ams_permission.value
FROM
ams_admin
LEFT JOIN ams_admin_role ON ams_admin.id = ams_admin_role.admin_id
LEFT JOIN ams_role_permission ON ams_admin_role.role_id = ams_role_permission.role_id
LEFT JOIN ams_permission ON ams_role_permission.permission_id = ams_permission.id
WHERE
username='root';
在AdminLoginInfoVO中,添加属性,以表示管理员的权限列表:
@Data
public class AdminLoginInfoVO implements Serializable {
// 原有其它属性
/**
* 权限列表
*/
private List<String> permissions;
}
然后,调整AdminMapper.xml中的相关配置:
<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginInfoResultMap">
SELECT
<include refid="LoginInfoQueryFields"/>
FROM
ams_admin
LEFT JOIN ams_admin_role ON ams_admin.id = ams_admin_role.admin_id
LEFT JOIN ams_role_permission ON ams_admin_role.role_id = ams_role_permission.role_id
LEFT JOIN ams_permission ON ams_role_permission.permission_id = ams_permission.id
WHERE
username=#{username}
</select>
<sql id="LoginInfoQueryFields">
<if test="true">
ams_admin.id,
ams_admin.username,
ams_admin.password,
ams_admin.enable,
ams_permission.value
</if>
</sql>
<!-- collection标签:用于配置1对多的查询,也可理解为配置List属性对应的值如何封装 -->
<!-- collection标签的property属性:与id或result标签的property属性相同 -->
<!-- collection标签的ofType属性:List中的元素类型 -->
<!-- collection标签的子级:如何创建List中的元素对象 -->
<resultMap id="LoginInfoResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="password" property="password"/>
<result column="enable" property="enable"/>
<collection property="permissions" ofType="java.lang.String">
<constructor>
<arg column="value"/>
</constructor>
</collection>
</resultMap>
完成后,可通过原有的测试进行检验,执行结果例如:
2023-03-07 15:51:12.352 DEBUG 30784 --- [ main] c.t.c.passport.mapper.AdminMapperTests : 根据用户名【root】查询数据详情完成,查询结果:
AdminLoginInfoVO(
id=1,
username=root,
password=$2a$10$N.ZOn9G6/YLFixAOPMg/h.z7pCu6v2XyFDtC4q.jeeGm/TEZyj15C,
enable=1,
permissions=[
/ams/admin/read, /ams/admin/add-new,
/ams/admin/delete, /ams/admin/update,
/pms/product/read, /pms/product/add-new,
/pms/product/delete, /pms/product/update,
/pms/brand/read, /pms/brand/add-new,
/pms/brand/delete, /pms/brand/update,
/pms/category/read, /pms/category/add-new,
/pms/category/delete, /pms/category/update,
/pms/picture/read, /pms/picture/add-new,
/pms/picture/delete, /pms/picture/update,
/pms/album/read, /pms/album/add-new,
/pms/album/delete, /pms/album/update
]
)
然后,调用UserDetailsServiceImpl中的实现:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
AdminLoginInfoVO loginInfo = adminMapper.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) {
authorities.add(new SimpleGrantedAuthority(permission));
}
AdminDetails adminDetails = new AdminDetails(
loginInfo.getId(),
loginInfo.getUsername(),
loginInfo.getPassword(),
loginInfo.getEnable() == 1,
authorities);
log.debug("即将向Spring Security返回UserDetails类型的对象:{}", adminDetails);
return adminDetails;
}
至此,当任何管理员登录后,在SecurityContext中的认证信息是包含此管理员的真实权限的!
提示:
UserDetailsService接口方法loadUserByUsername()返回的UserDetails是AuthenticationManager的authenticate()返回的认证信息中的当事人,而此认证信息会被存入到SecurityContext中!
接下来,就可以实现使用Spring Security验证已登录的管理员的权限!
需要在配置类上添加@EnableGlobalMethedSecurity(prePostEnabled = true),以开启在方法之前或之后的权限检查,例如:
@Slf4j
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
}
然后,在需要检查权限的方法(任意方法,不一定是控制器中处理请求的方法)上使用@PreAuthorize注解进行配置,以实现在执行方法之前的权限检查,例如:
@PreAuthorize("hasAuthority('/ams/admin/delete')") // 检查权限
@PostMapping("/{id:[0-9]+}/delete")
public JsonResult<Void> delete(@PathVariable Long id) {
log.debug("开始处理【根据ID删除管理员】的请求,参数:{}", id);
adminService.delete(id);
return JsonResult.ok();
}
提示:还可以使用@PostAuthorize注解配置执行方法之后的权限检查。
当不具备相关权限却尝试调用方法时,会出现异常:
org.springframework.security.access.AccessDeniedException: 不允许访问
所以,还应该在全局异常处理器中添加处理以上异常的方法:
@ExceptionHandler
public JsonResult<Void> handleAccessDeniedException(AccessDeniedException e) {
log.warn("程序运行过程中出现AccessDeniedException,将统一处理!");
log.warn("异常信息:{}", e.getMessage());
String message = "拒绝访问,您当前登录的账号无此操作权限!";
return JsonResult.fail(ServiceCode.ERROR_FORBIDDEN, message);
}
关于Session
服务器端的应用程序通常是基于HTTP协议的,HTTP协议本身是一种“无状态”协议,所以,它并不能保存客户端的状态,例如,无法识别客户端的身份,所以,即使同一个客户端多次访问同一个服务器,服务器并不能识别出它就是此前来访的客户端!
在开发实践中,大多是需要能够识别客户端身份的,通常可以使用Session机制来解决!
当某个客户端首次访问某个服务器端时,将直接发起请求,当服务器端收到此请求时,会在响应时返回一个Session ID值(本质上是一个UUID值),当客户端收到Session ID后,后续的访问都会自动携带此Session ID到服务器端,则服务器端可以根据这个Session ID值来识别客户端的身份。
在服务器端,使用K-V结构的数据表示Session,客户端携带的Session ID就是K-V结构中的Key,所以,每个客户端都可以访问到不同的Value,即每个客户端对应的Session数据。
Session是存储在服务器端的内存中的数据,而内存资源是相对有限的资源,存储空间相对较小,所以,必然存在清除Session的机制,默认的清除机制是“超时自动清除”,即某个客户端最后一次提交请求之后,在多长时间之内没有再次提交请求,服务器端就会清除此客户端对应的Session数据!至于过多久清除Session,没有明确的要求,大多软件的默认时间是15~30分钟,但是,也可以设置为更短或更长的时间。
基于Session的特点,通常,存入到Session中的数据大多是:
- 用户身份的标识,例如已登录的用户的ID
- 访问频率较高的数据,例如已登录的用户的用户名
- 不易于 / 不便于使用其它存储机制的数据,例如验证码
同时,Session还存在一些缺点:
-
不适合存储大量的数据
- 可以通过规范的开发避免此问题
-
不易于应用到集群或分布式系统中
- 可以通过共享Session解决此问题
-
不可以长时间存储
- 无解