若依(RuoYi)是一款基于 Spring Boot 和 Vue.js 的开源权限管理系统,若依登录和鉴权的实现还包含验证码的生成与校验,这是为了增加系统的安全性,防止恶意攻击和暴力破解等行为。其登录和鉴权实现主要包括以下几个步骤:
令牌生成:令牌通常是一个字符串,可以包含一些用户信息和权限信息,如用户 ID、用户名、角色、权限等。生成令牌的方式有多种,可以使用 JWT(JSON Web Token)、OAuth2 等。Spring Boot 应用将生成的令牌返回给前端应用,前端应用可以将令牌保存在本地存储中,如浏览器的 localStorage。
鉴权校验:前端应用在每次请求后端接口时,都会将保存在本地的令牌携带在请求头中发送给后端应用。后端应用在接收到请求时,会解析令牌并校验用户的权限信息,确定该用户是否有访问该接口的权限。如果校验通过,则会返回请求的数据;如果校验失败,则会返回权限不足的错误信息。
以上是若依登录和鉴权实现中验证码的生成与校验的主要实现步骤,需要注意的是,在生成验证码时需要使用一些开源的验证码生成工具,如 kaptcha、Google 的 reCAPTCHA 等,而验证码校验的实现需要考虑到安全性和效率等因素,比如使用 Redis 缓存验证码字符、设置验证码有效期等。
在ruoyi-common模块中引入了spring-boot-starter-security。
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
在系统管理,参数设置里,可以设置验证码是否开启,这个参数是存到redis里面的,键值是:sys.account.captchaOnOff。
在ruoyi-admin模块的application.yml里面可以设置验证码类型 math 数组计算 char 字符验证,我比较偏好math,因为输入的时候方便。
# 验证码类型 math 数组计算 char 字符验证
captchaType: math
在ruoyi-admin模块的 CaptchaController的getCode方法用于生成验证码。
@GetMapping("/captchaImage")
public AjaxResult getCode(HttpServletResponse response) throws IOException
{
AjaxResult ajax = AjaxResult.success();
boolean captchaOnOff = configService.selectCaptchaOnOff();
ajax.put("captchaOnOff", captchaOnOff);
if (!captchaOnOff)
{
return ajax;
}
// 保存验证码信息
String uuid = IdUtils.simpleUUID();
String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
String capStr = null, code = null;
BufferedImage image = null;
// 生成验证码
if ("math".equals(captchaType))
{
String capText = captchaProducerMath.createText();
capStr = capText.substring(0, capText.lastIndexOf("@"));
code = capText.substring(capText.lastIndexOf("@") + 1);
image = captchaProducerMath.createImage(capStr);
}
else if ("char".equals(captchaType))
{
capStr = code = captchaProducer.createText();
image = captchaProducer.createImage(capStr);
}
redisCache.setCacheObject(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);
// 转换流信息写出
FastByteArrayOutputStream os = new FastByteArrayOutputStream();
try
{
ImageIO.write(image, "jpg", os);
}
catch (IOException e)
{
return AjaxResult.error(e.getMessage());
}
ajax.put("uuid", uuid);
ajax.put("img", Base64.encode(os.toByteArray()));
return ajax;
}
后端应用的登录接口,在ruoyi-admin模块的 SysLoginController的login方法,该接口接收用户输入的用户名和密码和验证码,并通过调用 Spring Security 的登录方法对用户进行验证,如果验证通过,则生成令牌并返回给前端应用。
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody) {
AjaxResult ajax = AjaxResult.success();
// 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
ajax.put(Constants.TOKEN, token);
return ajax;
}
LoginBody类包含了用户名,密码,验证码以及uuid
@Data
public class LoginBody
{
/**
* 用户名
*/
private String username;
/**
* 用户密码
*/
private String password;
/**
* 验证码
*/
private String code;
/**
* 唯一标识
*/
private String uuid = "";
}
ruoyi-framework模块的SysLoginService的login方法来验证登录是否正确。
/**
* 登录验证
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public String login(String username, String password, String code, String uuid) {
boolean captchaOnOff = configService.selectCaptchaOnOff();
// 验证码开关
if (captchaOnOff) {
validateCaptcha(username, code, uuid);
}
// 用户验证
Authentication authentication = null;
try {
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
recordLoginInfo(loginUser.getUser());
// 生成token
return tokenService.createToken(loginUser);
}
ruoyi-framework模块的SysLoginService的validateCaptcha方法用于校验验证码,如果校验失败,会抛出异常。
/**
* 校验验证码
*
* @param username 用户名
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public void validateCaptcha(String username, String code, String uuid)
{
String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
String captcha = redisCache.getCacheObject(verifyKey);
redisCache.deleteObject(verifyKey);
if (captcha == null)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha))
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
throw new CaptchaException();
}
}
authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));方法会去调用UserDetailsServiceImpl.loadUserByUsername,此时只是根据用户名查看该用户是否存在,账户是否被删除或者停用。
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
{
SysUser user = userService.selectUserByUserName(username);
if (StringUtils.isNull(user))
{
log.info("登录用户:{} 不存在.", username);
throw new ServiceException("登录用户:" + username + " 不存在");
}
else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
{
log.info("登录用户:{} 已被删除.", username);
throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
}
else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
{
log.info("登录用户:{} 已被停用.", username);
throw new ServiceException("对不起,您的账号:" + username + " 已停用");
}
return createLoginUser(user);
}
在 Spring Security 中,用户密码的验证是通过实现 org.springframework.security.core.userdetails.UserDetailsService 接口来完成的。UserDetailsService 接口定义了 loadUserByUsername(String username) 方法,该方法返回一个 UserDetails 对象,该对象包含了用户的基本信息和密码等信息。
具体来说,Spring Security 在用户登录时,会通过调用 loadUserByUsername(String username) 方法,根据用户名从数据库或其他数据源中获取对应的 UserDetails 对象。然后,Spring Security 会使用 PasswordEncoder 对用户输入的密码进行加密,然后将加密后的密码和 UserDetails 对象中存储的密码进行比对。如果两者一致,则认为密码验证通过,否则认为密码验证失败。
PasswordEncoder 是一个密码加密器,它通常由开发者自行配置。Spring Security 提供了多种密码加密器实现,如 BCryptPasswordEncoder、ShaPasswordEncoder 等,开发者可以根据实际需求进行选择。一般情况下,建议使用强安全性的 BCryptPasswordEncoder 加密器,其使用 bcrypt 加密算法,安全性较高。
因此,在 Spring Security 中验证用户密码是否正确的流程如下:
用户登录时,Spring Security 通过调用 UserDetailsService 的 loadUserByUsername(String username) 方法获取用户的 UserDetails 对象。
Spring Security 将用户输入的密码使用 PasswordEncoder 加密,并将加密后的密码与 UserDetails 对象中存储的密码进行比对。
如果两者一致,则认为密码验证通过,否则认为密码验证失败,用户登录失败。
需要注意的是,由于密码的验证过程是由 Spring Security 自动完成的,开发者在实现登录接口时,只需要提供用户名和密码即可,无需手动进行密码的验证。
/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder()
{
return new BCryptPasswordEncoder();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception
{
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
登录成功后,一般会生成一个令牌(Token),该令牌会被用于后续的请求中,以验证用户的身份。在生成令牌时,一般会使用 JSON Web Token(JWT)技术,JWT 令牌包含了用户的身份信息以及一些元数据。
生成 JWT 令牌的过程一般分为以下几个步骤:
JWT 令牌由 Header、Payload 和 Signature 三部分组成。Header 包含了加密算法和类型信息,一般采用 Base64 编码。Payload 包含了用户的身份信息和一些元数据,一般采用 JSON 格式表示,例如:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
使用指定的加密算法对 Header 和 Payload 进行加密,一般使用 HMAC 或 RSA 等加密算法。
将加密后的 Header 和 Payload 进行拼接,然后使用 Secret Key 对其进行签名,生成 Signature。
将加密后的 Header、Payload 和 Signature 进行拼接,一般使用 “.” 进行分隔,得到最终的 JWT 令牌。
生成 JWT 令牌的具体实现方式可以使用现有的 JWT 库,例如 jjwt、Nimbus JOSE + JWT 等。在 Spring Security 中,可以使用 org.springframework.security.jwt.JwtHelper 类来生成 JWT 令牌,该类封装了 JWT 的加密和签名过程。
在ruoyi-framework模块下的TokenService的createToken方法可以生成令牌。
/**
* 从数据声明生成令牌
*
* @param claims 数据声明
* @return 令牌
*/
public String createToken(LoginUser loginUser)
{
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
refreshToken(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
/**
* 从数据声明生成令牌
*
* @param claims 数据声明
* @return 令牌
*/
private String createToken(Map<String, Object> claims)
{
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
/**
* 刷新令牌有效期
*
* @param loginUser 登录信息
*/
public void refreshToken(LoginUser loginUser)
{
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
前端应用在每次请求后端接口时,都会将保存在本地的令牌携带在请求头中发送给后端应用。后端应用在接收到请求时,会解析令牌并校验用户的权限信息,确定该用户是否有访问该接口的权限。如果校验通过,则会返回请求的数据;如果校验失败,则会返回权限不足的错误信息。
在若依系统中,鉴权校验一般是在接口请求时进行的。Spring Security 提供了丰富的权限控制机制,可以通过配置来实现鉴权校验。
具体实现步骤如下:
在 Spring Security 中配置权限控制,一般包括认证管理器、安全拦截器链、鉴权规则等。可以使用注解或 XML 配置方式实现。
例如,在若依系统中,可以在 SecurityConfig 类中使用 @EnableWebSecurity 注解来开启 Spring Security 配置,并通过 configure(HttpSecurity http) 方法来配置安全拦截器链,例如:
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
/**
* anyRequest | 匹配所有请求路径
* access | SpringEl表达式结果为true时可以访问
* anonymous | 匿名可以访问
* denyAll | 用户不能访问
* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
* permitAll | 用户可以任意访问
* rememberMe | 允许通过remember-me登录的用户访问
* authenticated | 用户登录后可访问
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login/*", "/login", "/code/get", "/register", "/captchaImage").anonymous()
.antMatchers(
HttpMethod.GET,
"/",
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/profile/**"
).permitAll()
.antMatchers("/common/download**").anonymous()
.antMatchers("/common/download/resource**").anonymous()
.antMatchers("/swagger-ui.html").anonymous()
.antMatchers("/swagger-resources/**").anonymous()
.antMatchers("/webjars/**").anonymous()
.antMatchers("/*/api-docs").anonymous()
.antMatchers("/druid/**").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}
// ...
}
在以上代码中,通过 antMatchers() 方法来设置访问控制规则,permitAll() 表示允许所有用户访问,anyRequest().authenticated() 表示需要登录才能访问。其中,jwtAuthenticationTokenFilter 用于对 JWT 令牌进行校验和解析,如果令牌有效,则通过 Spring Security 的认证流程进行身份认证。
下面的代码是httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);中的JwtAuthenticationTokenFilter处理,根据token从redis里获取用户信息:
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private Lock lock = new ReentrantLock();
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) {
tokenService.verifyToken(loginUser);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
.......
chain.doFilter(request, response);
}
}
根据token从redis里获取用户信息是通过tokenService.getLoginUser(request)来处理的:
public LoginUser getLoginUser(HttpServletRequest request)
{
// 获取请求携带的令牌
String token = getToken(request);
if (StringUtils.isNotEmpty(token))
{
try
{
Claims claims = parseToken(token);
// 解析对应的权限以及用户信息
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
String userKey = getTokenKey(uuid);
LoginUser user = redisCache.getCacheObject(userKey);
return user;
}
catch (Exception e)
{
}
}
return null;
}
在具体的接口实现中,可以使用 Spring Security 的注解来设置访问控制规则。例如,@PreAuthorize 注解可以在方法或类级别上设置访问控制规则,例如:
@RestController
@RequestMapping("/api/user")
public class UserController {
@PreAuthorize("hasAuthority('user:view')")
@GetMapping("/list")
public ResponseEntity list() {
// ...
}
// ...
}
在以上代码中,@PreAuthorize(“hasAuthority(‘user:view’)”) 表示只有拥有 “user:view” 权限的用户才能访问该接口。如果用户没有该权限,则会返回 403 Forbidden 错误。
若依系统还提供了基于角色的访问控制(RBAC),可以使用 @PreAuthorize 注解来设置角色控制规则。例如:
@RestController
@RequestMapping("/api/role")
public class RoleController {
@PreAuthorize("hasRole('admin')")
在以上代码中,@PreAuthorize(“hasRole(‘admin’)”) 表示只有拥有 “admin” 角色的用户才能访问该接口。如果用户没有该角色,则会返回 403 Forbidden 错误。
若依系统还支持动态权限规则,可以使用 @PreAuthorize 注解结合 SpEL 表达式来实现动态访问控制。例如:
@RestController
@RequestMapping("/api/menu")
public class MenuController {
@PreAuthorize("@ss.hasPermi('system:menu:add')")
@PostMapping("/add")
public ResponseEntity add(@RequestBody Menu menu) {
// ...
}
// ...
}
在以上代码中,@PreAuthorize(“@ss.hasPermi(‘system:menu:add’)”) 表示只有满足 SpEL 表达式 “@ss.hasPermi(‘system:menu:add’)” 的用户才能访问该接口。其中,@ss 是若依系统中自定义的权限控制注解,hasPermi() 是自定义的 SpEL 表达式函数,用于判断用户是否拥有指定的权限。如果用户没有该权限,则会返回 403 Forbidden 错误。
一下代码是RuoYi首创 自定义权限的实现PermissionService,ss取自SpringSecurity首字母。
/**
* RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母
*
* @author ruoyi
*/
@Service("ss")
public class PermissionService
{
/** 所有权限标识 */
private static final String ALL_PERMISSION = "*:*:*";
/** 管理员角色权限标识 */
private static final String SUPER_ADMIN = "admin";
private static final String ROLE_DELIMETER = ",";
private static final String PERMISSION_DELIMETER = ",";
/**
* 验证用户是否具备某权限
*
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
public boolean hasPermi(String permission)
{
if (StringUtils.isEmpty(permission))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
{
return false;
}
return hasPermissions(loginUser.getPermissions(), permission);
}
/**
* 验证用户是否不具备某权限,与 hasPermi逻辑相反
*
* @param permission 权限字符串
* @return 用户是否不具备某权限
*/
public boolean lacksPermi(String permission)
{
return hasPermi(permission) != true;
}
/**
* 验证用户是否具有以下任意一个权限
*
* @param permissions 以 PERMISSION_NAMES_DELIMETER 为分隔符的权限列表
* @return 用户是否具有以下任意一个权限
*/
public boolean hasAnyPermi(String permissions)
{
if (StringUtils.isEmpty(permissions))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
{
return false;
}
Set<String> authorities = loginUser.getPermissions();
for (String permission : permissions.split(PERMISSION_DELIMETER))
{
if (permission != null && hasPermissions(authorities, permission))
{
return true;
}
}
return false;
}
/**
* 判断用户是否拥有某个角色
*
* @param role 角色字符串
* @return 用户是否具备某角色
*/
public boolean hasRole(String role)
{
if (StringUtils.isEmpty(role))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
{
return false;
}
for (SysRole sysRole : loginUser.getUser().getRoles())
{
String roleKey = sysRole.getRoleKey();
if (SUPER_ADMIN.equals(roleKey) || roleKey.equals(StringUtils.trim(role)))
{
return true;
}
}
return false;
}
/**
* 验证用户是否不具备某角色,与 isRole逻辑相反。
*
* @param role 角色名称
* @return 用户是否不具备某角色
*/
public boolean lacksRole(String role)
{
return hasRole(role) != true;
}
/**
* 验证用户是否具有以下任意一个角色
*
* @param roles 以 ROLE_NAMES_DELIMETER 为分隔符的角色列表
* @return 用户是否具有以下任意一个角色
*/
public boolean hasAnyRoles(String roles)
{
if (StringUtils.isEmpty(roles))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
{
return false;
}
for (String role : roles.split(ROLE_DELIMETER))
{
if (hasRole(role))
{
return true;
}
}
return false;
}
/**
* 判断是否包含权限
*
* @param permissions 权限列表
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
private boolean hasPermissions(Set<String> permissions, String permission)
{
return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
}
}
在具体的接口实现中,需要根据访问控制规则和用户权限信息来实现鉴权逻辑。例如,在若依系统中,可以使用 SecurityUtils 工具类来获取当前登录用户的信息,例如:
@RestController
@RequestMapping("/api/user")
public class UserController {
@PreAuthorize("hasAuthority('user:view')")
@GetMapping("/list")
public ResponseEntity list() {
List<User> userList = userService.selectUserList();
return ResponseEntity.ok(userList);
}
// ...
}
在以上代码中,通过 SecurityUtils.getSubject().getPrincipal() 方法来获取当前登录用户的信息,如果用户没有登录,则该方法返回 null。根据用户信息和访问控制规则,可以判断用户是否有权限访问该接口。
如果用户没有权限访问接口,则需要返回相应的错误信息。在若依系统中,一般会返回 403 Forbidden 错误或自定义的错误码和错误信息。例如:
@RestControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity handleAccessDeniedException(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Result.error(ResultCode.FORBIDDEN));
}
// ...
}
在以上代码中,通过 @RestControllerAdvice 注解和 @ExceptionHandler 注解来实现全局的异常处理,当出现 AccessDeniedException 异常时,返回 HTTP 403 Forbidden 错误和自定义的错误码和错误信息。