Day11-前后端分离认证

132 阅读12分钟

使用前后端分离的登录

Spring Security框架自带了登录页面和退出登录的页面,不是前后端分离的,则不可与自行开发的前端项目进行交互,如果需要改为前后端分离的模式,需要:

  • 不再启用登录表单

  • 使用控制器接收客户端提交的登录请求

    • 需要自定义DTO类封装客户端提交的用户名、密码
  • 使用Service组件实现登录的验证

    • IAdminService中添加抽象方法,在AdminServiceImpl中实现

    • 具体的登录验证,仍可由Spring Security框架来完成,仅需调用AuthenticationManager对象的authenticate()方法即可,则AuthenticationManager会自动基于用户名调用UserDetailsService接口对象的loadUserByUsername()方法,并得到返回的UserDetails对象,然后自动判断密码是否正确、账号状态是否有效等

      • 可通过Spring Security的配置类中添加@Bean方法来配置AuthenticationManager

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()的认证结果

当调用AuthenticationManagerauthenticate()方法执行认证,且认证通过时,返回的认证结果例如:

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()返回的UserDetailsAuthenticationManagerauthenticate()返回的认证信息中的当事人,而此认证信息会被存入到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解决此问题
  • 不可以长时间存储

    • 无解