若以通过Spring Security结合JWT实现了RABC主要逻辑为
- 用户登录后,会根据当前用户的信息生成Token返回给前端
- 用户发送请求后首先会被
JwtAuthenticationTokenFilter
进行拦截,通过Token获取当前用户的所有信息,这些信息就包含了UserDetails
中的字段,这些信息会被Security存入上下文当中 - 当请求映射到控制器方法之后,会通过控制器方法的
@PreAuthorize
拦截并校验当前用户是否拥有该权限
SecurityConfig
SecurityConfig配置是Security的核心,一切都要从这里出发
framework/…/config/SecurityConfig
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 自定义用户认证逻辑
*/
@Autowired
private UserDetailsService userDetailsService;
/**
* 认证失败处理类
*/
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;
/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
/**
* token认证过滤器
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
/**
* 跨域过滤器
*/
@Autowired
private CorsFilter corsFilter;
/**
* 允许匿名访问的地址
*/
@Autowired
private PermitAllUrlProperties permitAllUrl;
/**
* 解决 无法直接注入 AuthenticationManager
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 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 {
// 注解标记允许匿名访问的url
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 禁用HTTP响应标头
.headers().cacheControl().disable().and()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
// 添加Logout filter
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);
}
/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
}
这些注入的Bean都比较重要:
UserDetailsService
用于注入当前系统的自定义认证逻辑,这是一个接口,Spring会注入注入其实现类,实现类中通过UserDetails loadUserByUsername()
这个核心方法完成核心的用户身份认证,最后在返回UserDetails
这个接口的实现类,也就是自定义用户信息实体类AuthenticationEntryPointImpl
自定义认证失败处理器,当用户无权访问时就会触发这个处理器的响应LogoutSuccessHandlerImpl
自定义退出处理器JwtAuthenticationTokenFilter
JWT认证过滤器,这就是上文中提到的过滤器,所有的请求都要经过这个过滤器,获取请求头重点 token并认证身份加入到上下文中,这个需要手动添加到过滤器链当中CorsFilter
跨域过滤器PermitAllUrlProperties
允许匿名访问的地址
核心配置方法
/**
* 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 {
// 注解标记允许匿名访问的url
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 禁用HTTP响应标头
.headers().cacheControl().disable().and()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
// 添加Logout filter
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);
}
这里比较核心的地方有放行请求与放行资源,.permitAll().anyRequest().authenticated()
然后除了以上之外,全部请求都需要鉴权,这个才是开启鉴权的关键,然后在httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
这里添加了JWT校验过滤器,并在authenticationEntryPoint()
这里设置了认证失败的处理器
/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
这个Bean的作用是全局密码加密逻辑,之后的PasswordEncoder
将使用这个加密逻辑,这个configure
方法的作用就是将上文中注入的自定义认证逻辑进行设置,并设置加密逻辑
framework/…/security/filter/JwtAuthenticationTokenFilter
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@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);
}
}
这就是JWT与Security的核心,在这里拦截请求并验证token解析通过,设置上下文身份信息,其中TokenService
会在下文说明
登录逻辑
framework/…/web/service/SysLoginService
一切登录逻辑的开始于这个方法
/**
* 登录验证
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public String login(String username, String password, String code, String uuid) {
// 验证码校验
validateCaptcha(username, code, uuid);
// 登录前置校验
loginPreCheck(username, password);
// 用户验证
Authentication authentication = null;
try {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
} 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());
}
} finally {
AuthenticationContextHolder.clearContext();
}
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
recordLoginInfo(loginUser.getUserId());
// 生成token
return tokenService.createToken(loginUser);
}
首先会校验验证码、并校验密码是否为空或者无效的密码或者是否在黑名单当中,然后开始进行验证,这里会首先准备一个Authentication
对象,然后将当前用户输入的用户名和密码放入一个临时的上下文中,通过authentication = authenticationManager.authenticate(authenticationToken);
这里会调用自定义认证逻辑,也就是上文配置的自定义认证逻辑的loadUserByUsername()
会生效
framework/…/web/service/UserDetailsServiceImpl
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
@Autowired
private ISysUserService userService;
@Autowired
private SysPasswordService passwordService;
@Autowired
private SysPermissionService permissionService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//这里拿到用户信息
SysUser user = userService.selectUserByUserName(username);
if (StringUtils.isNull(user)) {
log.info("登录用户:{} 不存在.", username);
throw new ServiceException(MessageUtils.message("user.not.exists"));
} else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
log.info("登录用户:{} 已被删除.", username);
throw new ServiceException(MessageUtils.message("user.password.delete"));
} else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
log.info("登录用户:{} 已被停用.", username);
throw new ServiceException(MessageUtils.message("user.blocked"));
}
passwordService.validate(user);
return createLoginUser(user);
}
public UserDetails createLoginUser(SysUser user) {
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
}
}
首先会从数据库中查询该用户名是否存在,或者这个用户名是否被停用…
然后通过passwordService.validate(user);
方法验证当前用户
framework/…/web/service/SysPasswordService
@Component
public class SysPasswordService {
@Autowired
private RedisCache redisCache;
@Value(value = "${user.password.maxRetryCount}")
private int maxRetryCount;
@Value(value = "${user.password.lockTime}")
private int lockTime;
/**
* 登录账户密码错误次数缓存键名
*
* @param username 用户名
* @return 缓存键key
*/
private String getCacheKey(String username) {
return CacheConstants.PWD_ERR_CNT_KEY + username;
}
public void validate(SysUser user) {
Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();
// 从上下文中拿到username
String username = usernamePasswordAuthenticationToken.getName();
// 从上下文中拿到明文password
String password = usernamePasswordAuthenticationToken.getCredentials().toString();
Integer retryCount = redisCache.getCacheObject(getCacheKey(username));
//初始化 重试次数
if (retryCount == null) {
retryCount = 0;
}
if (retryCount >= Integer.valueOf(maxRetryCount).intValue()) {
throw new UserPasswordRetryLimitExceedException(maxRetryCount, lockTime);
}
//开始尝试密码
if (!matches(user, password)) {
retryCount = retryCount + 1;
redisCache.setCacheObject(getCacheKey(username), retryCount, lockTime, TimeUnit.MINUTES);
throw new UserPasswordNotMatchException();
} else {
clearLoginRecordCache(username);
}
}
public boolean matches(SysUser user, String rawPassword) {
return SecurityUtils.matchesPassword(rawPassword, user.getPassword());
}
public void clearLoginRecordCache(String loginName) {
if (redisCache.hasKey(getCacheKey(loginName))) {
redisCache.deleteObject(getCacheKey(loginName));
}
}
}
这里会从之前的临时上下文当中拿到用户名和密码,并获取这个用户当前密码的重试次数,然后通过matches()
方法开始验证密码,这个方法最终会调用SecurityUtils
工具类当中的matchesPassword()
方法将当前用户输入的密码使用之前配置的加密逻辑加密后与数据库中的密文密码进行比较,如果不一致,则重试次数加一并返回错误提示,如果重试次数大于配置文件中的阈值则暂时禁用该账户
如果密码正确,则UserDetailsServicet
通过createLoginUser()
方法返回UserDetails
的实现
public UserDetails createLoginUser(SysUser user) {
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
}
这里是调用了LoginUser
的构造方法,并将当前用户的权限集合,id、部门等信息传入并最终返回,其中当前用户身份的集合需要额外编写逻辑通过当前用户角色的集合动态获取其权限集合
public Set<String> getMenuPermission(SysUser user) {
Set<String> perms = new HashSet<String>();
// 管理员拥有所有权限
if (user.isAdmin()) {
perms.add("*:*:*");
} else {
List<SysRole> roles = user.getRoles();
if (!CollectionUtils.isEmpty(roles)) {
// 多角色设置permissions属性,以便数据权限匹配权限
for (SysRole role : roles) {
Set<String> rolePerms = menuService.selectMenuPermsByRoleId(role.getRoleId());
role.setPermissions(rolePerms);
perms.addAll(rolePerms);
}
} else {
perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
}
}
return perms;
}
然后将当前LoginUser
生成token返回前端
common/../core/domain/model/LoginUser
public class LoginUser implements UserDetails {
//.......
}
这个类实现了UserDetails
接口,登录成功后通过该实体的对象生成token并返回给前端
其中UserDetails
接口就是为了规范用户实体中应该有哪些字段
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
//.....
}
TokenService
framework/…/web.service/TokenService
这个Service的核心就是处理Token
@Component
public class TokenService {
protected static final long MILLIS_SECOND = 1000;
protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
private static final Logger log = LoggerFactory.getLogger(TokenService.class);
private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;
// 令牌自定义标识
@Value("${token.header}")
private String header;
// 令牌秘钥
@Value("${token.secret}")
private String secret;
// 令牌有效期(默认30分钟)
@Value("${token.expireTime}")
private int expireTime;
@Autowired
private RedisCache redisCache;
/**
* 获取用户身份信息
*
* @return 用户信息
*/
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) {
log.error("获取用户信息异常'{}'", e.getMessage());
}
}
return null;
}
/**
* 设置用户身份信息
*/
public void setLoginUser(LoginUser loginUser) {
if (StringUtils.isNotNull(loginUser) && StringUtils.isNotEmpty(loginUser.getToken())) {
refreshToken(loginUser);
}
}
/**
* 删除用户身份信息
*/
public void delLoginUser(String token) {
if (StringUtils.isNotEmpty(token)) {
String userKey = getTokenKey(token);
redisCache.deleteObject(userKey);
}
}
/**
* 创建令牌
*
* @param loginUser 用户信息
* @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);
}
/**
* 验证令牌有效期,相差不足20分钟,自动刷新缓存
*
* @param loginUser
* @return 令牌
*/
public void verifyToken(LoginUser loginUser) {
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MILLIS_MINUTE_TEN) {
refreshToken(loginUser);
}
}
/**
* 刷新令牌有效期
*
* @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);
}
/**
* 设置用户代理信息
*
* @param loginUser 登录信息
*/
public void setUserAgent(LoginUser loginUser) {
UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
String ip = IpUtils.getIpAddr();
loginUser.setIpaddr(ip);
loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
loginUser.setBrowser(userAgent.getBrowser().getName());
loginUser.setOs(userAgent.getOperatingSystem().getName());
}
/**
* 从数据声明生成令牌
*
* @param claims 数据声明
* @return 令牌
*/
private String createToken(Map<String, Object> claims) {
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
/**
* 从令牌中获取数据声明
*
* @param token 令牌
* @return 数据声明
*/
private Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
/**
* 从令牌中获取用户名
*
* @param token 令牌
* @return 用户名
*/
public String getUsernameFromToken(String token) {
Claims claims = parseToken(token);
return claims.getSubject();
}
/**
* 获取请求token
*
* @param request
* @return token
*/
private String getToken(HttpServletRequest request) {
String token = request.getHeader(header);
if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) {
token = token.replace(Constants.TOKEN_PREFIX, "");
}
return token;
}
private String getTokenKey(String uuid) {
return CacheConstants.LOGIN_TOKEN_KEY + uuid;
}
}
在这里注入密钥、token头、有效时间等,并提供生成token、校验token等方法
其中生成token就是登录逻辑的结束点
/**
* 创建令牌
*
* @param loginUser 用户信息
* @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);
}
首先会为当前用户生成一个UUID,然后设置到用户信息当中,然后通过setUserAgent()
设置当前用户的代理信息,通过refreshToken()
刷新用户在Redis当中的缓存信息,这里会以生成的UUID为key,用户信息为value存入Redis当中做token双重判断
然后开始准备荷载以login_user_key
为key,生成的UUID为value调用createToken()
方法的重载
private String createToken(Map<String, Object> claims) {
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
在这里真正生成token并签名,最后并返回
双重判断
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) {
log.error("获取用户信息异常'{}'", e.getMessage());
}
}
return null;
}
这个方法会在JWT过滤器中调用,通过请求中的token获取其中的UUID,并通过这个UUID去缓存中查看是否拥有该用户的信息如果有则直接返回,过滤器直接存入上下文,如果没有则代表用户身份已经过期了或者退出了登录。需要重新登陆