前后端分离的登录
首先,应该先不启用Spring Security的默认登录表单,即:在Spring Security的配置类中,不再调用http.formLogin()方法,并且,API文档的相关请求必须配置在“白名单”中:
// 白名单
// 所有路径必须使用 / 作为第1个字符
// 使用1个星号作为通配符时,表示通配此层级的任意资源,例如:/admins/*,可以匹配 /admins/delete、/admins/add-new
// 但是,不可以匹配多层级,例如:/admins/* 不可匹配 /admins/9527/delete
// 使用2个连续的星号,表示通配若干层级的任意资源,例如:/admins/* 可以匹配 /admins/delete、/admins/9527/delete
String[] urls = {
"/doc.html",
"/favicon.ico",
"/**/*.css",
"/**/*.js",
"/swagger-resources",
"/v2/api-docs",
};
然后,还需要:
- 需要使用控制器(Controller)接收来自客户端提交的用户名和密码
- 通常,应该自定义类,封装客户端提交的请求参数
- 在Service层中验证登录
- 具体的处理,仍应该交给Spring Security来实现,只需要手动调用
AuthenticationManager(认证管理器)对象的authenticate()方法,接下来,Spring Security框架会自动调用UserDetailsServiceImpl接口对象的loadUserByUsername()方法获取用户信息详情,并自动验证此用户是否允许登录
- 具体的处理,仍应该交给Spring Security来实现,只需要手动调用
所以,在项目的根包下创建pojo.dto.AdminLoginDTO类,用于封装客户端提交的用户名和密码:
package cn.tedu.csmall.passport.pojo.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 管理员登录信息的DTO类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class AdminLoginDTO implements Serializable {
/**
* 用户名
*/
private String username;
/**
* 密码(明文)
*/
private String password;
}
然后,将csmall-product项目中的相关类复制到当前项目中:
ServiceCodeServiceExceptionJsonResult
在AdminController中添加处理请求的方法:
@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
// TODO 具体功能待定
return JsonResult.ok();
}
并且,将以上配置的请求路径添加在“白名单”中:
String[] urls = {
"/doc.html",
"/favicon.ico",
"/**/*.css",
"/**/*.js",
"/swagger-resources",
"/v2/api-docs",
"/admins/login" // 新增
};
至于Service处理验证登录的部分,需要:
在项目的根包下创建service.IAdminService接口,并在其中声明处理登录的抽象方法:
@Transactional
public interface IAdminService {
void login(AdminLoginDTO adminLoginDTO);
}
在项目的根包下创建service.impl.AdminServiceImpl类,实现以上接口:
@Slf4j
@Service
public class AdminServiceImpl implements IAdminService {
@Override
public void login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
}
}
至此,在AdminController中就可以注入Service对象,并调用Service方法处理登录(虽然Service中还没有具体的实现验证登录):
@Autowired
private IAdminService adminService;
@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
// 调用Service方法处理登录
adminService.login(adminLoginDTO);
return JsonResult.ok();
}
具体的验证过程,需要使用到AuthenticationManager,它是一个接口,这种类型的对象可以通过Spring Security的配置类中,重写父类的方法并添加@Bean注解得到,所以,在SecurityConfiguration中添加:
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
然后,在Service的方法中处理验证登录:
@Override
public void login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
// 创建认证信息对象
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
// 调用认证管理器执行认证
authenticationManager.authenticate(authentication);
// 如果没有出现异常,则表示验证登录成功
log.debug("验证登录成功");
}
目前,可以尝试通过API文档的调整功能测试登录,当使用正确的账号时,将响应成功,当用户名不存在、密码错误、账号状态错误时,将响应403。
在项目中添加全局异常处理器,可以发现,当登录失败时,全局异常处理器中捕获并处理Throwable异常的方法被执行,并且,通过处理过程中获取的异常信息,可以看到:
- 当用户名不存在时,Spring Security将抛出异常:
org.springframework.security.authentication.InternalAuthenticationServiceException: UserDetailsService returned null, which is an interface contract violation
- 当密码错误时,Spring Security将抛出异常:
org.springframework.security.authentication.BadCredentialsException: 用户名或密码错误
- 当账号状态错误时,Spring Security将抛出异常:
org.springframework.security.authentication.DisabledException: 用户已失效
以上异常的继承结构是:
AuthenticationException
-- BadCredentialsException(密码错误)
-- AuthenticationServiceException
-- -- InternalAuthenticationServiceException(用户名错误)
则先在ServiceCode中补充新的业务状态码:
/**
* 错误:登录失败,用户名或密码错
*/
ERR_UNAUTHORIZED(40100),
/**
* 错误:登录失败,账号被禁用
*/
ERR_UNAUTHORIZED_DISABLED(40101),
并在全局异常处理器中添加处理以上异常的方法:
@ExceptionHandler({
InternalAuthenticationServiceException.class,
BadCredentialsException.class
})
public JsonResult handleAuthenticationException(AuthenticationException e) {
String message = "登录失败,用户名或密码错!";
log.warn(message);
return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
}
@ExceptionHandler
public JsonResult handleDisabledException(DisabledException e) {
String message = "登录失败,此账号已经被禁用!";
log.warn(message);
return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED_DISABLED, message);
}
以上处理的流程大致是:
关于通过认证的标准
在Spring Security中,为每个客户端分配了一个SecurityContext,并且,Spring Security会根据在SecurityContext中是否存在有效的Authentication(认证信息)来判断是否已经通过认证!即:
- 如果在
SecurityContext中存在有效的Authentication:已通过认证 - 如果在
SecurityContext中不存在有效的Authentication:未通过认证
并且,SecurityContext默认是基于Session的,所以,也符合Session的相关特征,例如默认的有效期(过期时间)。
在项目中,可以通过SecurityContextHolder的静态方法getContext()方法即可得到当前客户端对应的SecurityContext对象。
所以,在AdminServiceImpl中验证登录时,当视为“登录成功”,需要将此用户(管理员)对应的Authentication存入到SecurityContext中:
@Override
public void login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
// 【调整】调用以下方法时,获取返回的结果
Authentication authenticationResult
= authenticationManager.authenticate(authentication);
// 【新增】将Authentication存入到SecurityContext中
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authenticationResult);
}
关于authenticate()的返回结果
当调用AuthenticationManager的authenticate()方法验证登录时,如果验证通过,返回的结果例如:
2023-04-04 14:45:03.800 DEBUG 6076 --- [nio-9081-exec-1] c.t.c.p.service.impl.AdminServiceImpl : 验证登录成功,返回的Authentication为:
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实现类中返回的UserDetails类型的对象!
识别当事人
在由Spring系列框架调用的方法中,可以在参数列表中添加当事人类型的参数,并在此参数上添加@AuthenticationPrincipal注解,则框架会自动为此参数注入值,例如:
@GetMapping("")
// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 注解
// 当事人的数据类型 ↓↓↓↓
public String list(@AuthenticationPrincipal @ApiIgnore User user) {
log.debug("当事人的用户名:{}", user.getUsername());
return "接收到【查询管理员列表】的请求,但是,服务器端尚未实现此功能!";
}
可以注入的当事人,就是SecurityContext中的Authentication中的Principal,目前,这个值就是UserDetailsSerivce类型中loadUserByUsername()返回的UserDetails类型的对象!
由Spring Security提供的UserDetails类型的典型实现类User不一定能满足编程需求,例如,此类型中没有id属性,则会导致后续在控制器中也无法直接得知当前登录的用户(管理员)的ID值!
可以自定义类,直接实现UserDetails接口,或继承自User类从而间接的实现UserDetails接口,并作为loadUserByUsername()方法的返回结果。
在项目的根包下创建security.AdminDetails类,继承自User类:
@ToString(callSuper = true)
public class AdminDetails extends User {
@Getter
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;
}
}
然后,调整UserDetailsServiceImpl类中loadUserByUsername()方法的返回结果,改为返回AdminDetails类型的对象:
@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) {
String message = "用户名不存在,将无法返回有效的UserDetails对象,则返回null";
log.warn(message);
return null;
}
log.debug("开始创建返回给Spring Security的UserDetails对象……");
// ========== 以下是新的代码,替换了原有的代码 ==========
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("这是一个临时的山寨权限,暂时没什么用"));
AdminDetails adminDetails = new AdminDetails(
loginInfo.getId(),
loginInfo.getUsername(),
loginInfo.getPassword(),
loginInfo.getEnable() == 1,
authorities
);
log.debug("即将向Spring Security返回UserDetails类型的对象,返回结果:{}", adminDetails);
return adminDetails;
}
经过以上调整后,在SecurityContext中的Authentication中的Principal就是以上返回的AdminDetails类型的对象,则可以在方法中注入:
@GetMapping("")
// 注入的类型改为AdminDetails ↓↓↓↓↓↓↓↓↓↓↓↓
public String list(@AuthenticationPrincipal @ApiIgnore AdminDetails adminDetails) {
log.debug("当事人的ID:{}", adminDetails.getId()); // 可以获取扩展的属性值
log.debug("当事人的用户名:{}", adminDetails.getUsername());
return "接收到【查询管理员列表】的请求,但是,服务器端尚未实现此功能!";
}
处理授权
Spring Security框架的核心价值之一就是“授权”,即验证当前用户是否具有访问某个资源的权限。
首先,需要调整原有的Mapper层的“根据用户名查询管理员的登录信息”功能,将用户名对应的管理员的权限列表查询出来,这是一个关联查询:
- 先根据用户名查询
ams_admin表,得到此用户名对应的管理员ID - 根据管理员ID,关联
ams_admin_role表,得到关联的角色ID - 根据角色ID,关联
ams_role_permission表,得到关联的权限ID - 根据权限ID,关联
ams_permission表,得到权限信息
需要执行的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标签:配置List类型的属性 -->
<!-- collection标签的property属性:List类型的属性的名称 -->
<!-- collection标签的ofType属性:List属性的元素的数据类型,取值为元素类型的全限定名,java.lang包下的类可以省略包名 -->
<!-- collection标签的子级:配置如何创建出List集合中的各个元素对象 -->
<!-- collection标签的子级的constructor标签:通过构造方法创建对象 -->
<!-- constructor标签的子级的arg标签:配置构造方法的参数 -->
<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="String">
<constructor>
<arg column="value"/>
</constructor>
</collection>
</resultMap>
以上查询结果例如:
2023-04-04 17:20:12.529 DEBUG 12956 --- [ 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中,调整原有的“山寨权限”的相关代码,改为真实的权限:
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private AdminMapper adminMapper;
@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) {
String message = "用户名不存在,将无法返回有效的UserDetails对象,则返回null";
log.warn(message);
return null;
}
// ===== 以下是本次调整的代码 ======
Collection<GrantedAuthority> authorities = new ArrayList<>();
for (String permission : loginInfo.getPermissions()) {
authorities.add(new SimpleGrantedAuthority(permission));
}
// 暂不关心后续的本次未调整的代码
}
}
经过以上调整后,在SecurityContext中的Authentication中的Principal中的权限,就是从数据库中查出来的管理员的真实权限!
接下来,就可以利用Spring Security的检查权限的机制,需要先在配置类上开启“基于方法的权限检查”,例如:
@Slf4j
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 新增
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// 暂不关心方法内部的代码
}
注意:以上注解是用于开启基于方法的权限检查,并不要求是哪个组件中的方法,也就是说,你可以把以上注解添加在项目中的任何方法上,例如Service中的方法、Controller中的方法,甚至其它组件中的方法,由于当前项目中,所有Service方法都是被Controller方法调用的,所以,推荐将检查权限的注解添加在Controller中的方法上。
然后,在AdminController中:
@GetMapping("")
@PreAuthorize("hasAuthority('/ams/admin/read')") // 新增
public String list(@AuthenticationPrincipal @ApiIgnore
AdminDetails adminDetails) {
// 暂不关心方法内部的代码
}
至此,权限的配置已经完成!如果使用无权限的账号发起请求,服务器端会出现错误:
org.springframework.security.access.AccessDeniedException: 不允许访问
所以,还应该在全局异常处理器中添加对以上异常进行处理的方法:
@ExceptionHandler
public JsonResult handleAccessDeniedException(AccessDeniedException e) {
String message = "禁止访问,当前登录的账号无此操作权限!";
log.warn(message);
return JsonResult.fail(ServiceCode.ERR_FORBIDDEN, message);
}